This commit is contained in:
Daniil
2026-04-04 14:51:40 +03:00
parent 10a1d28f77
commit 0523ef3d72
191 changed files with 12065 additions and 2658 deletions
+5
View File
@@ -12,9 +12,13 @@ package.lock
# testing # testing
/coverage /coverage
/test-results/
/playwright-report/
/blob-report/
# next.js # next.js
/.next/ /.next/
/.next-test/
/out/ /out/
# production # production
@@ -30,6 +34,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
.env
.env*.local .env*.local
# vercel # vercel
+3
View File
@@ -1,5 +1,8 @@
# AGENTS.md — Coffee Project Frontend # 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 ## Project Overview
Next.js 16 application using **Feature-Sliced Design (FSD)** architecture, powered by **Bun** runtime and package manager. Next.js 16 application using **Feature-Sliced Design (FSD)** architecture, powered by **Bun** runtime and package manager.
+11
View File
@@ -48,6 +48,7 @@ Next.js 16 App Router with Feature-Sliced Design. Strict unidirectional imports:
## Component Convention ## Component Convention
Generate new components with `bun run gc <layer> <Name>` — never create component files manually. Each component folder contains: Generate new components with `bun run gc <layer> <Name>` — never create component files manually. Each component folder contains:
- `index.ts` — public re-export only - `index.ts` — public re-export only
- `ComponentName.tsx` — implementation - `ComponentName.tsx` — implementation
- `ComponentName.module.scss` — scoped styles - `ComponentName.module.scss` — scoped styles
@@ -89,6 +90,7 @@ Use the shared `uploadFile` utility for any file upload — do not inline FormDa
```ts ```ts
import { uploadFile } from "@shared/api/uploadFile" import { uploadFile } from "@shared/api/uploadFile"
const result = await uploadFile(file, "avatars") const result = await uploadFile(file, "avatars")
// result.file_url, result.file_path // 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. - **`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. - **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. - **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
+5
View File
@@ -0,0 +1,5 @@
import { StaticLoader } from "@shared/ui/Loader"
export default function ProtectedLoading() {
return <StaticLoader block />
}
+29
View File
@@ -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 { JSX } from "react"
import { ProjectWorkspacePage } from "@pages/ProjectWorkspacePage" import { ProjectWizardPage } from "@pages/ProjectWizardPage"
export default function Projects(): JSX.Element { export default function Projects(): JSX.Element {
return ( return (
<main> <main>
<ProjectWorkspacePage /> <ProjectWizardPage />
</main> </main>
) )
} }
+18
View File
@@ -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
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { Open_Sans } from "next/font/google" import { Manrope } from "next/font/google"
import "@shared/styles/global.scss" import "@shared/styles/global.scss"
@@ -12,10 +12,11 @@ export const metadata: Metadata = {
description: "Standalone Next.js app using FSD structure", description: "Standalone Next.js app using FSD structure",
} }
const open_sans = Open_Sans({ const manrope = Manrope({
subsets: ["latin", "cyrillic"],
preload: true, preload: true,
display: "swap", display: "swap",
variable: "--font-open-sans", variable: "--font-manrope",
}) })
export default function RootLayout({ export default function RootLayout({
@@ -24,7 +25,7 @@ export default function RootLayout({
children: ReactNode children: ReactNode
}>) { }>) {
return ( return (
<html lang="ru" className={open_sans.variable} suppressHydrationWarning> <html lang="ru" className={manrope.variable} suppressHydrationWarning>
<head> <head>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -33,7 +34,9 @@ export default function RootLayout({
/> />
</head> </head>
<body> <body>
<div id="app-root">
<AppProviders>{children}</AppProviders> <AppProviders>{children}</AppProviders>
</div>
</body> </body>
</html> </html>
) )
+9 -1
View File
@@ -1,6 +1,7 @@
"use client" "use client"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { motion } from "framer-motion"
import { Header } from "@widgets/Header" import { Header } from "@widgets/Header"
@@ -12,7 +13,7 @@ export default function EssentialTemplate({
children: React.ReactNode children: React.ReactNode
}) { }) {
const pathname = usePathname() const pathname = usePathname()
const isAuthPage = AUTH_ROUTES.includes(pathname) const isAuthPage = AUTH_ROUTES.includes(pathname ?? "")
if (isAuthPage) { if (isAuthPage) {
return <>{children}</> return <>{children}</>
@@ -21,7 +22,14 @@ export default function EssentialTemplate({
return ( return (
<div> <div>
<Header /> <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} {children}
</motion.div>
</div> </div>
) )
} }
+4
View File
@@ -1,9 +1,13 @@
import { Suspense } from "react"
import { UnderMaintenancePage } from "@pages/UnderMaintenancePage" import { UnderMaintenancePage } from "@pages/UnderMaintenancePage"
export default function UnderMaintenance() { export default function UnderMaintenance() {
return ( return (
<main> <main>
<Suspense>
<UnderMaintenancePage /> <UnderMaintenancePage />
</Suspense>
</main> </main>
) )
} }
+82 -1
View File
@@ -31,9 +31,11 @@
"openapi-react-query": "^0.5.1", "openapi-react-query": "^0.5.1",
"react": "^19.2.3", "react": "^19.2.3",
"react-aria-components": "^1.14.0", "react-aria-components": "^1.14.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.71.0", "react-hook-form": "^7.71.0",
"react-modal": "^3.16.3",
"react-modern-drawer": "^1.4.0", "react-modern-drawer": "^1.4.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-resizable-panels": "^4.6.5", "react-resizable-panels": "^4.6.5",
@@ -45,12 +47,15 @@
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@playwright/test": "^1.58.2",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@types/bun": "^1.3.5", "@types/bun": "^1.3.5",
"@types/jest": "^30.0.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-modal": "^3.16.3",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "16.1.1", "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=="], "@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/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=="], "@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=="], "@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/colors": ["@radix-ui/colors@3.0.0", "", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@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=="], "@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/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=="], "@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/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/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@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-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/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/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=="], "@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=="], "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=="], "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "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=="], "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-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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
@@ -1397,6 +1456,10 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "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=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "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=="], "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=="], "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=="], "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-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-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-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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "stylelint/file-entry-cache": ["file-entry-cache@11.1.1", "", { "dependencies": { "flat-cache": "^6.1.19" } }, "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A=="],
+1 -2
View File
@@ -4,9 +4,8 @@ import { fileURLToPath } from "url"
const dirname = path.dirname(fileURLToPath(import.meta.url)) const dirname = path.dirname(fileURLToPath(import.meta.url))
const stylesPath = path.join(dirname, "src/shared/styles") const stylesPath = path.join(dirname, "src/shared/styles")
console.log("dirname", dirname)
const nextConfig = { const nextConfig = {
distDir: process.env.NEXT_TEST_DIR ?? ".next",
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
+11 -1
View File
@@ -1,6 +1,9 @@
{ {
"name": "fsd-nest-template", "name": "fsd-nest-template",
"version": "0.1.0", "version": "0.1.0",
"imports": {
"#tests/*": "./tests/*"
},
"private": true, "private": true,
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"scripts": { "scripts": {
@@ -11,7 +14,9 @@
"create-component": "npx generate-react-cli component", "create-component": "npx generate-react-cli component",
"gc": "bun run .scripts/create-fsd-component.ts", "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/", "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": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -40,9 +45,11 @@
"openapi-react-query": "^0.5.1", "openapi-react-query": "^0.5.1",
"react": "^19.2.3", "react": "^19.2.3",
"react-aria-components": "^1.14.0", "react-aria-components": "^1.14.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.71.0", "react-hook-form": "^7.71.0",
"react-modal": "^3.16.3",
"react-modern-drawer": "^1.4.0", "react-modern-drawer": "^1.4.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-resizable-panels": "^4.6.5", "react-resizable-panels": "^4.6.5",
@@ -54,12 +61,15 @@
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@playwright/test": "^1.58.2",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@types/bun": "^1.3.5", "@types/bun": "^1.3.5",
"@types/jest": "^30.0.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-modal": "^3.16.3",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
+58
View File
@@ -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; @include mixins.flex-column;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden;
position: relative; position: relative;
border: 1px solid variables.$border-default; border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md ; border-radius: variables.$radius-md ;
box-shadow: var(--shadow-sm);
transition: transition:
transform 0.2s ease, transform variables.$duration-normal variables.$ease-out,
box-shadow 0.2s ease, box-shadow variables.$duration-normal variables.$ease-out,
border-color 0.2s ease; border-color variables.$duration-normal variables.$ease-out;
cursor: pointer; cursor: pointer;
background: variables.$bg-default; background: variables.$bg-default;
overflow: hidden;
} }
.hero { .hero {
width: 100%;
height: 180px; height: 180px;
background-color: variables.$bg-surface; background-color: variables.$bg-surface;
position: relative; position: relative;
overflow: hidden;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-left: -24px;
margin-top: -24px;
width: calc(100% + 48px);
overflow: hidden;
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform variables.$duration-slow variables.$ease-out;
} }
.placeholder { .placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
@include mixins.flex-center; @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 { svg {
width: 40px; width: 48px;
height: 40px; height: 48px;
color: variables.$purple-300; color: variables.$text-tertiary;
opacity: 0.4; opacity: 0.35;
transition: transform variables.$duration-normal variables.$ease-out;
} }
} }
} }
.root:hover { .root:hover {
transform: translateY(-2px); transform: translateY(-4px);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-md);
border-color: transparent; border-color: variables.$purple-200;
.placeholder {
scale: 1.2;
}
}
.root:active {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
} }
.content { .content {
@@ -107,7 +139,7 @@
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
@include typography.font-caption-m; @include typography.font-caption-m;
font-weight: 600; font-weight: 700;
color: variables.$color-white; color: variables.$color-white;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
z-index: 2; z-index: 2;
@@ -123,7 +155,7 @@
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
pointer-events: none; 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; z-index: 1;
} }
@@ -169,7 +201,7 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@include typography.font-caption-m; @include typography.font-caption-m;
font-weight: 500; font-weight: 600;
&.statusGenerated { &.statusGenerated {
color: variables.$color-success; color: variables.$color-success;
@@ -204,7 +236,7 @@
@include mixins.flex-center; @include mixins.flex-center;
color: variables.$text-secondary; color: variables.$text-secondary;
cursor: pointer; 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, &:hover,
&[data-state="open"] { &[data-state="open"] {
@@ -230,10 +262,10 @@
left: 10px; left: 10px;
padding: 4px 10px; padding: 4px 10px;
border-radius: 20px; border-radius: 20px;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
@include typography.font-caption-m; @include typography.font-caption-m;
font-weight: 500; font-weight: 600;
color: variables.$text-primary; color: variables.$text-primary;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
z-index: 2; z-index: 2;
+5 -2
View File
@@ -65,7 +65,10 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
{imageUrl ? ( {imageUrl ? (
<img src={imageUrl} alt={name} loading="lazy" /> <img src={imageUrl} alt={name} loading="lazy" />
) : ( ) : (
<div className={styles.placeholder}> <div
className={styles.placeholder}
data-color-index={name.charCodeAt(0) % 4}
>
<ImageIcon /> <ImageIcon />
</div> </div>
)} )}
@@ -109,7 +112,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
> >
<Dropdown> <Dropdown>
<DropdownTrigger asChild> <DropdownTrigger asChild>
<button type="button" aria-label="Project actions"> <button type="button" aria-label="Действия проекта">
<MoreHorizontal size={16} /> <MoreHorizontal size={16} />
</button> </button>
</DropdownTrigger> </DropdownTrigger>
@@ -10,8 +10,8 @@
width: 160px; width: 160px;
height: 160px; height: 160px;
background: variables.$bg-default; background: linear-gradient(180deg, variables.$bg-default 0%, variables.$bg-surface 100%);
border: 1px solid variables.$border-default; border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-lg; border-radius: variables.$radius-lg;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@@ -19,29 +19,38 @@
cursor: pointer; cursor: pointer;
transition: transition:
transform 0.15s ease, transform variables.$duration-normal variables.$ease-out,
box-shadow 0.15s ease, box-shadow variables.$duration-normal variables.$ease-out,
border-color 0.15s ease, border-color variables.$duration-normal variables.$ease-out,
color 0.15s ease; background variables.$duration-normal variables.$ease-out,
color variables.$duration-normal variables.$ease-out;
&:hover { &:hover {
transform: translateY(-3px); transform: translateY(-4px);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-lg);
color: variables.$text-primary; color: variables.$text-primary;
border-color: variables.$purple-300;
}
&:active {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
} }
&.accent { &.accent {
background: variables.$purple-50; background: linear-gradient(135deg, variables.$purple-400 0%, variables.$purple-600 100%);
border-color: variables.$purple-100; border-color: transparent;
color: variables.$purple-400; color: variables.$color-white;
box-shadow: 0 4px 14px hsla(262, 75%, 48%, 0.25);
&:hover { &:hover {
background: variables.$purple-100; background: linear-gradient(135deg, variables.$purple-500 0%, variables.$purple-700 100%);
border-color: variables.$purple-400; box-shadow: 0 6px 20px hsla(262, 75%, 48%, 0.4);
} }
} }
} }
.label { .label {
@include typography.font-body-s; @include typography.font-body-s;
font-weight: 500;
} }
@@ -5,6 +5,8 @@
// Rounded hover for ghost icon button // Rounded hover for ghost icon button
:global(.rt-IconButton) { :global(.rt-IconButton) {
border-radius: variables.$radius-sm; 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; height: 16px;
padding: 0 4px; padding: 0 4px;
border-radius: 9999px; border-radius: 9999px;
background-color: #ef4444; background-color: var(--color-danger);
color: #fff; color: #fff;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
@@ -26,4 +28,10 @@
pointer-events: none; pointer-events: none;
border: 1.5px solid variables.$bg-default; border: 1.5px solid variables.$bg-default;
box-sizing: content-box; 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; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
right: 0; right: 0;
width: 360px; width: 380px;
max-height: 480px; max-height: 480px;
background-color: variables.$bg-surface; background-color: variables.$bg-default;
border: 1px solid variables.$border-default; border: 1px solid variables.$border-default;
border-radius: variables.$radius-md; border-radius: variables.$radius-md;
box-shadow: variables.$shadow-lg; box-shadow: var(--shadow-lg);
z-index: 100; z-index: 100;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
animation: popupEntrance 0.2s var(--ease-out) both;
} }
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px 16px; padding: 14px 16px;
border-bottom: 1px solid variables.$border-subtle; border-bottom: 1px solid variables.$border-default;
} }
.title { .title {
@include typography.font-body-14(600); @include typography.font-body-14(700);
letter-spacing: -0.006em;
color: variables.$text-primary; color: variables.$text-primary;
} }
.readAllBtn { .readAllBtn {
@include typography.font-caption-m; @include typography.font-caption-m;
font-weight: 500; font-weight: 600;
color: variables.$purple-500; color: variables.$purple-500;
background: none; background: none;
border: none; border: none;
cursor: pointer; 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 { &:hover {
color: variables.$purple-700; color: variables.$purple-700;
background-color: variables.$bg-surface;
} }
} }
@@ -57,10 +63,10 @@
gap: 12px; gap: 12px;
padding: 12px 16px; padding: 12px 16px;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s; transition: background-color variables.$duration-normal variables.$ease-out;
&:hover { &:hover {
background-color: variables.$bg-hover; background-color: variables.$bg-surface;
} }
&:not(:last-child) { &:not(:last-child) {
@@ -78,7 +84,7 @@
} }
.itemTitle { .itemTitle {
@include typography.font-body-14(500); @include typography.font-body-14(600);
color: variables.$text-primary; color: variables.$text-primary;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -109,23 +115,23 @@
padding: 1px 6px; padding: 1px 6px;
border-radius: 9999px; border-radius: 9999px;
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 600;
line-height: 16px; line-height: 16px;
} }
.statusRunning { .statusRunning {
background-color: #dbeafe; background-color: hsl(262, 50%, 94%);
color: #1d4ed8; color: hsl(262, 72%, 45%);
} }
.statusDone { .statusDone {
background-color: #dcfce7; background-color: hsl(150, 30%, 92%);
color: #15803d; color: hsl(150, 50%, 30%);
} }
.statusFailed { .statusFailed {
background-color: #fee2e2; background-color: hsl(0, 80%, 95%);
color: #b91c1c; color: hsl(0, 65%, 40%);
} }
.progressBar { .progressBar {
@@ -141,12 +147,23 @@
height: 100%; height: 100%;
background-color: variables.$purple-500; background-color: variables.$purple-500;
border-radius: 2px; border-radius: 2px;
transition: width 0.3s ease; transition: width 0.4s var(--ease-out);
} }
.empty { .empty {
padding: 32px 16px; padding: 40px 16px;
text-align: center; text-align: center;
@include typography.font-body-14(400); @include typography.font-body-14(400);
color: variables.$text-tertiary; 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 phone_number: string
} }
export const EditProfileForm: FunctionComponent< export const EditProfileForm: FunctionComponent<IEditProfileFormProps> = ({
IEditProfileFormProps user,
> = ({ user, className }): JSX.Element => { className,
}): JSX.Element => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [successMessage, setSuccessMessage] = useState(false) const [successMessage, setSuccessMessage] = useState(false)
@@ -78,7 +79,7 @@ export const EditProfileForm: FunctionComponent<
/> />
<TextField <TextField
id="email" id="email"
label="Email" label="Эл. почта"
placeholder="Ваш email" placeholder="Ваш email"
type="email" type="email"
{...register("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>
Удалить пресет &laquo;{deleteTarget.name}&raquo;? Это
действие нельзя отменить.
</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 { IModalProps } from "@shared/ui/Modal/Modal.d"
import type { ComponentProps } from "react"
export interface ICreateProjectModalProps extends Pick< export interface ICreateProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>, IModalProps,
"open" | "onOpenChange" "open" | "onOpenChange"
> { > {
onCreated?: () => void | Promise<void> onCreated?: () => void | Promise<void>
@@ -1,5 +1,5 @@
.root { .root {
min-width: 520px; width: 100%;
} }
.fields { .fields {
+2 -3
View File
@@ -1,8 +1,7 @@
import type { Dialog } from "@radix-ui/themes" import type { IModalProps } from "@shared/ui/Modal/Modal.d"
import type { ComponentProps } from "react"
export interface IDeleteFileModalProps export interface IDeleteFileModalProps
extends Pick<ComponentProps<typeof Dialog.Root>, "open" | "onOpenChange"> { extends Pick<IModalProps, "open" | "onOpenChange"> {
fileName: string fileName: string
onConfirm: () => void onConfirm: () => void
isPending: boolean isPending: boolean
@@ -1,5 +1,5 @@
.root { .root {
min-width: 420px; width: 100%;
} }
.message { .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 { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IDeleteProjectModalProps extends Pick< export interface IDeleteProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>, IModalProps,
"open" | "onOpenChange" "open" | "onOpenChange"
> { > {
project: components["schemas"]["ProjectRead"] project: components["schemas"]["ProjectRead"]
@@ -1,5 +1,5 @@
.root { .root {
min-width: 420px; width: 100%;
} }
.message { .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 { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IEditProjectModalProps extends Pick< export interface IEditProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>, IModalProps,
"open" | "onOpenChange" "open" | "onOpenChange"
> { > {
project: components["schemas"]["ProjectRead"] project: components["schemas"]["ProjectRead"]
@@ -1,5 +1,5 @@
.root { .root {
min-width: 520px; width: 100%;
} }
.fields { .fields {
+9
View File
@@ -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 { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IRenameProjectModalProps extends Pick< export interface IRenameProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>, IModalProps,
"open" | "onOpenChange" "open" | "onOpenChange"
> { > {
project: components["schemas"]["ProjectRead"] project: components["schemas"]["ProjectRead"]
@@ -1,5 +1,5 @@
.root { .root {
min-width: 420px; width: 100%;
} }
.fields { .fields {
@@ -4,17 +4,47 @@
gap: 16px; gap: 16px;
} }
.player { .playerWrapper {
display: flex;
flex-direction: column;
width: 100%; width: 100%;
border-radius: variables.$radius-md; border-radius: variables.$radius-md;
overflow: hidden; overflow: hidden;
aspect-ratio: 16 / 9;
} }
.playerWrapper { .videoArea {
position: relative; position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.video {
width: 100%; width: 100%;
height: 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 { .timeRange {
@@ -30,6 +60,52 @@
pointer-events: none; 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 { .textArea {
width: 100%; width: 100%;
min-height: 72px; min-height: 72px;
@@ -3,14 +3,7 @@
import type { ISegmentEditModalProps } from "./SegmentEditModal.d" import type { ISegmentEditModalProps } from "./SegmentEditModal.d"
import type { JSX } from "react" import type { JSX } from "react"
import { MediaPlayer, MediaProvider, useMediaState } from "@vidstack/react" import { LoaderCircle, Pause, Play, Scissors } from "lucide-react"
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default"
import "@vidstack/react/player/styles/default/theme.css"
import "@vidstack/react/player/styles/default/layouts/video.css"
import { LoaderCircle, Scissors } from "lucide-react"
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react" import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Button, Modal } from "@shared/ui" import { Button, Modal } from "@shared/ui"
@@ -23,52 +16,148 @@ import { SegmentSplitter } from "@features/project/SegmentSplitter"
import styles from "./SegmentEditModal.module.scss" import styles from "./SegmentEditModal.module.scss"
const SegmentPlayer = ({ const SegmentPlayer: FunctionComponent<{
videoUrl,
start,
end,
}: {
videoUrl: string videoUrl: string
start: number start: number
end: number end: number
}) => { }> = ({ videoUrl, start, end }) => {
const currentTime = useMediaState("currentTime") const videoRef = useRef<HTMLVideoElement>(null)
const playing = useMediaState("playing") const trackRef = useRef<HTMLDivElement>(null)
const hasPausedRef = useRef(false) const rafRef = useRef<number>(0)
const playerRef = useRef<HTMLElement | null>(null) 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(() => { 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]) }, [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(() => { useEffect(() => {
if (!playing) return if (!dragging) return
if (currentTime >= end && !hasPausedRef.current) { const handleMouseMove = (e: MouseEvent) => seekToPosition(e.clientX)
hasPausedRef.current = true const handleMouseUp = () => setDragging(false)
const player = playerRef.current as HTMLElement & { window.addEventListener("mousemove", handleMouseMove)
pause?: () => void window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
} }
player?.pause?.() }, [dragging, seekToPosition])
}
}, [currentTime, end, playing])
return ( return (
<div className={styles.playerWrapper}> <div className={styles.playerWrapper}>
<MediaProvider /> <div className={styles.videoArea}>
<DefaultVideoLayout <video
icons={defaultLayoutIcons} ref={videoRef}
slots={{ src={videoUrl}
settingsMenu: null, crossOrigin="anonymous"
pipButton: null, playsInline
fullscreenButton: null, preload="auto"
airPlayButton: null, className={styles.video}
googleCastButton: null,
}}
/> />
<button
type="button"
className={styles.playButton}
onClick={togglePlay}
>
{playing ? <Pause size={24} /> : <Play size={24} />}
</button>
<div className={styles.timeRange}> <div className={styles.timeRange}>
{secondsToTimecode(start)} {secondsToTimecode(end)} {secondsToTimecode(start)} {secondsToTimecode(end)}
</div> </div>
</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"> <div className={styles.root} data-testid="SegmentEditModal">
{videoUrl && ( {videoUrl && (
<MediaPlayer
src={videoUrl}
currentTime={segment.start}
className={styles.player}
autoPlay
>
<SegmentPlayer <SegmentPlayer
videoUrl={videoUrl} videoUrl={videoUrl}
start={segment.start} start={segment.start}
end={segment.end} end={segment.end}
/> />
</MediaPlayer>
)} )}
{splitMode ? ( {splitMode ? (
@@ -1,5 +1,5 @@
.root { .root {
min-width: 520px; width: 100%;
} }
.fields { .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 { .segment {
border: 1px solid variables.$border-default; border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md; border-radius: variables.$radius-md;
padding: 10px 12px; padding: 12px 16px;
background: variables.$bg-surface; 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 { &.highlight {
border-color: variables.$color-primary; border-color: variables.$color-primary;
@@ -94,51 +99,72 @@
.segmentTimes { .segmentTimes {
display: flex; display: flex;
align-items: flex-end; align-items: center;
gap: 10px; justify-content: space-between;
margin-bottom: 8px; margin-bottom: 12px;
}
.timesGroup {
display: flex;
align-items: center;
gap: 16px;
}
.actionsGroup {
display: flex;
align-items: center;
gap: 6px;
} }
.timeLabel { .timeLabel {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 2px; gap: 8px;
} }
.timeLabelText { .timeLabelText {
font-size: 11px; font-size: 11px;
color: variables.$text-tertiary; color: variables.$text-tertiary;
font-weight: 500; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.timeInput { .timeInput {
width: 100px; width: 84px;
padding: 4px 8px; padding: 4px 8px;
border: 1px solid variables.$border-default; border: 1px solid transparent;
border-radius: variables.$radius-sm; border-radius: variables.$radius-sm;
font-size: 13px; font-size: 12px;
font-family: monospace; font-family: monospace;
color: variables.$text-primary; color: variables.$text-secondary;
background: variables.$bg-default; background: variables.$bg-hover;
transition: all 0.2s ease;
text-align: center;
&:focus { &:focus {
outline: none; outline: none;
background: variables.$bg-surface;
border-color: variables.$color-primary; 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; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-left: auto; padding: 6px;
padding: 4px;
border: none; border: none;
background: none; background: transparent;
color: variables.$text-tertiary; color: variables.$text-tertiary;
cursor: pointer; cursor: pointer;
border-radius: variables.$radius-sm; border-radius: variables.$radius-sm;
transition: all 0.2s ease;
}
.splitButton {
&:hover:not(:disabled) { &:hover:not(:disabled) {
color: variables.$color-primary; color: variables.$color-primary;
background: variables.$bg-hover; background: variables.$bg-hover;
@@ -151,37 +177,34 @@
} }
.removeButton { .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 { &:hover {
color: variables.$color-danger; color: variables.$color-danger;
background: variables.$bg-hover; background: rgba(239, 68, 68, 0.1);
} }
} }
.textArea { .textArea {
width: 100%; width: 100%;
padding: 8px; padding: 10px 12px;
border: 1px solid variables.$border-default; border: 1px solid transparent;
border-radius: variables.$radius-sm; border-radius: variables.$radius-sm;
font-size: 13px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
color: variables.$text-primary; color: variables.$text-primary;
background: variables.$bg-default; background: variables.$bg-hover;
resize: vertical; resize: vertical;
font-family: inherit; font-family: inherit;
transition: all 0.2s ease;
&:hover {
background: variables.$bg-hover;
}
&:focus { &:focus {
outline: none; outline: none;
background: variables.$bg-surface;
border-color: variables.$color-primary; 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 type { JSX } from "react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import cs from "classnames" import { LoaderCircle, Plus, Scissors, Trash2 } from "lucide-react"
import { LoaderCircle, Plus, Save, Scissors, Trash2 } from "lucide-react"
import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react" import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react"
import api from "@shared/api" import api from "@shared/api"
@@ -146,6 +145,15 @@ export const TranscriptionEditor: FunctionComponent<
} }
}, [transcription, segments, artifactId, queryClient]) }, [transcription, segments, artifactId, queryClient])
// Auto-save when dirty (debounced)
useEffect(() => {
if (!dirty) return
const timer = setTimeout(() => {
handleSave()
}, 1500)
return () => clearTimeout(timer)
}, [dirty, handleSave])
/* Loading */ /* Loading */
if (isLoading) { if (isLoading) {
return ( return (
@@ -171,18 +179,6 @@ export const TranscriptionEditor: FunctionComponent<
{/* Header */} {/* Header */}
<div className={styles.header}> <div className={styles.header}>
<h3 className={styles.title}>Редактор транскрипции</h3> <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> </div>
{/* Segments list */} {/* Segments list */}
@@ -190,6 +186,7 @@ export const TranscriptionEditor: FunctionComponent<
{segments.map((seg, idx) => ( {segments.map((seg, idx) => (
<div key={idx} className={styles.segment} data-segment-index={idx}> <div key={idx} className={styles.segment} data-segment-index={idx}>
<div className={styles.segmentTimes}> <div className={styles.segmentTimes}>
<div className={styles.timesGroup}>
<label className={styles.timeLabel}> <label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Начало</span> <span className={styles.timeLabelText}>Начало</span>
<input <input
@@ -214,6 +211,8 @@ export const TranscriptionEditor: FunctionComponent<
placeholder="00:00.000" placeholder="00:00.000"
/> />
</label> </label>
</div>
<div className={styles.actionsGroup}>
<button <button
className={styles.splitButton} className={styles.splitButton}
onClick={() => setSplittingIdx(idx)} onClick={() => setSplittingIdx(idx)}
@@ -234,6 +233,7 @@ export const TranscriptionEditor: FunctionComponent<
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div> </div>
</div>
{splittingIdx === idx ? ( {splittingIdx === idx ? (
<SegmentSplitter <SegmentSplitter
segment={seg} segment={seg}
@@ -1,5 +1,5 @@
.root { .root {
min-width: 520px; width: 100%;
} }
.fields { .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, activeJobType,
setActiveJob, setActiveJob,
startProcessingJob, startProcessingJob,
goBack, goToStep,
} = useWizard() } = useWizard()
const isProcessing = const isProcessing =
@@ -310,7 +310,7 @@ export const TranscriptionSettingsStep: FunctionComponent<
type="button" type="button"
variant="outline" variant="outline"
disabled={isPending} disabled={isPending}
onClick={goBack} onClick={() => goToStep("fragments")}
> >
Назад Назад
</Button> </Button>
@@ -0,0 +1 @@
export * from "./TranscriptionSettingsStep"
+3
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./UploadStep"
+3
View File
@@ -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) : "—"}{" "}
&middot; {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>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./VerifyStep"
+9
View File
@@ -1,15 +1,24 @@
export { CaptionResultStep } from "./CaptionResultStep"
export { CaptionSettingsStep } from "./CaptionSettingsStep"
export { ConvertMediaView } from "./ConvertMediaView" export { ConvertMediaView } from "./ConvertMediaView"
export { CreateProjectModal } from "./CreateProjectModal" export { CreateProjectModal } from "./CreateProjectModal"
export { DeleteFileModal } from "./DeleteFileModal" export { DeleteFileModal } from "./DeleteFileModal"
export { DeleteProjectModal } from "./DeleteProjectModal" export { DeleteProjectModal } from "./DeleteProjectModal"
export { EditProjectModal } from "./EditProjectModal" export { EditProjectModal } from "./EditProjectModal"
export { FragmentsStep } from "./FragmentsStep"
export { ProcessingStep } from "./ProcessingStep"
export { RenameProjectModal } from "./RenameProjectModal" export { RenameProjectModal } from "./RenameProjectModal"
export { SegmentEditModal } from "./SegmentEditModal" export { SegmentEditModal } from "./SegmentEditModal"
export { SegmentSplitter } from "./SegmentSplitter" export { SegmentSplitter } from "./SegmentSplitter"
export { SilenceSettingsStep } from "./SilenceSettingsStep"
export { SubtitleRevisionStep } from "./SubtitleRevisionStep"
export { TranscriptionEditor } from "./TranscriptionEditor" export { TranscriptionEditor } from "./TranscriptionEditor"
export { TranscriptionSettingsStep } from "./TranscriptionSettingsStep"
export { SilenceResultModal } from "./SilenceResultModal" export { SilenceResultModal } from "./SilenceResultModal"
export { SilenceSettingsModal } from "./SilenceSettingsModal" export { SilenceSettingsModal } from "./SilenceSettingsModal"
export { TranscriptionModal } from "./TranscriptionModal" export { TranscriptionModal } from "./TranscriptionModal"
export { UploadStep } from "./UploadStep"
export { VerifyStep } from "./VerifyStep"
export { WaveformTrack } from "./WaveformTrack" export { WaveformTrack } from "./WaveformTrack"
export { SubtitlesTrack } from "./SubtitlesTrack" export { SubtitlesTrack } from "./SubtitlesTrack"
export { SilenceTrack } from "./SilenceTrack" export { SilenceTrack } from "./SilenceTrack"
+18
View File
@@ -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,
},
})
+5 -5
View File
@@ -1,8 +1,8 @@
.root { .root {
padding: 28px 24px 40px; padding: 32px 24px 48px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 32px; gap: 36px;
} }
.welcome { .welcome {
@@ -15,10 +15,10 @@
} }
.greeting { .greeting {
font-weight: 700; font-weight: 800;
font-size: 52px; font-size: 52px;
line-height: 1.1; line-height: 1.05;
letter-spacing: -1px; letter-spacing: -0.03em;
color: variables.$text-primary; color: variables.$text-primary;
margin: 0; margin: 0;
} }
-19
View File
@@ -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()
})
})
+35 -10
View File
@@ -4,6 +4,7 @@ import type { JSX } from "react"
import { FunctionComponent, useMemo, useState } from "react" import { FunctionComponent, useMemo, useState } from "react"
import { motion } from "framer-motion"
import { FolderKanban, PlusIcon } from "lucide-react" import { FolderKanban, PlusIcon } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
@@ -12,7 +13,12 @@ import { CreateProjectModal } from "@features/project"
import api from "@shared/api" import api from "@shared/api"
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext" import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
import { useAppSelector } from "@shared/hooks/useAppSelector" 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 { RecentProjects } from "@widgets/Dashboard/RecentProjects"
import { StatsGrid } from "@widgets/Dashboard/StatsGrid" import { StatsGrid } from "@widgets/Dashboard/StatsGrid"
@@ -56,16 +62,29 @@ export const HomePage: FunctionComponent<IHomePageProps> = (): JSX.Element => {
const userName = user?.first_name || user?.username || "пользователь" const userName = user?.first_name || user?.username || "пользователь"
return ( return (
<div className={cls.root}> <motion.div
{isLoading && <StaticLoader fullscreen />} className={cls.root}
variants={STAGGER_CONTAINER}
<div className={cls.welcome}> initial="initial"
animate="animate"
>
<motion.div
className={cls.welcome}
variants={SLIDE_UP}
transition={EASE_OUT_TRANSITION}
>
<h1 className={cls.greeting}>Добро пожаловать, {userName}</h1> <h1 className={cls.greeting}>Добро пожаловать, {userName}</h1>
<p className={cls.subtitle}>{subtitle}</p> <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 <RecentProjects
projects={recentProjects} projects={recentProjects}
isLoading={isLoading} isLoading={isLoading}
@@ -73,8 +92,14 @@ export const HomePage: FunctionComponent<IHomePageProps> = (): JSX.Element => {
onCreateClick={() => setIsCreateModalOpen(true)} onCreateClick={() => setIsCreateModalOpen(true)}
onViewAllClick={() => router.push("/projects")} 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> <h2 className={cls.actionsTitle}>Быстрые действия</h2>
<div className={cls.actions}> <div className={cls.actions}>
<ActionCard <ActionCard
@@ -89,7 +114,7 @@ export const HomePage: FunctionComponent<IHomePageProps> = (): JSX.Element => {
onClick={() => router.push("/projects")} onClick={() => router.push("/projects")}
/> />
</div> </div>
</div> </motion.div>
<CreateProjectModal <CreateProjectModal
open={isCreateModalOpen} open={isCreateModalOpen}
@@ -98,7 +123,7 @@ export const HomePage: FunctionComponent<IHomePageProps> = (): JSX.Element => {
await refetch() await refetch()
}} }}
/> />
</div> </motion.div>
) )
} }
+24 -6
View File
@@ -4,6 +4,10 @@
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
padding: 24px; 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 { .form {
@@ -11,16 +15,19 @@
max-width: 400px; max-width: 400px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 24px;
padding: 40px 32px; padding: 40px 32px;
background-color: variables.$bg-default; background-color: variables.$bg-default;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-lg; border-radius: variables.$radius-lg;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-lg);
animation: formEntrance 0.5s var(--ease-out) both;
} }
.title { .title {
@include typography.font-header-l; @include typography.font-header-l;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
width: 100%; width: 100%;
text-align: center; text-align: center;
color: variables.$text-primary; color: variables.$text-primary;
@@ -29,7 +36,7 @@
.fields { .fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 14px;
} }
.actions { .actions {
@@ -41,9 +48,20 @@
@include typography.font-body-s; @include typography.font-body-s;
color: variables.$text-secondary; color: variables.$text-secondary;
text-decoration: none; text-decoration: none;
transition: color 0.15s ease; transition: color var(--duration-normal) var(--ease-out);
&:hover { &: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 { .sectionTitle {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 700;
letter-spacing: -0.017em;
margin-bottom: 16px; margin-bottom: 16px;
} }
+13 -2
View File
@@ -13,12 +13,23 @@ import {
import api from "@shared/api" import api from "@shared/api"
import { formatDate } from "@shared/lib/dates" import { formatDate } from "@shared/lib/dates"
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext" import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
import { StaticLoader } from "@shared/ui/Loader" import { Skeleton } from "@shared/ui/Skeleton"
import { Card } from "@shared/ui" import { Card } from "@shared/ui"
import { IProfilePageProps } from "./ProfilePage.d" import { IProfilePageProps } from "./ProfilePage.d"
import styles from "./ProfilePage.module.scss" 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< export const ProfilePage: FunctionComponent<
IProfilePageProps IProfilePageProps
> = (): JSX.Element => { > = (): JSX.Element => {
@@ -46,7 +57,7 @@ export const ProfilePage: FunctionComponent<
) )
} }
if (isLoading) return <StaticLoader fullscreen /> if (isLoading) return <ProfileSkeleton />
if (!user) { if (!user) {
return ( return (
+3
View File
@@ -0,0 +1,3 @@
export interface IProjectWizardPageProps {
className?: string
}
@@ -1,3 +1,4 @@
.root { .root {
width: 100%; 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>
)
}
+1
View File
@@ -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
View File
@@ -1 +0,0 @@
export * from "./ProjectWorkspacePage"
+6 -3
View File
@@ -19,7 +19,7 @@ import {
import api from "@shared/api" import api from "@shared/api"
import { useDebounce } from "@shared/hooks/useDebounce" import { useDebounce } from "@shared/hooks/useDebounce"
import { Button } from "@shared/ui" import { Button } from "@shared/ui"
import { StaticLoader } from "@shared/ui/Loader" import { ProjectCardSkeleton } from "@shared/ui/Skeleton"
import { import {
ProjectsHeader, ProjectsHeader,
type ProjectStatusEnum, type ProjectStatusEnum,
@@ -59,7 +59,6 @@ export const ProjectsPage: FunctionComponent<
return ( return (
<div className={styles.root} data-testid="ProjectsPage"> <div className={styles.root} data-testid="ProjectsPage">
{projectsLoading && <StaticLoader fullscreen />}
<div className={styles.header}> <div className={styles.header}>
<div className={styles.titles}> <div className={styles.titles}>
<h1 className={styles.title}>Мои проекты</h1> <h1 className={styles.title}>Мои проекты</h1>
@@ -133,7 +132,11 @@ export const ProjectsPage: FunctionComponent<
/> />
<div className={styles.projectList}> <div className={styles.projectList}>
{projects?.map((project) => ( {projectsLoading
? Array.from({ length: 6 }).map((_, i) => (
<ProjectCardSkeleton key={i} />
))
: projects?.map((project) => (
<ProjectCard <ProjectCard
key={project.id} key={project.id}
project={project} project={project}
@@ -16,7 +16,7 @@ const PING_INTERVAL_MS = 5000
export const UnderMaintenancePage: FunctionComponent<IUnderMaintenancePageProps> = (): JSX.Element => { export const UnderMaintenancePage: FunctionComponent<IUnderMaintenancePageProps> = (): JSX.Element => {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const redirectPath = searchParams.get("path") || "/" const redirectPath = searchParams?.get("path") || "/"
const { isSuccess } = api.useQuery("get", "/api/ping/", {}, { const { isSuccess } = api.useQuery("get", "/api/ping/", {}, {
refetchInterval: PING_INTERVAL_MS, refetchInterval: PING_INTERVAL_MS,
+51
View File
@@ -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
View File
@@ -1,56 +1,8 @@
import createClient from "openapi-react-query" 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" export { fetchClient }
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 const api = createClient(fetchClient) export const api = createClient(fetchClient)
export default api export default api
+1 -1
View File
@@ -1,6 +1,6 @@
"use server" "use server"
import { fetchClient } from "." import { fetchClient } from "./fetchClient"
export const pingServer = async (): Promise<boolean> => { export const pingServer = async (): Promise<boolean> => {
try { try {

Some files were not shown because too many files have changed in this diff Show More