iter 2
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
@@ -102,8 +104,8 @@ Use `date-fns` with Russian locale for all date formatting — never use `moment
|
|||||||
```ts
|
```ts
|
||||||
import { formatDate, formatRelativeTime } from "@shared/lib/dates"
|
import { formatDate, formatRelativeTime } from "@shared/lib/dates"
|
||||||
|
|
||||||
formatDate(user.date_joined) // "21.02.2026"
|
formatDate(user.date_joined) // "21.02.2026"
|
||||||
formatDate(date, "dd MMM yyyy") // "21 февр. 2026"
|
formatDate(date, "dd MMM yyyy") // "21 февр. 2026"
|
||||||
formatRelativeTime(project.updated_at) // "2 дня назад"
|
formatRelativeTime(project.updated_at) // "2 дня назад"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { StaticLoader } from "@shared/ui/Loader"
|
||||||
|
|
||||||
|
export default function ProtectedLoading() {
|
||||||
|
return <StaticLoader block />
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Skeleton } from "@shared/ui/Skeleton"
|
||||||
|
|
||||||
|
export default function ProfileLoading() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "32px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "24px",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "640px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton width="120px" height="120px" borderRadius="50%" />
|
||||||
|
<Skeleton width="100%" height="200px" borderRadius="10px" />
|
||||||
|
<Skeleton width="100%" height="160px" borderRadius="10px" />
|
||||||
|
<Skeleton width="100%" height="140px" borderRadius="10px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { JSX } from "react"
|
import { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+8
-5
@@ -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>
|
||||||
<AppProviders>{children}</AppProviders>
|
<div id="app-root">
|
||||||
|
<AppProviders>{children}</AppProviders>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-2
@@ -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 />
|
||||||
{children}
|
<motion.div
|
||||||
|
key={pathname}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<UnderMaintenancePage />
|
<Suspense>
|
||||||
|
<UnderMaintenancePage />
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
Удалить пресет «{deleteTarget.name}»? Это
|
||||||
|
действие нельзя отменить.
|
||||||
|
</p>
|
||||||
|
<div className={styles.deleteActions}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={deletePreset.isPending}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameField {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--gray-2);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--gray-4);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--gray-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderField {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--gray-2);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--gray-4);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--gray-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gray-11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorField {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--gray-2);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--gray-4);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--gray-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorSwatch {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorPopover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--gray-1);
|
||||||
|
border: 1px solid var(--gray-6);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorClose {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent-9);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--accent-10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--gray-6);
|
||||||
|
}
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import cs from "classnames"
|
||||||
|
import { FunctionComponent, useCallback, useRef, useState } from "react"
|
||||||
|
import { HexColorPicker } from "react-colorful"
|
||||||
|
import { Controller, useForm, useWatch } from "react-hook-form"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Slider,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
TextField,
|
||||||
|
} from "@shared/ui"
|
||||||
|
|
||||||
|
import { StylePreview } from "./StylePreview"
|
||||||
|
import { useCreatePreset, useUpdatePreset } from "./useCaptionPresets"
|
||||||
|
|
||||||
|
import styles from "./StyleEditor.module.scss"
|
||||||
|
|
||||||
|
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
|
||||||
|
|
||||||
|
interface IStyleEditorProps {
|
||||||
|
initialConfig?: CaptionStyleConfig | null
|
||||||
|
presetId?: string | null
|
||||||
|
presetName?: string
|
||||||
|
onSaved: (presetId: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
name: string
|
||||||
|
text: {
|
||||||
|
font_family: string
|
||||||
|
font_size: number
|
||||||
|
font_weight: number
|
||||||
|
text_color: string
|
||||||
|
highlight_color: string
|
||||||
|
text_shadow: string | null
|
||||||
|
text_stroke_width: number
|
||||||
|
text_stroke_color: string
|
||||||
|
}
|
||||||
|
layout: {
|
||||||
|
vertical_position: "top" | "center" | "bottom"
|
||||||
|
horizontal_alignment: "left" | "center" | "right"
|
||||||
|
padding_px: number
|
||||||
|
max_width_pct: number
|
||||||
|
lines_per_screen: number
|
||||||
|
}
|
||||||
|
animation: {
|
||||||
|
highlight_style: "color" | "scale" | "underline" | "color_scale"
|
||||||
|
highlight_scale: number
|
||||||
|
segment_transition: "fade" | "slide" | "none"
|
||||||
|
fade_duration_frames: number
|
||||||
|
animation_speed: number
|
||||||
|
}
|
||||||
|
background: {
|
||||||
|
bg_color: string
|
||||||
|
bg_blur_px: number
|
||||||
|
bg_glow_color: string | null
|
||||||
|
bg_border_radius_px: number
|
||||||
|
bg_padding_px: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: FormValues = {
|
||||||
|
name: "",
|
||||||
|
text: {
|
||||||
|
font_family: "Lobster",
|
||||||
|
font_size: 40,
|
||||||
|
font_weight: 400,
|
||||||
|
text_color: "#FFFFFF",
|
||||||
|
highlight_color: "#FFFF00",
|
||||||
|
text_shadow: "0 2px 4px rgba(0,0,0,0.5)" as string | null,
|
||||||
|
text_stroke_width: 0,
|
||||||
|
text_stroke_color: "#000000",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
vertical_position: "bottom" as const,
|
||||||
|
horizontal_alignment: "center" as const,
|
||||||
|
padding_px: 16,
|
||||||
|
max_width_pct: 90,
|
||||||
|
lines_per_screen: 2,
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
highlight_style: "color" as const,
|
||||||
|
highlight_scale: 1.2,
|
||||||
|
segment_transition: "fade" as const,
|
||||||
|
fade_duration_frames: 5,
|
||||||
|
animation_speed: 1.0,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
bg_color: "rgba(0,0,0,0.6)",
|
||||||
|
bg_blur_px: 0,
|
||||||
|
bg_glow_color: null as string | null,
|
||||||
|
bg_border_radius_px: 8,
|
||||||
|
bg_padding_px: 12,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Color picker field */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const ColorField: FunctionComponent<{
|
||||||
|
value: string
|
||||||
|
onChange: (val: string) => void
|
||||||
|
label: string
|
||||||
|
}> = ({ value, onChange, label }) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.colorField} ref={ref}>
|
||||||
|
<span className={styles.fieldLabel}>{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.colorSwatch}
|
||||||
|
style={{ backgroundColor: value || "transparent" }}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
/>
|
||||||
|
{open && (
|
||||||
|
<div className={styles.colorPopover}>
|
||||||
|
<HexColorPicker color={value} onChange={onChange} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.colorClose}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sub-tab: Текст */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const TextFields: FunctionComponent<{
|
||||||
|
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||||
|
}> = ({ control }) => (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<Controller
|
||||||
|
name="text.font_family"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<span className={styles.fieldLabel}>Шрифт</span>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Шрифт"
|
||||||
|
>
|
||||||
|
{["Lobster", "Inter", "Roboto", "Montserrat", "Open Sans"].map(
|
||||||
|
(f) => (
|
||||||
|
<SelectItem key={f} value={f}>
|
||||||
|
{f}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="text.font_size"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Размер шрифта"
|
||||||
|
unit="px"
|
||||||
|
min={16}
|
||||||
|
max={96}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="text.font_weight"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<span className={styles.fieldLabel}>Начертание</span>
|
||||||
|
<Select
|
||||||
|
value={String(field.value)}
|
||||||
|
onValueChange={(v) => field.onChange(Number(v))}
|
||||||
|
placeholder="Начертание"
|
||||||
|
>
|
||||||
|
<SelectItem value="400">Обычный</SelectItem>
|
||||||
|
<SelectItem value="700">Жирный</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="text.text_color"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ColorField
|
||||||
|
label="Цвет текста"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="text.highlight_color"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ColorField
|
||||||
|
label="Цвет выделения"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="text.text_stroke_width"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Обводка текста"
|
||||||
|
unit="px"
|
||||||
|
min={0}
|
||||||
|
max={5}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="text.text_stroke_color"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ColorField
|
||||||
|
label="Цвет обводки"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sub-tab: Позиция */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const LayoutFields: FunctionComponent<{
|
||||||
|
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||||
|
}> = ({ control }) => (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<Controller
|
||||||
|
name="layout.vertical_position"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<span className={styles.fieldLabel}>
|
||||||
|
Вертикальная позиция
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Позиция"
|
||||||
|
>
|
||||||
|
<SelectItem value="top">Сверху</SelectItem>
|
||||||
|
<SelectItem value="center">По центру</SelectItem>
|
||||||
|
<SelectItem value="bottom">Снизу</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="layout.horizontal_alignment"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<span className={styles.fieldLabel}>Выравнивание</span>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Выравнивание"
|
||||||
|
>
|
||||||
|
<SelectItem value="left">Слева</SelectItem>
|
||||||
|
<SelectItem value="center">По центру</SelectItem>
|
||||||
|
<SelectItem value="right">Справа</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="layout.max_width_pct"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Макс. ширина"
|
||||||
|
unit="%"
|
||||||
|
min={20}
|
||||||
|
max={100}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="layout.padding_px"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Отступы"
|
||||||
|
unit="px"
|
||||||
|
min={0}
|
||||||
|
max={64}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="layout.lines_per_screen"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Строк на экране"
|
||||||
|
min={1}
|
||||||
|
max={4}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sub-tab: Анимация */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const AnimationFields: FunctionComponent<{
|
||||||
|
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||||
|
}> = ({ control }) => (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<Controller
|
||||||
|
name="animation.highlight_style"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<span className={styles.fieldLabel}>Стиль выделения</span>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Стиль"
|
||||||
|
>
|
||||||
|
<SelectItem value="color">Цвет</SelectItem>
|
||||||
|
<SelectItem value="scale">Масштаб</SelectItem>
|
||||||
|
<SelectItem value="underline">Подчёркивание</SelectItem>
|
||||||
|
<SelectItem value="color_scale">
|
||||||
|
Цвет + масштаб
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="animation.highlight_scale"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Масштаб выделения"
|
||||||
|
min={1.0}
|
||||||
|
max={2.0}
|
||||||
|
step={0.1}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="animation.segment_transition"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.fieldGroup}>
|
||||||
|
<span className={styles.fieldLabel}>Переход</span>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Переход"
|
||||||
|
>
|
||||||
|
<SelectItem value="fade">Затухание</SelectItem>
|
||||||
|
<SelectItem value="slide">Сдвиг</SelectItem>
|
||||||
|
<SelectItem value="none">Без перехода</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="animation.fade_duration_frames"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Длительность перехода"
|
||||||
|
unit=" кадров"
|
||||||
|
min={0}
|
||||||
|
max={30}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="animation.animation_speed"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Скорость анимации"
|
||||||
|
min={0.5}
|
||||||
|
max={2.0}
|
||||||
|
step={0.1}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sub-tab: Фон */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const BackgroundFields: FunctionComponent<{
|
||||||
|
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||||
|
}> = ({ control }) => (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<Controller
|
||||||
|
name="background.bg_color"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ColorField
|
||||||
|
label="Цвет фона"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="background.bg_blur_px"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Размытие фона"
|
||||||
|
unit="px"
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="background.bg_glow_color"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ColorField
|
||||||
|
label="Цвет свечения"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="background.bg_border_radius_px"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Скругление углов"
|
||||||
|
unit="px"
|
||||||
|
min={0}
|
||||||
|
max={24}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="background.bg_padding_px"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className={styles.sliderField}>
|
||||||
|
<Slider
|
||||||
|
label="Внутренний отступ"
|
||||||
|
unit="px"
|
||||||
|
min={0}
|
||||||
|
max={32}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main editor */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const buildDefaultValues = (
|
||||||
|
config?: CaptionStyleConfig | null,
|
||||||
|
name?: string,
|
||||||
|
): FormValues => ({
|
||||||
|
name: name ?? "",
|
||||||
|
text: { ...DEFAULT_VALUES.text, ...config?.text },
|
||||||
|
layout: { ...DEFAULT_VALUES.layout, ...config?.layout },
|
||||||
|
animation: { ...DEFAULT_VALUES.animation, ...config?.animation },
|
||||||
|
background: { ...DEFAULT_VALUES.background, ...config?.background },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const StyleEditor: FunctionComponent<IStyleEditorProps> = ({
|
||||||
|
initialConfig,
|
||||||
|
presetId,
|
||||||
|
presetName,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}): JSX.Element => {
|
||||||
|
const isEditing = !!presetId
|
||||||
|
|
||||||
|
const { control, handleSubmit, formState } = useForm<FormValues>({
|
||||||
|
defaultValues: buildDefaultValues(initialConfig, presetName),
|
||||||
|
})
|
||||||
|
|
||||||
|
const watchedValues = useWatch({ control })
|
||||||
|
const previewConfig: CaptionStyleConfig = {
|
||||||
|
text: watchedValues.text as CaptionStyleConfig["text"],
|
||||||
|
layout: watchedValues.layout as CaptionStyleConfig["layout"],
|
||||||
|
animation: watchedValues.animation as CaptionStyleConfig["animation"],
|
||||||
|
background:
|
||||||
|
watchedValues.background as CaptionStyleConfig["background"],
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPreset = useCreatePreset()
|
||||||
|
const updatePreset = useUpdatePreset()
|
||||||
|
const isSaving = createPreset.isPending || updatePreset.isPending
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
(data: FormValues) => {
|
||||||
|
const styleConfig: CaptionStyleConfig = {
|
||||||
|
text: data.text,
|
||||||
|
layout: data.layout,
|
||||||
|
animation: data.animation,
|
||||||
|
background: data.background,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing && presetId) {
|
||||||
|
updatePreset.mutate(
|
||||||
|
{
|
||||||
|
params: { path: { preset_id: presetId } },
|
||||||
|
body: {
|
||||||
|
name: data.name || undefined,
|
||||||
|
style_config: styleConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ onSuccess: () => onSaved(presetId) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
createPreset.mutate(
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
name: data.name,
|
||||||
|
style_config: styleConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ onSuccess: (res) => onSaved(res.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isEditing, presetId, createPreset, updatePreset, onSaved],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={styles.editor}
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
data-testid="StyleEditor"
|
||||||
|
>
|
||||||
|
<StylePreview config={previewConfig} size="large" />
|
||||||
|
|
||||||
|
<div className={styles.nameRow}>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: !isEditing }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
id="preset-name"
|
||||||
|
placeholder="Название пресета"
|
||||||
|
className={styles.nameField}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="text" className={styles.tabs}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="text">Текст</TabsTrigger>
|
||||||
|
<TabsTrigger value="layout">Позиция</TabsTrigger>
|
||||||
|
<TabsTrigger value="animation">Анимация</TabsTrigger>
|
||||||
|
<TabsTrigger value="background">Фон</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="text">
|
||||||
|
<TextFields control={control} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="layout">
|
||||||
|
<LayoutFields control={control} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="animation">
|
||||||
|
<AnimationFields control={control} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="background">
|
||||||
|
<BackgroundFields control={control} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className={styles.editorFooter}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={isSaving || (!isEditing && !formState.dirtyFields.name)}
|
||||||
|
>
|
||||||
|
{isSaving
|
||||||
|
? "Сохранение..."
|
||||||
|
: isEditing
|
||||||
|
? "Сохранить"
|
||||||
|
: "Создать пресет"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #0c0a1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
--preview-h: calc(100cqh - 38px);
|
||||||
|
height: var(--preview-h);
|
||||||
|
width: calc(var(--preview-h) * 9 / 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
aspect-ratio: 9 / 16;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
import cs from "classnames"
|
||||||
|
|
||||||
|
import styles from "./StylePreview.module.scss"
|
||||||
|
|
||||||
|
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
|
||||||
|
|
||||||
|
interface IStylePreviewProps {
|
||||||
|
config?: CaptionStyleConfig | null
|
||||||
|
size?: "small" | "large"
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SMALL_SCALE = 0.65
|
||||||
|
|
||||||
|
const buildContainerStyles = (
|
||||||
|
config: CaptionStyleConfig,
|
||||||
|
scale: number,
|
||||||
|
): React.CSSProperties => {
|
||||||
|
const bg = config.background
|
||||||
|
return {
|
||||||
|
backgroundColor: bg?.bg_color ?? "rgba(0,0,0,0.6)",
|
||||||
|
borderRadius: (bg?.bg_border_radius_px ?? 8) * scale,
|
||||||
|
padding: (bg?.bg_padding_px ?? 12) * scale,
|
||||||
|
...(bg?.bg_blur_px
|
||||||
|
? { backdropFilter: `blur(${bg.bg_blur_px * scale}px)` }
|
||||||
|
: {}),
|
||||||
|
...(bg?.bg_glow_color
|
||||||
|
? { boxShadow: `0 0 ${20 * scale}px ${bg.bg_glow_color}` }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildTextStyles = (
|
||||||
|
config: CaptionStyleConfig,
|
||||||
|
scale: number,
|
||||||
|
): React.CSSProperties => {
|
||||||
|
const text = config.text
|
||||||
|
return {
|
||||||
|
fontFamily: text?.font_family ?? "Lobster",
|
||||||
|
fontSize: (text?.font_size ?? 40) * scale,
|
||||||
|
fontWeight: text?.font_weight ?? 400,
|
||||||
|
color: text?.text_color ?? "#FFFFFF",
|
||||||
|
textAlign:
|
||||||
|
(config.layout?.horizontal_alignment as "left" | "center" | "right") ??
|
||||||
|
"center",
|
||||||
|
...(text?.text_shadow ? { textShadow: text.text_shadow } : {}),
|
||||||
|
...(text?.text_stroke_width
|
||||||
|
? {
|
||||||
|
WebkitTextStroke: `${(text.text_stroke_width ?? 0) * scale}px ${text.text_stroke_color ?? "#000000"}`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERTICAL_MAP: Record<string, string> = {
|
||||||
|
top: "flex-start",
|
||||||
|
center: "center",
|
||||||
|
bottom: "flex-end",
|
||||||
|
}
|
||||||
|
|
||||||
|
const HORIZONTAL_MAP: Record<string, string> = {
|
||||||
|
left: "flex-start",
|
||||||
|
center: "center",
|
||||||
|
right: "flex-end",
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPositionStyles = (
|
||||||
|
config: CaptionStyleConfig,
|
||||||
|
scale: number,
|
||||||
|
): React.CSSProperties => {
|
||||||
|
const layout = config.layout
|
||||||
|
const vPos = layout?.vertical_position ?? "bottom"
|
||||||
|
const hAlign = layout?.horizontal_alignment ?? "center"
|
||||||
|
const padding = (layout?.padding_px ?? 20) * scale
|
||||||
|
|
||||||
|
return {
|
||||||
|
justifyContent: VERTICAL_MAP[vPos] ?? "flex-end",
|
||||||
|
alignItems: HORIZONTAL_MAP[hAlign] ?? "center",
|
||||||
|
padding,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StylePreview: FunctionComponent<IStylePreviewProps> = ({
|
||||||
|
config,
|
||||||
|
size = "small",
|
||||||
|
className,
|
||||||
|
}): JSX.Element => {
|
||||||
|
const safeConfig = config ?? {}
|
||||||
|
const highlightColor = safeConfig.text?.highlight_color ?? "#FFFF00"
|
||||||
|
const scale = size === "small" ? SMALL_SCALE : 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cs(styles.root, styles[size], className)}
|
||||||
|
style={buildPositionStyles(safeConfig, scale)}
|
||||||
|
data-testid="StylePreview"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...buildContainerStyles(safeConfig, scale),
|
||||||
|
maxWidth: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...buildTextStyles(safeConfig, scale),
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Пример <span style={{ color: highlightColor }}>субтитров</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { CaptionSettingsStep } from "./CaptionSettingsStep"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
import api from "@shared/api"
|
||||||
|
|
||||||
|
const PRESETS_QUERY_KEY = ["get", "/api/captions/presets/"]
|
||||||
|
|
||||||
|
export const usePresetsQuery = () => {
|
||||||
|
return api.useQuery("get", "/api/captions/presets/", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreatePreset = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return api.useMutation("post", "/api/captions/presets/", {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdatePreset = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return api.useMutation("patch", "/api/captions/presets/{preset_id}/", {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeletePreset = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return api.useMutation("delete", "/api/captions/presets/{preset_id}/", {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import api from "@shared/api"
|
||||||
|
|
||||||
|
interface IUseSubmitCaptionGenerateParams {
|
||||||
|
onSuccess?: (data: { job_id: string }) => void
|
||||||
|
onError?: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSubmitCaptionGenerate = ({
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: IUseSubmitCaptionGenerateParams = {}) => {
|
||||||
|
return api.useMutation("post", "/api/tasks/captions-generate/", {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
onSuccess?.(data)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
onError?.(error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Dialog } from "@radix-ui/themes"
|
import type { 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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,50 +16,146 @@ 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 () => {
|
||||||
player?.pause?.()
|
window.removeEventListener("mousemove", handleMouseMove)
|
||||||
|
window.removeEventListener("mouseup", handleMouseUp)
|
||||||
}
|
}
|
||||||
}, [currentTime, end, playing])
|
}, [dragging, seekToPosition])
|
||||||
|
|
||||||
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"
|
||||||
<div className={styles.timeRange}>
|
className={styles.playButton}
|
||||||
{secondsToTimecode(start)} — {secondsToTimecode(end)}
|
onClick={togglePlay}
|
||||||
|
>
|
||||||
|
{playing ? <Pause size={24} /> : <Play size={24} />}
|
||||||
|
</button>
|
||||||
|
<div className={styles.timeRange}>
|
||||||
|
{secondsToTimecode(start)} — {secondsToTimecode(end)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.segmentControls}>
|
||||||
|
<span className={styles.segmentTime}>
|
||||||
|
{secondsToTimecode(Math.max(currentTime, start))}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={styles.segmentTrack}
|
||||||
|
ref={trackRef}
|
||||||
|
onMouseDown={handleTrackMouseDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.segmentTrackFill}
|
||||||
|
style={{ width: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={styles.segmentTrackThumb}
|
||||||
|
style={{ left: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.segmentTime}>
|
||||||
|
{secondsToTimecode(end)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<SegmentPlayer
|
||||||
src={videoUrl}
|
videoUrl={videoUrl}
|
||||||
currentTime={segment.start}
|
start={segment.start}
|
||||||
className={styles.player}
|
end={segment.end}
|
||||||
autoPlay
|
/>
|
||||||
>
|
|
||||||
<SegmentPlayer
|
|
||||||
videoUrl={videoUrl}
|
|
||||||
start={segment.start}
|
|
||||||
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,49 +186,53 @@ 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}>
|
||||||
<label className={styles.timeLabel}>
|
<div className={styles.timesGroup}>
|
||||||
<span className={styles.timeLabelText}>Начало</span>
|
<label className={styles.timeLabel}>
|
||||||
<input
|
<span className={styles.timeLabelText}>Начало</span>
|
||||||
className={styles.timeInput}
|
<input
|
||||||
type="text"
|
className={styles.timeInput}
|
||||||
value={seg.startTime}
|
type="text"
|
||||||
onChange={(e) =>
|
value={seg.startTime}
|
||||||
updateSegment(idx, "startTime", e.target.value)
|
onChange={(e) =>
|
||||||
|
updateSegment(idx, "startTime", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="00:00.000"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.timeLabel}>
|
||||||
|
<span className={styles.timeLabelText}>Конец</span>
|
||||||
|
<input
|
||||||
|
className={styles.timeInput}
|
||||||
|
type="text"
|
||||||
|
value={seg.endTime}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSegment(idx, "endTime", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="00:00.000"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actionsGroup}>
|
||||||
|
<button
|
||||||
|
className={styles.splitButton}
|
||||||
|
onClick={() => setSplittingIdx(idx)}
|
||||||
|
title={
|
||||||
|
!seg.words || seg.words.length < 2
|
||||||
|
? "Нет данных о словах для разделения"
|
||||||
|
: "Разделить сегмент"
|
||||||
}
|
}
|
||||||
placeholder="00:00.000"
|
disabled={!seg.words || seg.words.length < 2}
|
||||||
/>
|
>
|
||||||
</label>
|
<Scissors size={14} />
|
||||||
<label className={styles.timeLabel}>
|
</button>
|
||||||
<span className={styles.timeLabelText}>Конец</span>
|
<button
|
||||||
<input
|
className={styles.removeButton}
|
||||||
className={styles.timeInput}
|
onClick={() => removeSegment(idx)}
|
||||||
type="text"
|
title="Удалить сегмент"
|
||||||
value={seg.endTime}
|
>
|
||||||
onChange={(e) =>
|
<Trash2 size={14} />
|
||||||
updateSegment(idx, "endTime", e.target.value)
|
</button>
|
||||||
}
|
</div>
|
||||||
placeholder="00:00.000"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className={styles.splitButton}
|
|
||||||
onClick={() => setSplittingIdx(idx)}
|
|
||||||
title={
|
|
||||||
!seg.words || seg.words.length < 2
|
|
||||||
? "Нет данных о словах для разделения"
|
|
||||||
: "Разделить сегмент"
|
|
||||||
}
|
|
||||||
disabled={!seg.words || seg.words.length < 2}
|
|
||||||
>
|
|
||||||
<Scissors size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={styles.removeButton}
|
|
||||||
onClick={() => removeSegment(idx)}
|
|
||||||
title="Удалить сегмент"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{splittingIdx === idx ? (
|
{splittingIdx === idx ? (
|
||||||
<SegmentSplitter
|
<SegmentSplitter
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface IUploadStepProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropZone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 32px;
|
||||||
|
border: 2px dashed variables.$border-default;
|
||||||
|
border-radius: variables.$radius-lg;
|
||||||
|
background: variables.$bg-surface;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: variables.$color-primary;
|
||||||
|
background: variables.$bg-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropZoneActive {
|
||||||
|
border-color: variables.$color-primary;
|
||||||
|
background: rgba(var(--iris-a3), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropZoneUploading {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include typography.font-body-16(600);
|
||||||
|
color: variables.$text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
@include typography.font-body-14(400);
|
||||||
|
color: variables.$text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressTrack {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: variables.$border-subtle;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBar {
|
||||||
|
height: 100%;
|
||||||
|
background: variables.$color-primary;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressLabel {
|
||||||
|
@include typography.font-body-14(500);
|
||||||
|
color: variables.$text-secondary;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
@include typography.font-body-14(500);
|
||||||
|
color: variables.$color-danger;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { IUploadStepProps } from "./UploadStep.d"
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { Upload } from "lucide-react"
|
||||||
|
import { FunctionComponent, useCallback, useRef, useState } from "react"
|
||||||
|
|
||||||
|
import cs from "classnames"
|
||||||
|
|
||||||
|
import { uploadFileWithProgress } from "@shared/api/uploadFile"
|
||||||
|
import { useWizard } from "@shared/context/WizardContext"
|
||||||
|
import { Button } from "@shared/ui"
|
||||||
|
|
||||||
|
import styles from "./UploadStep.module.scss"
|
||||||
|
|
||||||
|
const ACCEPTED_VIDEO_TYPES = "video/*"
|
||||||
|
const ERROR_UPLOAD_FAILED = "Не удалось загрузить файл"
|
||||||
|
|
||||||
|
export const UploadStep: FunctionComponent<IUploadStepProps> = ({
|
||||||
|
className,
|
||||||
|
}): JSX.Element => {
|
||||||
|
const { projectId, setFileKey, markStepCompleted, goNext } = useWizard()
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleUpload = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
setIsUploading(true)
|
||||||
|
setProgress(0)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadFileWithProgress(
|
||||||
|
file,
|
||||||
|
`projects/${projectId}`,
|
||||||
|
setProgress,
|
||||||
|
)
|
||||||
|
setFileKey(result.file_path, result.file_url, result.filename ?? null)
|
||||||
|
markStepCompleted("upload")
|
||||||
|
goNext()
|
||||||
|
} catch {
|
||||||
|
setError(ERROR_UPLOAD_FAILED)
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, setFileKey, markStepCompleted, goNext],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) handleUpload(file)
|
||||||
|
/* Reset input so re-selecting the same file triggers change */
|
||||||
|
e.target.value = ""
|
||||||
|
},
|
||||||
|
[handleUpload],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (file) handleUpload(file)
|
||||||
|
},
|
||||||
|
[handleUpload],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cs(styles.root, className)} data-testid="UploadStep">
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div
|
||||||
|
className={cs(styles.dropZone, {
|
||||||
|
[styles.dropZoneActive]: isDragging,
|
||||||
|
[styles.dropZoneUploading]: isUploading,
|
||||||
|
})}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_VIDEO_TYPES}
|
||||||
|
className={styles.fileInput}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
<Upload size={48} className={styles.icon} />
|
||||||
|
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<p className={styles.title}>Загрузка файла...</p>
|
||||||
|
<div className={styles.progressTrack}>
|
||||||
|
<div
|
||||||
|
className={styles.progressBar}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className={styles.progressLabel}>{Math.round(progress)}%</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={styles.title}>Перетащите видеофайл сюда</p>
|
||||||
|
<p className={styles.subtitle}>или нажмите для выбора файла</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
inputRef.current?.click()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выбрать файл
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./UploadStep"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface IVerifyStepProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 24px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerWrapper {
|
||||||
|
position: relative;
|
||||||
|
border-radius: variables.$radius-md;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
:global([data-media-player]) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vds-video-layout) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoCards {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: variables.$bg-surface;
|
||||||
|
border: 1px solid variables.$border-subtle;
|
||||||
|
border-radius: variables.$radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoIcon {
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoLabel {
|
||||||
|
@include typography.font-caption-m;
|
||||||
|
font-weight: 500;
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoValue {
|
||||||
|
@include typography.font-body-14(500);
|
||||||
|
color: variables.$text-primary;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarActions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.convertErrorText {
|
||||||
|
font-size: 13px;
|
||||||
|
color: variables.$color-danger;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholderHint {
|
||||||
|
@include typography.font-body-14(400);
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Converting view */
|
||||||
|
|
||||||
|
.convertingContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressWrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circleBg {
|
||||||
|
stroke: variables.$border-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circleValue {
|
||||||
|
transition: stroke-dashoffset 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.percentage {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 36px;
|
||||||
|
color: variables.$text-primary;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusLabel {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.convertDescription {
|
||||||
|
@include typography.font-body-14(400);
|
||||||
|
color: variables.$text-secondary;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.convertInfoCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: variables.$bg-hover;
|
||||||
|
border-radius: variables.$radius-md;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: variables.$text-secondary;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.convertInfoIcon {
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid variables.$border-subtle;
|
||||||
|
background: variables.$bg-surface;
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { IVerifyStepProps } from "./VerifyStep.d"
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { MediaPlayer, MediaProvider } from "@vidstack/react"
|
||||||
|
import {
|
||||||
|
defaultLayoutIcons,
|
||||||
|
DefaultVideoLayout,
|
||||||
|
} from "@vidstack/react/player/layouts/default"
|
||||||
|
|
||||||
|
import "@vidstack/react/player/styles/default/theme.css"
|
||||||
|
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
FileVideo,
|
||||||
|
HardDrive,
|
||||||
|
Info,
|
||||||
|
Monitor,
|
||||||
|
Music,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
FunctionComponent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
import cs from "classnames"
|
||||||
|
|
||||||
|
import api, { fetchClient } from "@shared/api"
|
||||||
|
import { useWizard } from "@shared/context/WizardContext"
|
||||||
|
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||||
|
import { Badge, Button, CircularProgress } from "@shared/ui"
|
||||||
|
import { StaticLoader } from "@shared/ui/Loader"
|
||||||
|
|
||||||
|
import { buildCancelJobPayload, useCancelJob } from "../useCancelJob"
|
||||||
|
import styles from "./VerifyStep.module.scss"
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} Б`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`
|
||||||
|
if (bytes < 1024 * 1024 * 1024)
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} ГБ`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_CONVERT_FAILED = "Не удалось запустить конвертацию"
|
||||||
|
|
||||||
|
export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
|
||||||
|
className,
|
||||||
|
}): JSX.Element => {
|
||||||
|
const {
|
||||||
|
projectId,
|
||||||
|
primaryFileKey,
|
||||||
|
videoUrl,
|
||||||
|
originalFileName,
|
||||||
|
activeJobId,
|
||||||
|
activeJobType,
|
||||||
|
goBack,
|
||||||
|
goNext,
|
||||||
|
goToStep,
|
||||||
|
markStepCompleted,
|
||||||
|
setFileKey,
|
||||||
|
setActiveJob,
|
||||||
|
startProcessingJob,
|
||||||
|
} = useWizard()
|
||||||
|
|
||||||
|
const [convertError, setConvertError] = useState<string | null>(null)
|
||||||
|
const { mutate: cancelJob, isPending: isCancelling } = useCancelJob()
|
||||||
|
|
||||||
|
/* Derive conversion state from wizard-persisted activeJob */
|
||||||
|
const convertJobId = activeJobType === "MEDIA_CONVERT" ? activeJobId : null
|
||||||
|
const convertStatus: "idle" | "converting" | "failed" = convertJobId
|
||||||
|
? "converting"
|
||||||
|
: convertError
|
||||||
|
? "failed"
|
||||||
|
: "idle"
|
||||||
|
|
||||||
|
const { data: probeData, isPending: isProbing } = api.useQuery(
|
||||||
|
"get",
|
||||||
|
"/api/media/get_meta/",
|
||||||
|
{ params: { query: { file_path: primaryFileKey ?? "" } } },
|
||||||
|
{ enabled: !!primaryFileKey },
|
||||||
|
)
|
||||||
|
|
||||||
|
const mediaInfo = useMemo(() => {
|
||||||
|
if (!probeData) return null
|
||||||
|
const videoStream = probeData.streams?.find((s) => s.codec_type === "video")
|
||||||
|
const audioStream = probeData.streams?.find((s) => s.codec_type === "audio")
|
||||||
|
const format = probeData.format
|
||||||
|
const rawName = originalFileName ?? primaryFileKey?.split("/").pop() ?? null
|
||||||
|
const actualFileName = primaryFileKey?.split("/").pop() ?? rawName
|
||||||
|
const ext = actualFileName?.split(".").pop()?.toUpperCase() ?? null
|
||||||
|
return {
|
||||||
|
filename: rawName,
|
||||||
|
size: format?.size ? Number(format.size) : null,
|
||||||
|
formatName: ext,
|
||||||
|
width: videoStream?.width ?? null,
|
||||||
|
height: videoStream?.height ?? null,
|
||||||
|
audioCodec: audioStream?.codec_name ?? null,
|
||||||
|
}
|
||||||
|
}, [probeData, originalFileName, primaryFileKey])
|
||||||
|
|
||||||
|
const needsConversion = useMemo(() => {
|
||||||
|
if (!mediaInfo?.formatName) return false
|
||||||
|
return mediaInfo.formatName !== "MP4"
|
||||||
|
}, [mediaInfo])
|
||||||
|
|
||||||
|
/* ---- Conversion logic ---- */
|
||||||
|
|
||||||
|
const convertMutation = api.useMutation("post", "/api/tasks/media-convert/", {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
startProcessingJob(data.job_id, "MEDIA_CONVERT", "verify")
|
||||||
|
setConvertError(null)
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setConvertError(ERROR_CONVERT_FAILED)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleConvert = useCallback(() => {
|
||||||
|
if (!primaryFileKey) return
|
||||||
|
convertMutation.mutate({
|
||||||
|
body: {
|
||||||
|
file_key: primaryFileKey,
|
||||||
|
out_folder: `projects/${projectId}`,
|
||||||
|
output_format: "mp4",
|
||||||
|
project_id: projectId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [convertMutation, primaryFileKey, projectId])
|
||||||
|
|
||||||
|
const convertNotification = useAppSelector((state) =>
|
||||||
|
convertJobId
|
||||||
|
? state.notifications.items.find((n) => n.job_id === convertJobId)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const convertProgressPct = convertNotification?.progress_pct ?? 0
|
||||||
|
const convertMessage = convertNotification?.message ?? "Конвертация видео..."
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!convertJobId || convertStatus !== "converting") return
|
||||||
|
|
||||||
|
if (convertNotification?.status === "DONE") {
|
||||||
|
fetchConvertedFileFromJob(convertJobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convertNotification?.status === "FAILED") {
|
||||||
|
setActiveJob(null)
|
||||||
|
setConvertError(convertNotification?.message ?? "Ошибка конвертации")
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [convertNotification, convertJobId, convertStatus])
|
||||||
|
|
||||||
|
const fetchConvertedFileFromJob = useCallback(
|
||||||
|
async (jobId: string) => {
|
||||||
|
const { data: taskStatus } = await fetchClient.GET(
|
||||||
|
"/api/tasks/status/{job_id}/",
|
||||||
|
{ params: { path: { job_id: jobId } } },
|
||||||
|
)
|
||||||
|
const outputData = taskStatus?.output_data as {
|
||||||
|
file_path?: string
|
||||||
|
file_url?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
if (outputData?.file_path && outputData?.file_url) {
|
||||||
|
const convertedName = outputData.file_path.split("/").pop() ?? null
|
||||||
|
setFileKey(outputData.file_path, outputData.file_url, convertedName)
|
||||||
|
setActiveJob(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setFileKey, setActiveJob],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ---- Handlers ---- */
|
||||||
|
|
||||||
|
const handleReplace = () => {
|
||||||
|
setFileKey("", "", null)
|
||||||
|
goToStep("upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
markStepCompleted("verify")
|
||||||
|
goNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Converting view ---- */
|
||||||
|
|
||||||
|
if (convertStatus === "converting") {
|
||||||
|
return (
|
||||||
|
<div className={cs(styles.root, className)} data-testid="VerifyStep">
|
||||||
|
<div className={styles.convertingContent}>
|
||||||
|
<div className={styles.progressWrapper}>
|
||||||
|
<CircularProgress
|
||||||
|
percentage={convertProgressPct}
|
||||||
|
size={200}
|
||||||
|
strokeWidth={8}
|
||||||
|
color="var(--color-success)"
|
||||||
|
className={styles.circle}
|
||||||
|
bgClassName={styles.circleBg}
|
||||||
|
valueClassName={styles.circleValue}
|
||||||
|
/>
|
||||||
|
<div className={styles.progressInner}>
|
||||||
|
<span className={styles.percentage}>
|
||||||
|
{Math.round(convertProgressPct)}%
|
||||||
|
</span>
|
||||||
|
<span className={styles.statusLabel}>КОНВЕРТАЦИЯ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.convertDescription}>{convertMessage}</p>
|
||||||
|
|
||||||
|
<div className={styles.convertInfoCard}>
|
||||||
|
<Info size={16} className={styles.convertInfoIcon} />
|
||||||
|
<span>
|
||||||
|
Конвертация выполняется на сервере. Вы можете покинуть страницу —
|
||||||
|
прогресс сохранится.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!convertJobId || isCancelling) return
|
||||||
|
|
||||||
|
cancelJob(buildCancelJobPayload(convertJobId), {
|
||||||
|
onSuccess: () => {
|
||||||
|
setActiveJob(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={isCancelling}
|
||||||
|
>
|
||||||
|
{isCancelling ? "Отмена..." : "Отменить конвертацию"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Normal / needs-conversion view ---- */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cs(styles.root, className)} data-testid="VerifyStep">
|
||||||
|
<div className={styles.layout}>
|
||||||
|
{/* Video player */}
|
||||||
|
<div className={styles.playerWrapper}>
|
||||||
|
{isProbing && primaryFileKey ? (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<StaticLoader block description="Анализ видеофайла..." />
|
||||||
|
</div>
|
||||||
|
) : needsConversion ? (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<FileVideo size={48} />
|
||||||
|
<p>Формат {mediaInfo?.formatName ?? ""} не поддерживается</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConvert}
|
||||||
|
disabled={convertMutation.isPending}
|
||||||
|
>
|
||||||
|
Конвертировать в MP4
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : videoUrl ? (
|
||||||
|
<MediaPlayer src={videoUrl} crossOrigin="" playsInline>
|
||||||
|
<MediaProvider />
|
||||||
|
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||||
|
</MediaPlayer>
|
||||||
|
) : (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<FileVideo size={48} />
|
||||||
|
<p>Видео не загружено</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info sidebar */}
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<div className={styles.statusRow}>
|
||||||
|
{isProbing && primaryFileKey ? (
|
||||||
|
<Badge variant="info">
|
||||||
|
<Info size={14} />
|
||||||
|
Анализ файла...
|
||||||
|
</Badge>
|
||||||
|
) : needsConversion ? (
|
||||||
|
<Badge variant="warning">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
Требуется конвертация
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="success">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
Готово к обработке
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.infoCards}>
|
||||||
|
<div className={styles.infoCard}>
|
||||||
|
<FileVideo size={16} className={styles.infoIcon} />
|
||||||
|
<div className={styles.infoContent}>
|
||||||
|
<span className={styles.infoLabel}>Файл</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{mediaInfo?.filename ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoCard}>
|
||||||
|
<HardDrive size={16} className={styles.infoIcon} />
|
||||||
|
<div className={styles.infoContent}>
|
||||||
|
<span className={styles.infoLabel}>Размер и формат</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{mediaInfo?.size ? formatFileSize(mediaInfo.size) : "—"}{" "}
|
||||||
|
· {mediaInfo?.formatName ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoCard}>
|
||||||
|
<Monitor size={16} className={styles.infoIcon} />
|
||||||
|
<div className={styles.infoContent}>
|
||||||
|
<span className={styles.infoLabel}>Разрешение</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{mediaInfo?.width && mediaInfo?.height
|
||||||
|
? `${mediaInfo.width}x${mediaInfo.height}`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoCard}>
|
||||||
|
<Music size={16} className={styles.infoIcon} />
|
||||||
|
<div className={styles.infoContent}>
|
||||||
|
<span className={styles.infoLabel}>Аудиокодек</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{mediaInfo?.audioCodec ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.sidebarActions}>
|
||||||
|
{needsConversion ? (
|
||||||
|
<>
|
||||||
|
{convertError && (
|
||||||
|
<p className={styles.convertErrorText}>{convertError}</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConvert}
|
||||||
|
disabled={convertMutation.isPending}
|
||||||
|
>
|
||||||
|
Конвертировать в MP4
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Button variant="danger" size="sm" onClick={handleReplace}>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
Заменить видео
|
||||||
|
</Button>
|
||||||
|
{!needsConversion && (
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
Предварительная обрезка
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<Button variant="outline" onClick={goBack}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={needsConversion}
|
||||||
|
>
|
||||||
|
Далее: Настройки тишины
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./VerifyStep"
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
|
export { CaptionResultStep } from "./CaptionResultStep"
|
||||||
|
export { CaptionSettingsStep } from "./CaptionSettingsStep"
|
||||||
export { ConvertMediaView } from "./ConvertMediaView"
|
export { 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"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import api from "@shared/api"
|
||||||
|
|
||||||
|
const CANCEL_MESSAGE = "Отменено пользователем"
|
||||||
|
|
||||||
|
export const useCancelJob = () => {
|
||||||
|
return api.useMutation("patch", "/api/jobs/jobs/{job_id}/", {
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildCancelJobPayload = (jobId: string) => ({
|
||||||
|
params: { path: { job_id: jobId } },
|
||||||
|
body: {
|
||||||
|
status: "CANCELLED" as const,
|
||||||
|
current_message: CANCEL_MESSAGE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
.root {
|
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import "@testing-library/jest-dom"
|
|
||||||
|
|
||||||
import { render, screen } from "@testing-library/react"
|
|
||||||
|
|
||||||
import HomePage from "./HomePage"
|
|
||||||
|
|
||||||
describe("Page", () => {
|
|
||||||
test("renders a yunglocokid", () => {
|
|
||||||
render(<HomePage />)
|
|
||||||
const yunglocokid: HTMLElement = screen.getByText("yunglocokid")
|
|
||||||
expect(yunglocokid).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
test("renders a hint", () => {
|
|
||||||
render(<HomePage />)
|
|
||||||
screen.debug()
|
|
||||||
const hint: HTMLElement = screen.getByTestId("hint-code")
|
|
||||||
expect(hint).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -4,6 +4,7 @@ import type { JSX } from "react"
|
|||||||
|
|
||||||
import { FunctionComponent, useMemo, useState } from "react"
|
import { 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,25 +62,44 @@ 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>
|
||||||
|
|
||||||
<RecentProjects
|
<motion.div variants={SLIDE_UP} transition={EASE_OUT_TRANSITION}>
|
||||||
projects={recentProjects}
|
{isLoading ? (
|
||||||
isLoading={isLoading}
|
<RecentProjectsSkeleton />
|
||||||
onProjectClick={(id) => router.push(`/projects/${id}`)}
|
) : (
|
||||||
onCreateClick={() => setIsCreateModalOpen(true)}
|
<RecentProjects
|
||||||
onViewAllClick={() => router.push("/projects")}
|
projects={recentProjects}
|
||||||
/>
|
isLoading={isLoading}
|
||||||
|
onProjectClick={(id) => router.push(`/projects/${id}`)}
|
||||||
|
onCreateClick={() => setIsCreateModalOpen(true)}
|
||||||
|
onViewAllClick={() => router.push("/projects")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className={cls.actionsSection}>
|
<motion.div
|
||||||
|
className={cls.actionsSection}
|
||||||
|
variants={SLIDE_UP}
|
||||||
|
transition={EASE_OUT_TRANSITION}
|
||||||
|
>
|
||||||
<h2 className={cls.actionsTitle}>Быстрые действия</h2>
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 (
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface IProjectWizardPageProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
+1
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./ProjectWizardPage"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export interface IProjectWorkspacePageProps {
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { JSX } from "react"
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation"
|
|
||||||
import { FunctionComponent } from "react"
|
|
||||||
|
|
||||||
import api from "@shared/api"
|
|
||||||
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
|
|
||||||
import {
|
|
||||||
useWorkspaceFiles,
|
|
||||||
WorkspaceProvider,
|
|
||||||
} from "@shared/context/WorkspaceContext"
|
|
||||||
import { TranscriptionEditor } from "@features/project"
|
|
||||||
import {
|
|
||||||
ActionPanel,
|
|
||||||
FileTree,
|
|
||||||
VideoPlayer,
|
|
||||||
WorkspaceLayout,
|
|
||||||
} from "@widgets/Workspace"
|
|
||||||
|
|
||||||
import { IProjectWorkspacePageProps } from "./ProjectWorkspacePage.d"
|
|
||||||
import styles from "./ProjectWorkspacePage.module.scss"
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Inner wrapper — resolves which viewer to show based on selection */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const WorkspaceViewer: FunctionComponent<{ projectId: string }> = ({
|
|
||||||
projectId,
|
|
||||||
}) => {
|
|
||||||
const { selectedFile } = useWorkspaceFiles()
|
|
||||||
|
|
||||||
if (selectedFile?.artifactType === "TRANSCRIPTION_JSON") {
|
|
||||||
return <TranscriptionEditor artifactId={selectedFile.id} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return <VideoPlayer projectId={projectId} />
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Page */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export const ProjectWorkspacePage: FunctionComponent<
|
|
||||||
IProjectWorkspacePageProps
|
|
||||||
> = (): JSX.Element => {
|
|
||||||
const params = useParams<{ project_id: string }>()
|
|
||||||
const projectId = params?.project_id ?? ""
|
|
||||||
|
|
||||||
const { data: project } = api.useQuery("get", "/api/projects/{project_id}/", {
|
|
||||||
params: { path: { project_id: projectId } },
|
|
||||||
})
|
|
||||||
|
|
||||||
useBreadcrumbs([
|
|
||||||
{ label: "Проекты", href: "/projects" },
|
|
||||||
{ label: project?.name ?? "..." },
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkspaceProvider projectId={projectId}>
|
|
||||||
<div className={styles.root} data-testid="ProjectWorkspacePage">
|
|
||||||
<WorkspaceLayout
|
|
||||||
fileTree={<FileTree projectId={projectId} />}
|
|
||||||
player={<WorkspaceViewer projectId={projectId} />}
|
|
||||||
actionPanel={<ActionPanel projectId={projectId} />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</WorkspaceProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./ProjectWorkspacePage"
|
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import api from "@shared/api"
|
import 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,20 +132,24 @@ export const ProjectsPage: FunctionComponent<
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.projectList}>
|
<div className={styles.projectList}>
|
||||||
{projects?.map((project) => (
|
{projectsLoading
|
||||||
<ProjectCard
|
? Array.from({ length: 6 }).map((_, i) => (
|
||||||
key={project.id}
|
<ProjectCardSkeleton key={i} />
|
||||||
project={project}
|
))
|
||||||
progress={project.status === "PROCESSING" ? 45 : 0}
|
: projects?.map((project) => (
|
||||||
currentAction={
|
<ProjectCard
|
||||||
project.status === "PROCESSING" ? "Рендеринг" : undefined
|
key={project.id}
|
||||||
}
|
project={project}
|
||||||
onClick={() => router.push(`/projects/${project.id}`)}
|
progress={project.status === "PROCESSING" ? 45 : 0}
|
||||||
onEdit={() => setEditProject(project)}
|
currentAction={
|
||||||
onRename={() => setRenameProject(project)}
|
project.status === "PROCESSING" ? "Рендеринг" : undefined
|
||||||
onDelete={() => setDeleteProject(project)}
|
}
|
||||||
/>
|
onClick={() => router.push(`/projects/${project.id}`)}
|
||||||
))}
|
onEdit={() => setEditProject(project)}
|
||||||
|
onRename={() => setRenameProject(project)}
|
||||||
|
onDelete={() => setDeleteProject(project)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!projectsLoading && projects?.length === 0 && (
|
{!projectsLoading && projects?.length === 0 && (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import createFetchClient, { Middleware } from "openapi-fetch"
|
||||||
|
|
||||||
|
import { ACCESS_TOKEN_REGEXP, API_URL } from "@shared/lib/constants"
|
||||||
|
|
||||||
|
import { paths } from "./__generated__/openapi.types"
|
||||||
|
|
||||||
|
const isServer = typeof window === "undefined"
|
||||||
|
|
||||||
|
const getAccessTokenFromCookieHeader = (
|
||||||
|
cookieHeader: string | null,
|
||||||
|
): string | undefined => {
|
||||||
|
if (!cookieHeader) return
|
||||||
|
const token = cookieHeader.replace(ACCESS_TOKEN_REGEXP, "$1")
|
||||||
|
return token.length ? token : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchClient = createFetchClient<paths>({
|
||||||
|
baseUrl: API_URL,
|
||||||
|
// credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const middleware: Middleware = {
|
||||||
|
async onRequest({ request }) {
|
||||||
|
if (request.headers.has("Authorization")) return
|
||||||
|
|
||||||
|
let token: string | undefined
|
||||||
|
if (isServer) {
|
||||||
|
// In middleware/edge runtime there is no `next/headers` request scope.
|
||||||
|
token = getAccessTokenFromCookieHeader(request.headers.get("cookie"))
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const { cookies } = await import("next/headers")
|
||||||
|
token = (await cookies()).get("access_token")?.value
|
||||||
|
} catch {
|
||||||
|
// Not in a request scope (e.g. middleware/edge or build-time).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
token = document.cookie.replace(ACCESS_TOKEN_REGEXP, "$1")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token?.length) request.headers.set("Authorization", `Bearer ${token}`)
|
||||||
|
},
|
||||||
|
async onError({ error }) {
|
||||||
|
return new Error("Oops, fetch failed", { cause: error })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fetchClient.use(middleware)
|
||||||
+2
-50
@@ -1,56 +1,8 @@
|
|||||||
import createClient from "openapi-react-query"
|
import 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,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
Reference in New Issue
Block a user