iter 2
This commit is contained in:
@@ -12,9 +12,13 @@ package.lock
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-test/
|
||||
/out/
|
||||
|
||||
# production
|
||||
@@ -30,6 +34,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# AGENTS.md — Coffee Project Frontend
|
||||
|
||||
Primary Codex reference: [`../.codex/services/frontend.md`](/Users/daniilrakityansky/Documents/Work/Cofee/.codex/services/frontend.md).
|
||||
If this file conflicts with the `.codex` guide, prefer the `.codex` guide. `CLAUDE.md` remains for Claude-specific tooling.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Next.js 16 application using **Feature-Sliced Design (FSD)** architecture, powered by **Bun** runtime and package manager.
|
||||
|
||||
@@ -48,6 +48,7 @@ Next.js 16 App Router with Feature-Sliced Design. Strict unidirectional imports:
|
||||
## Component Convention
|
||||
|
||||
Generate new components with `bun run gc <layer> <Name>` — never create component files manually. Each component folder contains:
|
||||
|
||||
- `index.ts` — public re-export only
|
||||
- `ComponentName.tsx` — implementation
|
||||
- `ComponentName.module.scss` — scoped styles
|
||||
@@ -89,6 +90,7 @@ Use the shared `uploadFile` utility for any file upload — do not inline FormDa
|
||||
|
||||
```ts
|
||||
import { uploadFile } from "@shared/api/uploadFile"
|
||||
|
||||
const result = await uploadFile(file, "avatars")
|
||||
// result.file_url, result.file_path
|
||||
```
|
||||
@@ -124,3 +126,12 @@ All user-facing UI text **must be in Russian** — labels, headings, buttons, pl
|
||||
- **`next/image` remote hosts**: External image hostnames must be listed in `next.config.mjs` `images.remotePatterns`. MinIO (`localhost:9000`) is already configured. If you add another storage backend, add its hostname there too.
|
||||
- **Stale OpenAPI types**: Always run `bun run gen:api-types` before implementing against the API if the backend has changed. Stale types cause silent 404s at runtime.
|
||||
- **Never use raw `fetch`/`useEffect` for API calls** — always use `api.useQuery()`/`api.useMutation()` from `@shared/api` (TanStack Query + openapi-fetch wrapper). For polling, use the `refetchInterval` option. Raw `fetch` bypasses typed routes, auth middleware, and query caching.
|
||||
|
||||
Always use Context7 MCP when I need library/API documentation, code generation, setup or configuration steps without me having to explicitly ask.
|
||||
|
||||
## Testing Standards
|
||||
|
||||
- All E2E tests use Playwright with TypeScript
|
||||
- Test files live in tests/e2e/
|
||||
- Use `getByRole` as primary locator strategy
|
||||
- Every PR must include error-state tests, not just happy paths
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StaticLoader } from "@shared/ui/Loader"
|
||||
|
||||
export default function ProtectedLoading() {
|
||||
return <StaticLoader block />
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Skeleton } from "@shared/ui/Skeleton"
|
||||
|
||||
export default function ProfileLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
padding: "32px 16px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "24px",
|
||||
width: "100%",
|
||||
maxWidth: "640px",
|
||||
}}
|
||||
>
|
||||
<Skeleton width="120px" height="120px" borderRadius="50%" />
|
||||
<Skeleton width="100%" height="200px" borderRadius="10px" />
|
||||
<Skeleton width="100%" height="160px" borderRadius="10px" />
|
||||
<Skeleton width="100%" height="140px" borderRadius="10px" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { JSX } from "react"
|
||||
|
||||
import { ProjectWorkspacePage } from "@pages/ProjectWorkspacePage"
|
||||
import { ProjectWizardPage } from "@pages/ProjectWizardPage"
|
||||
|
||||
export default function Projects(): JSX.Element {
|
||||
return (
|
||||
<main>
|
||||
<ProjectWorkspacePage />
|
||||
<ProjectWizardPage />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ProjectCardSkeleton } from "@shared/ui/Skeleton"
|
||||
|
||||
export default function ProjectsLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "28px 24px 40px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
||||
gap: "20px",
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<ProjectCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+7
-4
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Open_Sans } from "next/font/google"
|
||||
import { Manrope } from "next/font/google"
|
||||
|
||||
import "@shared/styles/global.scss"
|
||||
|
||||
@@ -12,10 +12,11 @@ export const metadata: Metadata = {
|
||||
description: "Standalone Next.js app using FSD structure",
|
||||
}
|
||||
|
||||
const open_sans = Open_Sans({
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin", "cyrillic"],
|
||||
preload: true,
|
||||
display: "swap",
|
||||
variable: "--font-open-sans",
|
||||
variable: "--font-manrope",
|
||||
})
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,7 +25,7 @@ export default function RootLayout({
|
||||
children: ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ru" className={open_sans.variable} suppressHydrationWarning>
|
||||
<html lang="ru" className={manrope.variable} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -33,7 +34,9 @@ export default function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-root">
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
+9
-1
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
import { Header } from "@widgets/Header"
|
||||
|
||||
@@ -12,7 +13,7 @@ export default function EssentialTemplate({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const isAuthPage = AUTH_ROUTES.includes(pathname)
|
||||
const isAuthPage = AUTH_ROUTES.includes(pathname ?? "")
|
||||
|
||||
if (isAuthPage) {
|
||||
return <>{children}</>
|
||||
@@ -21,7 +22,14 @@ export default function EssentialTemplate({
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { UnderMaintenancePage } from "@pages/UnderMaintenancePage"
|
||||
|
||||
export default function UnderMaintenance() {
|
||||
return (
|
||||
<main>
|
||||
<Suspense>
|
||||
<UnderMaintenancePage />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,9 +31,11 @@
|
||||
"openapi-react-query": "^0.5.1",
|
||||
"react": "^19.2.3",
|
||||
"react-aria-components": "^1.14.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.71.0",
|
||||
"react-modal": "^3.16.3",
|
||||
"react-modern-drawer": "^1.4.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^4.6.5",
|
||||
@@ -45,12 +47,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
@@ -235,6 +240,18 @@
|
||||
|
||||
"@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="],
|
||||
|
||||
"@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="],
|
||||
|
||||
"@jest/expect-utils": ["@jest/expect-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA=="],
|
||||
|
||||
"@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="],
|
||||
|
||||
"@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="],
|
||||
|
||||
"@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="],
|
||||
|
||||
"@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -307,6 +324,8 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
||||
|
||||
"@radix-ui/colors": ["@radix-ui/colors@3.0.0", "", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
@@ -657,6 +676,8 @@
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
@@ -717,6 +738,14 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
||||
|
||||
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
|
||||
|
||||
"@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="],
|
||||
|
||||
"@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="],
|
||||
|
||||
"@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
@@ -729,8 +758,16 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/react-modal": ["@types/react-modal@3.16.3", "", { "dependencies": { "@types/react": "*" } }, "sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg=="],
|
||||
|
||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
|
||||
|
||||
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/type-utils": "8.50.1", "@typescript-eslint/utils": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.50.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", "@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg=="],
|
||||
@@ -887,6 +924,8 @@
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
@@ -1049,6 +1088,10 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"exenv": ["exenv@1.2.2", "", {}, "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="],
|
||||
|
||||
"expect": ["expect@30.2.0", "", { "dependencies": { "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.2.0", "jest-message-util": "30.2.0", "jest-mock": "30.2.0", "jest-util": "30.2.0" } }, "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
@@ -1087,6 +1130,8 @@
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
||||
@@ -1127,6 +1172,8 @@
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
@@ -1241,6 +1288,18 @@
|
||||
|
||||
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
|
||||
|
||||
"jest-diff": ["jest-diff@30.2.0", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.2.0" } }, "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A=="],
|
||||
|
||||
"jest-matcher-utils": ["jest-matcher-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.2.0", "pretty-format": "30.2.0" } }, "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg=="],
|
||||
|
||||
"jest-message-util": ["jest-message-util@30.2.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw=="],
|
||||
|
||||
"jest-mock": ["jest-mock@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "jest-util": "30.2.0" } }, "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw=="],
|
||||
|
||||
"jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="],
|
||||
|
||||
"jest-util": ["jest-util@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA=="],
|
||||
|
||||
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
|
||||
|
||||
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
|
||||
@@ -1397,6 +1456,10 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
|
||||
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
@@ -1421,6 +1484,8 @@
|
||||
|
||||
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
|
||||
|
||||
"pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
@@ -1439,13 +1504,19 @@
|
||||
|
||||
"react-aria-components": ["react-aria-components@1.14.0", "", { "dependencies": { "@internationalized/date": "^3.10.1", "@internationalized/string": "^3.2.7", "@react-aria/autocomplete": "3.0.0-rc.4", "@react-aria/collections": "^3.0.1", "@react-aria/dnd": "^3.11.4", "@react-aria/focus": "^3.21.3", "@react-aria/interactions": "^3.26.0", "@react-aria/live-announcer": "^3.4.4", "@react-aria/overlays": "^3.31.0", "@react-aria/ssr": "^3.9.10", "@react-aria/textfield": "^3.18.3", "@react-aria/toolbar": "3.0.0-beta.22", "@react-aria/utils": "^3.32.0", "@react-aria/virtualizer": "^4.1.11", "@react-stately/autocomplete": "3.0.0-beta.4", "@react-stately/layout": "^4.5.2", "@react-stately/selection": "^3.20.7", "@react-stately/table": "^3.15.2", "@react-stately/utils": "^3.11.0", "@react-stately/virtualizer": "^4.4.4", "@react-types/form": "^3.7.16", "@react-types/grid": "^3.3.6", "@react-types/shared": "^3.32.1", "@react-types/table": "^3.13.4", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "^3.45.0", "react-stately": "^3.43.0", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-u21N/yS6Ozk9P9oO8wxMNZSFiPk6F3aAE9w6aN7pseGPApkjXqDyPNCnTsTTvMtVL3QRBkVbf7fJ5yi2hksVEg=="],
|
||||
|
||||
"react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||
|
||||
"react-dropzone": ["react-dropzone@14.3.8", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.71.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oFDt/iIFMV9ZfV52waONXzg4xuSlbwKUPvXVH2jumL1me5qFhBMc4knZxuXiZ2+j6h546sYe3ZKJcg/900/iHw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-lifecycles-compat": ["react-lifecycles-compat@3.0.4", "", {}, "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="],
|
||||
|
||||
"react-modal": ["react-modal@3.16.3", "", { "dependencies": { "exenv": "^1.2.0", "prop-types": "^15.7.2", "react-lifecycles-compat": "^3.0.0", "warning": "^4.0.3" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19", "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" } }, "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw=="],
|
||||
|
||||
"react-modern-drawer": ["react-modern-drawer@1.4.0", "", { "peerDependencies": { "react": ">16.0.0" } }, "sha512-5OkcUstqUdd/CNW9+BvLkzm36R2G54RFXWF2mWCH13cUsz5SNo9aB9KzPRbJp2LEVfRL/u+MgikOWRe7/6wKEQ=="],
|
||||
|
||||
@@ -1541,6 +1612,8 @@
|
||||
|
||||
"stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
|
||||
|
||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -1653,6 +1726,8 @@
|
||||
|
||||
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
|
||||
|
||||
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.12.1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
@@ -1769,10 +1844,16 @@
|
||||
|
||||
"openapi-typescript/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
|
||||
|
||||
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
||||
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"stylelint/file-entry-cache": ["file-entry-cache@11.1.1", "", { "dependencies": { "flat-cache": "^6.1.19" } }, "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A=="],
|
||||
|
||||
+1
-2
@@ -4,9 +4,8 @@ import { fileURLToPath } from "url"
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const stylesPath = path.join(dirname, "src/shared/styles")
|
||||
console.log("dirname", dirname)
|
||||
|
||||
const nextConfig = {
|
||||
distDir: process.env.NEXT_TEST_DIR ?? ".next",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
+11
-1
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "fsd-nest-template",
|
||||
"version": "0.1.0",
|
||||
"imports": {
|
||||
"#tests/*": "./tests/*"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.5",
|
||||
"scripts": {
|
||||
@@ -11,7 +14,9 @@
|
||||
"create-component": "npx generate-react-cli component",
|
||||
"gc": "bun run .scripts/create-fsd-component.ts",
|
||||
"gicons": "npx @svgr/cli --ext tsx --typescript --no-prettier --icon --ref --no-svgo ./src/shared/assets/raw-icons/ --out-dir ./src/shared/ui/Icons/",
|
||||
"gen:api-types": "openapi-typescript http://127.0.0.1:8000/api/schema/ --output src/shared/api/__generated__/openapi.types.ts"
|
||||
"gen:api-types": "openapi-typescript http://127.0.0.1:8000/api/schema/ --output src/shared/api/__generated__/openapi.types.ts",
|
||||
"test:e2e": "bunx playwright test --project=chromium --headed",
|
||||
"test:integration": "bunx playwright test --project=integration --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -40,9 +45,11 @@
|
||||
"openapi-react-query": "^0.5.1",
|
||||
"react": "^19.2.3",
|
||||
"react-aria-components": "^1.14.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.71.0",
|
||||
"react-modal": "^3.16.3",
|
||||
"react-modern-drawer": "^1.4.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^4.6.5",
|
||||
@@ -54,12 +61,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
import {
|
||||
FRONTEND_INTEGRATION_PORT,
|
||||
FRONTEND_INTEGRATION_URL,
|
||||
FRONTEND_MOCK_PORT,
|
||||
FRONTEND_MOCK_URL,
|
||||
MOCK_API_URL,
|
||||
} from "./tests/e2e/support/config"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/specs",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: "html",
|
||||
use: {
|
||||
actionTimeout: 10_000,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: /\.integration\./,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: FRONTEND_MOCK_URL,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "integration",
|
||||
testMatch: /\.integration\./,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: FRONTEND_INTEGRATION_URL,
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
command: "bun --bun tests/e2e/support/mock-api.ts",
|
||||
url: `${MOCK_API_URL}/api/ping/`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
{
|
||||
command: `NEXT_PUBLIC_API_URL=${MOCK_API_URL} NEXT_TEST_DIR=.next-test bun dev --port ${FRONTEND_MOCK_PORT}`,
|
||||
url: FRONTEND_MOCK_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
{
|
||||
command: `bun dev --port ${FRONTEND_INTEGRATION_PORT}`,
|
||||
url: FRONTEND_INTEGRATION_URL,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -3,53 +3,85 @@
|
||||
@include mixins.flex-column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-md;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-md ;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
transform variables.$duration-normal variables.$ease-out,
|
||||
box-shadow variables.$duration-normal variables.$ease-out,
|
||||
border-color variables.$duration-normal variables.$ease-out;
|
||||
cursor: pointer;
|
||||
background: variables.$bg-default;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background-color: variables.$bg-surface;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-left: -24px;
|
||||
margin-top: -24px;
|
||||
width: calc(100% + 48px);
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform variables.$duration-slow variables.$ease-out;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@include mixins.flex-center;
|
||||
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%);
|
||||
background: linear-gradient(135deg, variables.$bg-surface 0%, variables.$bg-default 100%);
|
||||
transition: scale variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&[data-color-index="0"] {
|
||||
background: linear-gradient(135deg, variables.$purple-100 0%, variables.$purple-300 100%);
|
||||
svg { color: variables.$purple-600; opacity: 0.6; }
|
||||
}
|
||||
&[data-color-index="1"] {
|
||||
background: linear-gradient(135deg, variables.$green-100 0%, variables.$green-300 100%);
|
||||
svg { color: variables.$green-700; opacity: 0.6; }
|
||||
}
|
||||
&[data-color-index="2"] {
|
||||
background: radial-gradient(circle at top left, variables.$purple-200 0%, variables.$purple-50 100%);
|
||||
svg { color: variables.$purple-500; opacity: 0.5; }
|
||||
}
|
||||
&[data-color-index="3"] {
|
||||
background: radial-gradient(circle at bottom right, variables.$green-200 0%, variables.$green-50 100%);
|
||||
svg { color: variables.$green-600; opacity: 0.5; }
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: variables.$purple-300;
|
||||
opacity: 0.4;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: variables.$text-tertiary;
|
||||
opacity: 0.35;
|
||||
transition: transform variables.$duration-normal variables.$ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: transparent;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: variables.$purple-200;
|
||||
|
||||
.placeholder {
|
||||
scale: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.root:active {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -107,7 +139,7 @@
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: variables.$color-white;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
z-index: 2;
|
||||
@@ -123,7 +155,7 @@
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.18) 100%);
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.06) 0%, rgba(0, 0, 0, 0.16) 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -169,7 +201,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
|
||||
&.statusGenerated {
|
||||
color: variables.$color-success;
|
||||
@@ -204,7 +236,7 @@
|
||||
@include mixins.flex-center;
|
||||
color: variables.$text-secondary;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
transition: background-color variables.$duration-normal variables.$ease-out, color variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&:hover,
|
||||
&[data-state="open"] {
|
||||
@@ -230,10 +262,10 @@
|
||||
left: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: variables.$text-primary;
|
||||
box-shadow: var(--shadow-sm);
|
||||
z-index: 2;
|
||||
|
||||
@@ -65,7 +65,10 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={name} loading="lazy" />
|
||||
) : (
|
||||
<div className={styles.placeholder}>
|
||||
<div
|
||||
className={styles.placeholder}
|
||||
data-color-index={name.charCodeAt(0) % 4}
|
||||
>
|
||||
<ImageIcon />
|
||||
</div>
|
||||
)}
|
||||
@@ -109,7 +112,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||
>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<button type="button" aria-label="Project actions">
|
||||
<button type="button" aria-label="Действия проекта">
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
|
||||
background: variables.$bg-default;
|
||||
border: 1px solid variables.$border-default;
|
||||
background: linear-gradient(180deg, variables.$bg-default 0%, variables.$bg-surface 100%);
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-lg;
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
@@ -19,29 +19,38 @@
|
||||
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
transform variables.$duration-normal variables.$ease-out,
|
||||
box-shadow variables.$duration-normal variables.$ease-out,
|
||||
border-color variables.$duration-normal variables.$ease-out,
|
||||
background variables.$duration-normal variables.$ease-out,
|
||||
color variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: variables.$text-primary;
|
||||
border-color: variables.$purple-300;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&.accent {
|
||||
background: variables.$purple-50;
|
||||
border-color: variables.$purple-100;
|
||||
color: variables.$purple-400;
|
||||
background: linear-gradient(135deg, variables.$purple-400 0%, variables.$purple-600 100%);
|
||||
border-color: transparent;
|
||||
color: variables.$color-white;
|
||||
box-shadow: 0 4px 14px hsla(262, 75%, 48%, 0.25);
|
||||
|
||||
&:hover {
|
||||
background: variables.$purple-100;
|
||||
border-color: variables.$purple-400;
|
||||
background: linear-gradient(135deg, variables.$purple-500 0%, variables.$purple-700 100%);
|
||||
box-shadow: 0 6px 20px hsla(262, 75%, 48%, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
@include typography.font-body-s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// Rounded hover for ghost icon button
|
||||
:global(.rt-IconButton) {
|
||||
border-radius: variables.$radius-sm;
|
||||
transition: background-color variables.$duration-normal variables.$ease-out,
|
||||
color variables.$duration-normal variables.$ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +19,7 @@
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 9999px;
|
||||
background-color: #ef4444;
|
||||
background-color: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
@@ -26,4 +28,10 @@
|
||||
pointer-events: none;
|
||||
border: 1.5px solid variables.$bg-default;
|
||||
box-sizing: content-box;
|
||||
animation: badgePulse 2s var(--ease-out) infinite;
|
||||
}
|
||||
|
||||
@keyframes badgePulse {
|
||||
0%, 100% { transform: translate(50%, -50%) scale(1); }
|
||||
50% { transform: translate(50%, -50%) scale(1.08); }
|
||||
}
|
||||
|
||||
@@ -8,42 +8,48 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 360px;
|
||||
width: 380px;
|
||||
max-height: 480px;
|
||||
background-color: variables.$bg-surface;
|
||||
background-color: variables.$bg-default;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-md;
|
||||
box-shadow: variables.$shadow-lg;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: popupEntrance 0.2s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid variables.$border-default;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include typography.font-body-14(600);
|
||||
@include typography.font-body-14(700);
|
||||
letter-spacing: -0.006em;
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
.readAllBtn {
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: variables.$purple-500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: variables.$radius-sm;
|
||||
transition: background-color variables.$duration-normal variables.$ease-out,
|
||||
color variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&:hover {
|
||||
color: variables.$purple-700;
|
||||
background-color: variables.$bg-surface;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,10 +63,10 @@
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
transition: background-color variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: variables.$bg-hover;
|
||||
background-color: variables.$bg-surface;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
@@ -78,7 +84,7 @@
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
@include typography.font-body-14(500);
|
||||
@include typography.font-body-14(600);
|
||||
color: variables.$text-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -109,23 +115,23 @@
|
||||
padding: 1px 6px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.statusRunning {
|
||||
background-color: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
background-color: hsl(262, 50%, 94%);
|
||||
color: hsl(262, 72%, 45%);
|
||||
}
|
||||
|
||||
.statusDone {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
background-color: hsl(150, 30%, 92%);
|
||||
color: hsl(150, 50%, 30%);
|
||||
}
|
||||
|
||||
.statusFailed {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
background-color: hsl(0, 80%, 95%);
|
||||
color: hsl(0, 65%, 40%);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
@@ -141,12 +147,23 @@
|
||||
height: 100%;
|
||||
background-color: variables.$purple-500;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
transition: width 0.4s var(--ease-out);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px 16px;
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
|
||||
@keyframes popupEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ interface IProfileFormData {
|
||||
phone_number: string
|
||||
}
|
||||
|
||||
export const EditProfileForm: FunctionComponent<
|
||||
IEditProfileFormProps
|
||||
> = ({ user, className }): JSX.Element => {
|
||||
export const EditProfileForm: FunctionComponent<IEditProfileFormProps> = ({
|
||||
user,
|
||||
className,
|
||||
}): JSX.Element => {
|
||||
const dispatch = useAppDispatch()
|
||||
const [successMessage, setSuccessMessage] = useState(false)
|
||||
|
||||
@@ -78,7 +79,7 @@ export const EditProfileForm: FunctionComponent<
|
||||
/>
|
||||
<TextField
|
||||
id="email"
|
||||
label="Email"
|
||||
label="Эл. почта"
|
||||
placeholder="Ваш email"
|
||||
type="email"
|
||||
{...register("email")}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ICaptionResultStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-12);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
max-height: 60vh;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
color: var(--gray-9);
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-size: 13px;
|
||||
color: var(--gray-9);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--gray-9);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--gray-6);
|
||||
}
|
||||
|
||||
.rightActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import type { ICaptionResultStepProps } from "./CaptionResultStep.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { MediaPlayer, MediaProvider } from "@vidstack/react"
|
||||
import {
|
||||
defaultLayoutIcons,
|
||||
DefaultVideoLayout,
|
||||
} from "@vidstack/react/player/layouts/default"
|
||||
|
||||
import "@vidstack/react/player/styles/default/theme.css"
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||
|
||||
import { Download, RefreshCw } from "lucide-react"
|
||||
import { FunctionComponent, useMemo } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { Button } from "@shared/ui"
|
||||
|
||||
import styles from "./CaptionResultStep.module.scss"
|
||||
|
||||
export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
|
||||
className,
|
||||
}): JSX.Element => {
|
||||
const {
|
||||
projectId,
|
||||
captionedVideoFileId,
|
||||
captionedVideoPath,
|
||||
goToStep,
|
||||
markStepCompleted,
|
||||
setCaptionedVideoFileId,
|
||||
setCaptionedVideoPath,
|
||||
} = useWizard()
|
||||
|
||||
// Recovery: if wizard state lost the file data, look up the latest caption job
|
||||
const needsRecovery = !captionedVideoFileId && !captionedVideoPath
|
||||
const { data: jobs } = api.useQuery(
|
||||
"get",
|
||||
"/api/jobs/jobs/",
|
||||
{},
|
||||
{ enabled: needsRecovery },
|
||||
)
|
||||
|
||||
const recoveredJob = useMemo(() => {
|
||||
if (!needsRecovery || !jobs) return null
|
||||
return jobs.find(
|
||||
(j) =>
|
||||
j.project_id === projectId &&
|
||||
j.job_type === "CAPTIONS_GENERATE" &&
|
||||
j.status === "DONE" &&
|
||||
j.output_data?.file_id,
|
||||
)
|
||||
}, [needsRecovery, jobs, projectId])
|
||||
|
||||
const effectiveFileId =
|
||||
captionedVideoFileId ??
|
||||
(recoveredJob?.output_data?.file_id as string | undefined) ??
|
||||
null
|
||||
const effectivePath =
|
||||
captionedVideoPath ??
|
||||
(recoveredJob?.output_data?.output_path as string | undefined) ??
|
||||
null
|
||||
|
||||
// Persist recovered values back to wizard state
|
||||
if (recoveredJob && !captionedVideoFileId && effectiveFileId) {
|
||||
setCaptionedVideoFileId(effectiveFileId)
|
||||
}
|
||||
if (recoveredJob && !captionedVideoPath && effectivePath) {
|
||||
setCaptionedVideoPath(effectivePath)
|
||||
}
|
||||
|
||||
const { data: fileRecord } = api.useQuery(
|
||||
"get",
|
||||
"/api/files/files/{file_id}/",
|
||||
{ params: { path: { file_id: effectiveFileId ?? "" } } },
|
||||
{ enabled: !!effectiveFileId },
|
||||
)
|
||||
|
||||
const filePath = fileRecord?.path ?? effectivePath ?? ""
|
||||
|
||||
const { data: fileInfo, isLoading } = api.useQuery(
|
||||
"get",
|
||||
"/api/files/get_file/",
|
||||
{ params: { query: { file_path: filePath } } },
|
||||
{ enabled: !!filePath },
|
||||
)
|
||||
|
||||
const videoUrl = fileInfo?.file_url ?? ""
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!videoUrl) return
|
||||
const link = document.createElement("a")
|
||||
link.href = videoUrl
|
||||
link.download = fileInfo?.filename ?? "captioned-video.mp4"
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleRerender = () => {
|
||||
goToStep("caption-settings")
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
markStepCompleted("caption-result")
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cs(styles.root, className)}>
|
||||
<p className={styles.loading}>Загрузка видео...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cs(styles.root, className)} data-testid="CaptionResultStep">
|
||||
<h2 className={styles.title}>Результат</h2>
|
||||
|
||||
<div className={styles.playerWrapper}>
|
||||
{videoUrl ? (
|
||||
<MediaPlayer
|
||||
src={videoUrl}
|
||||
crossOrigin=""
|
||||
playsInline
|
||||
className={styles.player}
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
) : (
|
||||
<div className={styles.placeholder}>Видео недоступно</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fileInfo?.filename && (
|
||||
<p className={styles.filename}>{fileInfo.filename}</p>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button variant="outline" onClick={handleRerender}>
|
||||
<RefreshCw size={16} />
|
||||
Перегенерировать
|
||||
</Button>
|
||||
<div className={styles.rightActions}>
|
||||
<Button variant="outline" onClick={handleDownload}>
|
||||
<Download size={16} />
|
||||
Скачать
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleFinish}>
|
||||
Завершить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CaptionResultStep } from "./CaptionResultStep"
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ICaptionSettingsStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-12);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--gray-6);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client"
|
||||
|
||||
import type { ICaptionSettingsStepProps } from "./CaptionSettingsStep.d"
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import {
|
||||
FunctionComponent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { Button } from "@shared/ui"
|
||||
|
||||
import { PresetGrid } from "./PresetGrid"
|
||||
import { StyleEditor } from "./StyleEditor"
|
||||
import { useSubmitCaptionGenerate } from "./useSubmitCaptionGenerate"
|
||||
import styles from "./CaptionSettingsStep.module.scss"
|
||||
|
||||
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
|
||||
|
||||
const ERROR_SUBMIT = "Не удалось запустить генерацию субтитров"
|
||||
const ERROR_MISSING_DATA =
|
||||
"Для генерации субтитров необходимы видеофайл и транскрипция. Пройдите предыдущие шаги."
|
||||
const TRANSCRIPTION_ARTIFACT_TYPE = "TRANSCRIPTION_JSON"
|
||||
|
||||
export const CaptionSettingsStep: FunctionComponent<
|
||||
ICaptionSettingsStepProps
|
||||
> = ({ className }): JSX.Element => {
|
||||
const {
|
||||
projectId,
|
||||
primaryFileKey,
|
||||
transcriptionArtifactId: contextArtifactId,
|
||||
captionPresetId,
|
||||
setCaptionPresetId,
|
||||
setTranscriptionArtifactId,
|
||||
startProcessingJob,
|
||||
goBack,
|
||||
} = useWizard()
|
||||
|
||||
const { data: artifacts, isLoading: isArtifactsLoading } = api.useQuery(
|
||||
"get",
|
||||
"/api/media/artifacts/",
|
||||
{},
|
||||
{ enabled: !contextArtifactId },
|
||||
)
|
||||
|
||||
const transcriptionArtifactId = useMemo(() => {
|
||||
if (contextArtifactId) return contextArtifactId
|
||||
if (!artifacts) return null
|
||||
|
||||
const match = artifacts.find(
|
||||
(artifact) =>
|
||||
artifact.project_id === projectId &&
|
||||
artifact.artifact_type === TRANSCRIPTION_ARTIFACT_TYPE &&
|
||||
!artifact.is_deleted,
|
||||
)
|
||||
|
||||
return match?.id ?? null
|
||||
}, [artifacts, contextArtifactId, projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!transcriptionArtifactId ||
|
||||
transcriptionArtifactId === contextArtifactId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setTranscriptionArtifactId(transcriptionArtifactId)
|
||||
}, [
|
||||
contextArtifactId,
|
||||
setTranscriptionArtifactId,
|
||||
transcriptionArtifactId,
|
||||
])
|
||||
|
||||
const { data: transcriptionEntry, isLoading: isTranscriptionLoading } =
|
||||
api.useQuery(
|
||||
"get",
|
||||
"/api/transcribe/transcriptions/by-artifact/{artifact_id}/",
|
||||
{
|
||||
params: {
|
||||
path: { artifact_id: transcriptionArtifactId ?? "" },
|
||||
},
|
||||
},
|
||||
{ enabled: !!transcriptionArtifactId },
|
||||
)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"select" | "editor">("select")
|
||||
const [editingPreset, setEditingPreset] = useState<CaptionPresetRead | null>(
|
||||
null,
|
||||
)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const submitLockRef = useRef(false)
|
||||
const isResolvingSourceData = isArtifactsLoading || isTranscriptionLoading
|
||||
|
||||
const { mutate, isPending } = useSubmitCaptionGenerate({
|
||||
onSuccess: (data) => {
|
||||
if (!data?.job_id) {
|
||||
submitLockRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (data?.job_id) {
|
||||
startProcessingJob(
|
||||
data.job_id,
|
||||
"CAPTIONS_GENERATE",
|
||||
"caption-processing",
|
||||
"caption-settings",
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
submitLockRef.current = false
|
||||
setSubmitError(ERROR_SUBMIT)
|
||||
},
|
||||
})
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (submitLockRef.current || isPending) return
|
||||
|
||||
const transcriptionId = transcriptionEntry?.id
|
||||
if (!primaryFileKey || !transcriptionId) {
|
||||
setSubmitError(ERROR_MISSING_DATA)
|
||||
return
|
||||
}
|
||||
|
||||
submitLockRef.current = true
|
||||
setSubmitError(null)
|
||||
mutate({
|
||||
body: {
|
||||
video_s3_path: primaryFileKey,
|
||||
folder: "output_files",
|
||||
transcription_id: transcriptionId,
|
||||
project_id: projectId,
|
||||
preset_id: captionPresetId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (preset: CaptionPresetRead) => {
|
||||
setEditingPreset(preset)
|
||||
setActiveTab("editor")
|
||||
}
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditingPreset(null)
|
||||
setActiveTab("editor")
|
||||
}
|
||||
|
||||
const handleSaved = (presetId: string) => {
|
||||
setCaptionPresetId(presetId)
|
||||
setActiveTab("select")
|
||||
}
|
||||
|
||||
if (activeTab === "editor") {
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, className)}
|
||||
data-testid="CaptionSettingsStep"
|
||||
>
|
||||
<h2 className={styles.title}>Редактор стиля</h2>
|
||||
<StyleEditor
|
||||
initialConfig={editingPreset?.style_config}
|
||||
presetId={editingPreset?.id}
|
||||
presetName={editingPreset?.name}
|
||||
onSaved={handleSaved}
|
||||
onCancel={() => setActiveTab("select")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, className)}
|
||||
data-testid="CaptionSettingsStep"
|
||||
>
|
||||
<h2 className={styles.title}>Выбор пресета субтитров</h2>
|
||||
|
||||
<div className={styles.scrollArea}>
|
||||
<PresetGrid
|
||||
selectedPresetId={captionPresetId}
|
||||
onSelect={setCaptionPresetId}
|
||||
onEdit={handleEdit}
|
||||
onCreateNew={handleCreateNew}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submitError && <p className={styles.error}>{submitError}</p>}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button variant="outline" onClick={goBack}>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={
|
||||
!captionPresetId || isPending || isResolvingSourceData
|
||||
}
|
||||
>
|
||||
{isPending ? "Запуск..." : "Генерировать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-content: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100cqh;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid var(--gray-6);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
background: var(--gray-2);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-8);
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: var(--accent-9);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-10);
|
||||
}
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-12);
|
||||
}
|
||||
|
||||
.systemBadge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-11);
|
||||
background: var(--accent-3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.card:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--gray-3);
|
||||
color: var(--gray-11);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-5);
|
||||
color: var(--gray-12);
|
||||
}
|
||||
}
|
||||
|
||||
.createCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 100cqh;
|
||||
box-sizing: border-box;
|
||||
aspect-ratio: 9 / 16;
|
||||
border-style: dashed;
|
||||
border-color: var(--gray-7);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-8);
|
||||
}
|
||||
}
|
||||
|
||||
.createIcon {
|
||||
color: var(--gray-9);
|
||||
}
|
||||
|
||||
.createLabel {
|
||||
font-size: 13px;
|
||||
color: var(--gray-9);
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--gray-9);
|
||||
}
|
||||
|
||||
.deleteActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { FunctionComponent, useState } from "react"
|
||||
|
||||
import { Button, Modal } from "@shared/ui"
|
||||
|
||||
import { StylePreview } from "./StylePreview"
|
||||
import { useDeletePreset, usePresetsQuery } from "./useCaptionPresets"
|
||||
|
||||
import styles from "./PresetGrid.module.scss"
|
||||
|
||||
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
|
||||
|
||||
interface IPresetGridProps {
|
||||
selectedPresetId: string | null
|
||||
onSelect: (presetId: string) => void
|
||||
onEdit: (preset: CaptionPresetRead) => void
|
||||
onCreateNew: () => void
|
||||
}
|
||||
|
||||
const PresetCard: FunctionComponent<{
|
||||
preset: CaptionPresetRead
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}> = ({ preset, isSelected, onSelect, onEdit, onDelete }) => (
|
||||
<div
|
||||
className={cs(styles.card, { [styles.selected]: isSelected })}
|
||||
onClick={onSelect}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<StylePreview config={preset.style_config} size="small" />
|
||||
<div className={styles.cardFooter}>
|
||||
<span className={styles.cardName}>{preset.name}</span>
|
||||
{preset.is_system && (
|
||||
<span className={styles.systemBadge}>Системный</span>
|
||||
)}
|
||||
</div>
|
||||
{!preset.is_system && (
|
||||
<div className={styles.cardActions}>
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
title="Редактировать"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
|
||||
selectedPresetId,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onCreateNew,
|
||||
}): JSX.Element => {
|
||||
const { data: presets, isLoading } = usePresetsQuery()
|
||||
const deletePreset = useDeletePreset()
|
||||
const [deleteTarget, setDeleteTarget] = useState<CaptionPresetRead | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!deleteTarget) return
|
||||
deletePreset.mutate(
|
||||
{ params: { path: { preset_id: deleteTarget.id } } },
|
||||
{ onSettled: () => setDeleteTarget(null) },
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={styles.loading}>Загрузка пресетов...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{presets?.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id}
|
||||
onSelect={() => onSelect(preset.id)}
|
||||
onEdit={() => onEdit(preset)}
|
||||
onDelete={() => setDeleteTarget(preset)}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={cs(styles.card, styles.createCard)}
|
||||
onClick={onCreateNew}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Plus size={32} className={styles.createIcon} />
|
||||
<span className={styles.createLabel}>Создать пресет</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteTarget && (
|
||||
<Modal
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
title="Удаление пресета"
|
||||
>
|
||||
<p>
|
||||
Удалить пресет «{deleteTarget.name}»? Это
|
||||
действие нельзя отменить.
|
||||
</p>
|
||||
<div className={styles.deleteActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deletePreset.isPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nameRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nameField {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.fieldGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--gray-2);
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--gray-4);
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-6);
|
||||
}
|
||||
}
|
||||
|
||||
.sliderField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--gray-2);
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--gray-4);
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-6);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-11);
|
||||
}
|
||||
|
||||
.colorField {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
background: var(--gray-2);
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--gray-4);
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-6);
|
||||
}
|
||||
}
|
||||
|
||||
.colorSwatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.colorPopover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
padding: 16px;
|
||||
background: var(--gray-1);
|
||||
border: 1px solid var(--gray-6);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.colorClose {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-9);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-10);
|
||||
}
|
||||
}
|
||||
|
||||
.editorFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--gray-6);
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
"use client"
|
||||
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
import { FunctionComponent, useCallback, useRef, useState } from "react"
|
||||
import { HexColorPicker } from "react-colorful"
|
||||
import { Controller, useForm, useWatch } from "react-hook-form"
|
||||
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectItem,
|
||||
Slider,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TextField,
|
||||
} from "@shared/ui"
|
||||
|
||||
import { StylePreview } from "./StylePreview"
|
||||
import { useCreatePreset, useUpdatePreset } from "./useCaptionPresets"
|
||||
|
||||
import styles from "./StyleEditor.module.scss"
|
||||
|
||||
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
|
||||
|
||||
interface IStyleEditorProps {
|
||||
initialConfig?: CaptionStyleConfig | null
|
||||
presetId?: string | null
|
||||
presetName?: string
|
||||
onSaved: (presetId: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string
|
||||
text: {
|
||||
font_family: string
|
||||
font_size: number
|
||||
font_weight: number
|
||||
text_color: string
|
||||
highlight_color: string
|
||||
text_shadow: string | null
|
||||
text_stroke_width: number
|
||||
text_stroke_color: string
|
||||
}
|
||||
layout: {
|
||||
vertical_position: "top" | "center" | "bottom"
|
||||
horizontal_alignment: "left" | "center" | "right"
|
||||
padding_px: number
|
||||
max_width_pct: number
|
||||
lines_per_screen: number
|
||||
}
|
||||
animation: {
|
||||
highlight_style: "color" | "scale" | "underline" | "color_scale"
|
||||
highlight_scale: number
|
||||
segment_transition: "fade" | "slide" | "none"
|
||||
fade_duration_frames: number
|
||||
animation_speed: number
|
||||
}
|
||||
background: {
|
||||
bg_color: string
|
||||
bg_blur_px: number
|
||||
bg_glow_color: string | null
|
||||
bg_border_radius_px: number
|
||||
bg_padding_px: number
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: FormValues = {
|
||||
name: "",
|
||||
text: {
|
||||
font_family: "Lobster",
|
||||
font_size: 40,
|
||||
font_weight: 400,
|
||||
text_color: "#FFFFFF",
|
||||
highlight_color: "#FFFF00",
|
||||
text_shadow: "0 2px 4px rgba(0,0,0,0.5)" as string | null,
|
||||
text_stroke_width: 0,
|
||||
text_stroke_color: "#000000",
|
||||
},
|
||||
layout: {
|
||||
vertical_position: "bottom" as const,
|
||||
horizontal_alignment: "center" as const,
|
||||
padding_px: 16,
|
||||
max_width_pct: 90,
|
||||
lines_per_screen: 2,
|
||||
},
|
||||
animation: {
|
||||
highlight_style: "color" as const,
|
||||
highlight_scale: 1.2,
|
||||
segment_transition: "fade" as const,
|
||||
fade_duration_frames: 5,
|
||||
animation_speed: 1.0,
|
||||
},
|
||||
background: {
|
||||
bg_color: "rgba(0,0,0,0.6)",
|
||||
bg_blur_px: 0,
|
||||
bg_glow_color: null as string | null,
|
||||
bg_border_radius_px: 8,
|
||||
bg_padding_px: 12,
|
||||
},
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Color picker field */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ColorField: FunctionComponent<{
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
label: string
|
||||
}> = ({ value, onChange, label }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div className={styles.colorField} ref={ref}>
|
||||
<span className={styles.fieldLabel}>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.colorSwatch}
|
||||
style={{ backgroundColor: value || "transparent" }}
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
{open && (
|
||||
<div className={styles.colorPopover}>
|
||||
<HexColorPicker color={value} onChange={onChange} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.colorClose}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Готово
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-tab: Текст */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TextFields: FunctionComponent<{
|
||||
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||
}> = ({ control }) => (
|
||||
<div className={styles.fields}>
|
||||
<Controller
|
||||
name="text.font_family"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Шрифт</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Шрифт"
|
||||
>
|
||||
{["Lobster", "Inter", "Roboto", "Montserrat", "Open Sans"].map(
|
||||
(f) => (
|
||||
<SelectItem key={f} value={f}>
|
||||
{f}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.font_size"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Размер шрифта"
|
||||
unit="px"
|
||||
min={16}
|
||||
max={96}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.font_weight"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Начертание</span>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(v) => field.onChange(Number(v))}
|
||||
placeholder="Начертание"
|
||||
>
|
||||
<SelectItem value="400">Обычный</SelectItem>
|
||||
<SelectItem value="700">Жирный</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.text_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет текста"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.highlight_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет выделения"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.text_stroke_width"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Обводка текста"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={5}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.text_stroke_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет обводки"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-tab: Позиция */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LayoutFields: FunctionComponent<{
|
||||
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||
}> = ({ control }) => (
|
||||
<div className={styles.fields}>
|
||||
<Controller
|
||||
name="layout.vertical_position"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>
|
||||
Вертикальная позиция
|
||||
</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Позиция"
|
||||
>
|
||||
<SelectItem value="top">Сверху</SelectItem>
|
||||
<SelectItem value="center">По центру</SelectItem>
|
||||
<SelectItem value="bottom">Снизу</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="layout.horizontal_alignment"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Выравнивание</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Выравнивание"
|
||||
>
|
||||
<SelectItem value="left">Слева</SelectItem>
|
||||
<SelectItem value="center">По центру</SelectItem>
|
||||
<SelectItem value="right">Справа</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="layout.max_width_pct"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Макс. ширина"
|
||||
unit="%"
|
||||
min={20}
|
||||
max={100}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="layout.padding_px"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Отступы"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={64}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="layout.lines_per_screen"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Строк на экране"
|
||||
min={1}
|
||||
max={4}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-tab: Анимация */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const AnimationFields: FunctionComponent<{
|
||||
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||
}> = ({ control }) => (
|
||||
<div className={styles.fields}>
|
||||
<Controller
|
||||
name="animation.highlight_style"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Стиль выделения</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Стиль"
|
||||
>
|
||||
<SelectItem value="color">Цвет</SelectItem>
|
||||
<SelectItem value="scale">Масштаб</SelectItem>
|
||||
<SelectItem value="underline">Подчёркивание</SelectItem>
|
||||
<SelectItem value="color_scale">
|
||||
Цвет + масштаб
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="animation.highlight_scale"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Масштаб выделения"
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="animation.segment_transition"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Переход</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Переход"
|
||||
>
|
||||
<SelectItem value="fade">Затухание</SelectItem>
|
||||
<SelectItem value="slide">Сдвиг</SelectItem>
|
||||
<SelectItem value="none">Без перехода</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="animation.fade_duration_frames"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Длительность перехода"
|
||||
unit=" кадров"
|
||||
min={0}
|
||||
max={30}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="animation.animation_speed"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Скорость анимации"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-tab: Фон */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const BackgroundFields: FunctionComponent<{
|
||||
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||
}> = ({ control }) => (
|
||||
<div className={styles.fields}>
|
||||
<Controller
|
||||
name="background.bg_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет фона"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="background.bg_blur_px"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Размытие фона"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={20}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="background.bg_glow_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет свечения"
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="background.bg_border_radius_px"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Скругление углов"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={24}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="background.bg_padding_px"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Внутренний отступ"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={32}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main editor */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const buildDefaultValues = (
|
||||
config?: CaptionStyleConfig | null,
|
||||
name?: string,
|
||||
): FormValues => ({
|
||||
name: name ?? "",
|
||||
text: { ...DEFAULT_VALUES.text, ...config?.text },
|
||||
layout: { ...DEFAULT_VALUES.layout, ...config?.layout },
|
||||
animation: { ...DEFAULT_VALUES.animation, ...config?.animation },
|
||||
background: { ...DEFAULT_VALUES.background, ...config?.background },
|
||||
})
|
||||
|
||||
export const StyleEditor: FunctionComponent<IStyleEditorProps> = ({
|
||||
initialConfig,
|
||||
presetId,
|
||||
presetName,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}): JSX.Element => {
|
||||
const isEditing = !!presetId
|
||||
|
||||
const { control, handleSubmit, formState } = useForm<FormValues>({
|
||||
defaultValues: buildDefaultValues(initialConfig, presetName),
|
||||
})
|
||||
|
||||
const watchedValues = useWatch({ control })
|
||||
const previewConfig: CaptionStyleConfig = {
|
||||
text: watchedValues.text as CaptionStyleConfig["text"],
|
||||
layout: watchedValues.layout as CaptionStyleConfig["layout"],
|
||||
animation: watchedValues.animation as CaptionStyleConfig["animation"],
|
||||
background:
|
||||
watchedValues.background as CaptionStyleConfig["background"],
|
||||
}
|
||||
|
||||
const createPreset = useCreatePreset()
|
||||
const updatePreset = useUpdatePreset()
|
||||
const isSaving = createPreset.isPending || updatePreset.isPending
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormValues) => {
|
||||
const styleConfig: CaptionStyleConfig = {
|
||||
text: data.text,
|
||||
layout: data.layout,
|
||||
animation: data.animation,
|
||||
background: data.background,
|
||||
}
|
||||
|
||||
if (isEditing && presetId) {
|
||||
updatePreset.mutate(
|
||||
{
|
||||
params: { path: { preset_id: presetId } },
|
||||
body: {
|
||||
name: data.name || undefined,
|
||||
style_config: styleConfig,
|
||||
},
|
||||
},
|
||||
{ onSuccess: () => onSaved(presetId) },
|
||||
)
|
||||
} else {
|
||||
createPreset.mutate(
|
||||
{
|
||||
body: {
|
||||
name: data.name,
|
||||
style_config: styleConfig,
|
||||
},
|
||||
},
|
||||
{ onSuccess: (res) => onSaved(res.id) },
|
||||
)
|
||||
}
|
||||
},
|
||||
[isEditing, presetId, createPreset, updatePreset, onSaved],
|
||||
)
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.editor}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
data-testid="StyleEditor"
|
||||
>
|
||||
<StylePreview config={previewConfig} size="large" />
|
||||
|
||||
<div className={styles.nameRow}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: !isEditing }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
id="preset-name"
|
||||
placeholder="Название пресета"
|
||||
className={styles.nameField}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="text" className={styles.tabs}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="text">Текст</TabsTrigger>
|
||||
<TabsTrigger value="layout">Позиция</TabsTrigger>
|
||||
<TabsTrigger value="animation">Анимация</TabsTrigger>
|
||||
<TabsTrigger value="background">Фон</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="text">
|
||||
<TextFields control={control} />
|
||||
</TabsContent>
|
||||
<TabsContent value="layout">
|
||||
<LayoutFields control={control} />
|
||||
</TabsContent>
|
||||
<TabsContent value="animation">
|
||||
<AnimationFields control={control} />
|
||||
</TabsContent>
|
||||
<TabsContent value="background">
|
||||
<BackgroundFields control={control} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className={styles.editorFooter}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isSaving || (!isEditing && !formState.dirtyFields.name)}
|
||||
>
|
||||
{isSaving
|
||||
? "Сохранение..."
|
||||
: isEditing
|
||||
? "Сохранить"
|
||||
: "Создать пресет"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0c0a1a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.small {
|
||||
--preview-h: calc(100cqh - 38px);
|
||||
height: var(--preview-h);
|
||||
width: calc(var(--preview-h) * 9 / 16);
|
||||
}
|
||||
|
||||
.large {
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 400px;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import styles from "./StylePreview.module.scss"
|
||||
|
||||
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
|
||||
|
||||
interface IStylePreviewProps {
|
||||
config?: CaptionStyleConfig | null
|
||||
size?: "small" | "large"
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SMALL_SCALE = 0.65
|
||||
|
||||
const buildContainerStyles = (
|
||||
config: CaptionStyleConfig,
|
||||
scale: number,
|
||||
): React.CSSProperties => {
|
||||
const bg = config.background
|
||||
return {
|
||||
backgroundColor: bg?.bg_color ?? "rgba(0,0,0,0.6)",
|
||||
borderRadius: (bg?.bg_border_radius_px ?? 8) * scale,
|
||||
padding: (bg?.bg_padding_px ?? 12) * scale,
|
||||
...(bg?.bg_blur_px
|
||||
? { backdropFilter: `blur(${bg.bg_blur_px * scale}px)` }
|
||||
: {}),
|
||||
...(bg?.bg_glow_color
|
||||
? { boxShadow: `0 0 ${20 * scale}px ${bg.bg_glow_color}` }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
const buildTextStyles = (
|
||||
config: CaptionStyleConfig,
|
||||
scale: number,
|
||||
): React.CSSProperties => {
|
||||
const text = config.text
|
||||
return {
|
||||
fontFamily: text?.font_family ?? "Lobster",
|
||||
fontSize: (text?.font_size ?? 40) * scale,
|
||||
fontWeight: text?.font_weight ?? 400,
|
||||
color: text?.text_color ?? "#FFFFFF",
|
||||
textAlign:
|
||||
(config.layout?.horizontal_alignment as "left" | "center" | "right") ??
|
||||
"center",
|
||||
...(text?.text_shadow ? { textShadow: text.text_shadow } : {}),
|
||||
...(text?.text_stroke_width
|
||||
? {
|
||||
WebkitTextStroke: `${(text.text_stroke_width ?? 0) * scale}px ${text.text_stroke_color ?? "#000000"}`,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
const VERTICAL_MAP: Record<string, string> = {
|
||||
top: "flex-start",
|
||||
center: "center",
|
||||
bottom: "flex-end",
|
||||
}
|
||||
|
||||
const HORIZONTAL_MAP: Record<string, string> = {
|
||||
left: "flex-start",
|
||||
center: "center",
|
||||
right: "flex-end",
|
||||
}
|
||||
|
||||
const buildPositionStyles = (
|
||||
config: CaptionStyleConfig,
|
||||
scale: number,
|
||||
): React.CSSProperties => {
|
||||
const layout = config.layout
|
||||
const vPos = layout?.vertical_position ?? "bottom"
|
||||
const hAlign = layout?.horizontal_alignment ?? "center"
|
||||
const padding = (layout?.padding_px ?? 20) * scale
|
||||
|
||||
return {
|
||||
justifyContent: VERTICAL_MAP[vPos] ?? "flex-end",
|
||||
alignItems: HORIZONTAL_MAP[hAlign] ?? "center",
|
||||
padding,
|
||||
}
|
||||
}
|
||||
|
||||
export const StylePreview: FunctionComponent<IStylePreviewProps> = ({
|
||||
config,
|
||||
size = "small",
|
||||
className,
|
||||
}): JSX.Element => {
|
||||
const safeConfig = config ?? {}
|
||||
const highlightColor = safeConfig.text?.highlight_color ?? "#FFFF00"
|
||||
const scale = size === "small" ? SMALL_SCALE : 1
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, styles[size], className)}
|
||||
style={buildPositionStyles(safeConfig, scale)}
|
||||
data-testid="StylePreview"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...buildContainerStyles(safeConfig, scale),
|
||||
maxWidth: "100%",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...buildTextStyles(safeConfig, scale),
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
Пример <span style={{ color: highlightColor }}>субтитров</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CaptionSettingsStep } from "./CaptionSettingsStep"
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import api from "@shared/api"
|
||||
|
||||
const PRESETS_QUERY_KEY = ["get", "/api/captions/presets/"]
|
||||
|
||||
export const usePresetsQuery = () => {
|
||||
return api.useQuery("get", "/api/captions/presets/", {})
|
||||
}
|
||||
|
||||
export const useCreatePreset = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return api.useMutation("post", "/api/captions/presets/", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdatePreset = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return api.useMutation("patch", "/api/captions/presets/{preset_id}/", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeletePreset = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return api.useMutation("delete", "/api/captions/presets/{preset_id}/", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import api from "@shared/api"
|
||||
|
||||
interface IUseSubmitCaptionGenerateParams {
|
||||
onSuccess?: (data: { job_id: string }) => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const useSubmitCaptionGenerate = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IUseSubmitCaptionGenerateParams = {}) => {
|
||||
return api.useMutation("post", "/api/tasks/captions-generate/", {
|
||||
onSuccess: (data) => {
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { ComponentProps } from "react"
|
||||
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
|
||||
|
||||
export interface ICreateProjectModalProps extends Pick<
|
||||
ComponentProps<typeof Dialog.Root>,
|
||||
IModalProps,
|
||||
"open" | "onOpenChange"
|
||||
> {
|
||||
onCreated?: () => void | Promise<void>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.root {
|
||||
min-width: 520px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fields {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { ComponentProps } from "react"
|
||||
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
|
||||
|
||||
export interface IDeleteFileModalProps
|
||||
extends Pick<ComponentProps<typeof Dialog.Root>, "open" | "onOpenChange"> {
|
||||
extends Pick<IModalProps, "open" | "onOpenChange"> {
|
||||
fileName: string
|
||||
onConfirm: () => void
|
||||
isPending: boolean
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.root {
|
||||
min-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
export interface IDeleteProjectModalProps extends Pick<
|
||||
ComponentProps<typeof Dialog.Root>,
|
||||
IModalProps,
|
||||
"open" | "onOpenChange"
|
||||
> {
|
||||
project: components["schemas"]["ProjectRead"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.root {
|
||||
min-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
export interface IEditProjectModalProps extends Pick<
|
||||
ComponentProps<typeof Dialog.Root>,
|
||||
IModalProps,
|
||||
"open" | "onOpenChange"
|
||||
> {
|
||||
project: components["schemas"]["ProjectRead"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.root {
|
||||
min-width: 520px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fields {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface IFragmentsStepProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface CutRegion {
|
||||
id: string
|
||||
startMs: number
|
||||
endMs: number
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 16px 24px 0;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: variables.$radius-md;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
|
||||
:global([data-media-player]) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
:global(.vds-video-layout) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.timelineSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoomControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: variables.$text-secondary;
|
||||
}
|
||||
|
||||
.zoomButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: variables.$bg-default;
|
||||
color: variables.$text-primary;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: variables.$bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.timelineContainer {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-md;
|
||||
background: variables.$bg-surface;
|
||||
}
|
||||
|
||||
.timelineInner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rulerRow {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
}
|
||||
|
||||
.rulerCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.framesRow {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.framesCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.waveformRow {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
}
|
||||
|
||||
.cutRegionsRow {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.infoBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: variables.$text-secondary;
|
||||
}
|
||||
|
||||
.infoTotal {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// --- Cut region blocks ---
|
||||
|
||||
.cutRegion {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: rgba(255, 152, 0, 0.3);
|
||||
border: 1px solid rgba(255, 152, 0, 0.7);
|
||||
border-radius: 2px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.handleLeft,
|
||||
.handleRight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.handleLeft {
|
||||
left: -3px;
|
||||
}
|
||||
|
||||
.handleRight {
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
.contextMenu {
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
background: variables.$bg-surface;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-md;
|
||||
box-shadow: variables.$shadow-md;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.contextMenuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: none;
|
||||
color: variables.$text-primary;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: variables.$bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.contextMenuDanger {
|
||||
color: variables.$color-danger;
|
||||
}
|
||||
|
||||
// --- Playhead ---
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: variables.$color-danger;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid variables.$border-subtle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
"use client"
|
||||
|
||||
import type { CutRegion, IFragmentsStepProps } from "./FragmentsStep.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { MediaPlayer, MediaProvider } from "@vidstack/react"
|
||||
import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default"
|
||||
import "@vidstack/react/player/styles/default/theme.css"
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||
import cs from "classnames"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import WaveSurfer from "wavesurfer.js"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { useSegmentResize } from "@shared/hooks/useSegmentResize"
|
||||
import { Button } from "@shared/ui"
|
||||
|
||||
import { useSubmitSilenceApply } from "../SilenceResultModal/useSubmitSilenceApply"
|
||||
|
||||
import styles from "./FragmentsStep.module.scss"
|
||||
|
||||
const MIN_REGION_MS = 100
|
||||
const DEFAULT_NEW_REGION_MS = 1000
|
||||
const DEFAULT_PPS = 10
|
||||
const MIN_PPS = 2
|
||||
const MAX_PPS = 200
|
||||
const PPS_STEP = 2
|
||||
const FRAMES_HEIGHT = 48
|
||||
const WAVEFORM_HEIGHT = 48
|
||||
const RULER_HEIGHT = 24
|
||||
const MAX_EXTRACTED_FRAMES = 150
|
||||
const CANVAS_OVERSCAN = 300
|
||||
|
||||
let regionIdCounter = 0
|
||||
const nextRegionId = (): string => `region_${++regionIdCounter}`
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const min = Math.floor(totalSec / 60)
|
||||
const sec = totalSec % 60
|
||||
if (min > 0) return `${min}м ${sec}с`
|
||||
return `${sec}с`
|
||||
}
|
||||
|
||||
function resolveWaveformColors(): { wave: string; progress: string } {
|
||||
const root = getComputedStyle(document.documentElement)
|
||||
return {
|
||||
wave:
|
||||
root.getPropertyValue("--waveform-wave").trim() ||
|
||||
"hsl(297, 70%, 44%)",
|
||||
progress:
|
||||
root.getPropertyValue("--waveform-progress").trim() ||
|
||||
"hsl(293, 100%, 34%)",
|
||||
}
|
||||
}
|
||||
|
||||
export const FragmentsStep: FunctionComponent<IFragmentsStepProps> = ({
|
||||
className,
|
||||
}): JSX.Element => {
|
||||
const {
|
||||
projectId,
|
||||
silenceJobId,
|
||||
primaryFileKey,
|
||||
startProcessingJob,
|
||||
goBack,
|
||||
markStepCompleted,
|
||||
goToStep,
|
||||
} = useWizard()
|
||||
|
||||
const [cutRegions, setCutRegions] = useState<CutRegion[]>([])
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PPS)
|
||||
const [durationMs, setDurationMs] = useState(0)
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
regionId: string | null
|
||||
timeMs: number
|
||||
} | null>(null)
|
||||
const timelineRef = useRef<HTMLDivElement>(null)
|
||||
const playerRef = useRef<any>(null)
|
||||
const waveformRef = useRef<HTMLDivElement>(null)
|
||||
const wsRef = useRef<WaveSurfer | null>(null)
|
||||
|
||||
/* ---- Data loading ---- */
|
||||
const { data: taskStatus } = api.useQuery(
|
||||
"get",
|
||||
"/api/tasks/status/{job_id}/",
|
||||
{ params: { path: { job_id: silenceJobId ?? "" } } },
|
||||
{ enabled: !!silenceJobId },
|
||||
)
|
||||
|
||||
const outputData = taskStatus?.output_data as Record<string, unknown> | null
|
||||
const fileKey = primaryFileKey ?? ((outputData?.file_key as string) ?? "")
|
||||
|
||||
const { data: fileInfo } = api.useQuery(
|
||||
"get",
|
||||
"/api/files/get_file/",
|
||||
{ params: { query: { file_path: fileKey } } },
|
||||
{ enabled: !!fileKey },
|
||||
)
|
||||
|
||||
const videoUrl = fileInfo?.file_url ?? null
|
||||
|
||||
/* ---- Initialize cut regions from detection results ---- */
|
||||
useEffect(() => {
|
||||
if (!outputData) return
|
||||
const segments = outputData.silent_segments as
|
||||
| { start_ms: number; end_ms: number }[]
|
||||
| undefined
|
||||
const dur = outputData.duration_ms as number | undefined
|
||||
|
||||
if (segments && dur) {
|
||||
setDurationMs(dur)
|
||||
setCutRegions(
|
||||
segments.map((s) => ({
|
||||
id: nextRegionId(),
|
||||
startMs: s.start_ms,
|
||||
endMs: s.end_ms,
|
||||
})),
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [outputData])
|
||||
|
||||
/* ---- Timeline calculations ---- */
|
||||
const totalWidth = Math.max(1, (durationMs / 1000) * pixelsPerSecond)
|
||||
|
||||
const msToPixels = useCallback(
|
||||
(ms: number) => (ms / 1000) * pixelsPerSecond,
|
||||
[pixelsPerSecond],
|
||||
)
|
||||
|
||||
const pixelsToMs = useCallback(
|
||||
(px: number) => (px / pixelsPerSecond) * 1000,
|
||||
[pixelsPerSecond],
|
||||
)
|
||||
|
||||
const totalRemovedMs = useMemo(
|
||||
() => cutRegions.reduce((sum, r) => sum + (r.endMs - r.startMs), 0),
|
||||
[cutRegions],
|
||||
)
|
||||
|
||||
/* ---- Region mutations ---- */
|
||||
const addRegion = useCallback(
|
||||
(atMs: number) => {
|
||||
const startMs = Math.max(0, atMs - DEFAULT_NEW_REGION_MS / 2)
|
||||
const endMs = Math.min(durationMs, startMs + DEFAULT_NEW_REGION_MS)
|
||||
setCutRegions((prev) =>
|
||||
[...prev, { id: nextRegionId(), startMs, endMs }].sort(
|
||||
(a, b) => a.startMs - b.startMs,
|
||||
),
|
||||
)
|
||||
},
|
||||
[durationMs],
|
||||
)
|
||||
|
||||
const removeRegion = useCallback((regionId: string) => {
|
||||
setCutRegions((prev) => prev.filter((r) => r.id !== regionId))
|
||||
}, [])
|
||||
|
||||
/* ---- Resize handling ---- */
|
||||
const { handlePointerDown: handleResizePointerDown } = useSegmentResize({
|
||||
pixelsPerSecond,
|
||||
onResize: (index, edge, deltaSec) => {
|
||||
setCutRegions((prev) => {
|
||||
const updated = [...prev]
|
||||
const region = { ...updated[index] }
|
||||
const deltaMs = deltaSec * 1000
|
||||
|
||||
if (edge === "left") {
|
||||
region.startMs = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
region.endMs - MIN_REGION_MS,
|
||||
region.startMs + deltaMs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
region.endMs = Math.min(
|
||||
durationMs,
|
||||
Math.max(
|
||||
region.startMs + MIN_REGION_MS,
|
||||
region.endMs + deltaMs,
|
||||
),
|
||||
)
|
||||
}
|
||||
updated[index] = region
|
||||
return updated
|
||||
})
|
||||
},
|
||||
onResizeEnd: () => {},
|
||||
})
|
||||
|
||||
/* ---- Drag-to-move handling ---- */
|
||||
const handleRegionDragStart = useCallback(
|
||||
(e: React.PointerEvent, index: number) => {
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const region = cutRegions[index]
|
||||
const regionDuration = region.endMs - region.startMs
|
||||
|
||||
const onMove = (moveE: PointerEvent) => {
|
||||
const dx = moveE.clientX - startX
|
||||
const deltaMs = pixelsToMs(dx)
|
||||
let newStart = region.startMs + deltaMs
|
||||
newStart = Math.max(
|
||||
0,
|
||||
Math.min(durationMs - regionDuration, newStart),
|
||||
)
|
||||
|
||||
setCutRegions((prev) => {
|
||||
const updated = [...prev]
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
startMs: Math.round(newStart),
|
||||
endMs: Math.round(newStart + regionDuration),
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("pointermove", onMove)
|
||||
document.removeEventListener("pointerup", onUp)
|
||||
}
|
||||
|
||||
document.addEventListener("pointermove", onMove)
|
||||
document.addEventListener("pointerup", onUp)
|
||||
},
|
||||
[cutRegions, durationMs, pixelsToMs],
|
||||
)
|
||||
|
||||
/* ---- Context menu ---- */
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent, regionId: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const rect = timelineRef.current?.getBoundingClientRect()
|
||||
const scrollLeft = timelineRef.current?.scrollLeft ?? 0
|
||||
const x = e.clientX - (rect?.left ?? 0) + scrollLeft
|
||||
const timeMs = pixelsToMs(x)
|
||||
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, regionId, timeMs })
|
||||
},
|
||||
[pixelsToMs],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const close = () => setContextMenu(null)
|
||||
document.addEventListener("click", close)
|
||||
return () => document.removeEventListener("click", close)
|
||||
}, [contextMenu])
|
||||
|
||||
/* ---- Timeline click to seek ---- */
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const rect = timelineRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const scrollLeft = timelineRef.current?.scrollLeft ?? 0
|
||||
const x = e.clientX - rect.left + scrollLeft
|
||||
const timeSec = pixelsToMs(x) / 1000
|
||||
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime = timeSec
|
||||
}
|
||||
},
|
||||
[pixelsToMs],
|
||||
)
|
||||
|
||||
/* ---- Canvas: ruler ---- */
|
||||
const rulerRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
const drawRuler = useCallback(() => {
|
||||
const container = timelineRef.current
|
||||
const canvas = rulerRef.current
|
||||
if (!container || !canvas || !durationMs) return
|
||||
|
||||
const sl = container.scrollLeft
|
||||
const vw = container.clientWidth
|
||||
if (!vw) return
|
||||
const canvasW = Math.min(vw + CANVAS_OVERSCAN * 2, totalWidth)
|
||||
const offset = Math.max(
|
||||
0,
|
||||
Math.min(sl - CANVAS_OVERSCAN, totalWidth - canvasW),
|
||||
)
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = canvasW * dpr
|
||||
canvas.height = RULER_HEIGHT * dpr
|
||||
canvas.style.width = `${canvasW}px`
|
||||
canvas.style.height = `${RULER_HEIGHT}px`
|
||||
canvas.style.transform = `translateX(${offset}px)`
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, canvasW, RULER_HEIGHT)
|
||||
|
||||
const rootStyles = getComputedStyle(document.documentElement)
|
||||
const textColor =
|
||||
rootStyles.getPropertyValue("--text-secondary").trim() || "#888"
|
||||
const lineColor =
|
||||
rootStyles.getPropertyValue("--border-subtle").trim() || "#444"
|
||||
|
||||
ctx.strokeStyle = lineColor
|
||||
ctx.fillStyle = textColor
|
||||
ctx.font = "10px monospace"
|
||||
ctx.textAlign = "center"
|
||||
|
||||
const totalSec = durationMs / 1000
|
||||
let tickInterval = 1
|
||||
if (pixelsPerSecond < 5) tickInterval = 30
|
||||
else if (pixelsPerSecond < 10) tickInterval = 15
|
||||
else if (pixelsPerSecond < 20) tickInterval = 10
|
||||
else if (pixelsPerSecond < 50) tickInterval = 5
|
||||
else if (pixelsPerSecond < 150) tickInterval = 1
|
||||
else tickInterval = 0.5
|
||||
|
||||
const majorMultiple = tickInterval >= 1 ? 5 : 1
|
||||
const startSec =
|
||||
Math.floor(offset / pixelsPerSecond / tickInterval) * tickInterval
|
||||
const endSec = Math.min(
|
||||
totalSec,
|
||||
(offset + canvasW) / pixelsPerSecond,
|
||||
)
|
||||
|
||||
for (let sec = startSec; sec <= endSec; sec += tickInterval) {
|
||||
const x = sec * pixelsPerSecond - offset
|
||||
if (x < -20 || x > canvasW + 20) continue
|
||||
const isMajor =
|
||||
Math.round(sec / tickInterval) % majorMultiple === 0
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, isMajor ? 0 : 14)
|
||||
ctx.lineTo(x, RULER_HEIGHT)
|
||||
ctx.stroke()
|
||||
|
||||
if (isMajor) {
|
||||
const min = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
const label = `${min}:${s.toString().padStart(2, "0")}`
|
||||
const labelW = ctx.measureText(label).width
|
||||
const tx = Math.max(labelW / 2, x)
|
||||
ctx.fillText(label, tx, 10)
|
||||
}
|
||||
}
|
||||
}, [durationMs, pixelsPerSecond, totalWidth])
|
||||
|
||||
/* ---- WaveSurfer ---- */
|
||||
useEffect(() => {
|
||||
if (!videoUrl || !waveformRef.current || !durationMs) return
|
||||
|
||||
const durationSec = durationMs / 1000
|
||||
const colors = resolveWaveformColors()
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: waveformRef.current,
|
||||
url: videoUrl,
|
||||
duration: durationSec,
|
||||
height: WAVEFORM_HEIGHT,
|
||||
waveColor: colors.wave,
|
||||
progressColor: colors.progress,
|
||||
cursorWidth: 0,
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
normalize: true,
|
||||
interact: false,
|
||||
minPxPerSec: pixelsPerSecond,
|
||||
hideScrollbar: true,
|
||||
fillParent: false,
|
||||
autoCenter: false,
|
||||
autoScroll: false,
|
||||
dragToSeek: false,
|
||||
mediaControls: false,
|
||||
backend: "MediaElement",
|
||||
})
|
||||
|
||||
ws.setVolume(0)
|
||||
wsRef.current = ws
|
||||
|
||||
return () => {
|
||||
ws.destroy()
|
||||
wsRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoUrl, durationMs])
|
||||
|
||||
useEffect(() => {
|
||||
const ws = wsRef.current
|
||||
if (!ws) return
|
||||
try {
|
||||
ws.zoom(pixelsPerSecond)
|
||||
} catch {
|
||||
// WaveSurfer might not be ready yet
|
||||
}
|
||||
}, [pixelsPerSecond])
|
||||
|
||||
/* ---- Video frames extraction ---- */
|
||||
const framesCanvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const framesCacheRef = useRef<{ timeSec: number; bitmap: ImageBitmap }[]>(
|
||||
[],
|
||||
)
|
||||
const [framesReady, setFramesReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl || !durationMs) return
|
||||
|
||||
framesCacheRef.current.forEach((f) => f.bitmap.close())
|
||||
framesCacheRef.current = []
|
||||
setFramesReady(false)
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const video = document.createElement("video")
|
||||
video.crossOrigin = "anonymous"
|
||||
video.muted = true
|
||||
video.preload = "auto"
|
||||
video.src = videoUrl
|
||||
|
||||
const extract = async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadedmetadata = () => resolve()
|
||||
video.onerror = () => reject(new Error("video load error"))
|
||||
if (video.readyState >= 1) resolve()
|
||||
})
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
const durationSec = durationMs / 1000
|
||||
const frameCount = Math.min(
|
||||
Math.ceil(durationSec / 2),
|
||||
MAX_EXTRACTED_FRAMES,
|
||||
)
|
||||
const interval = durationSec / frameCount
|
||||
const aspect = video.videoWidth / video.videoHeight || 16 / 9
|
||||
const frameW = Math.round(FRAMES_HEIGHT * aspect)
|
||||
|
||||
const offCanvas = document.createElement("canvas")
|
||||
offCanvas.width = frameW * 2
|
||||
offCanvas.height = FRAMES_HEIGHT * 2
|
||||
const offCtx = offCanvas.getContext("2d")!
|
||||
|
||||
const cache: { timeSec: number; bitmap: ImageBitmap }[] = []
|
||||
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
if (cancelled) return
|
||||
|
||||
const timeSec = i * interval
|
||||
video.currentTime = timeSec
|
||||
await new Promise<void>((r) => {
|
||||
video.onseeked = () => r()
|
||||
})
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
offCtx.drawImage(
|
||||
video,
|
||||
0,
|
||||
0,
|
||||
offCanvas.width,
|
||||
offCanvas.height,
|
||||
)
|
||||
const bitmap = await createImageBitmap(offCanvas)
|
||||
cache.push({ timeSec, bitmap })
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
framesCacheRef.current = cache
|
||||
setFramesReady(true)
|
||||
}
|
||||
}
|
||||
|
||||
extract().catch(() => {})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
video.src = ""
|
||||
video.load()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoUrl, durationMs])
|
||||
|
||||
const drawFrames = useCallback(() => {
|
||||
const container = timelineRef.current
|
||||
const canvas = framesCanvasRef.current
|
||||
if (!container || !canvas || !framesReady) return
|
||||
|
||||
const cache = framesCacheRef.current
|
||||
if (cache.length === 0) return
|
||||
|
||||
const sl = container.scrollLeft
|
||||
const vw = container.clientWidth
|
||||
if (!vw) return
|
||||
const canvasW = Math.min(vw + CANVAS_OVERSCAN * 2, totalWidth)
|
||||
const offset = Math.max(
|
||||
0,
|
||||
Math.min(sl - CANVAS_OVERSCAN, totalWidth - canvasW),
|
||||
)
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = canvasW * dpr
|
||||
canvas.height = FRAMES_HEIGHT * dpr
|
||||
canvas.style.width = `${canvasW}px`
|
||||
canvas.style.height = `${FRAMES_HEIGHT}px`
|
||||
canvas.style.transform = `translateX(${offset}px)`
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.fillStyle = "#111"
|
||||
ctx.fillRect(0, 0, canvasW, FRAMES_HEIGHT)
|
||||
|
||||
const firstBitmap = cache[0].bitmap
|
||||
const aspect = firstBitmap.width / firstBitmap.height || 16 / 9
|
||||
const naturalW = Math.round(FRAMES_HEIGHT * aspect)
|
||||
const step = Math.max(
|
||||
1,
|
||||
Math.ceil(cache.length / Math.max(1, Math.floor(totalWidth / naturalW))),
|
||||
)
|
||||
|
||||
for (let i = 0; i < cache.length; i += step) {
|
||||
const globalX = cache[i].timeSec * pixelsPerSecond
|
||||
const nextIdx = Math.min(i + step, cache.length - 1)
|
||||
const nextGlobalX =
|
||||
nextIdx > i
|
||||
? cache[nextIdx].timeSec * pixelsPerSecond
|
||||
: totalWidth
|
||||
|
||||
if (nextGlobalX < offset) continue
|
||||
if (globalX > offset + canvasW) break
|
||||
|
||||
const x = globalX - offset
|
||||
const tileW = Math.max(naturalW, nextGlobalX - globalX)
|
||||
ctx.drawImage(cache[i].bitmap, x, 0, tileW, FRAMES_HEIGHT)
|
||||
}
|
||||
}, [framesReady, pixelsPerSecond, totalWidth])
|
||||
|
||||
/* ---- Animation loop: playhead sync + canvas redraw ---- */
|
||||
const [playheadMs, setPlayheadMs] = useState(0)
|
||||
const animRef = useRef<number>(0)
|
||||
const lastScrollRef = useRef(-1)
|
||||
const lastViewportRef = useRef(-1)
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
if (playerRef.current) {
|
||||
const timeMs = playerRef.current.currentTime * 1000
|
||||
setPlayheadMs(timeMs)
|
||||
|
||||
const ws = wsRef.current
|
||||
if (ws) {
|
||||
try {
|
||||
ws.setTime(playerRef.current.currentTime)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const container = timelineRef.current
|
||||
if (container) {
|
||||
const sl = container.scrollLeft
|
||||
const vw = container.clientWidth
|
||||
if (
|
||||
sl !== lastScrollRef.current ||
|
||||
vw !== lastViewportRef.current
|
||||
) {
|
||||
lastScrollRef.current = sl
|
||||
lastViewportRef.current = vw
|
||||
drawRuler()
|
||||
drawFrames()
|
||||
}
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
animRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
if (animRef.current) cancelAnimationFrame(animRef.current)
|
||||
lastScrollRef.current = -1
|
||||
lastViewportRef.current = -1
|
||||
}
|
||||
}, [drawRuler, drawFrames])
|
||||
|
||||
/* ---- Apply ---- */
|
||||
const { mutate: applyMutate, isPending: isApplying } =
|
||||
useSubmitSilenceApply({
|
||||
onSuccess: (data) => {
|
||||
const result = data as { job_id?: string }
|
||||
if (result?.job_id) {
|
||||
startProcessingJob(
|
||||
result.job_id,
|
||||
"SILENCE_APPLY",
|
||||
"silence-apply-processing",
|
||||
"fragments",
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Silence apply failed:", error)
|
||||
},
|
||||
})
|
||||
|
||||
const handleApply = () => {
|
||||
if (cutRegions.length === 0) {
|
||||
markStepCompleted("fragments")
|
||||
goToStep("transcription-settings")
|
||||
return
|
||||
}
|
||||
|
||||
if (!fileKey) return
|
||||
|
||||
const fileName = fileKey.split("/").pop() ?? "video.mp4"
|
||||
const outputName = `Без тишины ${fileName}`
|
||||
|
||||
;(applyMutate as (args: { body: Record<string, unknown> }) => void)({
|
||||
body: {
|
||||
file_key: fileKey,
|
||||
out_folder: "",
|
||||
project_id: projectId,
|
||||
output_name: outputName,
|
||||
cuts: cutRegions.map((r) => ({
|
||||
start_ms: Math.round(r.startMs),
|
||||
end_ms: Math.round(r.endMs),
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, className)}
|
||||
data-testid="FragmentsStep"
|
||||
>
|
||||
{/* Video player */}
|
||||
<div className={styles.playerWrapper}>
|
||||
{videoUrl && (
|
||||
<MediaPlayer
|
||||
ref={playerRef}
|
||||
src={videoUrl}
|
||||
crossOrigin=""
|
||||
playsInline
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline section */}
|
||||
<div className={styles.timelineSection}>
|
||||
<div className={styles.zoomControls}>
|
||||
<button
|
||||
className={styles.zoomButton}
|
||||
onClick={() =>
|
||||
setPixelsPerSecond((p) =>
|
||||
Math.max(MIN_PPS, p - PPS_STEP),
|
||||
)
|
||||
}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span>Масштаб</span>
|
||||
<button
|
||||
className={styles.zoomButton}
|
||||
onClick={() =>
|
||||
setPixelsPerSecond((p) =>
|
||||
Math.min(MAX_PPS, p + PPS_STEP),
|
||||
)
|
||||
}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className={styles.timelineContainer}
|
||||
onClick={handleTimelineClick}
|
||||
onContextMenu={(e) => handleContextMenu(e, null)}
|
||||
>
|
||||
<div
|
||||
className={styles.timelineInner}
|
||||
style={{ width: `${totalWidth}px` }}
|
||||
>
|
||||
<div className={styles.rulerRow}>
|
||||
<canvas
|
||||
ref={rulerRef}
|
||||
className={styles.rulerCanvas}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.framesRow}>
|
||||
<canvas
|
||||
ref={framesCanvasRef}
|
||||
className={styles.framesCanvas}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.waveformRow}>
|
||||
<div
|
||||
ref={waveformRef}
|
||||
style={{ height: WAVEFORM_HEIGHT }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.cutRegionsRow}>
|
||||
{cutRegions.map((region, index) => {
|
||||
const left = msToPixels(region.startMs)
|
||||
const width = msToPixels(
|
||||
region.endMs - region.startMs,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={region.id}
|
||||
data-testid="cut-region"
|
||||
className={styles.cutRegion}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button === 0) {
|
||||
handleRegionDragStart(e, index)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(e, region.id)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles.handleLeft}
|
||||
onPointerDown={(e) =>
|
||||
handleResizePointerDown(
|
||||
e,
|
||||
index,
|
||||
"left",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={styles.handleRight}
|
||||
onPointerDown={(e) =>
|
||||
handleResizePointerDown(
|
||||
e,
|
||||
index,
|
||||
"right",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div
|
||||
className={styles.playhead}
|
||||
style={{
|
||||
left: `${msToPixels(playheadMs)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className={styles.infoBar}>
|
||||
<span>Фрагментов: {cutRegions.length}</span>
|
||||
<span className={styles.infoTotal}>
|
||||
Будет удалено: {formatDuration(totalRemovedMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className={styles.contextMenu}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contextMenu.regionId && (
|
||||
<button
|
||||
className={cs(
|
||||
styles.contextMenuItem,
|
||||
styles.contextMenuDanger,
|
||||
)}
|
||||
onClick={() => {
|
||||
removeRegion(contextMenu.regionId!)
|
||||
setContextMenu(null)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Удалить</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={styles.contextMenuItem}
|
||||
onClick={() => {
|
||||
addRegion(contextMenu.timeMs)
|
||||
setContextMenu(null)
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Добавить новый</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isApplying}
|
||||
onClick={goBack}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isApplying}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{cutRegions.length === 0 ? "Пропустить" : "Применить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FragmentsStep"
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IProcessingStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progressWrapper {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.circleBg {
|
||||
stroke: variables.$border-subtle;
|
||||
}
|
||||
|
||||
.circleValue {
|
||||
transition: stroke-dashoffset 0.4s ease;
|
||||
}
|
||||
|
||||
.progressInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-weight: 700;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
color: variables.$text-primary;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: variables.$text-tertiary;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.description {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.descriptionError {
|
||||
color: variables.$color-danger;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: variables.$bg-hover;
|
||||
border-radius: variables.$radius-md;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: variables.$text-secondary;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
color: variables.$text-tertiary;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import type { IProcessingStepProps } from "./ProcessingStep.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
import { Info } from "lucide-react"
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||
import { Button, CircularProgress } from "@shared/ui"
|
||||
|
||||
import {
|
||||
buildCancelJobPayload,
|
||||
useCancelJob,
|
||||
} from "../useCancelJob"
|
||||
|
||||
import styles from "./ProcessingStep.module.scss"
|
||||
|
||||
const JOB_TYPE_LABELS: Record<string, string> = {
|
||||
SILENCE_DETECT: "АНАЛИЗ",
|
||||
SILENCE_APPLY: "ПРИМЕНЕНИЕ ВЫРЕЗОК",
|
||||
TRANSCRIPTION_GENERATE: "ТРАНСКРИБАЦИЯ",
|
||||
CAPTIONS_GENERATE: "ГЕНЕРАЦИЯ СУБТИТРОВ",
|
||||
}
|
||||
|
||||
const JOB_TYPE_BACK_STEP_MAP = {
|
||||
SILENCE_APPLY: "fragments",
|
||||
} as const
|
||||
|
||||
export const ProcessingStep: FunctionComponent<IProcessingStepProps> = ({
|
||||
className,
|
||||
}): JSX.Element => {
|
||||
const { activeJobId, activeJobType, setActiveJob, goBack, goToStep } =
|
||||
useWizard()
|
||||
const { mutate: cancelJob, isPending: isCancelling } = useCancelJob()
|
||||
|
||||
const navigateBack = () => {
|
||||
const targetStep = activeJobType
|
||||
? JOB_TYPE_BACK_STEP_MAP[
|
||||
activeJobType as keyof typeof JOB_TYPE_BACK_STEP_MAP
|
||||
]
|
||||
: null
|
||||
|
||||
if (targetStep) {
|
||||
goToStep(targetStep)
|
||||
return
|
||||
}
|
||||
|
||||
goBack()
|
||||
}
|
||||
|
||||
const notification = useAppSelector((state) =>
|
||||
activeJobId
|
||||
? state.notifications.items.find(
|
||||
(n) => n.job_id === activeJobId,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
|
||||
const progressPct = notification?.progress_pct ?? 0
|
||||
const statusLabel = activeJobType
|
||||
? (JOB_TYPE_LABELS[activeJobType] ?? "ОБРАБОТКА")
|
||||
: "ОБРАБОТКА"
|
||||
const statusMessage = notification?.message ?? "Подождите, идёт обработка..."
|
||||
const isFailed = notification?.status === "FAILED"
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!activeJobId || isCancelling) return
|
||||
|
||||
cancelJob(buildCancelJobPayload(activeJobId), {
|
||||
onSuccess: () => {
|
||||
setActiveJob(null)
|
||||
navigateBack()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleFailedBack = () => {
|
||||
setActiveJob(null)
|
||||
navigateBack()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, className)}
|
||||
data-testid="ProcessingStep"
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.progressWrapper}>
|
||||
<CircularProgress
|
||||
percentage={progressPct}
|
||||
size={200}
|
||||
strokeWidth={8}
|
||||
color={
|
||||
isFailed
|
||||
? "var(--color-danger)"
|
||||
: "var(--color-success)"
|
||||
}
|
||||
className={styles.circle}
|
||||
bgClassName={styles.circleBg}
|
||||
valueClassName={styles.circleValue}
|
||||
/>
|
||||
<div className={styles.progressInner}>
|
||||
<span className={styles.percentage}>
|
||||
{Math.round(progressPct)}%
|
||||
</span>
|
||||
<span className={styles.statusLabel}>
|
||||
{isFailed ? "ОШИБКА" : statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cs(styles.description, {
|
||||
[styles.descriptionError]: isFailed,
|
||||
})}
|
||||
>
|
||||
{isFailed
|
||||
? (notification?.message ?? "Произошла ошибка при обработке")
|
||||
: statusMessage}
|
||||
</p>
|
||||
|
||||
<div className={styles.infoCard}>
|
||||
<Info size={16} className={styles.infoIcon} />
|
||||
<span>
|
||||
Обработка выполняется на сервере. Вы можете покинуть
|
||||
страницу — прогресс сохранится.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isFailed ? "outline" : "danger"}
|
||||
size="sm"
|
||||
onClick={isFailed ? handleFailedBack : handleCancel}
|
||||
disabled={isCancelling}
|
||||
>
|
||||
{isFailed
|
||||
? "Назад"
|
||||
: isCancelling
|
||||
? "Отмена..."
|
||||
: "Отменить обработку"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ProcessingStep"
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
export interface IRenameProjectModalProps extends Pick<
|
||||
ComponentProps<typeof Dialog.Root>,
|
||||
IModalProps,
|
||||
"open" | "onOpenChange"
|
||||
> {
|
||||
project: components["schemas"]["ProjectRead"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.root {
|
||||
min-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fields {
|
||||
|
||||
@@ -4,17 +4,47 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.player {
|
||||
.playerWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
border-radius: variables.$radius-md;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
.videoArea {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playButton {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.timeRange {
|
||||
@@ -30,6 +60,52 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.segmentControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: variables.$bg-canvas;
|
||||
}
|
||||
|
||||
.segmentTime {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: variables.$text-secondary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.segmentTrack {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: variables.$border-subtle;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segmentTrackFill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: variables.$purple-400;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.segmentTrackThumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: variables.$purple-400;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.textArea {
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
|
||||
@@ -3,14 +3,7 @@
|
||||
import type { ISegmentEditModalProps } from "./SegmentEditModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { MediaPlayer, MediaProvider, useMediaState } from "@vidstack/react"
|
||||
import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default"
|
||||
import "@vidstack/react/player/styles/default/theme.css"
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||
import { LoaderCircle, Scissors } from "lucide-react"
|
||||
import { LoaderCircle, Pause, Play, Scissors } from "lucide-react"
|
||||
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { Button, Modal } from "@shared/ui"
|
||||
@@ -23,52 +16,148 @@ import { SegmentSplitter } from "@features/project/SegmentSplitter"
|
||||
|
||||
import styles from "./SegmentEditModal.module.scss"
|
||||
|
||||
const SegmentPlayer = ({
|
||||
videoUrl,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
const SegmentPlayer: FunctionComponent<{
|
||||
videoUrl: string
|
||||
start: number
|
||||
end: number
|
||||
}) => {
|
||||
const currentTime = useMediaState("currentTime")
|
||||
const playing = useMediaState("playing")
|
||||
const hasPausedRef = useRef(false)
|
||||
const playerRef = useRef<HTMLElement | null>(null)
|
||||
}> = ({ videoUrl, start, end }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const rafRef = useRef<number>(0)
|
||||
const [currentTime, setCurrentTime] = useState(start)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const duration = end - start
|
||||
const progress =
|
||||
duration > 0
|
||||
? Math.min(Math.max((currentTime - start) / duration, 0), 1)
|
||||
: 0
|
||||
|
||||
/* Time tracking via rAF — only runs while playing or dragging */
|
||||
useEffect(() => {
|
||||
hasPausedRef.current = false
|
||||
if (!playing && !dragging) return
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const tick = () => {
|
||||
setCurrentTime(video.currentTime)
|
||||
if (video.currentTime >= end && !video.paused) {
|
||||
video.pause()
|
||||
setPlaying(false)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [playing, dragging, end])
|
||||
|
||||
/* Set initial time once video is ready */
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const onLoaded = () => {
|
||||
video.currentTime = start
|
||||
}
|
||||
video.addEventListener("loadedmetadata", onLoaded)
|
||||
if (video.readyState >= 1) onLoaded()
|
||||
return () => video.removeEventListener("loadedmetadata", onLoaded)
|
||||
}, [start])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.paused) {
|
||||
if (video.currentTime >= end) video.currentTime = start
|
||||
video.play()
|
||||
setPlaying(true)
|
||||
} else {
|
||||
video.pause()
|
||||
setPlaying(false)
|
||||
}
|
||||
}, [start, end])
|
||||
|
||||
const seekToPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
const track = trackRef.current
|
||||
const video = videoRef.current
|
||||
if (!track || !video || duration <= 0) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
const fraction = Math.min(
|
||||
Math.max((clientX - rect.left) / rect.width, 0),
|
||||
1,
|
||||
)
|
||||
video.currentTime = start + fraction * duration
|
||||
},
|
||||
[start, duration],
|
||||
)
|
||||
|
||||
const handleTrackMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
seekToPosition(e.clientX)
|
||||
},
|
||||
[seekToPosition],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return
|
||||
if (currentTime >= end && !hasPausedRef.current) {
|
||||
hasPausedRef.current = true
|
||||
const player = playerRef.current as HTMLElement & {
|
||||
pause?: () => void
|
||||
if (!dragging) return
|
||||
const handleMouseMove = (e: MouseEvent) => seekToPosition(e.clientX)
|
||||
const handleMouseUp = () => setDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
player?.pause?.()
|
||||
}
|
||||
}, [currentTime, end, playing])
|
||||
}, [dragging, seekToPosition])
|
||||
|
||||
return (
|
||||
<div className={styles.playerWrapper}>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout
|
||||
icons={defaultLayoutIcons}
|
||||
slots={{
|
||||
settingsMenu: null,
|
||||
pipButton: null,
|
||||
fullscreenButton: null,
|
||||
airPlayButton: null,
|
||||
googleCastButton: null,
|
||||
}}
|
||||
<div className={styles.videoArea}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
crossOrigin="anonymous"
|
||||
playsInline
|
||||
preload="auto"
|
||||
className={styles.video}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.playButton}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{playing ? <Pause size={24} /> : <Play size={24} />}
|
||||
</button>
|
||||
<div className={styles.timeRange}>
|
||||
{secondsToTimecode(start)} — {secondsToTimecode(end)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.segmentControls}>
|
||||
<span className={styles.segmentTime}>
|
||||
{secondsToTimecode(Math.max(currentTime, start))}
|
||||
</span>
|
||||
<div
|
||||
className={styles.segmentTrack}
|
||||
ref={trackRef}
|
||||
onMouseDown={handleTrackMouseDown}
|
||||
>
|
||||
<div
|
||||
className={styles.segmentTrackFill}
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className={styles.segmentTrackThumb}
|
||||
style={{ left: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.segmentTime}>
|
||||
{secondsToTimecode(end)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,18 +236,11 @@ export const SegmentEditModal: FunctionComponent<
|
||||
>
|
||||
<div className={styles.root} data-testid="SegmentEditModal">
|
||||
{videoUrl && (
|
||||
<MediaPlayer
|
||||
src={videoUrl}
|
||||
currentTime={segment.start}
|
||||
className={styles.player}
|
||||
autoPlay
|
||||
>
|
||||
<SegmentPlayer
|
||||
videoUrl={videoUrl}
|
||||
start={segment.start}
|
||||
end={segment.end}
|
||||
/>
|
||||
</MediaPlayer>
|
||||
)}
|
||||
|
||||
{splitMode ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.root {
|
||||
min-width: 520px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fields {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ISilenceSettingsStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include typography.font-header-l;
|
||||
color: variables.$text-primary;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid variables.$border-subtle;
|
||||
background: variables.$bg-surface;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import type { ISilenceSettingsStepProps } from "./SilenceSettingsStep.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
import { FunctionComponent, useCallback } from "react"
|
||||
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { Button, Slider } from "@shared/ui"
|
||||
|
||||
import { useSubmitSilenceDetect } from "../SilenceSettingsModal/useSubmitSilenceDetect"
|
||||
|
||||
import styles from "./SilenceSettingsStep.module.scss"
|
||||
|
||||
export const SilenceSettingsStep: FunctionComponent<
|
||||
ISilenceSettingsStepProps
|
||||
> = ({ className }): JSX.Element => {
|
||||
const {
|
||||
projectId,
|
||||
primaryFileKey,
|
||||
silenceSettings,
|
||||
setSilenceSettings,
|
||||
startProcessingJob,
|
||||
goBack,
|
||||
} = useWizard()
|
||||
|
||||
const { mutate, isPending } = useSubmitSilenceDetect({
|
||||
onSuccess: (data) => {
|
||||
const result = data as { job_id?: string }
|
||||
if (result?.job_id) {
|
||||
startProcessingJob(
|
||||
result.job_id,
|
||||
"SILENCE_DETECT",
|
||||
"processing",
|
||||
"silence-settings",
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Silence detect submit failed:", error)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!primaryFileKey) return
|
||||
|
||||
;(mutate as (args: { body: Record<string, unknown> }) => void)({
|
||||
body: {
|
||||
file_key: primaryFileKey,
|
||||
project_id: projectId,
|
||||
min_silence_duration_ms: silenceSettings.min_silence_duration_ms,
|
||||
silence_threshold_db: silenceSettings.silence_threshold_db,
|
||||
padding_ms: silenceSettings.padding_ms,
|
||||
},
|
||||
})
|
||||
}, [mutate, primaryFileKey, projectId, silenceSettings])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, className)}
|
||||
data-testid="SilenceSettingsStep"
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>Параметры обнаружения тишины</h2>
|
||||
<p className={styles.description}>
|
||||
Настройте параметры для автоматического обнаружения
|
||||
тихих участков в видео
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.fields}>
|
||||
<Slider
|
||||
label="Мин. длительность тишины"
|
||||
value={silenceSettings.min_silence_duration_ms}
|
||||
min={100}
|
||||
max={2000}
|
||||
step={50}
|
||||
unit="мс"
|
||||
helpText="Минимальная длительность тихого участка для обнаружения"
|
||||
onChange={(v) =>
|
||||
setSilenceSettings({
|
||||
...silenceSettings,
|
||||
min_silence_duration_ms: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
label="Порог тишины"
|
||||
value={silenceSettings.silence_threshold_db}
|
||||
min={6}
|
||||
max={40}
|
||||
step={2}
|
||||
unit="дБ"
|
||||
helpText="Уровень громкости ниже которого звук считается тишиной"
|
||||
onChange={(v) =>
|
||||
setSilenceSettings({
|
||||
...silenceSettings,
|
||||
silence_threshold_db: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
label="Отступ"
|
||||
value={silenceSettings.padding_ms}
|
||||
min={0}
|
||||
max={500}
|
||||
step={25}
|
||||
unit="мс"
|
||||
helpText="Дополнительный отступ по краям тихих участков"
|
||||
onChange={(v) =>
|
||||
setSilenceSettings({
|
||||
...silenceSettings,
|
||||
padding_ms: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={styles.footer}>
|
||||
<Button variant="outline" onClick={goBack} disabled={isPending}>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || !primaryFileKey}
|
||||
>
|
||||
{isPending ? "Запуск..." : "Далее"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SilenceSettingsStep"
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ISubtitleRevisionStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mediaPlayer {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// Reset vidstack player defaults
|
||||
aspect-ratio: unset !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.mainGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
padding: 16px 24px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.playerColumn {
|
||||
position: relative;
|
||||
border-radius: variables.$radius-md;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
min-height: 0;
|
||||
|
||||
:global([data-media-player]) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
:global(.vds-video-layout) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.editorColumn {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-md;
|
||||
background: variables.$bg-surface;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: variables.$text-tertiary;
|
||||
@include typography.font-body-14(500);
|
||||
}
|
||||
|
||||
.timelineWrapper {
|
||||
border-top: 1px solid variables.$border-subtle;
|
||||
padding: 0 24px;
|
||||
align-self: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid variables.$border-subtle;
|
||||
background: variables.$bg-surface;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import type { ISubtitleRevisionStepProps } from "./SubtitleRevisionStep.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { MediaPlayer, MediaProvider } from "@vidstack/react"
|
||||
import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default"
|
||||
import "@vidstack/react/player/styles/default/theme.css"
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||
import cs from "classnames"
|
||||
import { FunctionComponent, useEffect, useMemo, useRef } from "react"
|
||||
|
||||
import api from "@shared/api"
|
||||
import {
|
||||
StaticWorkspaceProvider,
|
||||
useWorkspaceFiles,
|
||||
} from "@shared/context/WorkspaceContext"
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { Button } from "@shared/ui"
|
||||
import { TranscriptionEditor } from "@features/project"
|
||||
import { TimelinePanel } from "@widgets/TimelinePanel"
|
||||
|
||||
import styles from "./SubtitleRevisionStep.module.scss"
|
||||
|
||||
const TRANSCRIPTION_ARTIFACT_TYPE = "TRANSCRIPTION_JSON"
|
||||
|
||||
/**
|
||||
* Auto-initializes WorkspaceContext with the video file
|
||||
* and transcription artifact so TimelinePanel and
|
||||
* TranscriptionEditor work correctly.
|
||||
*/
|
||||
const WorkspaceInit: FunctionComponent<{
|
||||
fileKey: string | null
|
||||
transcriptionArtifactId: string | null
|
||||
}> = ({ fileKey, transcriptionArtifactId }) => {
|
||||
const { selectedFile, setSelectedFile, addUsedFile } = useWorkspaceFiles()
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileKey) return
|
||||
|
||||
addUsedFile({
|
||||
id: fileKey,
|
||||
path: fileKey,
|
||||
source: "file",
|
||||
mimeType: "video/mp4",
|
||||
displayName: "Видео",
|
||||
iconType: "video",
|
||||
})
|
||||
|
||||
if (!selectedFile) {
|
||||
setSelectedFile({
|
||||
id: fileKey,
|
||||
path: fileKey,
|
||||
source: "file",
|
||||
mimeType: "video/mp4",
|
||||
})
|
||||
}
|
||||
}, [fileKey, addUsedFile, setSelectedFile, selectedFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!transcriptionArtifactId) return
|
||||
|
||||
addUsedFile({
|
||||
id: transcriptionArtifactId,
|
||||
path: "transcription",
|
||||
source: "artifact",
|
||||
artifactType: "TRANSCRIPTION_JSON",
|
||||
displayName: "Субтитры",
|
||||
iconType: "text",
|
||||
})
|
||||
}, [transcriptionArtifactId, addUsedFile])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const SubtitleRevisionContent: FunctionComponent<{
|
||||
className?: string
|
||||
}> = ({ className }) => {
|
||||
const {
|
||||
projectId,
|
||||
videoUrl,
|
||||
primaryFileKey,
|
||||
transcriptionArtifactId: contextArtifactId,
|
||||
setTranscriptionArtifactId,
|
||||
goBack,
|
||||
goToStep,
|
||||
markStepCompleted,
|
||||
} = useWizard()
|
||||
|
||||
const { data: artifacts } = api.useQuery(
|
||||
"get",
|
||||
"/api/media/artifacts/",
|
||||
{},
|
||||
{ enabled: !contextArtifactId },
|
||||
)
|
||||
|
||||
const transcriptionArtifactId = useMemo(() => {
|
||||
if (contextArtifactId) return contextArtifactId
|
||||
if (!artifacts) return null
|
||||
const match = artifacts.find(
|
||||
(a) =>
|
||||
a.project_id === projectId &&
|
||||
a.artifact_type === TRANSCRIPTION_ARTIFACT_TYPE &&
|
||||
!a.is_deleted,
|
||||
)
|
||||
return match?.id ?? null
|
||||
}, [contextArtifactId, artifacts, projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!transcriptionArtifactId ||
|
||||
transcriptionArtifactId === contextArtifactId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setTranscriptionArtifactId(transcriptionArtifactId)
|
||||
}, [
|
||||
contextArtifactId,
|
||||
setTranscriptionArtifactId,
|
||||
transcriptionArtifactId,
|
||||
])
|
||||
|
||||
// Auto-trigger frame extraction so video frames appear in timeline
|
||||
const frameExtractMutation = api.useMutation(
|
||||
"post",
|
||||
"/api/tasks/frame-extract/",
|
||||
)
|
||||
const extractTriggeredRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!primaryFileKey || !projectId || extractTriggeredRef.current) return
|
||||
extractTriggeredRef.current = true
|
||||
frameExtractMutation.mutate({
|
||||
body: {
|
||||
file_key: primaryFileKey,
|
||||
project_id: projectId,
|
||||
regenerate: false,
|
||||
},
|
||||
})
|
||||
}, [primaryFileKey, projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleFinish = () => {
|
||||
markStepCompleted("subtitle-revision")
|
||||
goToStep("caption-settings")
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, className)}
|
||||
data-testid="SubtitleRevisionStep"
|
||||
>
|
||||
<WorkspaceInit
|
||||
fileKey={primaryFileKey}
|
||||
transcriptionArtifactId={transcriptionArtifactId}
|
||||
/>
|
||||
|
||||
<MediaPlayer
|
||||
src={videoUrl ?? ""}
|
||||
crossOrigin=""
|
||||
playsInline
|
||||
className={styles.mediaPlayer}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
aspectRatio: "unset",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Main content: video + editor */}
|
||||
<div className={styles.mainGrid}>
|
||||
{/* Left column: video player */}
|
||||
<div className={styles.playerColumn}>
|
||||
{videoUrl ? (
|
||||
<>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout
|
||||
icons={defaultLayoutIcons}
|
||||
disableTimeSlider
|
||||
slots={{
|
||||
timeSlider: null,
|
||||
currentTime: null,
|
||||
timeDivider: null,
|
||||
endTime: null,
|
||||
startDuration: null,
|
||||
seekBackwardButton: null,
|
||||
seekForwardButton: null,
|
||||
captionButton: null,
|
||||
settingsMenu: null,
|
||||
pipButton: null,
|
||||
airPlayButton: null,
|
||||
googleCastButton: null,
|
||||
downloadButton: null,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.placeholder}>
|
||||
Видео недоступно
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: transcription editor */}
|
||||
<div className={styles.editorColumn}>
|
||||
{transcriptionArtifactId ? (
|
||||
<TranscriptionEditor
|
||||
artifactId={transcriptionArtifactId}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.placeholder}>
|
||||
Транскрипция не найдена
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: timeline */}
|
||||
<div className={styles.timelineWrapper}>
|
||||
<TimelinePanel
|
||||
projectId={projectId}
|
||||
audioUrl={videoUrl}
|
||||
className={styles.timeline}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={styles.footer}>
|
||||
<Button variant="outline" onClick={goBack}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleFinish}>
|
||||
Далее
|
||||
</Button>
|
||||
</div>
|
||||
</MediaPlayer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SubtitleRevisionStep: FunctionComponent<
|
||||
ISubtitleRevisionStepProps
|
||||
> = ({ className }): JSX.Element => {
|
||||
return (
|
||||
<StaticWorkspaceProvider>
|
||||
<SubtitleRevisionContent className={className} />
|
||||
</StaticWorkspaceProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SubtitleRevisionStep"
|
||||
@@ -80,11 +80,16 @@
|
||||
}
|
||||
|
||||
.segment {
|
||||
border: 1px solid variables.$border-default;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-md;
|
||||
padding: 10px 12px;
|
||||
padding: 12px 16px;
|
||||
background: variables.$bg-surface;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: variables.$border-default;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&.highlight {
|
||||
border-color: variables.$color-primary;
|
||||
@@ -94,51 +99,72 @@
|
||||
|
||||
.segmentTimes {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.timesGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.actionsGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.timeLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeLabelText {
|
||||
font-size: 11px;
|
||||
color: variables.$text-tertiary;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.timeInput {
|
||||
width: 100px;
|
||||
width: 84px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid variables.$border-default;
|
||||
border: 1px solid transparent;
|
||||
border-radius: variables.$radius-sm;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
color: variables.$text-primary;
|
||||
background: variables.$bg-default;
|
||||
color: variables.$text-secondary;
|
||||
background: variables.$bg-hover;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: variables.$bg-surface;
|
||||
border-color: variables.$color-primary;
|
||||
color: variables.$text-primary;
|
||||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.splitButton {
|
||||
.splitButton, .removeButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
padding: 4px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
background: transparent;
|
||||
color: variables.$text-tertiary;
|
||||
cursor: pointer;
|
||||
border-radius: variables.$radius-sm;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.splitButton {
|
||||
&:hover:not(:disabled) {
|
||||
color: variables.$color-primary;
|
||||
background: variables.$bg-hover;
|
||||
@@ -151,37 +177,34 @@
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: variables.$text-tertiary;
|
||||
cursor: pointer;
|
||||
border-radius: variables.$radius-sm;
|
||||
|
||||
&:hover {
|
||||
color: variables.$color-danger;
|
||||
background: variables.$bg-hover;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.textArea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid variables.$border-default;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: variables.$radius-sm;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: variables.$text-primary;
|
||||
background: variables.$bg-default;
|
||||
background: variables.$bg-hover;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: variables.$bg-hover;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: variables.$bg-surface;
|
||||
border-color: variables.$color-primary;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ 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 { LoaderCircle, Plus, Scissors, Trash2 } from "lucide-react"
|
||||
import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import api from "@shared/api"
|
||||
@@ -146,6 +145,15 @@ export const TranscriptionEditor: FunctionComponent<
|
||||
}
|
||||
}, [transcription, segments, artifactId, queryClient])
|
||||
|
||||
// Auto-save when dirty (debounced)
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const timer = setTimeout(() => {
|
||||
handleSave()
|
||||
}, 1500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [dirty, handleSave])
|
||||
|
||||
/* Loading */
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -171,18 +179,6 @@ export const TranscriptionEditor: FunctionComponent<
|
||||
{/* 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 */}
|
||||
@@ -190,6 +186,7 @@ export const TranscriptionEditor: FunctionComponent<
|
||||
{segments.map((seg, idx) => (
|
||||
<div key={idx} className={styles.segment} data-segment-index={idx}>
|
||||
<div className={styles.segmentTimes}>
|
||||
<div className={styles.timesGroup}>
|
||||
<label className={styles.timeLabel}>
|
||||
<span className={styles.timeLabelText}>Начало</span>
|
||||
<input
|
||||
@@ -214,6 +211,8 @@ export const TranscriptionEditor: FunctionComponent<
|
||||
placeholder="00:00.000"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.actionsGroup}>
|
||||
<button
|
||||
className={styles.splitButton}
|
||||
onClick={() => setSplittingIdx(idx)}
|
||||
@@ -234,6 +233,7 @@ export const TranscriptionEditor: FunctionComponent<
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{splittingIdx === idx ? (
|
||||
<SegmentSplitter
|
||||
segment={seg}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.root {
|
||||
min-width: 520px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fields {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ITranscriptionSettingsStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include typography.font-header-l;
|
||||
color: variables.$text-primary;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.selectField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.selectLabel {
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
.error {
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$color-danger;
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.formFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
// --- Inline processing view ---
|
||||
|
||||
.processingContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progressWrapper {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.circleBg {
|
||||
stroke: variables.$border-subtle;
|
||||
}
|
||||
|
||||
.circleValue {
|
||||
transition: stroke-dashoffset 0.4s ease;
|
||||
}
|
||||
|
||||
.progressInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-weight: 700;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
color: variables.$text-primary;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: variables.$text-tertiary;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.processingDescription {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.descriptionError {
|
||||
color: variables.$color-danger;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: variables.$bg-hover;
|
||||
border-radius: variables.$radius-md;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: variables.$text-secondary;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
color: variables.$text-tertiary;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export const TranscriptionSettingsStep: FunctionComponent<
|
||||
activeJobType,
|
||||
setActiveJob,
|
||||
startProcessingJob,
|
||||
goBack,
|
||||
goToStep,
|
||||
} = useWizard()
|
||||
|
||||
const isProcessing =
|
||||
@@ -310,7 +310,7 @@ export const TranscriptionSettingsStep: FunctionComponent<
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={goBack}
|
||||
onClick={() => goToStep("fragments")}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./TranscriptionSettingsStep"
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IUploadStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.dropZone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 32px;
|
||||
border: 2px dashed variables.$border-default;
|
||||
border-radius: variables.$radius-lg;
|
||||
background: variables.$bg-surface;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
border-color: variables.$color-primary;
|
||||
background: variables.$bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.dropZoneActive {
|
||||
border-color: variables.$color-primary;
|
||||
background: rgba(var(--iris-a3), 0.08);
|
||||
}
|
||||
|
||||
.dropZoneUploading {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: variables.$text-tertiary;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include typography.font-body-16(600);
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-secondary;
|
||||
}
|
||||
|
||||
.progressTrack {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: variables.$border-subtle;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
background: variables.$color-primary;
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.progressLabel {
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-secondary;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error {
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$color-danger;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import type { IUploadStepProps } from "./UploadStep.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { Upload } from "lucide-react"
|
||||
import { FunctionComponent, useCallback, useRef, useState } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import { uploadFileWithProgress } from "@shared/api/uploadFile"
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { Button } from "@shared/ui"
|
||||
|
||||
import styles from "./UploadStep.module.scss"
|
||||
|
||||
const ACCEPTED_VIDEO_TYPES = "video/*"
|
||||
const ERROR_UPLOAD_FAILED = "Не удалось загрузить файл"
|
||||
|
||||
export const UploadStep: FunctionComponent<IUploadStepProps> = ({
|
||||
className,
|
||||
}): JSX.Element => {
|
||||
const { projectId, setFileKey, markStepCompleted, goNext } = useWizard()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
setIsUploading(true)
|
||||
setProgress(0)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await uploadFileWithProgress(
|
||||
file,
|
||||
`projects/${projectId}`,
|
||||
setProgress,
|
||||
)
|
||||
setFileKey(result.file_path, result.file_url, result.filename ?? null)
|
||||
markStepCompleted("upload")
|
||||
goNext()
|
||||
} catch {
|
||||
setError(ERROR_UPLOAD_FAILED)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
},
|
||||
[projectId, setFileKey, markStepCompleted, goNext],
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleUpload(file)
|
||||
/* Reset input so re-selecting the same file triggers change */
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleUpload],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleUpload(file)
|
||||
},
|
||||
[handleUpload],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cs(styles.root, className)} data-testid="UploadStep">
|
||||
<div className={styles.content}>
|
||||
<div
|
||||
className={cs(styles.dropZone, {
|
||||
[styles.dropZoneActive]: isDragging,
|
||||
[styles.dropZoneUploading]: isUploading,
|
||||
})}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_VIDEO_TYPES}
|
||||
className={styles.fileInput}
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<Upload size={48} className={styles.icon} />
|
||||
|
||||
{isUploading ? (
|
||||
<>
|
||||
<p className={styles.title}>Загрузка файла...</p>
|
||||
<div className={styles.progressTrack}>
|
||||
<div
|
||||
className={styles.progressBar}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={styles.progressLabel}>{Math.round(progress)}%</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.title}>Перетащите видеофайл сюда</p>
|
||||
<p className={styles.subtitle}>или нажмите для выбора файла</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
inputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
Выбрать файл
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./UploadStep"
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IVerifyStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
position: relative;
|
||||
border-radius: variables.$radius-md;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
|
||||
:global([data-media-player]) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
:global(.vds-video-layout) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.infoCards {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: variables.$bg-surface;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-sm;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
color: variables.$text-tertiary;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.infoContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 500;
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-primary;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sidebarActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.convertErrorText {
|
||||
font-size: 13px;
|
||||
color: variables.$color-danger;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.placeholderHint {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-tertiary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Converting view */
|
||||
|
||||
.convertingContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
padding: 40px;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progressWrapper {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.circleBg {
|
||||
stroke: variables.$border-subtle;
|
||||
}
|
||||
|
||||
.circleValue {
|
||||
transition: stroke-dashoffset 0.4s ease;
|
||||
}
|
||||
|
||||
.progressInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-weight: 700;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
color: variables.$text-primary;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: variables.$text-tertiary;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.convertDescription {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.convertInfoCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: variables.$bg-hover;
|
||||
border-radius: variables.$radius-md;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: variables.$text-secondary;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.convertInfoIcon {
|
||||
color: variables.$text-tertiary;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid variables.$border-subtle;
|
||||
background: variables.$bg-surface;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"use client"
|
||||
|
||||
import type { IVerifyStepProps } from "./VerifyStep.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { MediaPlayer, MediaProvider } from "@vidstack/react"
|
||||
import {
|
||||
defaultLayoutIcons,
|
||||
DefaultVideoLayout,
|
||||
} from "@vidstack/react/player/layouts/default"
|
||||
|
||||
import "@vidstack/react/player/styles/default/theme.css"
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
FileVideo,
|
||||
HardDrive,
|
||||
Info,
|
||||
Monitor,
|
||||
Music,
|
||||
RefreshCw,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import api, { fetchClient } from "@shared/api"
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||
import { Badge, Button, CircularProgress } from "@shared/ui"
|
||||
import { StaticLoader } from "@shared/ui/Loader"
|
||||
|
||||
import { buildCancelJobPayload, useCancelJob } from "../useCancelJob"
|
||||
import styles from "./VerifyStep.module.scss"
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Б`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} ГБ`
|
||||
}
|
||||
|
||||
const ERROR_CONVERT_FAILED = "Не удалось запустить конвертацию"
|
||||
|
||||
export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
|
||||
className,
|
||||
}): JSX.Element => {
|
||||
const {
|
||||
projectId,
|
||||
primaryFileKey,
|
||||
videoUrl,
|
||||
originalFileName,
|
||||
activeJobId,
|
||||
activeJobType,
|
||||
goBack,
|
||||
goNext,
|
||||
goToStep,
|
||||
markStepCompleted,
|
||||
setFileKey,
|
||||
setActiveJob,
|
||||
startProcessingJob,
|
||||
} = useWizard()
|
||||
|
||||
const [convertError, setConvertError] = useState<string | null>(null)
|
||||
const { mutate: cancelJob, isPending: isCancelling } = useCancelJob()
|
||||
|
||||
/* Derive conversion state from wizard-persisted activeJob */
|
||||
const convertJobId = activeJobType === "MEDIA_CONVERT" ? activeJobId : null
|
||||
const convertStatus: "idle" | "converting" | "failed" = convertJobId
|
||||
? "converting"
|
||||
: convertError
|
||||
? "failed"
|
||||
: "idle"
|
||||
|
||||
const { data: probeData, isPending: isProbing } = api.useQuery(
|
||||
"get",
|
||||
"/api/media/get_meta/",
|
||||
{ params: { query: { file_path: primaryFileKey ?? "" } } },
|
||||
{ enabled: !!primaryFileKey },
|
||||
)
|
||||
|
||||
const mediaInfo = useMemo(() => {
|
||||
if (!probeData) return null
|
||||
const videoStream = probeData.streams?.find((s) => s.codec_type === "video")
|
||||
const audioStream = probeData.streams?.find((s) => s.codec_type === "audio")
|
||||
const format = probeData.format
|
||||
const rawName = originalFileName ?? primaryFileKey?.split("/").pop() ?? null
|
||||
const actualFileName = primaryFileKey?.split("/").pop() ?? rawName
|
||||
const ext = actualFileName?.split(".").pop()?.toUpperCase() ?? null
|
||||
return {
|
||||
filename: rawName,
|
||||
size: format?.size ? Number(format.size) : null,
|
||||
formatName: ext,
|
||||
width: videoStream?.width ?? null,
|
||||
height: videoStream?.height ?? null,
|
||||
audioCodec: audioStream?.codec_name ?? null,
|
||||
}
|
||||
}, [probeData, originalFileName, primaryFileKey])
|
||||
|
||||
const needsConversion = useMemo(() => {
|
||||
if (!mediaInfo?.formatName) return false
|
||||
return mediaInfo.formatName !== "MP4"
|
||||
}, [mediaInfo])
|
||||
|
||||
/* ---- Conversion logic ---- */
|
||||
|
||||
const convertMutation = api.useMutation("post", "/api/tasks/media-convert/", {
|
||||
onSuccess: (data) => {
|
||||
startProcessingJob(data.job_id, "MEDIA_CONVERT", "verify")
|
||||
setConvertError(null)
|
||||
},
|
||||
onError: () => {
|
||||
setConvertError(ERROR_CONVERT_FAILED)
|
||||
},
|
||||
})
|
||||
|
||||
const handleConvert = useCallback(() => {
|
||||
if (!primaryFileKey) return
|
||||
convertMutation.mutate({
|
||||
body: {
|
||||
file_key: primaryFileKey,
|
||||
out_folder: `projects/${projectId}`,
|
||||
output_format: "mp4",
|
||||
project_id: projectId,
|
||||
},
|
||||
})
|
||||
}, [convertMutation, primaryFileKey, projectId])
|
||||
|
||||
const convertNotification = useAppSelector((state) =>
|
||||
convertJobId
|
||||
? state.notifications.items.find((n) => n.job_id === convertJobId)
|
||||
: null,
|
||||
)
|
||||
|
||||
const convertProgressPct = convertNotification?.progress_pct ?? 0
|
||||
const convertMessage = convertNotification?.message ?? "Конвертация видео..."
|
||||
|
||||
useEffect(() => {
|
||||
if (!convertJobId || convertStatus !== "converting") return
|
||||
|
||||
if (convertNotification?.status === "DONE") {
|
||||
fetchConvertedFileFromJob(convertJobId)
|
||||
}
|
||||
|
||||
if (convertNotification?.status === "FAILED") {
|
||||
setActiveJob(null)
|
||||
setConvertError(convertNotification?.message ?? "Ошибка конвертации")
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [convertNotification, convertJobId, convertStatus])
|
||||
|
||||
const fetchConvertedFileFromJob = useCallback(
|
||||
async (jobId: string) => {
|
||||
const { data: taskStatus } = await fetchClient.GET(
|
||||
"/api/tasks/status/{job_id}/",
|
||||
{ params: { path: { job_id: jobId } } },
|
||||
)
|
||||
const outputData = taskStatus?.output_data as {
|
||||
file_path?: string
|
||||
file_url?: string
|
||||
} | null
|
||||
|
||||
if (outputData?.file_path && outputData?.file_url) {
|
||||
const convertedName = outputData.file_path.split("/").pop() ?? null
|
||||
setFileKey(outputData.file_path, outputData.file_url, convertedName)
|
||||
setActiveJob(null)
|
||||
}
|
||||
},
|
||||
[setFileKey, setActiveJob],
|
||||
)
|
||||
|
||||
/* ---- Handlers ---- */
|
||||
|
||||
const handleReplace = () => {
|
||||
setFileKey("", "", null)
|
||||
goToStep("upload")
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
markStepCompleted("verify")
|
||||
goNext()
|
||||
}
|
||||
|
||||
/* ---- Converting view ---- */
|
||||
|
||||
if (convertStatus === "converting") {
|
||||
return (
|
||||
<div className={cs(styles.root, className)} data-testid="VerifyStep">
|
||||
<div className={styles.convertingContent}>
|
||||
<div className={styles.progressWrapper}>
|
||||
<CircularProgress
|
||||
percentage={convertProgressPct}
|
||||
size={200}
|
||||
strokeWidth={8}
|
||||
color="var(--color-success)"
|
||||
className={styles.circle}
|
||||
bgClassName={styles.circleBg}
|
||||
valueClassName={styles.circleValue}
|
||||
/>
|
||||
<div className={styles.progressInner}>
|
||||
<span className={styles.percentage}>
|
||||
{Math.round(convertProgressPct)}%
|
||||
</span>
|
||||
<span className={styles.statusLabel}>КОНВЕРТАЦИЯ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.convertDescription}>{convertMessage}</p>
|
||||
|
||||
<div className={styles.convertInfoCard}>
|
||||
<Info size={16} className={styles.convertInfoIcon} />
|
||||
<span>
|
||||
Конвертация выполняется на сервере. Вы можете покинуть страницу —
|
||||
прогресс сохранится.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!convertJobId || isCancelling) return
|
||||
|
||||
cancelJob(buildCancelJobPayload(convertJobId), {
|
||||
onSuccess: () => {
|
||||
setActiveJob(null)
|
||||
},
|
||||
})
|
||||
}}
|
||||
disabled={isCancelling}
|
||||
>
|
||||
{isCancelling ? "Отмена..." : "Отменить конвертацию"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Normal / needs-conversion view ---- */
|
||||
|
||||
return (
|
||||
<div className={cs(styles.root, className)} data-testid="VerifyStep">
|
||||
<div className={styles.layout}>
|
||||
{/* Video player */}
|
||||
<div className={styles.playerWrapper}>
|
||||
{isProbing && primaryFileKey ? (
|
||||
<div className={styles.placeholder}>
|
||||
<StaticLoader block description="Анализ видеофайла..." />
|
||||
</div>
|
||||
) : needsConversion ? (
|
||||
<div className={styles.placeholder}>
|
||||
<FileVideo size={48} />
|
||||
<p>Формат {mediaInfo?.formatName ?? ""} не поддерживается</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleConvert}
|
||||
disabled={convertMutation.isPending}
|
||||
>
|
||||
Конвертировать в MP4
|
||||
</Button>
|
||||
</div>
|
||||
) : videoUrl ? (
|
||||
<MediaPlayer src={videoUrl} crossOrigin="" playsInline>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
) : (
|
||||
<div className={styles.placeholder}>
|
||||
<FileVideo size={48} />
|
||||
<p>Видео не загружено</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info sidebar */}
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.statusRow}>
|
||||
{isProbing && primaryFileKey ? (
|
||||
<Badge variant="info">
|
||||
<Info size={14} />
|
||||
Анализ файла...
|
||||
</Badge>
|
||||
) : needsConversion ? (
|
||||
<Badge variant="warning">
|
||||
<AlertTriangle size={14} />
|
||||
Требуется конвертация
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success">
|
||||
<CheckCircle size={14} />
|
||||
Готово к обработке
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.infoCards}>
|
||||
<div className={styles.infoCard}>
|
||||
<FileVideo size={16} className={styles.infoIcon} />
|
||||
<div className={styles.infoContent}>
|
||||
<span className={styles.infoLabel}>Файл</span>
|
||||
<span className={styles.infoValue}>
|
||||
{mediaInfo?.filename ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.infoCard}>
|
||||
<HardDrive size={16} className={styles.infoIcon} />
|
||||
<div className={styles.infoContent}>
|
||||
<span className={styles.infoLabel}>Размер и формат</span>
|
||||
<span className={styles.infoValue}>
|
||||
{mediaInfo?.size ? formatFileSize(mediaInfo.size) : "—"}{" "}
|
||||
· {mediaInfo?.formatName ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.infoCard}>
|
||||
<Monitor size={16} className={styles.infoIcon} />
|
||||
<div className={styles.infoContent}>
|
||||
<span className={styles.infoLabel}>Разрешение</span>
|
||||
<span className={styles.infoValue}>
|
||||
{mediaInfo?.width && mediaInfo?.height
|
||||
? `${mediaInfo.width}x${mediaInfo.height}`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.infoCard}>
|
||||
<Music size={16} className={styles.infoIcon} />
|
||||
<div className={styles.infoContent}>
|
||||
<span className={styles.infoLabel}>Аудиокодек</span>
|
||||
<span className={styles.infoValue}>
|
||||
{mediaInfo?.audioCodec ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sidebarActions}>
|
||||
{needsConversion ? (
|
||||
<>
|
||||
{convertError && (
|
||||
<p className={styles.convertErrorText}>{convertError}</p>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleConvert}
|
||||
disabled={convertMutation.isPending}
|
||||
>
|
||||
Конвертировать в MP4
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="danger" size="sm" onClick={handleReplace}>
|
||||
<RefreshCw size={14} />
|
||||
Заменить видео
|
||||
</Button>
|
||||
{!needsConversion && (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Предварительная обрезка
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={styles.footer}>
|
||||
<Button variant="outline" onClick={goBack}>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleNext}
|
||||
disabled={needsConversion}
|
||||
>
|
||||
Далее: Настройки тишины
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VerifyStep"
|
||||
@@ -1,15 +1,24 @@
|
||||
export { CaptionResultStep } from "./CaptionResultStep"
|
||||
export { CaptionSettingsStep } from "./CaptionSettingsStep"
|
||||
export { ConvertMediaView } from "./ConvertMediaView"
|
||||
export { CreateProjectModal } from "./CreateProjectModal"
|
||||
export { DeleteFileModal } from "./DeleteFileModal"
|
||||
export { DeleteProjectModal } from "./DeleteProjectModal"
|
||||
export { EditProjectModal } from "./EditProjectModal"
|
||||
export { FragmentsStep } from "./FragmentsStep"
|
||||
export { ProcessingStep } from "./ProcessingStep"
|
||||
export { RenameProjectModal } from "./RenameProjectModal"
|
||||
export { SegmentEditModal } from "./SegmentEditModal"
|
||||
export { SegmentSplitter } from "./SegmentSplitter"
|
||||
export { SilenceSettingsStep } from "./SilenceSettingsStep"
|
||||
export { SubtitleRevisionStep } from "./SubtitleRevisionStep"
|
||||
export { TranscriptionEditor } from "./TranscriptionEditor"
|
||||
export { TranscriptionSettingsStep } from "./TranscriptionSettingsStep"
|
||||
export { SilenceResultModal } from "./SilenceResultModal"
|
||||
export { SilenceSettingsModal } from "./SilenceSettingsModal"
|
||||
export { TranscriptionModal } from "./TranscriptionModal"
|
||||
export { UploadStep } from "./UploadStep"
|
||||
export { VerifyStep } from "./VerifyStep"
|
||||
export { WaveformTrack } from "./WaveformTrack"
|
||||
export { SubtitlesTrack } from "./SubtitlesTrack"
|
||||
export { SilenceTrack } from "./SilenceTrack"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import api from "@shared/api"
|
||||
|
||||
const CANCEL_MESSAGE = "Отменено пользователем"
|
||||
|
||||
export const useCancelJob = () => {
|
||||
return api.useMutation("patch", "/api/jobs/jobs/{job_id}/", {
|
||||
})
|
||||
}
|
||||
|
||||
export const buildCancelJobPayload = (jobId: string) => ({
|
||||
params: { path: { job_id: jobId } },
|
||||
body: {
|
||||
status: "CANCELLED" as const,
|
||||
current_message: CANCEL_MESSAGE,
|
||||
},
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
.root {
|
||||
padding: 28px 24px 40px;
|
||||
padding: 32px 24px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
@@ -15,10 +15,10 @@
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
font-size: 52px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
color: variables.$text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import "@testing-library/jest-dom"
|
||||
|
||||
import { render, screen } from "@testing-library/react"
|
||||
|
||||
import HomePage from "./HomePage"
|
||||
|
||||
describe("Page", () => {
|
||||
test("renders a yunglocokid", () => {
|
||||
render(<HomePage />)
|
||||
const yunglocokid: HTMLElement = screen.getByText("yunglocokid")
|
||||
expect(yunglocokid).toBeInTheDocument()
|
||||
})
|
||||
test("renders a hint", () => {
|
||||
render(<HomePage />)
|
||||
screen.debug()
|
||||
const hint: HTMLElement = screen.getByTestId("hint-code")
|
||||
expect(hint).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent, useMemo, useState } from "react"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { FolderKanban, PlusIcon } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
@@ -12,7 +13,12 @@ import { CreateProjectModal } from "@features/project"
|
||||
import api from "@shared/api"
|
||||
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
|
||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||
import { StaticLoader } from "@shared/ui/Loader"
|
||||
import {
|
||||
STAGGER_CONTAINER,
|
||||
SLIDE_UP,
|
||||
EASE_OUT_TRANSITION,
|
||||
} from "@shared/lib/motion"
|
||||
import { StatsGridSkeleton, RecentProjectsSkeleton } from "@shared/ui/Skeleton"
|
||||
import { RecentProjects } from "@widgets/Dashboard/RecentProjects"
|
||||
import { StatsGrid } from "@widgets/Dashboard/StatsGrid"
|
||||
|
||||
@@ -56,16 +62,29 @@ export const HomePage: FunctionComponent<IHomePageProps> = (): JSX.Element => {
|
||||
const userName = user?.first_name || user?.username || "пользователь"
|
||||
|
||||
return (
|
||||
<div className={cls.root}>
|
||||
{isLoading && <StaticLoader fullscreen />}
|
||||
|
||||
<div className={cls.welcome}>
|
||||
<motion.div
|
||||
className={cls.root}
|
||||
variants={STAGGER_CONTAINER}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div
|
||||
className={cls.welcome}
|
||||
variants={SLIDE_UP}
|
||||
transition={EASE_OUT_TRANSITION}
|
||||
>
|
||||
<h1 className={cls.greeting}>Добро пожаловать, {userName}</h1>
|
||||
<p className={cls.subtitle}>{subtitle}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<StatsGrid {...stats} />
|
||||
<motion.div variants={SLIDE_UP} transition={EASE_OUT_TRANSITION}>
|
||||
{isLoading ? <StatsGridSkeleton /> : <StatsGrid {...stats} />}
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={SLIDE_UP} transition={EASE_OUT_TRANSITION}>
|
||||
{isLoading ? (
|
||||
<RecentProjectsSkeleton />
|
||||
) : (
|
||||
<RecentProjects
|
||||
projects={recentProjects}
|
||||
isLoading={isLoading}
|
||||
@@ -73,8 +92,14 @@ export const HomePage: FunctionComponent<IHomePageProps> = (): JSX.Element => {
|
||||
onCreateClick={() => setIsCreateModalOpen(true)}
|
||||
onViewAllClick={() => router.push("/projects")}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<div className={cls.actionsSection}>
|
||||
<motion.div
|
||||
className={cls.actionsSection}
|
||||
variants={SLIDE_UP}
|
||||
transition={EASE_OUT_TRANSITION}
|
||||
>
|
||||
<h2 className={cls.actionsTitle}>Быстрые действия</h2>
|
||||
<div className={cls.actions}>
|
||||
<ActionCard
|
||||
@@ -89,7 +114,7 @@ export const HomePage: FunctionComponent<IHomePageProps> = (): JSX.Element => {
|
||||
onClick={() => router.push("/projects")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<CreateProjectModal
|
||||
open={isCreateModalOpen}
|
||||
@@ -98,7 +123,7 @@ export const HomePage: FunctionComponent<IHomePageProps> = (): JSX.Element => {
|
||||
await refetch()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% -10%, hsla(262, 68%, 52%, 0.06) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 100% 100%, hsla(150, 40%, 42%, 0.04) 0%, transparent 50%),
|
||||
var(--bg-canvas);
|
||||
}
|
||||
|
||||
.form {
|
||||
@@ -11,16 +15,19 @@
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 24px;
|
||||
padding: 40px 32px;
|
||||
background-color: variables.$bg-default;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-lg;
|
||||
box-shadow: var(--shadow-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: formEntrance 0.5s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include typography.font-header-l;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: variables.$text-primary;
|
||||
@@ -29,7 +36,7 @@
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -41,9 +48,20 @@
|
||||
@include typography.font-body-s;
|
||||
color: variables.$text-secondary;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
transition: color var(--duration-normal) var(--ease-out);
|
||||
|
||||
&:hover {
|
||||
color: variables.$text-primary;
|
||||
color: variables.$color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes formEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.017em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,23 @@ import {
|
||||
import api from "@shared/api"
|
||||
import { formatDate } from "@shared/lib/dates"
|
||||
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
|
||||
import { StaticLoader } from "@shared/ui/Loader"
|
||||
import { Skeleton } from "@shared/ui/Skeleton"
|
||||
import { Card } from "@shared/ui"
|
||||
|
||||
import { IProfilePageProps } from "./ProfilePage.d"
|
||||
import styles from "./ProfilePage.module.scss"
|
||||
|
||||
const ProfileSkeleton = () => (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.container}>
|
||||
<Skeleton width="120px" height="120px" borderRadius="50%" />
|
||||
<Skeleton width="100%" height="200px" borderRadius="10px" />
|
||||
<Skeleton width="100%" height="160px" borderRadius="10px" />
|
||||
<Skeleton width="100%" height="140px" borderRadius="10px" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const ProfilePage: FunctionComponent<
|
||||
IProfilePageProps
|
||||
> = (): JSX.Element => {
|
||||
@@ -46,7 +57,7 @@ export const ProfilePage: FunctionComponent<
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) return <StaticLoader fullscreen />
|
||||
if (isLoading) return <ProfileSkeleton />
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IProjectWizardPageProps {
|
||||
className?: string
|
||||
}
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import type { IProjectWizardPageProps } from "./ProjectWizardPage.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
|
||||
import { WizardProvider } from "@shared/context/WizardContext"
|
||||
import { WorkspaceProvider } from "@shared/context/WorkspaceContext"
|
||||
import { ProjectWizard } from "@widgets/ProjectWizard"
|
||||
|
||||
import styles from "./ProjectWizardPage.module.scss"
|
||||
|
||||
export const ProjectWizardPage: FunctionComponent<
|
||||
IProjectWizardPageProps
|
||||
> = (): JSX.Element => {
|
||||
const params = useParams<{ project_id: string }>()
|
||||
const projectId = params?.project_id ?? ""
|
||||
|
||||
const { data: project } = api.useQuery(
|
||||
"get",
|
||||
"/api/projects/{project_id}/",
|
||||
{
|
||||
params: { path: { project_id: projectId } },
|
||||
},
|
||||
)
|
||||
|
||||
useBreadcrumbs([
|
||||
{ label: "Проекты", href: "/projects" },
|
||||
{ label: project?.name ?? "..." },
|
||||
])
|
||||
|
||||
return (
|
||||
<WorkspaceProvider projectId={projectId}>
|
||||
<WizardProvider projectId={projectId}>
|
||||
<div
|
||||
className={styles.root}
|
||||
data-testid="ProjectWizardPage"
|
||||
>
|
||||
<ProjectWizard />
|
||||
</div>
|
||||
</WizardProvider>
|
||||
</WorkspaceProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ProjectWizardPage"
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface IProjectWorkspacePageProps {
|
||||
message?: string
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { useParams } from "next/navigation"
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
|
||||
import {
|
||||
useWorkspaceFiles,
|
||||
WorkspaceProvider,
|
||||
} from "@shared/context/WorkspaceContext"
|
||||
import { TranscriptionEditor } from "@features/project"
|
||||
import {
|
||||
ActionPanel,
|
||||
FileTree,
|
||||
VideoPlayer,
|
||||
WorkspaceLayout,
|
||||
} from "@widgets/Workspace"
|
||||
|
||||
import { IProjectWorkspacePageProps } from "./ProjectWorkspacePage.d"
|
||||
import styles from "./ProjectWorkspacePage.module.scss"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Inner wrapper — resolves which viewer to show based on selection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const WorkspaceViewer: FunctionComponent<{ projectId: string }> = ({
|
||||
projectId,
|
||||
}) => {
|
||||
const { selectedFile } = useWorkspaceFiles()
|
||||
|
||||
if (selectedFile?.artifactType === "TRANSCRIPTION_JSON") {
|
||||
return <TranscriptionEditor artifactId={selectedFile.id} />
|
||||
}
|
||||
|
||||
return <VideoPlayer projectId={projectId} />
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const ProjectWorkspacePage: FunctionComponent<
|
||||
IProjectWorkspacePageProps
|
||||
> = (): JSX.Element => {
|
||||
const params = useParams<{ project_id: string }>()
|
||||
const projectId = params?.project_id ?? ""
|
||||
|
||||
const { data: project } = api.useQuery("get", "/api/projects/{project_id}/", {
|
||||
params: { path: { project_id: projectId } },
|
||||
})
|
||||
|
||||
useBreadcrumbs([
|
||||
{ label: "Проекты", href: "/projects" },
|
||||
{ label: project?.name ?? "..." },
|
||||
])
|
||||
|
||||
return (
|
||||
<WorkspaceProvider projectId={projectId}>
|
||||
<div className={styles.root} data-testid="ProjectWorkspacePage">
|
||||
<WorkspaceLayout
|
||||
fileTree={<FileTree projectId={projectId} />}
|
||||
player={<WorkspaceViewer projectId={projectId} />}
|
||||
actionPanel={<ActionPanel projectId={projectId} />}
|
||||
/>
|
||||
</div>
|
||||
</WorkspaceProvider>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./ProjectWorkspacePage"
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import api from "@shared/api"
|
||||
import { useDebounce } from "@shared/hooks/useDebounce"
|
||||
import { Button } from "@shared/ui"
|
||||
import { StaticLoader } from "@shared/ui/Loader"
|
||||
import { ProjectCardSkeleton } from "@shared/ui/Skeleton"
|
||||
import {
|
||||
ProjectsHeader,
|
||||
type ProjectStatusEnum,
|
||||
@@ -59,7 +59,6 @@ export const ProjectsPage: FunctionComponent<
|
||||
|
||||
return (
|
||||
<div className={styles.root} data-testid="ProjectsPage">
|
||||
{projectsLoading && <StaticLoader fullscreen />}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titles}>
|
||||
<h1 className={styles.title}>Мои проекты</h1>
|
||||
@@ -133,7 +132,11 @@ export const ProjectsPage: FunctionComponent<
|
||||
/>
|
||||
|
||||
<div className={styles.projectList}>
|
||||
{projects?.map((project) => (
|
||||
{projectsLoading
|
||||
? Array.from({ length: 6 }).map((_, i) => (
|
||||
<ProjectCardSkeleton key={i} />
|
||||
))
|
||||
: projects?.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
|
||||
@@ -16,7 +16,7 @@ const PING_INTERVAL_MS = 5000
|
||||
export const UnderMaintenancePage: FunctionComponent<IUnderMaintenancePageProps> = (): JSX.Element => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectPath = searchParams.get("path") || "/"
|
||||
const redirectPath = searchParams?.get("path") || "/"
|
||||
|
||||
const { isSuccess } = api.useQuery("get", "/api/ping/", {}, {
|
||||
refetchInterval: PING_INTERVAL_MS,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import createFetchClient, { Middleware } from "openapi-fetch"
|
||||
|
||||
import { ACCESS_TOKEN_REGEXP, API_URL } from "@shared/lib/constants"
|
||||
|
||||
import { paths } from "./__generated__/openapi.types"
|
||||
|
||||
const isServer = typeof window === "undefined"
|
||||
|
||||
const getAccessTokenFromCookieHeader = (
|
||||
cookieHeader: string | null,
|
||||
): string | undefined => {
|
||||
if (!cookieHeader) return
|
||||
const token = cookieHeader.replace(ACCESS_TOKEN_REGEXP, "$1")
|
||||
return token.length ? token : undefined
|
||||
}
|
||||
|
||||
export const fetchClient = createFetchClient<paths>({
|
||||
baseUrl: API_URL,
|
||||
// credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
const middleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
if (request.headers.has("Authorization")) return
|
||||
|
||||
let token: string | undefined
|
||||
if (isServer) {
|
||||
// In middleware/edge runtime there is no `next/headers` request scope.
|
||||
token = getAccessTokenFromCookieHeader(request.headers.get("cookie"))
|
||||
if (!token) {
|
||||
try {
|
||||
const { cookies } = await import("next/headers")
|
||||
token = (await cookies()).get("access_token")?.value
|
||||
} catch {
|
||||
// Not in a request scope (e.g. middleware/edge or build-time).
|
||||
}
|
||||
}
|
||||
} else {
|
||||
token = document.cookie.replace(ACCESS_TOKEN_REGEXP, "$1")
|
||||
}
|
||||
|
||||
if (token?.length) request.headers.set("Authorization", `Bearer ${token}`)
|
||||
},
|
||||
async onError({ error }) {
|
||||
return new Error("Oops, fetch failed", { cause: error })
|
||||
},
|
||||
}
|
||||
fetchClient.use(middleware)
|
||||
+2
-50
@@ -1,56 +1,8 @@
|
||||
import createClient from "openapi-react-query"
|
||||
|
||||
import createFetchClient, { Middleware } from "openapi-fetch"
|
||||
import { fetchClient } from "./fetchClient"
|
||||
|
||||
import { ACCESS_TOKEN_REGEXP, API_URL } from "@shared/lib/constants"
|
||||
|
||||
import { paths } from "./__generated__/openapi.types"
|
||||
|
||||
const isServer = typeof window === "undefined"
|
||||
|
||||
const getAccessTokenFromCookieHeader = (
|
||||
cookieHeader: string | null,
|
||||
): string | undefined => {
|
||||
if (!cookieHeader) return
|
||||
const token = cookieHeader.replace(ACCESS_TOKEN_REGEXP, "$1")
|
||||
return token.length ? token : undefined
|
||||
}
|
||||
|
||||
export const fetchClient = createFetchClient<paths>({
|
||||
baseUrl: API_URL,
|
||||
// credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
const middleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
if (request.headers.has("Authorization")) return
|
||||
|
||||
let token: string | undefined
|
||||
if (isServer) {
|
||||
// In middleware/edge runtime there is no `next/headers` request scope.
|
||||
token = getAccessTokenFromCookieHeader(request.headers.get("cookie"))
|
||||
if (!token) {
|
||||
try {
|
||||
const { cookies } = await import("next/headers")
|
||||
token = (await cookies()).get("access_token")?.value
|
||||
} catch {
|
||||
// Not in a request scope (e.g. middleware/edge or build-time).
|
||||
}
|
||||
}
|
||||
} else {
|
||||
token = document.cookie.replace(ACCESS_TOKEN_REGEXP, "$1")
|
||||
}
|
||||
|
||||
if (token?.length) request.headers.set("Authorization", `Bearer ${token}`)
|
||||
},
|
||||
async onError({ error }) {
|
||||
return new Error("Oops, fetch failed", { cause: error })
|
||||
},
|
||||
}
|
||||
fetchClient.use(middleware)
|
||||
export { fetchClient }
|
||||
|
||||
export const api = createClient(fetchClient)
|
||||
export default api
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server"
|
||||
|
||||
import { fetchClient } from "."
|
||||
import { fetchClient } from "./fetchClient"
|
||||
|
||||
export const pingServer = async (): Promise<boolean> => {
|
||||
try {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user