new features

This commit is contained in:
Daniil
2026-02-27 23:34:17 +03:00
parent 42ce5fa0fe
commit 71b974903a
191 changed files with 11300 additions and 373 deletions
+36
View File
@@ -0,0 +1,36 @@
# Convert Icons
Convert raw SVG icons to React TSX components using SVGR.
## Usage
```
/convert-icons
```
## Instructions
1. Check that raw SVG files exist in `src/shared/assets/raw-icons/`. If the directory doesn't exist or is empty, inform the user and ask them to place SVG files there first.
2. Run the conversion:
```bash
bun run gicons
```
This executes:
```
npx @svgr/cli --ext tsx --typescript --no-prettier --icon --ref --no-svgo ./src/shared/assets/raw-icons/ --out-dir ./src/shared/ui/Icons/
```
3. Report which icon components were generated in `src/shared/ui/Icons/`.
4. The generated components can be imported as:
```tsx
import { IconName } from "@shared/ui/Icons/IconName"
```
## Notes
- Raw SVGs go in: `src/shared/assets/raw-icons/`
- Generated TSX components output to: `src/shared/ui/Icons/`
- SVGR flags: TypeScript, icon mode (scales with font-size), forwardRef support, no Prettier formatting, no SVGO optimization
- The primary icon library is `lucide-react` — custom SVG icons are for icons not available in Lucide
+55
View File
@@ -0,0 +1,55 @@
# Generate Component (gc)
Generate an FSD component using the project's generator script.
## Usage
```
/gc <layer> <ComponentName>
```
**Arguments:**
- `$ARGUMENTS` — expects `<layer> <ComponentName>`, e.g. `shared Button`, `entity ProjectCard`, `feature CreateProjectModal`
## Layers
| Alias | Path |
|-------|------|
| `shared` | `src/shared/ui/` |
| `entity` / `entities` | `src/entities/` |
| `feature` / `features` | `src/features/` |
| `widget` / `widgets` | `src/widgets/` |
| `page` / `pages` | `src/pages/` |
## Instructions
1. Parse `$ARGUMENTS` to extract `<layer>` and `<ComponentName>`. If arguments are missing or unclear, ask the user.
2. Run the generator:
```bash
bun run gc <layer> <ComponentName>
```
3. This creates 4 files:
- `index.ts` — re-exports the component
- `<ComponentName>.tsx` — component implementation with `FunctionComponent`, `JSX.Element`, SCSS module import, `data-testid`
- `<ComponentName>.d.ts` — props interface `I<ComponentName>Props` with `className?: string`
- `<ComponentName>.module.scss` — empty `.root {}` class
4. After generation, report the created files and the full path.
5. If the user provides additional context about what the component should do or look like, modify the generated files accordingly:
- Add props to the `.d.ts` file
- Implement the component logic in `.tsx`
- Add styles to `.module.scss`
- Use existing project patterns: `classnames` as `cs`, typography/mixin includes from auto-injected SCSS partials, Radix UI primitives or Themes where appropriate, `lucide-react` icons
## Project Conventions
- Props interface: `I<ComponentName>Props` in a `.d.ts` file
- Import types with `import type { ... }`
- Import order: types → react/libs → path aliases (`@shared/`, `@entities/`, etc.) → local
- Use `cs()` from `classnames` for combining classes
- Root element gets `className={styles.root}` and `data-testid="<ComponentName>"`
- SCSS auto-injected namespaces: `variables`, `breakpoints`, `typography`, `mixins`
- Use `"use client"` directive only when component uses hooks or browser APIs
+51
View File
@@ -0,0 +1,51 @@
# Generate API Types
Fetch the OpenAPI schema from the backend and generate TypeScript types.
## Usage
```
/gen-api-types
```
## Instructions
1. Ensure the backend server is running at `http://127.0.0.1:8000`. If the command fails with a connection error, inform the user that the backend must be running first.
2. Run the type generation:
```bash
bun run gen:api-types
```
This executes:
```
openapi-typescript http://127.0.0.1:8000/api/schema/ --output src/shared/api/__generated__/openapi.types.ts
```
3. Report success and note that the generated types are at `src/shared/api/__generated__/openapi.types.ts`.
4. The generated file exports:
- `paths` — all API endpoints with request/response types
- `components` — schema definitions (used as `components["schemas"]["ModelName"]`)
- `operations` — operation-level types
## Usage in Code
```tsx
// Import schema types
import type { components } from "@shared/api/__generated__/openapi.types"
type ProjectRead = components["schemas"]["ProjectRead"]
// Use with the API client
import api from "@shared/api"
api.useQuery("get", "/api/projects/")
api.useMutation("post", "/api/projects/", { onSuccess, onError })
```
## Notes
- Requires the backend running at `http://127.0.0.1:8000`
- Uses `openapi-typescript` to generate types from the `/api/schema/` endpoint
- The API client (`src/shared/api/index.ts`) uses `openapi-fetch` + `openapi-react-query` with these generated types
- After regeneration, check for any TypeScript errors in components that use the API types, as schema changes may break existing code
+3 -1
View File
@@ -1 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:8000/
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_WS_URL=ws://localhost:8000
NEXT_PUBLIC_MOCK_WS=false
+87 -1
View File
@@ -17,7 +17,7 @@ Next.js 16 application using **Feature-Sliced Design (FSD)** architecture, power
| Styling | SCSS Modules, normalize.css |
| State/Fetch | TanStack React Query 5, Axios, Xior |
| Animation | Framer Motion |
| Utilities | Lodash, Moment.js, classnames, usehooks-ts |
| Utilities | Lodash, date-fns, classnames, usehooks-ts |
| Icons | Lucide React, SVGR (custom icons) |
| Notifications | React Toastify |
| File Upload | React Dropzone |
@@ -174,10 +174,12 @@ export const Button: FC<IButtonProps> = ({ variant, onClick }): JSX.Element => {
2. **Public API** — export only through `index.ts`
3. **No Cross-Slice Imports** — features cannot import from other features
4. **Shared is Agnostic** — no business logic in shared layer
5. **Features are module-aware** — group features by domain inside module folders (see below)
### When to Split Files
Split into separate files **only when**:
- Hook/API is reused by multiple components
- File exceeds ~200 lines
- Props interface is shared across 3+ components
@@ -208,6 +210,72 @@ Split into separate files **only when**:
---
## Features Layer — Module-Aware Structure
Features **must be grouped by domain module**. Never place feature folders flat at the top of `src/features/`.
```
src/features/
├── profile/ # Profile domain module
│ ├── index.ts # Barrel export for all features in this module
│ ├── AvatarUpload/
│ ├── EditProfileForm/
│ └── LogoutButton/
└── project/ # Project domain module
├── index.ts
├── CreateProjectModal/
└── ...
```
**Rules:**
- Each module folder has an `index.ts` barrel that re-exports all its features
- Import via the module barrel: `import { AvatarUpload } from "@features/profile"`
- When creating a new feature, place it inside the relevant domain folder
- After running `bun run gc feature <Name>`, move the generated folder into the correct module
- Create a new module folder + barrel if the domain doesn't exist yet
---
## Shared Utilities
Reusable operations should live in `src/shared/`**do not inline shared logic inside feature components**.
### File Upload
Use `uploadFile()` from `@shared/api/uploadFile` for any file upload:
```ts
import { uploadFile } from "@shared/api/uploadFile"
const result = await uploadFile(file, "avatars")
// result.file_url — URL of the uploaded file
// result.file_path — storage path
```
This handles FormData construction, Content-Type header override, and JWT auth automatically.
### Date Formatting
Use `date-fns` with Russian locale via shared utilities in `src/shared/lib/dates.ts`. **Never use `moment.js` or inline `Date` formatting in components.**
```ts
import { formatDate, formatRelativeTime } from "@shared/lib/dates"
formatDate(user.date_joined) // "21.02.2026" (default: "dd.MM.yyyy")
formatDate(date, "dd MMM yyyy") // "21 февр. 2026"
formatRelativeTime(project.updated_at) // "2 дня назад"
```
Add new date helpers to `src/shared/lib/dates.ts`, not to individual components.
### API Client
- **In React components**: use `api.useQuery()` / `api.useMutation()` from `@shared/api`
- **Outside React** (utilities, event handlers): use `fetchClient` from `@shared/api`
- **File uploads**: use `uploadFile()` from `@shared/api/uploadFile`
---
## Icons Workflow
1. Place raw SVG in `src/shared/assets/raw-icons/`
@@ -236,6 +304,10 @@ Split into separate files **only when**:
| API client | Use Axios/Xior with React Query |
| State management | TanStack Query (server state) |
## Localization
All user-facing UI text **must be in Russian**. This includes: labels, headings, buttons, placeholders, tooltips, aria-labels, error messages, breadcrumbs, and any other text visible to the user. The only exception is the brand name "Coffee Project" / "Cofee Project" — it stays in English.
## Implementation sentiments
Write less complicated code, simple but readable code
@@ -245,3 +317,17 @@ To import classNames lib use
`import cs from 'classnames'`
Always install packages using
`bun install <package>`
To test is project have no errors use
`bunx tsc --noEmit`
---
## Common Mistakes to Avoid
1. **Flat features folder** — never place feature component folders directly in `src/features/`. Always group them inside a domain module folder (`profile/`, `project/`, etc.).
2. **Inlining reusable logic** — if an operation (file upload, date formatting, etc.) could be used by multiple features, extract it to `src/shared/`. Features should be thin wrappers around shared utilities.
3. **Wrong StaticLoader import** — it lives at `@shared/ui/Loader`, not `@shared/ui/Loader/StaticLoader`. There is no subdirectory.
4. **multipart/form-data with fetchClient** — the default `fetchClient` sets `Content-Type: application/json`. For file uploads you must override headers and body serializer. Use the shared `uploadFile()` utility instead.
5. **Broken lint scripts**`bun run lint` calls `lint:es` and `lint:prettier` which are not defined in `package.json`. Use `bunx tsc --noEmit` for type checking until lint is fixed.
6. **Generator output needs moving**`bun run gc feature <Name>` creates the folder flat in `src/features/`. You must manually move it into the correct domain module folder afterward.
7. **Raw `fetch` / `useEffect` for API calls** — never use plain `fetch` or `useEffect`-based polling for API requests. Always use `api.useQuery()` / `api.useMutation()` from `@shared/api` which wraps TanStack Query + openapi-fetch. For polling, use `refetchInterval`. Raw `fetch` bypasses typed routes, auth middleware, and query caching.
+126
View File
@@ -0,0 +1,126 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
See also the monorepo-level `../CLAUDE.md` for full architecture overview and backend docs.
## Commands
```bash
bun dev # Dev server (localhost:3000)
bun run build # Production build
bun run lint # ESLint + Prettier (concurrent)
bunx tsc --noEmit # Type-check without emitting
bun run gc <layer> <Name> # Generate FSD component (e.g. bun run gc shared Button)
bun run gicons # Convert raw SVGs → React icon components
bun run gen:api-types # Regenerate API types from OpenAPI schema (backend must be running)
```
## Architecture
Next.js 16 App Router with Feature-Sliced Design. Strict unidirectional imports: `pages → widgets → features → entities → shared`.
- **App directory** is at `app/` (project root), not `src/app/`. The `src/app/` layer holds global styles and providers.
- **`app/template.tsx`** wraps all routes with Header (conditionally hidden on auth routes).
- **All components are `"use client"`** unless explicitly marked otherwise.
## API & Data Layer
- **`fetchClient`** (`openapi-fetch`) — typed HTTP client with JWT middleware that reads `access_token` from cookies. Defined in `src/shared/api/index.ts`.
- **`api`** (`openapi-react-query`) — wraps `fetchClient` for use with TanStack Query hooks in components. Import as `import api from "@shared/api"`.
- **Generated types** live in `src/shared/api/__generated__/openapi.types.ts` — never edit manually.
- **Server actions** in `src/shared/api/server.ts` — used for server-side API calls (ping, token verification).
## Styling
- **SCSS Modules** (`.module.scss`) for all component styles.
- **SCSS partials auto-injected** via `next.config.mjs` using `@use`: `_variables.scss`, `_breakpoints.scss`, `_typography.scss`, `_mixins.scss`. No need to import them manually in `.module.scss` files.
- **Radix UI Themes** wraps the app (`accentColor="iris"`, `grayColor="slate"`). Some components use Radix primitives directly (e.g., Dropdown uses `@radix-ui/react-dropdown-menu`, not Radix Themes).
- **Class composition**: `import cs from "classnames"`.
- **Design tokens** defined as CSS custom properties in `src/shared/styles/global.scss`, mirrored as SCSS vars in `_variables.scss`.
## State Management
- **Server state**: TanStack React Query (primary for all API data).
- **Client state**: Redux Toolkit with two slices: `appState` and `user` (in `src/shared/store/`).
- **Provider hierarchy** (in `src/shared/context/AppProviders.tsx`): Redux → QueryClient → UserSync → Radix Theme.
## Component Convention
Generate new components with `bun run gc <layer> <Name>` — never create component files manually. Each component folder contains:
- `index.ts` — public re-export only
- `ComponentName.tsx` — implementation
- `ComponentName.module.scss` — scoped styles
- `ComponentName.d.ts` — props interface (`IComponentNameProps`)
## Code Style
- **Prettier**: tabs (width 2), no semicolons, double quotes, sorted imports.
- **Imports**: use path aliases (`@shared/*`, `@entities/*`, etc.), never relative paths across layers.
- **Forms**: `react-hook-form` for form state management.
- **Icons**: Lucide React for standard icons. Custom icons: place SVG in `src/shared/assets/raw-icons/`, run `bun run gicons`, import from `@shared/ui/Icons/IconName`.
## Features Layer — Module-Aware Structure
Features are **grouped by domain module**, not placed flat at the top level. Each module folder has a barrel `index.ts`:
```
src/features/
├── profile/ # Profile domain
│ ├── index.ts # Barrel: re-exports all features in module
│ ├── AvatarUpload/
│ ├── EditProfileForm/
│ └── LogoutButton/
└── project/ # Project domain
├── index.ts
├── CreateProjectModal/
├── DeleteProjectModal/
├── EditProjectModal/
└── RenameProjectModal/
```
Import via module barrel: `import { AvatarUpload, EditProfileForm } from "@features/profile"`.
When adding a new feature, place it inside the relevant domain module folder (create one if needed).
## File Uploads
Use the shared `uploadFile` utility for any file upload — do not inline FormData logic in components:
```ts
import { uploadFile } from "@shared/api/uploadFile"
const result = await uploadFile(file, "avatars")
// result.file_url, result.file_path
```
The utility handles FormData construction, Content-Type override, and auth middleware automatically.
## Date Formatting
Use `date-fns` with Russian locale for all date formatting — never use `moment.js` or inline `Date` logic:
```ts
import { formatDate, formatRelativeTime } from "@shared/lib/dates"
formatDate(user.date_joined) // "21.02.2026"
formatDate(date, "dd MMM yyyy") // "21 февр. 2026"
formatRelativeTime(project.updated_at) // "2 дня назад"
```
Utilities live in `src/shared/lib/dates.ts`. Add new date helpers there, not in components.
## Localization
All user-facing UI text **must be in Russian** — labels, headings, buttons, placeholders, tooltips, aria-labels, error messages, breadcrumbs. The brand name "Coffee Project" / "Cofee Project" stays in English.
## Gotchas
- The `pages/` directory in the project root must exist (even if empty) — removing it causes Next.js build errors.
- Dropdown component's `asChild` trigger applies both the Dropdown's trigger class AND the child's class. Avoid `all: unset` on Dropdown triggers — it strips child flex/display styles.
- SCSS auto-import uses `@use` (not `@import`), so variables/mixins are namespaced (e.g., `variables.$color`). The files are injected as `additionalData` in `next.config.mjs`.
- **openapi-fetch + multipart**: `fetchClient` defaults to `Content-Type: application/json`. For file uploads, you must override the header and body serializer. Use the shared `uploadFile()` utility instead of doing this manually.
- **StaticLoader** is exported from `@shared/ui/Loader` (file is `Loader.tsx`), not from `@shared/ui/Loader/StaticLoader` — there is no subdirectory.
- **`lint:es` / `lint:prettier` scripts** are referenced by `bun run lint` but not defined in `package.json`. Linting is currently broken — use `bunx tsc --noEmit` for type checking.
- **`next/image` remote hosts**: External image hostnames must be listed in `next.config.mjs` `images.remotePatterns`. MinIO (`localhost:9000`) is already configured. If you add another storage backend, add its hostname there too.
- **Stale OpenAPI types**: Always run `bun run gen:api-types` before implementing against the API if the backend has changed. Stale types cause silent 404s at runtime.
- **Never use raw `fetch`/`useEffect` for API calls** — always use `api.useQuery()`/`api.useMutation()` from `@shared/api` (TanStack Query + openapi-fetch wrapper). For polling, use the `refetchInterval` option. Raw `fetch` bypasses typed routes, auth middleware, and query caching.
+11
View File
@@ -0,0 +1,11 @@
import { JSX } from "react"
import { ProfilePage } from "@pages/ProfilePage"
export default function Profile(): JSX.Element {
return (
<main>
<ProfilePage />
</main>
)
}
+8 -1
View File
@@ -24,7 +24,14 @@ export default function RootLayout({
children: ReactNode
}>) {
return (
<html lang="ru" className={open_sans.variable}>
<html lang="ru" className={open_sans.variable} suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("theme");var d;if(t==="light"||t==="dark"){d=t}else{d=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"}document.documentElement.setAttribute("data-theme",d);document.documentElement.classList.add(d)}catch(e){document.documentElement.setAttribute("data-theme","light");document.documentElement.classList.add("light")}})()`,
}}
/>
</head>
<body>
<AppProviders>{children}</AppProviders>
</body>
+13
View File
@@ -1,10 +1,23 @@
"use client"
import { usePathname } from "next/navigation"
import { Header } from "@widgets/Header"
const AUTH_ROUTES = ["/login", "/register", "/under_maintenance"]
export default function EssentialTemplate({
children,
}: {
children: React.ReactNode
}) {
const pathname = usePathname()
const isAuthPage = AUTH_ROUTES.includes(pathname)
if (isAuthPage) {
return <>{children}</>
}
return (
<div>
<Header />
+9
View File
@@ -0,0 +1,9 @@
import { UnderMaintenancePage } from "@pages/UnderMaintenancePage"
export default function UnderMaintenance() {
return (
<main>
<UnderMaintenancePage />
</main>
)
}
+17 -3
View File
@@ -16,13 +16,15 @@
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-query-devtools": "^5.91.2",
"@vidstack/react": "^1",
"@wavesurfer/react": "^1.0.12",
"axios": "^1.13.2",
"classnames": "^2.5.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.26",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.562.0",
"moment": "^2.30.1",
"next": "16.1.1",
"normalize.css": "^8.0.1",
"openapi-fetch": "^0.15.0",
@@ -34,9 +36,11 @@
"react-hook-form": "^7.71.0",
"react-modern-drawer": "^1.4.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^4.6.5",
"react-toastify": "^11.0.5",
"use-mask-input": "^3.6.0",
"usehooks-ts": "^3.1.1",
"wavesurfer.js": "^7.12.1",
"xior": "^0.8.2",
},
"devDependencies": {
@@ -785,6 +789,10 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@vidstack/react": ["@vidstack/react@1.12.13", "", { "dependencies": { "@floating-ui/dom": "^1.6.10", "media-captions": "^1.0.4" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" } }, "sha512-zyNydy1+HtoK6cJ8EmqFNkPPGHIFMrr2KH+ef3654EqXx4IcJ8A5LCNMXBuALQE8IMxtk040JMoR9OKyeXjBOQ=="],
"@wavesurfer/react": ["@wavesurfer/react@1.0.12", "", { "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "wavesurfer.js": ">=7.7.14" } }, "sha512-BNHpz2ryKNVvJdxB47pCPUsNCsjb2pZRysg82M5djIiw0vsiSJwdlt5jaAfDo3vd5IWrcoK9OiPQKO9ZEVNpDQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -935,6 +943,8 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@@ -1293,6 +1303,8 @@
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"media-captions": ["media-captions@1.0.4", "", {}, "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w=="],
"meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -1307,8 +1319,6 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
@@ -1445,6 +1455,8 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable-panels": ["react-resizable-panels@4.6.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-pmQP6qv9KmsesNMvWVNvVfVJAwYSOWWbAOAtrPR8Cre20+j1NWIlyft0btjtDQE+OepXmI6g3VPrCXQY0oD7+Q=="],
"react-stately": ["react-stately@3.43.0", "", { "dependencies": { "@react-stately/calendar": "^3.9.1", "@react-stately/checkbox": "^3.7.3", "@react-stately/collections": "^3.12.8", "@react-stately/color": "^3.9.3", "@react-stately/combobox": "^3.12.1", "@react-stately/data": "^3.15.0", "@react-stately/datepicker": "^3.15.3", "@react-stately/disclosure": "^3.0.9", "@react-stately/dnd": "^3.7.2", "@react-stately/form": "^3.2.2", "@react-stately/list": "^3.13.2", "@react-stately/menu": "^3.9.9", "@react-stately/numberfield": "^3.10.3", "@react-stately/overlays": "^3.6.21", "@react-stately/radio": "^3.11.3", "@react-stately/searchfield": "^3.5.17", "@react-stately/select": "^3.9.0", "@react-stately/selection": "^3.20.7", "@react-stately/slider": "^3.7.3", "@react-stately/table": "^3.15.2", "@react-stately/tabs": "^3.8.7", "@react-stately/toast": "^3.1.2", "@react-stately/toggle": "^3.9.3", "@react-stately/tooltip": "^3.5.9", "@react-stately/tree": "^3.9.4", "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-dScb9fTL1tRtFODPnk/2rP0a9kp1C+7+40RArS0C7j0auAUmnrO/wDILojwQUso7/kkys4fP707fTwGJDeJ7vg=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
@@ -1641,6 +1653,8 @@
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
"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-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
+13
View File
@@ -7,6 +7,19 @@ const stylesPath = path.join(dirname, "src/shared/styles")
console.log("dirname", dirname)
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "9000",
},
],
dangerouslyAllowSVG: true,
contentDispositionType: "inline",
localPatterns: undefined,
unoptimized: process.env.NODE_ENV === "development",
},
sassOptions: {
includePaths: [stylesPath],
additionalData: `@use "${path.join(stylesPath, "_variables.scss")}";
+5 -1
View File
@@ -25,13 +25,15 @@
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-query-devtools": "^5.91.2",
"@vidstack/react": "^1",
"@wavesurfer/react": "^1.0.12",
"axios": "^1.13.2",
"classnames": "^2.5.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.26",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.562.0",
"moment": "^2.30.1",
"next": "16.1.1",
"normalize.css": "^8.0.1",
"openapi-fetch": "^0.15.0",
@@ -43,9 +45,11 @@
"react-hook-form": "^7.71.0",
"react-modern-drawer": "^1.4.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^4.6.5",
"react-toastify": "^11.0.5",
"use-mask-input": "^3.6.0",
"usehooks-ts": "^3.1.1",
"wavesurfer.js": "^7.12.1",
"xior": "^0.8.2"
},
"devDependencies": {
@@ -1,33 +1,62 @@
@use "@shared/styles/variables" as *;
.drawer {
background: transparent;
background: transparent !important;
}
.root {
display: flex;
flex-direction: column;
min-width: 220px;
height: 100%;
padding: 16px 12px;
background: #ffffff;
border-radius: 12px 12px 0 0;
box-shadow: 0 10px 30px rgba(12, 18, 38, 0.08);
background: variables.$bg-default;
border-radius: 0 variables.$radius-lg variables.$radius-lg 0;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.header {
margin-bottom: 12px;
font-weight: 700;
font-size: 16px;
color: #0c1226;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
border-bottom: 1px solid variables.$border-subtle;
}
.brand {
@include typography.font-body-16(600);
color: variables.$text-primary;
letter-spacing: -0.01em;
}
.closeButton {
@include mixins.flex-center;
width: 32px;
height: 32px;
border: none;
border-radius: variables.$radius-sm;
background: transparent;
color: variables.$text-secondary;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
svg {
width: 18px;
height: 18px;
}
}
.list {
list-style: none;
margin: 0;
padding: 0;
@include mixins.reset-list;
display: flex;
flex-direction: column;
gap: 6px;
gap: 2px;
padding: 8px;
flex: 1;
}
.item {
@@ -36,40 +65,44 @@
.link,
.button {
display: inline-flex;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 10px;
border-radius: variables.$radius-sm;
background: transparent;
color: #0c1226;
color: variables.$text-secondary;
text-decoration: none;
font-weight: 600;
font-size: 14px;
line-height: 1.4;
@include typography.font-body-14(500);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
transition: background-color 0.15s ease, color 0.15s ease;
.link:hover,
.button:hover,
.link:focus-visible,
.button:focus-visible {
outline: none;
background-color: #f4f6fb;
&:hover {
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
&:focus-visible {
outline: 2px solid variables.$color-secondary;
outline-offset: -2px;
border-radius: variables.$radius-sm;
}
&.active {
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
}
.icon {
display: inline-flex;
flex-shrink: 0;
width: 18px;
height: 18px;
color: #5a6473;
}
.label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@include mixins.text-ellipsis;
}
@@ -1,10 +1,12 @@
import type { JSX } from "react"
import { X } from "lucide-react"
import { FunctionComponent } from "react"
import Drawer from "react-modern-drawer"
import cs from "classnames"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { INavigationDrawerProps } from "./NavigationDrawer.d"
import styles from "./NavigationDrawer.module.scss"
@@ -20,6 +22,8 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
size = 280,
title,
}): JSX.Element => {
const pathname = usePathname()
return (
<Drawer
open={open}
@@ -27,14 +31,25 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
direction={position}
size={size}
className={cs(styles.drawer, className)}
aria-label="Navigation drawer"
aria-label="Навигация"
duration={200}
>
<nav className={styles.root} data-testid="NavigationDrawer">
{title ? <div className={styles.header}>{title}</div> : null}
<div className={styles.header}>
<span className={styles.brand}>{title ?? "Coffee Project"}</span>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Закрыть навигацию"
>
<X />
</button>
</div>
<ul className={styles.list}>
{buttons.map(({ label, icon: Icon, path, action }, index) => {
const key = `${label}-${path ?? index}`
const isActive = path ? pathname === path : false
const content = (
<>
@@ -53,7 +68,7 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
{path ? (
<Link
href={path}
className={styles.link}
className={cs(styles.link, isActive && styles.active)}
onClick={handleClick}
>
{content}
+3
View File
@@ -17,4 +17,7 @@ export interface IProjectCardProps {
*/
imageUrl?: string
onClick?: () => void
onEdit?: () => void
onRename?: () => void
onDelete?: () => void
}
@@ -5,19 +5,22 @@
height: 100%;
overflow: hidden;
position: relative;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
box-shadow 0.2s ease,
border-color 0.2s ease;
cursor: pointer;
background: variables.$bg-default;
}
.hero {
width: 100%;
height: 180px;
background-color: variables.$purple-50;
background-color: variables.$bg-surface;
position: relative;
overflow: hidden;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: center;
align-items: center;
@@ -35,24 +38,25 @@
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%);
svg {
width: 48px;
height: 48px;
width: 40px;
height: 40px;
color: variables.$purple-300;
opacity: 0.5;
opacity: 0.4;
}
}
}
.root:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
box-shadow: var(--shadow-lg);
border-color: transparent;
}
.content {
display: flex;
flex-direction: column;
padding: 16px;
gap: 8px;
padding: 14px 16px;
gap: 6px;
flex: 1;
}
@@ -90,8 +94,8 @@
}
.progressValue {
stroke: variables.$purple-400;
stroke: variables.$purple-400;
&.completed {
stroke: variables.$color-success;
}
@@ -135,7 +139,7 @@
.info {
@include mixins.flex-column;
gap: 8px;
gap: 4px;
flex: 1;
min-width: 0;
}
@@ -155,16 +159,16 @@
.date {
@include typography.font-caption-m;
color: variables.$text-secondary;
color: variables.$text-tertiary;
}
.status {
margin-top: auto;
display: flex;
align-items: center;
gap: 6px;
@include typography.font-body-s;
@include typography.font-caption-m;
font-weight: 500;
&.statusGenerated {
@@ -172,7 +176,7 @@
}
&.statusProcessing, &.statusRendering, &.statusUploading {
color: variables.$purple-500;
color: variables.$purple-400;
}
&.statusDraft {
@@ -185,28 +189,27 @@
}
.statusDot {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: currentColor;
}
.menuTrigger {
margin-left: auto;
background-color: variables.$bg-surface;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06);
width: 32px;
height: 32px;
background-color: transparent;
border-radius: variables.$radius-sm;
width: 28px;
height: 28px;
@include mixins.flex-center;
color: variables.$text-primary;
color: variables.$text-secondary;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover,
&[data-state="open"] {
background-color: variables.$bg-default;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
button {
@@ -223,13 +226,15 @@
.statusBadge {
position: absolute;
top: 12px;
left: 12px;
padding: 6px 10px;
border-radius: 12px;
background: variables.$bg-canvas;
top: 10px;
left: 10px;
padding: 4px 10px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
@include typography.font-caption-m;
font-weight: 500;
color: variables.$text-primary;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-sm);
z-index: 2;
}
+9 -11
View File
@@ -7,8 +7,8 @@ import { Image as ImageIcon, MoreHorizontal } from "lucide-react"
import { FunctionComponent } from "react"
import cs from "classnames"
import moment from "moment"
import { formatRelativeTime } from "@shared/lib/dates"
import { Card } from "@shared/ui/Card"
import { CircularProgress } from "@shared/ui/CircularProgress"
import {
@@ -27,6 +27,9 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
currentAction,
imageUrl,
onClick,
onEdit,
onRename,
onDelete,
}): JSX.Element => {
const { name, updated_at, status } = project
@@ -38,7 +41,6 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
const shouldShowProgress = isProcessing
// Helper to determine status color/class
const getStatusClass = () => {
if (isCompleted) return styles.statusGenerated
if (isProcessing) return styles.statusProcessing
@@ -49,7 +51,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
const getStatusLabel = () => {
if (isCompleted) return "Завершено"
if (isProcessing) return "В процессе" // Or more specific state
if (isProcessing) return "В процессе"
if (isDraft) return "Черновик"
if (isFailed) return "Ошибка"
return status
@@ -112,19 +114,15 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
</button>
</DropdownTrigger>
<DropdownContent align="end">
<DropdownItem
onSelect={() => console.log("Edit", project.id)}
>
<DropdownItem onSelect={() => onEdit?.()}>
Изменить
</DropdownItem>
<DropdownItem
onSelect={() => console.log("Rename", project.id)}
>
<DropdownItem onSelect={() => onRename?.()}>
Переименовать
</DropdownItem>
<DropdownItem
className="text-red-500"
onSelect={() => console.log("Delete", project.id)}
onSelect={() => onDelete?.()}
>
Удалить
</DropdownItem>
@@ -133,7 +131,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
</div>
</div>
<span className={styles.date}>
Создано {moment(updated_at).fromNow()}
Создано {formatRelativeTime(updated_at)}
</span>
</div>
</div>
@@ -1,9 +1,9 @@
.root {
display: flex
display: flex;
}
.username {
@include typography.font-body-16(500);
@include typography.font-body-14(500);
color: variables.$text-primary;
user-select: none;
}
@@ -12,14 +12,21 @@
display: flex;
align-items: center;
gap: 8px;
padding: 8px ;
padding: 6px 12px 6px 6px;
border-radius: 24px;
cursor: pointer;
transition: background-color 0.15s ease;
flex-shrink: 0;
&:hover {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
background-color: variables.$bg-surface;
}
}
}
.item {
gap: 8px;
}
.themeItem {
gap: 8px;
}
+75 -4
View File
@@ -1,23 +1,60 @@
import type { JSX } from "react"
import Cookies from "js-cookie"
import { Monitor, Moon, Sun } from "lucide-react"
import { FunctionComponent } from "react"
import { useRouter } from "next/navigation"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
} from "@shared/lib/constants"
import { setThemePreference } from "@shared/store/appState"
import type { ThemePreference } from "@shared/store/appState/types"
import { resetUser } from "@shared/store/user"
import { Avatar } from "@shared/ui/Avatar"
import {
Dropdown,
DropdownContent,
DropdownItem,
DropdownRadioGroup,
DropdownRadioItem,
DropdownSeparator,
DropdownSub,
DropdownSubContent,
DropdownSubTrigger,
DropdownTrigger,
} from "@shared/ui/Dropdown"
import { userDropdownValues } from "./constants"
import { navItems } from "./constants"
import { IUserDropdownProps } from "./UserDropdown.d"
import styles from "./UserDropdown.module.scss"
const themeOptions: { value: ThemePreference; label: string; icon: typeof Sun }[] = [
{ value: "light", label: "Светлая", icon: Sun },
{ value: "dark", label: "Тёмная", icon: Moon },
{ value: "system", label: "Системная", icon: Monitor },
]
export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
user,
}): JSX.Element => {
const router = useRouter()
const dispatch = useAppDispatch()
const themePreference = useAppSelector(
(state) => state.appState.themePreference,
)
const handleLogout = () => {
Cookies.remove(ACCESS_TOKEN_COOKIE)
Cookies.remove(REFRESH_TOKEN_COOKIE)
dispatch(resetUser())
router.push("/login")
}
return (
<div className={styles.root} data-testid="UserDropdown">
<Dropdown>
@@ -28,15 +65,49 @@ export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
</div>
</DropdownTrigger>
<DropdownContent>
{userDropdownValues.map((item) => (
{navItems.map((item) => (
<DropdownItem
key={item.acton}
key={item.action}
className={styles.item}
onSelect={() => console.log(`${item.acton} selected`)}
onSelect={() => router.push(item.path)}
>
{item.label}
</DropdownItem>
))}
<DropdownSeparator />
<DropdownSub>
<DropdownSubTrigger className={styles.item}>
<Sun size={16} />
Тема
</DropdownSubTrigger>
<DropdownSubContent>
<DropdownRadioGroup
value={themePreference}
onValueChange={(v) =>
dispatch(setThemePreference(v as ThemePreference))
}
>
{themeOptions.map((opt) => (
<DropdownRadioItem
key={opt.value}
value={opt.value}
className={styles.themeItem}
>
<opt.icon size={16} />
{opt.label}
</DropdownRadioItem>
))}
</DropdownRadioGroup>
</DropdownSubContent>
</DropdownSub>
<DropdownSeparator />
<DropdownItem className={styles.item} onSelect={handleLogout}>
Выйти
</DropdownItem>
</DropdownContent>
</Dropdown>
</div>
+3 -7
View File
@@ -1,16 +1,12 @@
export const userDropdownValues = [
export const navItems = [
{
label: "Профиль",
acton: "profile",
action: "profile",
path: "/profile",
},
{
label: "Настройки",
acton: "settings",
action: "settings",
path: "/settings",
},
{
label: "Выйти",
acton: "logout",
},
]
View File
@@ -0,0 +1,3 @@
export interface INotificationBellProps {
className?: string
}
@@ -0,0 +1,29 @@
.root {
position: relative;
display: inline-flex;
// Rounded hover for ghost icon button
:global(.rt-IconButton) {
border-radius: variables.$radius-sm;
}
}
.badge {
position: absolute;
top: 0;
right: 0;
transform: translate(50%, -50%);
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 9999px;
background-color: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
pointer-events: none;
border: 1.5px solid variables.$bg-default;
box-sizing: content-box;
}
@@ -0,0 +1,45 @@
"use client"
import type { INotificationBellProps } from "./NotificationBell.d"
import type { JSX } from "react"
import { Bell } from "lucide-react"
import { FunctionComponent, useCallback, useRef, useState } from "react"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { Button } from "@shared/ui"
import { NotificationPopup } from "../NotificationPopup"
import styles from "./NotificationBell.module.scss"
export const NotificationBell: FunctionComponent<INotificationBellProps> =
(): JSX.Element => {
const unreadCount = useAppSelector(
(state) => state.notifications.unreadCount,
)
const [isOpen, setIsOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
const close = useCallback(() => setIsOpen(false), [])
return (
<div className={styles.root} ref={rootRef}>
<Button variant="icon" size="lg" onClick={toggle}>
<Bell size={22} />
</Button>
{unreadCount > 0 && (
<span className={styles.badge}>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{isOpen && (
<NotificationPopup
onClose={close}
anchorRef={rootRef}
/>
)}
</div>
)
}
@@ -0,0 +1 @@
export { NotificationBell } from "./NotificationBell"
@@ -0,0 +1,6 @@
import type { RefObject } from "react"
export interface INotificationPopupProps {
onClose: () => void
anchorRef: RefObject<HTMLDivElement | null>
}
@@ -0,0 +1,152 @@
.overlay {
position: fixed;
inset: 0;
z-index: 99;
}
.root {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 360px;
max-height: 480px;
background-color: variables.$bg-surface;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
box-shadow: variables.$shadow-lg;
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid variables.$border-subtle;
}
.title {
@include typography.font-body-14(600);
color: variables.$text-primary;
}
.readAllBtn {
@include typography.font-caption-m;
font-weight: 500;
color: variables.$purple-500;
background: none;
border: none;
cursor: pointer;
padding: 0;
&:hover {
color: variables.$purple-700;
}
}
.list {
flex: 1;
overflow-y: auto;
}
.item {
display: flex;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: variables.$bg-hover;
}
&:not(:last-child) {
border-bottom: 1px solid variables.$border-subtle;
}
}
.itemUnread {
border-left: 3px solid variables.$purple-500;
}
.itemContent {
flex: 1;
min-width: 0;
}
.itemTitle {
@include typography.font-body-14(500);
color: variables.$text-primary;
display: flex;
align-items: center;
gap: 8px;
}
.itemMessage {
@include typography.font-caption-m;
color: variables.$text-secondary;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemMeta {
@include typography.font-caption-m;
color: variables.$text-tertiary;
margin-top: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
line-height: 16px;
}
.statusRunning {
background-color: #dbeafe;
color: #1d4ed8;
}
.statusDone {
background-color: #dcfce7;
color: #15803d;
}
.statusFailed {
background-color: #fee2e2;
color: #b91c1c;
}
.progressBar {
width: 100%;
height: 4px;
background-color: variables.$border-subtle;
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.progressFill {
height: 100%;
background-color: variables.$purple-500;
border-radius: 2px;
transition: width 0.3s ease;
}
.empty {
padding: 32px 16px;
text-align: center;
@include typography.font-body-14(400);
color: variables.$text-tertiary;
}
@@ -0,0 +1,177 @@
"use client"
import type { INotificationPopupProps } from "./NotificationPopup.d"
import type { JSX } from "react"
import cs from "classnames"
import Cookies from "js-cookie"
import { useRouter } from "next/navigation"
import { FunctionComponent, useCallback, useEffect, useRef } from "react"
import { useDispatch } from "react-redux"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { formatRelativeTime } from "@shared/lib/dates"
import { API_URL } from "@shared/lib/constants"
import {
markAllRead,
markRead,
NotificationItem,
} from "@shared/store/notifications"
const apiBase = API_URL || "http://localhost:8000"
function authHeaders(): HeadersInit {
const token = Cookies.get("access_token")
return token ? { Authorization: `Bearer ${token}` } : {}
}
import styles from "./NotificationPopup.module.scss"
const JOB_TYPE_LABELS: Record<string, string> = {
MEDIA_PROBE: "Анализ медиа",
SILENCE_REMOVE: "Удаление тишины",
MEDIA_CONVERT: "Конвертация",
TRANSCRIPTION_GENERATE: "Транскрипция",
CAPTIONS_GENERATE: "Генерация субтитров",
}
const STATUS_LABELS: Record<string, string> = {
PENDING: "Ожидание",
RUNNING: "Выполняется",
DONE: "Завершено",
FAILED: "Ошибка",
}
function getStatusClass(status: string | null): string {
switch (status) {
case "RUNNING":
return styles.statusRunning
case "DONE":
return styles.statusDone
case "FAILED":
return styles.statusFailed
default:
return ""
}
}
export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
onClose,
anchorRef,
}): JSX.Element => {
const items = useAppSelector((state) => state.notifications.items)
const dispatch = useDispatch()
const router = useRouter()
const popupRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
popupRef.current &&
!popupRef.current.contains(e.target as Node) &&
anchorRef.current &&
!anchorRef.current.contains(e.target as Node)
) {
onClose()
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [onClose, anchorRef])
const handleMarkAllRead = useCallback(() => {
dispatch(markAllRead())
fetch(`${apiBase}/api/notifications/read-all/`, {
method: "POST",
headers: authHeaders(),
}).catch(() => {})
}, [dispatch])
const handleItemClick = useCallback(
(item: NotificationItem) => {
if (item.notification_id && !item.is_read) {
dispatch(markRead(item.notification_id))
fetch(`${apiBase}/api/notifications/${item.notification_id}/read/`, {
method: "POST",
headers: authHeaders(),
}).catch(() => {})
}
if (item.project_id) {
router.push(`/projects/${item.project_id}`)
onClose()
}
},
[dispatch, router, onClose],
)
return (
<div className={styles.root} ref={popupRef}>
<div className={styles.header}>
<span className={styles.title}>Уведомления</span>
<button
className={styles.readAllBtn}
onClick={handleMarkAllRead}
type="button"
>
Прочитать все
</button>
</div>
<div className={styles.list}>
{items.length === 0 ? (
<div className={styles.empty}>Нет уведомлений</div>
) : (
items.map((item, idx) => (
<div
key={item.notification_id || `${item.job_id}-${idx}`}
className={cs(styles.item, {
[styles.itemUnread]: !item.is_read,
})}
onClick={() => handleItemClick(item)}
>
<div className={styles.itemContent}>
<div className={styles.itemTitle}>
<span>
{item.job_type
? (JOB_TYPE_LABELS[item.job_type] ||
item.title)
: item.title}
</span>
{item.status && (
<span
className={cs(
styles.statusBadge,
getStatusClass(item.status),
)}
>
{STATUS_LABELS[item.status] ||
item.status}
</span>
)}
</div>
{item.message && (
<div className={styles.itemMessage}>
{item.message}
</div>
)}
{item.status === "RUNNING" &&
item.progress_pct != null && (
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{
width: `${item.progress_pct}%`,
}}
/>
</div>
)}
<div className={styles.itemMeta}>
{formatRelativeTime(item.created_at)}
</div>
</div>
</div>
))
)}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { NotificationPopup } from "./NotificationPopup"
+2
View File
@@ -0,0 +1,2 @@
export { NotificationBell } from "./NotificationBell"
export { NotificationPopup } from "./NotificationPopup"
+5
View File
@@ -0,0 +1,5 @@
export interface IAvatarUploadProps {
currentAvatarUrl: string | null
onAvatarChange: (url: string) => void
className?: string
}
@@ -0,0 +1,38 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatarButton {
all: unset;
cursor: pointer;
border-radius: 50%;
overflow: hidden;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.changeLink {
all: unset;
cursor: pointer;
color: var(--accent-9);
font-size: 14px;
&:hover {
text-decoration: underline;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.fileInput {
display: none;
}
@@ -0,0 +1,81 @@
"use client"
import type { IAvatarUploadProps } from "./AvatarUpload.d"
import type { JSX } from "react"
import cs from "classnames"
import { FunctionComponent, useRef, useState } from "react"
import { uploadFileWithProgress } from "@shared/api/uploadFile"
import { Avatar } from "@shared/ui/Avatar"
import styles from "./AvatarUpload.module.scss"
export const AvatarUpload: FunctionComponent<
IAvatarUploadProps
> = ({ currentAvatarUrl, onAvatarChange, className }): JSX.Element => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const handleClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0]
if (!file) return
setUploadProgress(0)
try {
const result = await uploadFileWithProgress(
file,
"avatars",
setUploadProgress,
)
onAvatarChange(result.file_path)
} catch (err) {
console.error("Avatar upload failed:", err)
} finally {
setUploadProgress(null)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
return (
<div
className={cs(styles.root, className)}
data-testid="AvatarUpload"
>
<button
type="button"
className={styles.avatarButton}
onClick={handleClick}
disabled={uploadProgress !== null}
>
<Avatar
size="xlarge"
url={currentAvatarUrl || ""}
/>
</button>
<button
type="button"
className={styles.changeLink}
onClick={handleClick}
disabled={uploadProgress !== null}
>
{uploadProgress !== null
? `Загрузка ${uploadProgress}%`
: "Изменить фото"}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className={styles.fileInput}
onChange={handleFileChange}
/>
</div>
)
}
@@ -0,0 +1 @@
export * from "./AvatarUpload"
@@ -0,0 +1,3 @@
export interface IChangePasswordFormProps {
className?: string
}
@@ -0,0 +1,17 @@
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
}
@@ -0,0 +1,108 @@
"use client"
import type { IChangePasswordFormProps } from "./ChangePasswordForm.d"
import type { JSX } from "react"
import { FunctionComponent, useState } from "react"
import { useForm } from "react-hook-form"
import api from "@shared/api"
import { Alert, Button, Form, TextField } from "@shared/ui"
import styles from "./ChangePasswordForm.module.scss"
interface IPasswordFormData {
current_password: string
new_password: string
confirm_password: string
}
export const ChangePasswordForm: FunctionComponent<
IChangePasswordFormProps
> = ({ className }): JSX.Element => {
const [successMessage, setSuccessMessage] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { register, handleSubmit, reset, formState: { errors } } = useForm<IPasswordFormData>({
defaultValues: {
current_password: "",
new_password: "",
confirm_password: "",
},
})
const { mutate, isPending } = api.useMutation(
"post",
"/api/users/me/change-password/",
{
onSuccess: () => {
setErrorMessage(null)
setSuccessMessage(true)
reset()
setTimeout(() => setSuccessMessage(false), 3000)
},
onError: (error) => {
setSuccessMessage(false)
setErrorMessage(
(error as { detail?: string })?.detail || "Не удалось сменить пароль",
)
},
},
)
const onSubmit = (data: IPasswordFormData) => {
setErrorMessage(null)
mutate({
body: {
current_password: data.current_password,
new_password: data.new_password,
},
})
}
return (
<div className={className} data-testid="ChangePasswordForm">
<h3 className={styles.title}>Смена пароля</h3>
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="current_password"
label="Текущий пароль"
placeholder="Введите текущий пароль"
type="password"
{...register("current_password", { required: "Обязательное поле" })}
/>
<TextField
id="new_password"
label="Новый пароль"
placeholder="Введите новый пароль"
type="password"
{...register("new_password", { required: "Обязательное поле" })}
/>
<TextField
id="confirm_password"
label="Подтверждение пароля"
placeholder="Повторите новый пароль"
type="password"
error={!!errors.confirm_password}
{...register("confirm_password", {
required: "Обязательное поле",
validate: (value, formValues) =>
value === formValues.new_password || "Пароли не совпадают",
})}
/>
{errors.confirm_password && (
<Alert variant="danger">{errors.confirm_password.message}</Alert>
)}
</div>
{successMessage && (
<Alert variant="success">Пароль успешно изменён</Alert>
)}
{errorMessage && <Alert variant="danger">{errorMessage}</Alert>}
<Button type="submit" variant="primary" disabled={isPending}>
{isPending ? "Сохранение..." : "Сменить пароль"}
</Button>
</Form>
</div>
)
}
@@ -0,0 +1 @@
export * from "./ChangePasswordForm"
@@ -0,0 +1,6 @@
import type { components } from "@shared/api/__generated__/openapi.types"
export interface IEditProfileFormProps {
user: components["schemas"]["UserRead"]
className?: string
}
@@ -0,0 +1,17 @@
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
}
@@ -0,0 +1,102 @@
"use client"
import type { IEditProfileFormProps } from "./EditProfileForm.d"
import type { JSX } from "react"
import { FunctionComponent, useState } from "react"
import { useForm } from "react-hook-form"
import api from "@shared/api"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import { setUser } from "@shared/store/user"
import { Alert, Button, Form, TextField } from "@shared/ui"
import styles from "./EditProfileForm.module.scss"
interface IProfileFormData {
first_name: string
last_name: string
email: string
phone_number: string
}
export const EditProfileForm: FunctionComponent<
IEditProfileFormProps
> = ({ user, className }): JSX.Element => {
const dispatch = useAppDispatch()
const [successMessage, setSuccessMessage] = useState(false)
const { register, handleSubmit } = useForm<IProfileFormData>({
defaultValues: {
first_name: user.first_name || "",
last_name: user.last_name || "",
email: user.email || "",
phone_number: user.phone_number || "",
},
})
const { mutate, isPending } = api.useMutation(
"patch",
"/api/users/{user_id}/",
{
onSuccess: (data) => {
dispatch(setUser(data))
setSuccessMessage(true)
setTimeout(() => setSuccessMessage(false), 3000)
},
},
)
const onSubmit = (data: IProfileFormData) => {
mutate({
params: { path: { user_id: user.id } },
body: {
first_name: data.first_name || null,
last_name: data.last_name || null,
email: data.email || null,
phone_number: data.phone_number || null,
},
})
}
return (
<div className={className} data-testid="EditProfileForm">
<h3 className={styles.title}>Личная информация</h3>
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="first_name"
label="Имя"
placeholder="Ваше имя"
{...register("first_name")}
/>
<TextField
id="last_name"
label="Фамилия"
placeholder="Ваша фамилия"
{...register("last_name")}
/>
<TextField
id="email"
label="Email"
placeholder="Ваш email"
type="email"
{...register("email")}
/>
<TextField
id="phone_number"
label="Телефон"
placeholder="Ваш телефон"
{...register("phone_number")}
/>
</div>
{successMessage && (
<Alert variant="success">Данные успешно сохранены</Alert>
)}
<Button type="submit" variant="primary" disabled={isPending}>
{isPending ? "Сохранение..." : "Сохранить"}
</Button>
</Form>
</div>
)
}
@@ -0,0 +1 @@
export * from "./EditProfileForm"
+3
View File
@@ -0,0 +1,3 @@
export interface ILogoutButtonProps {
className?: string
}
@@ -0,0 +1,2 @@
.root {
}
@@ -0,0 +1,43 @@
"use client"
import type { ILogoutButtonProps } from "./LogoutButton.d"
import type { JSX } from "react"
import Cookies from "js-cookie"
import { FunctionComponent } from "react"
import { useRouter } from "next/navigation"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
} from "@shared/lib/constants"
import { resetUser } from "@shared/store/user"
import { Button } from "@shared/ui"
export const LogoutButton: FunctionComponent<
ILogoutButtonProps
> = ({ className }): JSX.Element => {
const router = useRouter()
const dispatch = useAppDispatch()
const handleLogout = () => {
Cookies.remove(ACCESS_TOKEN_COOKIE)
Cookies.remove(REFRESH_TOKEN_COOKIE)
dispatch(resetUser())
router.push("/login")
}
return (
<Button
variant="danger"
size="lg"
className={className}
onClick={handleLogout}
data-testid="LogoutButton"
>
Выйти из аккаунта
</Button>
)
}
@@ -0,0 +1 @@
export * from "./LogoutButton"
+4
View File
@@ -0,0 +1,4 @@
export { AvatarUpload } from "./AvatarUpload"
export { ChangePasswordForm } from "./ChangePasswordForm"
export { EditProfileForm } from "./EditProfileForm"
export { LogoutButton } from "./LogoutButton"
@@ -0,0 +1,6 @@
export interface IConvertMediaViewProps {
projectId: string
fileKey: string
fileName: string
mimeType: string
}
@@ -0,0 +1,69 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-height: 300px;
padding: 24px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 360px;
text-align: center;
}
.icon {
color: variables.$text-tertiary;
}
.successIcon {
color: variables.$color-success;
}
.fileName {
font-size: 15px;
font-weight: 500;
color: variables.$text-primary;
word-break: break-all;
}
.message {
font-size: 14px;
color: variables.$text-secondary;
}
.hint {
font-size: 13px;
color: variables.$text-tertiary;
}
.error {
font-size: 13px;
color: variables.$color-danger;
}
.progressTrack {
width: 100%;
height: 6px;
border-radius: 3px;
background: variables.$bg-surface;
overflow: hidden;
}
.progressBar {
height: 100%;
border-radius: 3px;
background: variables.$color-primary;
transition: width 0.3s ease;
}
.progressLabel {
font-size: 13px;
color: variables.$text-tertiary;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,142 @@
"use client"
import type { IConvertMediaViewProps } from "./ConvertMediaView.d"
import type { JSX } from "react"
import { CheckCircle, FileVideo } from "lucide-react"
import { FunctionComponent, useCallback, useState } from "react"
import api from "@shared/api"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { Button } from "@shared/ui"
import styles from "./ConvertMediaView.module.scss"
const STATUS_IDLE = "idle"
const STATUS_CONVERTING = "converting"
const STATUS_DONE = "done"
const STATUS_FAILED = "failed"
type ConvertStatus =
| typeof STATUS_IDLE
| typeof STATUS_CONVERTING
| typeof STATUS_DONE
| typeof STATUS_FAILED
const ERROR_CONVERT_FAILED = "Не удалось запустить конвертацию"
function formatNameFromMime(mime: string): string {
const sub = mime.split("/")[1] ?? mime
return sub.toUpperCase()
}
export const ConvertMediaView: FunctionComponent<
IConvertMediaViewProps
> = ({ projectId, fileKey, fileName, mimeType }): JSX.Element => {
const [status, setStatus] = useState<ConvertStatus>(STATUS_IDLE)
const [jobId, setJobId] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const notification = useAppSelector((state) =>
jobId
? state.notifications.items.find((n) => n.job_id === jobId)
: null,
)
const progressPct = notification?.progress_pct ?? 0
const notifStatus = notification?.status
const notifMessage = notification?.message
// Update status from notification
if (status === STATUS_CONVERTING && notifStatus === "DONE") {
setStatus(STATUS_DONE)
}
if (status === STATUS_CONVERTING && notifStatus === "FAILED") {
setStatus(STATUS_FAILED)
setErrorMessage(notifMessage ?? ERROR_CONVERT_FAILED)
}
const { mutate, isPending } = api.useMutation(
"post",
"/api/tasks/media-convert/",
{
onSuccess: (data) => {
setJobId(data.job_id)
setStatus(STATUS_CONVERTING)
setErrorMessage(null)
},
onError: () => {
setErrorMessage(ERROR_CONVERT_FAILED)
},
},
)
const handleConvert = useCallback(() => {
mutate({
body: {
file_key: fileKey,
out_folder: "output_files",
output_format: "mp4",
project_id: projectId,
},
})
}, [mutate, fileKey, projectId])
const formatName = formatNameFromMime(mimeType)
if (status === STATUS_DONE) {
return (
<div className={styles.root} data-testid="ConvertMediaView">
<div className={styles.content}>
<CheckCircle size={48} className={styles.successIcon} />
<p className={styles.message}>Конвертация завершена</p>
<p className={styles.hint}>
Файл MP4 доступен в разделе «Артефакты»
</p>
</div>
</div>
)
}
if (status === STATUS_CONVERTING) {
return (
<div className={styles.root} data-testid="ConvertMediaView">
<div className={styles.content}>
<FileVideo size={48} className={styles.icon} />
<p className={styles.message}>
{notifMessage ?? "Конвертация..."}
</p>
<div className={styles.progressTrack}>
<div
className={styles.progressBar}
style={{ width: `${progressPct}%` }}
/>
</div>
<p className={styles.progressLabel}>{Math.round(progressPct)}%</p>
</div>
</div>
)
}
return (
<div className={styles.root} data-testid="ConvertMediaView">
<div className={styles.content}>
<FileVideo size={48} className={styles.icon} />
<p className={styles.fileName}>{fileName}</p>
<p className={styles.message}>
Формат {formatName} не поддерживается для воспроизведения
</p>
{errorMessage && (
<p className={styles.error}>{errorMessage}</p>
)}
<Button
variant="primary"
onClick={handleConvert}
disabled={isPending}
>
Конвертировать в MP4
</Button>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { ConvertMediaView } from "./ConvertMediaView"
@@ -20,6 +20,6 @@
}
.selectLabel {
font-size: 14px;
font-weight: 500;
@include typography.font-body-14(500);
color: variables.$text-primary;
}
@@ -1,6 +1,5 @@
"use client"
import type { ProjectCreateBody } from "./useCreateProject"
import type { ICreateProjectModalProps } from "./CreateProjectModal.d"
import type { JSX } from "react"
@@ -12,27 +11,16 @@ import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
import { useCreateProject } from "./useCreateProject"
import styles from "./CreateProjectModal.module.scss"
type ProjectStatus = ProjectCreateBody["status"]
interface ICreateProjectFormData {
name: string
description?: string
language: string
folder?: string
status: ProjectStatus
}
const STATUS_OPTIONS: Array<{ value: ProjectStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "PROCESSING", label: "Processing" },
{ value: "DONE", label: "Done" },
{ value: "FAILED", label: "Failed" },
]
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "auto", label: "Auto" },
{ value: "ru", label: "Russian" },
{ value: "en", label: "English" },
{ value: "auto", label: "Авто" },
{ value: "ru", label: "Русский" },
{ value: "en", label: "Английский" },
]
export const CreateProjectModal: FunctionComponent<
@@ -43,9 +31,7 @@ export const CreateProjectModal: FunctionComponent<
defaultValues: {
name: "",
description: "",
folder: "",
language: "auto",
status: "DRAFT",
},
})
@@ -66,15 +52,12 @@ export const CreateProjectModal: FunctionComponent<
const onSubmit = (data: ICreateProjectFormData): void => {
const name = data.name.trim()
const description = data.description?.trim()
const folder = data.folder?.trim()
mutate({
body: {
name,
description: description?.length ? description : undefined,
folder: folder?.length ? folder : undefined,
language: data.language,
status: data.status,
},
})
}
@@ -109,13 +92,6 @@ export const CreateProjectModal: FunctionComponent<
{...register("description")}
/>
<TextField
id="project_folder"
label="Папка"
placeholder="Например: /projects/my-project (необязательно)"
{...register("folder")}
/>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Язык</div>
<Controller
@@ -136,33 +112,12 @@ export const CreateProjectModal: FunctionComponent<
)}
/>
</div>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Статус</div>
<Controller
name="status"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите статус"
>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="ghost"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
@@ -0,0 +1,9 @@
import type { Dialog } from "@radix-ui/themes"
import type { ComponentProps } from "react"
export interface IDeleteFileModalProps
extends Pick<ComponentProps<typeof Dialog.Root>, "open" | "onOpenChange"> {
fileName: string
onConfirm: () => void
isPending: boolean
}
@@ -0,0 +1,20 @@
.root {
min-width: 420px;
}
.message {
@include typography.font-body-14(400);
color: variables.$text-secondary;
}
.fileName {
@include typography.font-body-14(600);
color: variables.$text-primary;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
@@ -0,0 +1,53 @@
"use client"
import type { IDeleteFileModalProps } from "./DeleteFileModal.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import { Button, Modal } from "@shared/ui"
import styles from "./DeleteFileModal.module.scss"
export const DeleteFileModal: FunctionComponent<IDeleteFileModalProps> = ({
open,
onOpenChange,
fileName,
onConfirm,
isPending,
}): JSX.Element => {
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Удалить файл"
description="Это действие нельзя отменить"
>
<div className={styles.root} data-testid="DeleteFileModal">
<p className={styles.message}>
Вы уверены, что хотите удалить файл{" "}
<span className={styles.fileName}>{fileName}</span>?
</p>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button
type="button"
variant="danger"
disabled={isPending}
onClick={onConfirm}
>
Удалить
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,3 @@
export { DeleteFileModal } from "./DeleteFileModal"
export type { IDeleteFileModalProps } from "./DeleteFileModal.d"
@@ -0,0 +1,48 @@
import api from "@shared/api"
interface IUseDeleteFileParams {
onSuccess?: () => void
onError?: (error: unknown) => void
}
export const useDeleteUserFile = ({
onSuccess,
onError,
}: IUseDeleteFileParams = {}) => {
return api.useMutation("delete", "/api/files/files/{file_id}/", {
onSuccess: () => {
onSuccess?.()
},
onError: (error) => {
onError?.(error)
},
})
}
export const useDeleteArtifact = ({
onSuccess,
onError,
}: IUseDeleteFileParams = {}) => {
return api.useMutation("delete", "/api/media/artifacts/{artifact_id}/", {
onSuccess: () => {
onSuccess?.()
},
onError: (error) => {
onError?.(error)
},
})
}
export const useDeleteMediaFile = ({
onSuccess,
onError,
}: IUseDeleteFileParams = {}) => {
return api.useMutation("delete", "/api/media/mediafiles/{media_file_id}/", {
onSuccess: () => {
onSuccess?.()
},
onError: (error) => {
onError?.(error)
},
})
}
@@ -0,0 +1,11 @@
import type { Dialog } from "@radix-ui/themes"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IDeleteProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
"open" | "onOpenChange"
> {
project: components["schemas"]["ProjectRead"]
onDeleted?: () => void | Promise<void>
}
@@ -0,0 +1,20 @@
.root {
min-width: 420px;
}
.message {
@include typography.font-body-14(400);
color: variables.$text-secondary;
}
.projectName {
@include typography.font-body-14(600);
color: variables.$text-primary;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
@@ -0,0 +1,66 @@
"use client"
import type { IDeleteProjectModalProps } from "./DeleteProjectModal.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import { Button, Modal } from "@shared/ui"
import { useDeleteProject } from "./useDeleteProject"
import styles from "./DeleteProjectModal.module.scss"
export const DeleteProjectModal: FunctionComponent<
IDeleteProjectModalProps
> = ({ open, onOpenChange, project, onDeleted }): JSX.Element => {
const { mutate, isPending } = useDeleteProject({
onSuccess: async () => {
await onDeleted?.()
onOpenChange?.(false)
},
onError: (error) => {
console.error("Delete project failed:", error)
},
})
const handleDelete = (): void => {
mutate({
params: { path: { project_id: project.id } },
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Удалить проект"
description="Это действие нельзя отменить"
>
<div className={styles.root} data-testid="DeleteProjectModal">
<p className={styles.message}>
Вы уверены, что хотите удалить проект{" "}
<span className={styles.projectName}>{project.name}</span>?
</p>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button
type="button"
variant="danger"
disabled={isPending}
onClick={handleDelete}
>
Удалить
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,3 @@
export { DeleteProjectModal } from "./DeleteProjectModal"
export type { IDeleteProjectModalProps } from "./DeleteProjectModal.d"
@@ -0,0 +1,20 @@
import api from "@shared/api"
interface IUseDeleteProjectParams {
onSuccess?: () => void
onError?: (error: unknown) => void
}
export const useDeleteProject = ({
onSuccess,
onError,
}: IUseDeleteProjectParams = {}) => {
return api.useMutation("delete", "/api/projects/{project_id}/", {
onSuccess: () => {
onSuccess?.()
},
onError: (error) => {
onError?.(error)
},
})
}
@@ -0,0 +1,11 @@
import type { Dialog } from "@radix-ui/themes"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IEditProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
"open" | "onOpenChange"
> {
project: components["schemas"]["ProjectRead"]
onUpdated?: () => void | Promise<void>
}
@@ -0,0 +1,25 @@
.root {
min-width: 520px;
}
.fields {
display: grid;
gap: 12px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.selectField {
display: grid;
gap: 6px;
}
.selectLabel {
@include typography.font-body-14(500);
color: variables.$text-primary;
}
@@ -0,0 +1,144 @@
"use client"
import type { IEditProjectModalProps } from "./EditProjectModal.d"
import type { JSX } from "react"
import { FunctionComponent, useEffect } from "react"
import { Controller, useForm } from "react-hook-form"
import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
import { useUpdateProject } from "./useUpdateProject"
import styles from "./EditProjectModal.module.scss"
interface IEditProjectFormData {
name: string
description?: string
language: string
}
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "auto", label: "Авто" },
{ value: "ru", label: "Русский" },
{ value: "en", label: "Английский" },
]
export const EditProjectModal: FunctionComponent<IEditProjectModalProps> = ({
open,
onOpenChange,
project,
onUpdated,
}): JSX.Element => {
const { control, register, handleSubmit, reset, formState } =
useForm<IEditProjectFormData>({
defaultValues: {
name: project.name,
description: project.description ?? "",
language: project.language,
},
})
const { mutate, isPending } = useUpdateProject({
onSuccess: async () => {
await onUpdated?.()
onOpenChange?.(false)
},
onError: (error) => {
console.error("Update project failed:", error)
},
})
useEffect(() => {
if (open) {
reset({
name: project.name,
description: project.description ?? "",
language: project.language,
})
}
}, [open, project, reset])
const onSubmit = (data: IEditProjectFormData): void => {
const name = data.name.trim()
const description = data.description?.trim()
mutate({
params: { path: { project_id: project.id } },
body: {
name,
description: description?.length ? description : null,
language: data.language,
},
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Изменить проект"
description="Измените параметры проекта"
>
<div className={styles.root} data-testid="EditProjectModal">
<Form onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="project_name"
label="Название"
placeholder="Например: Мой первый проект"
error={Boolean(formState.errors.name)}
undertitle={formState.errors.name?.message}
{...register("name", {
required: "Введите название проекта",
validate: (v) =>
v.trim().length > 0 || "Введите название проекта",
})}
/>
<TextField
id="project_description"
label="Описание"
placeholder="Коротко опишите проект (необязательно)"
{...register("description")}
/>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Язык</div>
<Controller
name="language"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите язык"
>
{LANGUAGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button type="submit" variant="primary" disabled={isPending}>
Сохранить
</Button>
</div>
</Form>
</div>
</Modal>
)
}
@@ -0,0 +1,3 @@
export { EditProjectModal } from "./EditProjectModal"
export type { IEditProjectModalProps } from "./EditProjectModal.d"
@@ -0,0 +1,25 @@
import type { components } from "@shared/api/__generated__/openapi.types"
import api from "@shared/api"
export type ProjectUpdateBody = components["schemas"]["ProjectUpdate"]
export type ProjectRead = components["schemas"]["ProjectRead"]
interface IUseUpdateProjectParams {
onSuccess?: (project: ProjectRead) => void
onError?: (error: unknown) => void
}
export const useUpdateProject = ({
onSuccess,
onError,
}: IUseUpdateProjectParams = {}) => {
return api.useMutation("patch", "/api/projects/{project_id}/", {
onSuccess: (project) => {
onSuccess?.(project)
},
onError: (error) => {
onError?.(error)
},
})
}
@@ -0,0 +1,11 @@
import type { Dialog } from "@radix-ui/themes"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IRenameProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
"open" | "onOpenChange"
> {
project: components["schemas"]["ProjectRead"]
onRenamed?: () => void | Promise<void>
}
@@ -0,0 +1,15 @@
.root {
min-width: 420px;
}
.fields {
display: grid;
gap: 12px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
@@ -0,0 +1,98 @@
"use client"
import type { IRenameProjectModalProps } from "./RenameProjectModal.d"
import type { JSX } from "react"
import { FunctionComponent, useEffect } from "react"
import { useForm } from "react-hook-form"
import api from "@shared/api"
import { Button, Form, Modal, TextField } from "@shared/ui"
import styles from "./RenameProjectModal.module.scss"
interface IRenameProjectFormData {
name: string
}
export const RenameProjectModal: FunctionComponent<
IRenameProjectModalProps
> = ({ open, onOpenChange, project, onRenamed }): JSX.Element => {
const { register, handleSubmit, reset, formState } =
useForm<IRenameProjectFormData>({
defaultValues: {
name: project.name,
},
})
const { mutate, isPending } = api.useMutation(
"patch",
"/api/projects/{project_id}/",
{
onSuccess: async () => {
await onRenamed?.()
onOpenChange?.(false)
},
onError: (error) => {
console.error("Rename project failed:", error)
},
},
)
useEffect(() => {
if (open) {
reset({ name: project.name })
}
}, [open, project, reset])
const onSubmit = (data: IRenameProjectFormData): void => {
const name = data.name.trim()
mutate({
params: { path: { project_id: project.id } },
body: { name },
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Переименовать проект"
description="Введите новое название проекта"
>
<div className={styles.root} data-testid="RenameProjectModal">
<Form onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="project_name"
label="Название"
placeholder="Например: Мой первый проект"
error={Boolean(formState.errors.name)}
undertitle={formState.errors.name?.message}
{...register("name", {
required: "Введите название проекта",
validate: (v) =>
v.trim().length > 0 || "Введите название проекта",
})}
/>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button type="submit" variant="primary" disabled={isPending}>
Переименовать
</Button>
</div>
</Form>
</div>
</Modal>
)
}
@@ -0,0 +1,3 @@
export { RenameProjectModal } from "./RenameProjectModal"
export type { IRenameProjectModalProps } from "./RenameProjectModal.d"
@@ -0,0 +1,19 @@
import type { WordData } from "@shared/lib/transcriptionDocument"
export interface SegmentEditData {
start: number
end: number
text: string
words?: WordData[]
}
export interface ISegmentEditModalProps {
open: boolean
onOpenChange: (open: boolean) => void
videoUrl?: string
segment: SegmentEditData
onSave: (text: string) => Promise<void>
onSplit?: (
newSegments: Array<{ start: number; end: number; text: string; words?: WordData[] }>,
) => Promise<void>
}
@@ -0,0 +1,80 @@
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
.player {
width: 100%;
border-radius: variables.$radius-md;
overflow: hidden;
aspect-ratio: 16 / 9;
}
.playerWrapper {
position: relative;
width: 100%;
height: 100%;
}
.timeRange {
position: absolute;
bottom: 8px;
left: 8px;
padding: 2px 8px;
border-radius: variables.$radius-sm;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.textArea {
width: 100%;
min-height: 72px;
padding: 10px 12px;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-sm;
background: variables.$bg-surface;
color: variables.$text-primary;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
resize: vertical;
&:focus {
outline: none;
border-color: variables.$purple-400;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.15);
}
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.splitAction {
display: inline-flex;
align-items: center;
gap: 4px;
}
.actionsSpacer {
flex: 1;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,220 @@
"use client"
import type { ISegmentEditModalProps } from "./SegmentEditModal.d"
import type { JSX } from "react"
import { MediaPlayer, MediaProvider, useMediaState } from "@vidstack/react"
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default"
import "@vidstack/react/player/styles/default/theme.css"
import "@vidstack/react/player/styles/default/layouts/video.css"
import { LoaderCircle, Scissors } from "lucide-react"
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Button, Modal } from "@shared/ui"
import {
type EditorSegment,
secondsToTimecode,
splitSegmentAtMarkers,
} from "@shared/lib/transcriptionDocument"
import { SegmentSplitter } from "@features/project/SegmentSplitter"
import styles from "./SegmentEditModal.module.scss"
const SegmentPlayer = ({
videoUrl,
start,
end,
}: {
videoUrl: string
start: number
end: number
}) => {
const currentTime = useMediaState("currentTime")
const playing = useMediaState("playing")
const hasPausedRef = useRef(false)
const playerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
hasPausedRef.current = false
}, [start, end])
useEffect(() => {
if (!playing) return
if (currentTime >= end && !hasPausedRef.current) {
hasPausedRef.current = true
const player = playerRef.current as HTMLElement & {
pause?: () => void
}
player?.pause?.()
}
}, [currentTime, end, playing])
return (
<div className={styles.playerWrapper}>
<MediaProvider />
<DefaultVideoLayout
icons={defaultLayoutIcons}
slots={{
settingsMenu: null,
pipButton: null,
fullscreenButton: null,
airPlayButton: null,
googleCastButton: null,
}}
/>
<div className={styles.timeRange}>
{secondsToTimecode(start)} {secondsToTimecode(end)}
</div>
</div>
)
}
export const SegmentEditModal: FunctionComponent<
ISegmentEditModalProps
> = ({ open, onOpenChange, videoUrl, segment, onSave, onSplit }): JSX.Element => {
const [text, setText] = useState(segment.text)
const [saving, setSaving] = useState(false)
const [splitMode, setSplitMode] = useState(false)
const canSplit = !!onSplit && !!segment.words && segment.words.length >= 2
useEffect(() => {
if (open) {
setText(segment.text)
setSplitMode(false)
}
}, [open, segment.text])
const editorSegment: EditorSegment = useMemo(
() => ({
startTime: secondsToTimecode(segment.start),
endTime: secondsToTimecode(segment.end),
text: segment.text,
words: segment.words,
}),
[segment],
)
const handleSave = useCallback(async () => {
setSaving(true)
try {
await onSave(text)
onOpenChange(false)
} finally {
setSaving(false)
}
}, [text, onSave, onOpenChange])
const handleSplit = useCallback(
async (newSegments: EditorSegment[]) => {
if (!onSplit) return
setSaving(true)
try {
await onSplit(
newSegments.map((s) => ({
start: s.words?.[0]?.start ?? segment.start,
end: s.words?.[s.words.length - 1]?.end ?? segment.end,
text: s.text,
words: s.words,
})),
)
onOpenChange(false)
} finally {
setSaving(false)
}
},
[onSplit, onOpenChange, segment],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSave()
}
},
[handleSave],
)
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Редактировать субтитр"
>
<div className={styles.root} data-testid="SegmentEditModal">
{videoUrl && (
<MediaPlayer
src={videoUrl}
currentTime={segment.start}
className={styles.player}
autoPlay
>
<SegmentPlayer
videoUrl={videoUrl}
start={segment.start}
end={segment.end}
/>
</MediaPlayer>
)}
{splitMode ? (
<SegmentSplitter
segment={editorSegment}
onSplit={handleSplit}
onCancel={() => setSplitMode(false)}
/>
) : (
<>
<textarea
className={styles.textArea}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
rows={3}
placeholder="Текст субтитра..."
autoFocus
/>
<div className={styles.actions}>
{canSplit && (
<Button
type="button"
variant="outline"
onClick={() => setSplitMode(true)}
className={styles.splitAction}
>
<Scissors size={14} />
Разделить
</Button>
)}
<div className={styles.actionsSpacer} />
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Отмена
</Button>
<Button
type="button"
variant="primary"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<LoaderCircle size={16} className={styles.spinner} />
) : null}
Сохранить
</Button>
</div>
</>
)}
</div>
</Modal>
)
}
@@ -0,0 +1 @@
export * from "./SegmentEditModal"
@@ -0,0 +1,7 @@
import type { EditorSegment } from "@shared/lib/transcriptionDocument"
export interface ISegmentSplitterProps {
segment: EditorSegment
onSplit: (newSegments: EditorSegment[]) => void
onCancel: () => void
}
@@ -0,0 +1,150 @@
.root {
display: flex;
flex-direction: column;
gap: 10px;
}
.wordsRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px 0;
padding: 8px;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-sm;
background: variables.$bg-default;
min-height: 40px;
}
.wordGroup {
display: inline-flex;
align-items: center;
}
.word {
display: inline-block;
padding: 2px 4px;
font-size: 13px;
line-height: 1.5;
color: variables.$text-primary;
border-radius: 2px;
user-select: none;
}
.gap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 22px;
padding: 0;
margin: 0 1px;
border: none;
background: none;
cursor: pointer;
border-radius: 2px;
position: relative;
flex-shrink: 0;
&::after {
content: "";
display: block;
width: 2px;
height: 100%;
background: transparent;
border-radius: 1px;
transition: background 0.15s;
}
&:hover::after {
background: variables.$color-primary;
opacity: 0.4;
}
&.active::after {
background: variables.$color-danger;
opacity: 1;
}
}
.preview {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px dashed variables.$border-default;
border-radius: variables.$radius-sm;
background: variables.$bg-surface;
}
.previewLabel {
font-size: 11px;
font-weight: 500;
color: variables.$text-tertiary;
margin-bottom: 2px;
}
.previewSegment {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 0;
& + & {
border-top: 1px solid variables.$border-default;
}
}
.previewTime {
font-size: 11px;
font-family: monospace;
color: variables.$text-tertiary;
white-space: nowrap;
flex-shrink: 0;
}
.previewText {
font-size: 13px;
color: variables.$text-primary;
}
.actions {
display: flex;
gap: 8px;
}
.splitBtn {
padding: 4px 12px;
border: none;
border-radius: variables.$radius-sm;
background: variables.$color-primary;
color: variables.$color-white;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
&:hover {
opacity: 0.9;
}
&:disabled {
background: variables.$border-default;
color: variables.$text-tertiary;
cursor: default;
}
}
.cancelBtn {
padding: 4px 12px;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-sm;
background: none;
color: variables.$text-secondary;
font-size: 12px;
cursor: pointer;
&:hover {
background: variables.$bg-hover;
}
}
@@ -0,0 +1,93 @@
"use client"
import type { ISegmentSplitterProps } from "./SegmentSplitter.d"
import type { JSX } from "react"
import { FunctionComponent, useCallback, useMemo, useState } from "react"
import {
type EditorSegment,
splitSegmentAtMarkers,
} from "@shared/lib/transcriptionDocument"
import styles from "./SegmentSplitter.module.scss"
export const SegmentSplitter: FunctionComponent<ISegmentSplitterProps> = ({
segment,
onSplit,
onCancel,
}): JSX.Element => {
const [markers, setMarkers] = useState<Set<number>>(new Set())
const words = segment.words!
const toggleMarker = useCallback((idx: number) => {
setMarkers((prev) => {
const next = new Set(prev)
if (next.has(idx)) {
next.delete(idx)
} else {
next.add(idx)
}
return next
})
}, [])
const preview: EditorSegment[] = useMemo(() => {
if (markers.size === 0) return [segment]
return splitSegmentAtMarkers(segment, Array.from(markers))
}, [segment, markers])
const handleSplit = useCallback(() => {
if (markers.size === 0) return
onSplit(preview)
}, [markers, preview, onSplit])
return (
<div className={styles.root} data-testid="SegmentSplitter">
<div className={styles.wordsRow}>
{words.map((word, idx) => (
<span key={idx} className={styles.wordGroup}>
{idx > 0 && (
<button
className={`${styles.gap} ${markers.has(idx) ? styles.active : ""}`}
onClick={() => toggleMarker(idx)}
title="Разделить здесь"
type="button"
/>
)}
<span className={styles.word}>{word.text}</span>
</span>
))}
</div>
{markers.size > 0 && (
<div className={styles.preview}>
<span className={styles.previewLabel}>Результат:</span>
{preview.map((seg, idx) => (
<div key={idx} className={styles.previewSegment}>
<span className={styles.previewTime}>
{seg.startTime} {seg.endTime}
</span>
<span className={styles.previewText}>{seg.text}</span>
</div>
))}
</div>
)}
<div className={styles.actions}>
<button
className={styles.splitBtn}
onClick={handleSplit}
disabled={markers.size === 0}
type="button"
>
Разделить
</button>
<button className={styles.cancelBtn} onClick={onCancel} type="button">
Отмена
</button>
</div>
</div>
)
}
@@ -0,0 +1 @@
export * from "./SegmentSplitter"
@@ -0,0 +1,13 @@
export interface ISilenceResultModalProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string
jobId: string
fileKey: string
}
export interface CutRegion {
id: string
startMs: number
endMs: number
}
@@ -0,0 +1,234 @@
// Override Radix Dialog.Content max-width for this wide modal
:global(.rt-DialogContent):has([data-testid="SilenceResultModal"]) {
max-width: 80vw !important;
width: 80vw !important;
max-height: 90vh;
}
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
.playerWrapper {
position: relative;
width: 100%;
height: 45vh;
border-radius: variables.$radius-md;
overflow: hidden;
background: #000;
// Force Vidstack player to fill the container exactly
: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;
}
.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: 4px 0;
font-size: 13px;
color: variables.$text-secondary;
}
.infoTotal {
font-variant-numeric: tabular-nums;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
}
// --- 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);
}
}
.cutRegionActive {
background: rgba(255, 152, 0, 0.5);
cursor: grabbing;
}
.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;
}
@@ -0,0 +1,801 @@
"use client"
import type { CutRegion, ISilenceResultModalProps } from "./SilenceResultModal.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 { useSegmentResize } from "@shared/hooks/useSegmentResize"
import { Button, Modal } from "@shared/ui"
import { useSubmitSilenceApply } from "./useSubmitSilenceApply"
import styles from "./SilenceResultModal.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 SilenceResultModal: FunctionComponent<ISilenceResultModalProps> = ({
open,
onOpenChange,
projectId,
jobId,
}): JSX.Element => {
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: jobId } } },
{ enabled: open && !!jobId },
)
const outputData = taskStatus?.output_data as Record<string, unknown> | null
const fileKey = (outputData?.file_key as string) ?? ""
const { data: fileInfo } = api.useQuery(
"get",
"/api/files/get_file/",
{ params: { query: { file_path: fileKey } } },
{ enabled: open && !!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],
)
// --- Total removed calculation ---
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],
)
// Close context menu on click anywhere
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 timeMs = pixelsToMs(x)
const timeSec = timeMs / 1000
if (playerRef.current) {
playerRef.current.currentTime = timeSec
}
},
[pixelsToMs],
)
// --- Canvas drawing functions (stable refs, called from animation loop) ---
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 (!open || !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
}
// Only recreate when URL or open state changes, not on every PPS change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, videoUrl, durationMs])
// Update WaveSurfer zoom when PPS changes
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)
// Extract frames once when video URL is available
useEffect(() => {
if (!open || !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
}, [open, 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)
for (let i = 0; i < cache.length; i++) {
const globalX = cache[i].timeSec * pixelsPerSecond
const nextGlobalX =
i < cache.length - 1
? cache[i + 1].timeSec * pixelsPerSecond
: totalWidth
// Skip frames entirely outside visible canvas
if (nextGlobalX < offset) continue
if (globalX > offset + canvasW) break
const x = globalX - offset
const tileW = nextGlobalX - globalX
ctx.drawImage(cache[i].bitmap, x, 0, tileW, FRAMES_HEIGHT)
}
}, [framesReady, pixelsPerSecond, totalWidth])
// --- Animation loop: playhead sync + canvas redraw on scroll ---
const [playheadMs, setPlayheadMs] = useState(0)
const animRef = useRef<number>(0)
const lastScrollRef = useRef(-1)
const lastViewportRef = useRef(-1)
useEffect(() => {
if (!open) return
const tick = () => {
// Sync playhead with video
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
}
}
}
// Redraw canvases when scroll position or viewport size changes
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
}
}, [open, drawRuler, drawFrames])
// --- Apply ---
const { mutate: applyMutate, isPending: isApplying } = useSubmitSilenceApply(
{
onSuccess: () => {
onOpenChange(false)
},
onError: (error) => {
console.error("Silence apply failed:", error)
},
},
)
const handleApply = () => {
if (!fileKey || cutRegions.length === 0) return
const fileName = fileKey.split("/").pop() ?? "video.mp4"
const outputName = `Без тишины ${fileName}`
// Body shape matches SilenceApplyRequest — types available after gen:api-types
;(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 (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Удаление тишины"
description="Просмотрите и отредактируйте участки для удаления"
>
<div className={styles.root} data-testid="SilenceResultModal">
{/* 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` }}
>
{/* Ruler */}
<div className={styles.rulerRow}>
<canvas ref={rulerRef} className={styles.rulerCanvas} />
</div>
{/* Video frames */}
<div className={styles.framesRow}>
<canvas
ref={framesCanvasRef}
className={styles.framesCanvas}
/>
</div>
{/* Waveform */}
<div className={styles.waveformRow}>
<div ref={waveformRef} style={{ height: WAVEFORM_HEIGHT }} />
</div>
{/* Cut regions */}
<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}
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>
)
})}
{/* Playhead */}
<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>
)}
{/* Actions */}
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isApplying}
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<Button
type="button"
variant="primary"
disabled={isApplying || cutRegions.length === 0}
onClick={handleApply}
>
Применить
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1 @@
export { SilenceResultModal } from "./SilenceResultModal"
@@ -0,0 +1,25 @@
import api from "@shared/api"
interface IUseSubmitSilenceApplyParams {
onSuccess?: (data: unknown) => void
onError?: (error: unknown) => void
}
export const useSubmitSilenceApply = ({
onSuccess,
onError,
}: IUseSubmitSilenceApplyParams = {}) => {
// NOTE: Endpoint types will be available after running `bun run gen:api-types`
return api.useMutation(
"post",
"/api/tasks/silence-apply/" as "/api/tasks/silence-remove/",
{
onSuccess: (data) => {
onSuccess?.(data)
},
onError: (error) => {
onError?.(error)
},
},
)
}
@@ -0,0 +1,5 @@
export interface ISilenceSettingsModalProps {
projectId: string
open: boolean
onOpenChange?: (open: boolean) => void
}
@@ -0,0 +1,49 @@
.root {
min-width: 520px;
}
.fields {
display: grid;
gap: 12px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.selectField {
display: grid;
gap: 6px;
}
.selectLabel {
@include typography.font-body-14(500);
color: variables.$text-primary;
}
.rangeField {
display: grid;
gap: 6px;
}
.rangeLabel {
@include typography.font-body-14(500);
color: variables.$text-primary;
display: flex;
justify-content: space-between;
align-items: center;
}
.rangeValue {
@include typography.font-body-14(400);
color: variables.$text-secondary;
font-variant-numeric: tabular-nums;
}
.rangeInput {
width: 100%;
accent-color: variables.$color-primary;
}
@@ -0,0 +1,184 @@
"use client"
import type { ISilenceSettingsModalProps } from "./SilenceSettingsModal.d"
import type { JSX } from "react"
import { FunctionComponent, useEffect } from "react"
import { Controller, useForm } from "react-hook-form"
import api from "@shared/api"
import { Button, Form, Modal, Select, SelectItem } from "@shared/ui"
import { useSubmitSilenceDetect } from "./useSubmitSilenceDetect"
import styles from "./SilenceSettingsModal.module.scss"
interface ISilenceSettingsFormData {
file_key: string
min_silence_duration_ms: number
silence_threshold_db: number
padding_ms: number
}
const DEFAULT_MIN_SILENCE_MS = 200
const DEFAULT_THRESHOLD_DB = 16
const DEFAULT_PADDING_MS = 100
export const SilenceSettingsModal: FunctionComponent<
ISilenceSettingsModalProps
> = ({ projectId, open, onOpenChange }): JSX.Element => {
const { control, handleSubmit, reset, watch, setValue } =
useForm<ISilenceSettingsFormData>({
defaultValues: {
file_key: "",
min_silence_duration_ms: DEFAULT_MIN_SILENCE_MS,
silence_threshold_db: DEFAULT_THRESHOLD_DB,
padding_ms: DEFAULT_PADDING_MS,
},
})
const minSilence = watch("min_silence_duration_ms")
const threshold = watch("silence_threshold_db")
const padding = watch("padding_ms")
const { data: files } = api.useQuery("get", "/api/files/files/", {
queryKey: ["files", projectId],
})
const projectFiles = (files ?? []).filter(
(f) => f.project_id === projectId && !f.is_deleted,
)
const { mutate, isPending } = useSubmitSilenceDetect({
onSuccess: () => {
onOpenChange?.(false)
},
onError: (error) => {
console.error("Silence detect submit failed:", error)
},
})
useEffect(() => {
if (!open) reset()
}, [open, reset])
const onSubmit = (data: ISilenceSettingsFormData): void => {
// Body shape matches SilenceDetectRequest — types available after gen:api-types
;(mutate as (args: { body: Record<string, unknown> }) => void)({
body: {
file_key: data.file_key,
project_id: projectId,
min_silence_duration_ms: data.min_silence_duration_ms,
silence_threshold_db: data.silence_threshold_db,
padding_ms: data.padding_ms,
},
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Удалить тишину"
description="Выберите файл и настройте параметры обнаружения тишины"
>
<div className={styles.root} data-testid="SilenceSettingsModal">
<Form onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Файл</div>
<Controller
name="file_key"
control={control}
rules={{ required: "Выберите файл" }}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите файл"
>
{projectFiles.map((f) => (
<SelectItem key={f.id} value={f.path}>
{f.original_filename}
</SelectItem>
))}
</Select>
)}
/>
</div>
<div className={styles.rangeField}>
<div className={styles.rangeLabel}>
<span>Мин. длительность тишины</span>
<span className={styles.rangeValue}>{minSilence} мс</span>
</div>
<input
type="range"
className={styles.rangeInput}
min={100}
max={2000}
step={50}
value={minSilence}
onChange={(e) =>
setValue(
"min_silence_duration_ms",
Number(e.target.value),
)
}
/>
</div>
<div className={styles.rangeField}>
<div className={styles.rangeLabel}>
<span>Порог тишины</span>
<span className={styles.rangeValue}>{threshold} дБ</span>
</div>
<input
type="range"
className={styles.rangeInput}
min={6}
max={40}
step={2}
value={threshold}
onChange={(e) =>
setValue("silence_threshold_db", Number(e.target.value))
}
/>
</div>
<div className={styles.rangeField}>
<div className={styles.rangeLabel}>
<span>Отступ</span>
<span className={styles.rangeValue}>{padding} мс</span>
</div>
<input
type="range"
className={styles.rangeInput}
min={0}
max={500}
step={25}
value={padding}
onChange={(e) =>
setValue("padding_ms", Number(e.target.value))
}
/>
</div>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button type="submit" variant="primary" disabled={isPending}>
Запустить
</Button>
</div>
</Form>
</div>
</Modal>
)
}
@@ -0,0 +1 @@
export { SilenceSettingsModal } from "./SilenceSettingsModal"
@@ -0,0 +1,25 @@
import api from "@shared/api"
interface IUseSubmitSilenceDetectParams {
onSuccess?: (data: unknown) => void
onError?: (error: unknown) => void
}
export const useSubmitSilenceDetect = ({
onSuccess,
onError,
}: IUseSubmitSilenceDetectParams = {}) => {
// NOTE: Endpoint types will be available after running `bun run gen:api-types`
return api.useMutation(
"post",
"/api/tasks/silence-detect/" as "/api/tasks/silence-remove/",
{
onSuccess: (data) => {
onSuccess?.(data)
},
onError: (error) => {
onError?.(error)
},
},
)
}
+3
View File
@@ -0,0 +1,3 @@
export interface ISilenceTrackProps {
className?: string
}
@@ -0,0 +1,2 @@
.root {
}
@@ -0,0 +1,14 @@
import type { ISilenceTrackProps } from "./SilenceTrack.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import styles from "./SilenceTrack.module.scss"
export const SilenceTrack: FunctionComponent<ISilenceTrackProps> = (): JSX.Element => {
return (
<div className={styles.root} data-testid="SilenceTrack">
SilenceTrack
</div>
)
}
@@ -0,0 +1 @@
export * from "./SilenceTrack"
+10
View File
@@ -0,0 +1,10 @@
export interface ISubtitlesTrackProps {
artifactId: string
pixelsPerSecond: number
height: number
duration: number
scrollLeft?: number
viewportWidth?: number
videoUrl?: string
onSegmentClick?: (segmentIndex: number, artifactId: string) => void
}
@@ -0,0 +1,90 @@
.wrapper {
position: relative;
height: 100%;
}
.segment {
position: absolute;
top: 4px;
bottom: 4px;
border-radius: variables.$radius-sm;
background: rgba(139, 92, 246, 0.3);
border: 1px solid rgba(139, 92, 246, 0.7);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
overflow: hidden;
transition: background 0.1s;
&:hover {
background: rgba(139, 92, 246, 0.45);
}
}
.tooltip {
position: fixed;
z-index: 9999;
max-width: 320px;
padding: 6px 10px;
border-radius: variables.$radius-sm;
background: variables.$bg-surface;
color: variables.$text-primary;
font-size: 12px;
line-height: 1.4;
white-space: normal;
word-break: break-word;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.active {
background: rgba(139, 92, 246, 0.6);
&:hover {
background: rgba(139, 92, 246, 0.65);
}
}
.resizing {
background: rgba(139, 92, 246, 0.5);
z-index: 2;
}
.segmentText {
padding: 0 4px;
font-size: 11px;
line-height: 1.2;
color: variables.$text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
flex: 1;
min-width: 0;
}
.handleLeft,
.handleRight {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 3;
&:hover {
background: rgba(139, 92, 246, 0.5);
}
}
.handleLeft {
left: 0;
border-radius: variables.$radius-sm 0 0 variables.$radius-sm;
}
.handleRight {
right: 0;
border-radius: 0 variables.$radius-sm variables.$radius-sm 0;
}

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