new features
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -17,7 +17,7 @@ Next.js 16 application using **Feature-Sliced Design (FSD)** architecture, power
|
|||||||
| Styling | SCSS Modules, normalize.css |
|
| Styling | SCSS Modules, normalize.css |
|
||||||
| State/Fetch | TanStack React Query 5, Axios, Xior |
|
| State/Fetch | TanStack React Query 5, Axios, Xior |
|
||||||
| Animation | Framer Motion |
|
| Animation | Framer Motion |
|
||||||
| Utilities | Lodash, Moment.js, classnames, usehooks-ts |
|
| Utilities | Lodash, date-fns, classnames, usehooks-ts |
|
||||||
| Icons | Lucide React, SVGR (custom icons) |
|
| Icons | Lucide React, SVGR (custom icons) |
|
||||||
| Notifications | React Toastify |
|
| Notifications | React Toastify |
|
||||||
| File Upload | React Dropzone |
|
| 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`
|
2. **Public API** — export only through `index.ts`
|
||||||
3. **No Cross-Slice Imports** — features cannot import from other features
|
3. **No Cross-Slice Imports** — features cannot import from other features
|
||||||
4. **Shared is Agnostic** — no business logic in shared layer
|
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
|
### When to Split Files
|
||||||
|
|
||||||
Split into separate files **only when**:
|
Split into separate files **only when**:
|
||||||
|
|
||||||
- Hook/API is reused by multiple components
|
- Hook/API is reused by multiple components
|
||||||
- File exceeds ~200 lines
|
- File exceeds ~200 lines
|
||||||
- Props interface is shared across 3+ components
|
- 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
|
## Icons Workflow
|
||||||
|
|
||||||
1. Place raw SVG in `src/shared/assets/raw-icons/`
|
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 |
|
| API client | Use Axios/Xior with React Query |
|
||||||
| State management | TanStack Query (server state) |
|
| 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
|
## Implementation sentiments
|
||||||
|
|
||||||
Write less complicated code, simple but readable code
|
Write less complicated code, simple but readable code
|
||||||
@@ -245,3 +317,17 @@ To import classNames lib use
|
|||||||
`import cs from 'classnames'`
|
`import cs from 'classnames'`
|
||||||
Always install packages using
|
Always install packages using
|
||||||
`bun install <package>`
|
`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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -24,7 +24,14 @@ export default function RootLayout({
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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>
|
<body>
|
||||||
<AppProviders>{children}</AppProviders>
|
<AppProviders>{children}</AppProviders>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
import { Header } from "@widgets/Header"
|
import { Header } from "@widgets/Header"
|
||||||
|
|
||||||
|
const AUTH_ROUTES = ["/login", "/register", "/under_maintenance"]
|
||||||
|
|
||||||
export default function EssentialTemplate({
|
export default function EssentialTemplate({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const isAuthPage = AUTH_ROUTES.includes(pathname)
|
||||||
|
|
||||||
|
if (isAuthPage) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { UnderMaintenancePage } from "@pages/UnderMaintenancePage"
|
||||||
|
|
||||||
|
export default function UnderMaintenance() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<UnderMaintenancePage />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,13 +16,15 @@
|
|||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@tanstack/react-query": "^5.90.14",
|
"@tanstack/react-query": "^5.90.14",
|
||||||
"@tanstack/react-query-devtools": "^5.91.2",
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
|
"@vidstack/react": "^1",
|
||||||
|
"@wavesurfer/react": "^1.0.12",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"moment": "^2.30.1",
|
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"openapi-fetch": "^0.15.0",
|
"openapi-fetch": "^0.15.0",
|
||||||
@@ -34,9 +36,11 @@
|
|||||||
"react-hook-form": "^7.71.0",
|
"react-hook-form": "^7.71.0",
|
||||||
"react-modern-drawer": "^1.4.0",
|
"react-modern-drawer": "^1.4.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
|
"react-resizable-panels": "^4.6.5",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"use-mask-input": "^3.6.0",
|
"use-mask-input": "^3.6.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
|
"wavesurfer.js": "^7.12.1",
|
||||||
"xior": "^0.8.2",
|
"xior": "^0.8.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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=="],
|
"@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": ["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=="],
|
"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=="],
|
"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=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
"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=="],
|
"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=="],
|
"meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||||
|
|
||||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||||
@@ -1307,8 +1319,6 @@
|
|||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"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-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=="],
|
"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-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-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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ const stylesPath = path.join(dirname, "src/shared/styles")
|
|||||||
console.log("dirname", dirname)
|
console.log("dirname", dirname)
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "localhost",
|
||||||
|
port: "9000",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dangerouslyAllowSVG: true,
|
||||||
|
contentDispositionType: "inline",
|
||||||
|
localPatterns: undefined,
|
||||||
|
unoptimized: process.env.NODE_ENV === "development",
|
||||||
|
},
|
||||||
sassOptions: {
|
sassOptions: {
|
||||||
includePaths: [stylesPath],
|
includePaths: [stylesPath],
|
||||||
additionalData: `@use "${path.join(stylesPath, "_variables.scss")}";
|
additionalData: `@use "${path.join(stylesPath, "_variables.scss")}";
|
||||||
|
|||||||
+5
-1
@@ -25,13 +25,15 @@
|
|||||||
"@reduxjs/toolkit": "^2.11.2",
|
"@reduxjs/toolkit": "^2.11.2",
|
||||||
"@tanstack/react-query": "^5.90.14",
|
"@tanstack/react-query": "^5.90.14",
|
||||||
"@tanstack/react-query-devtools": "^5.91.2",
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
|
"@vidstack/react": "^1",
|
||||||
|
"@wavesurfer/react": "^1.0.12",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"moment": "^2.30.1",
|
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"openapi-fetch": "^0.15.0",
|
"openapi-fetch": "^0.15.0",
|
||||||
@@ -43,9 +45,11 @@
|
|||||||
"react-hook-form": "^7.71.0",
|
"react-hook-form": "^7.71.0",
|
||||||
"react-modern-drawer": "^1.4.0",
|
"react-modern-drawer": "^1.4.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
|
"react-resizable-panels": "^4.6.5",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"use-mask-input": "^3.6.0",
|
"use-mask-input": "^3.6.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
|
"wavesurfer.js": "^7.12.1",
|
||||||
"xior": "^0.8.2"
|
"xior": "^0.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,33 +1,62 @@
|
|||||||
|
@use "@shared/styles/variables" as *;
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
background: transparent;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 220px;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 16px 12px;
|
background: variables.$bg-default;
|
||||||
background: #ffffff;
|
border-radius: 0 variables.$radius-lg variables.$radius-lg 0;
|
||||||
border-radius: 12px 12px 0 0;
|
box-shadow: var(--shadow-lg);
|
||||||
box-shadow: 0 10px 30px rgba(12, 18, 38, 0.08);
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
margin-bottom: 12px;
|
display: flex;
|
||||||
font-weight: 700;
|
align-items: center;
|
||||||
font-size: 16px;
|
justify-content: space-between;
|
||||||
color: #0c1226;
|
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 {
|
||||||
list-style: none;
|
@include mixins.reset-list;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
|
padding: 8px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
@@ -36,40 +65,44 @@
|
|||||||
|
|
||||||
.link,
|
.link,
|
||||||
.button {
|
.button {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: variables.$radius-sm;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #0c1226;
|
color: variables.$text-secondary;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
@include typography.font-body-14(500);
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease, color 0.2s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: variables.$bg-surface;
|
||||||
|
color: variables.$text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link:hover,
|
&:focus-visible {
|
||||||
.button:hover,
|
outline: 2px solid variables.$color-secondary;
|
||||||
.link:focus-visible,
|
outline-offset: -2px;
|
||||||
.button:focus-visible {
|
border-radius: variables.$radius-sm;
|
||||||
outline: none;
|
}
|
||||||
background-color: #f4f6fb;
|
|
||||||
|
&.active {
|
||||||
|
background-color: variables.$bg-surface;
|
||||||
|
color: variables.$text-primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
color: #5a6473;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
white-space: nowrap;
|
@include mixins.text-ellipsis;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { X } from "lucide-react"
|
||||||
import { FunctionComponent } from "react"
|
import { FunctionComponent } from "react"
|
||||||
import Drawer from "react-modern-drawer"
|
import Drawer from "react-modern-drawer"
|
||||||
|
|
||||||
import cs from "classnames"
|
import cs from "classnames"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
import { INavigationDrawerProps } from "./NavigationDrawer.d"
|
import { INavigationDrawerProps } from "./NavigationDrawer.d"
|
||||||
import styles from "./NavigationDrawer.module.scss"
|
import styles from "./NavigationDrawer.module.scss"
|
||||||
@@ -20,6 +22,8 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
|
|||||||
size = 280,
|
size = 280,
|
||||||
title,
|
title,
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
@@ -27,14 +31,25 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
|
|||||||
direction={position}
|
direction={position}
|
||||||
size={size}
|
size={size}
|
||||||
className={cs(styles.drawer, className)}
|
className={cs(styles.drawer, className)}
|
||||||
aria-label="Navigation drawer"
|
aria-label="Навигация"
|
||||||
duration={200}
|
duration={200}
|
||||||
>
|
>
|
||||||
<nav className={styles.root} data-testid="NavigationDrawer">
|
<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}>
|
<ul className={styles.list}>
|
||||||
{buttons.map(({ label, icon: Icon, path, action }, index) => {
|
{buttons.map(({ label, icon: Icon, path, action }, index) => {
|
||||||
const key = `${label}-${path ?? index}`
|
const key = `${label}-${path ?? index}`
|
||||||
|
const isActive = path ? pathname === path : false
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
@@ -53,7 +68,7 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
|
|||||||
{path ? (
|
{path ? (
|
||||||
<Link
|
<Link
|
||||||
href={path}
|
href={path}
|
||||||
className={styles.link}
|
className={cs(styles.link, isActive && styles.active)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
+3
@@ -17,4 +17,7 @@ export interface IProjectCardProps {
|
|||||||
*/
|
*/
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
|
onEdit?: () => void
|
||||||
|
onRename?: () => void
|
||||||
|
onDelete?: () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,22 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border: 1px solid variables.$border-default;
|
||||||
|
border-radius: variables.$radius-md;
|
||||||
transition:
|
transition:
|
||||||
transform 0.2s ease,
|
transform 0.2s ease,
|
||||||
box-shadow 0.2s ease;
|
box-shadow 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background: variables.$bg-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
background-color: variables.$purple-50;
|
background-color: variables.$bg-surface;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 12px 12px 0 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -35,24 +38,25 @@
|
|||||||
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%);
|
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 48px;
|
width: 40px;
|
||||||
height: 48px;
|
height: 40px;
|
||||||
color: variables.$purple-300;
|
color: variables.$purple-300;
|
||||||
opacity: 0.5;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.root:hover {
|
.root:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
|
box-shadow: var(--shadow-lg);
|
||||||
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px;
|
padding: 14px 16px;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +139,7 @@
|
|||||||
|
|
||||||
.info {
|
.info {
|
||||||
@include mixins.flex-column;
|
@include mixins.flex-column;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -155,7 +159,7 @@
|
|||||||
|
|
||||||
.date {
|
.date {
|
||||||
@include typography.font-caption-m;
|
@include typography.font-caption-m;
|
||||||
color: variables.$text-secondary;
|
color: variables.$text-tertiary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
@@ -164,7 +168,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@include typography.font-body-s;
|
@include typography.font-caption-m;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&.statusGenerated {
|
&.statusGenerated {
|
||||||
@@ -172,7 +176,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.statusProcessing, &.statusRendering, &.statusUploading {
|
&.statusProcessing, &.statusRendering, &.statusUploading {
|
||||||
color: variables.$purple-500;
|
color: variables.$purple-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.statusDraft {
|
&.statusDraft {
|
||||||
@@ -185,28 +189,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statusDot {
|
.statusDot {
|
||||||
width: 8px;
|
width: 6px;
|
||||||
height: 8px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: currentColor;
|
background-color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuTrigger {
|
.menuTrigger {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
background-color: variables.$bg-surface;
|
background-color: transparent;
|
||||||
border-radius: 8px;
|
border-radius: variables.$radius-sm;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
width: 28px;
|
||||||
width: 32px;
|
height: 28px;
|
||||||
height: 32px;
|
|
||||||
@include mixins.flex-center;
|
@include mixins.flex-center;
|
||||||
color: variables.$text-primary;
|
color: variables.$text-secondary;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&[data-state="open"] {
|
&[data-state="open"] {
|
||||||
background-color: variables.$bg-default;
|
background-color: variables.$bg-surface;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
color: variables.$text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -223,13 +226,15 @@
|
|||||||
|
|
||||||
.statusBadge {
|
.statusBadge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 10px;
|
||||||
left: 12px;
|
left: 10px;
|
||||||
padding: 6px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 20px;
|
||||||
background: variables.$bg-canvas;
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
@include typography.font-caption-m;
|
@include typography.font-caption-m;
|
||||||
|
font-weight: 500;
|
||||||
color: variables.$text-primary;
|
color: variables.$text-primary;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { Image as ImageIcon, MoreHorizontal } from "lucide-react"
|
|||||||
import { FunctionComponent } from "react"
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
import cs from "classnames"
|
import cs from "classnames"
|
||||||
import moment from "moment"
|
|
||||||
|
|
||||||
|
import { formatRelativeTime } from "@shared/lib/dates"
|
||||||
import { Card } from "@shared/ui/Card"
|
import { Card } from "@shared/ui/Card"
|
||||||
import { CircularProgress } from "@shared/ui/CircularProgress"
|
import { CircularProgress } from "@shared/ui/CircularProgress"
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +27,9 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
|||||||
currentAction,
|
currentAction,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
onClick,
|
onClick,
|
||||||
|
onEdit,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const { name, updated_at, status } = project
|
const { name, updated_at, status } = project
|
||||||
|
|
||||||
@@ -38,7 +41,6 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
|||||||
|
|
||||||
const shouldShowProgress = isProcessing
|
const shouldShowProgress = isProcessing
|
||||||
|
|
||||||
// Helper to determine status color/class
|
|
||||||
const getStatusClass = () => {
|
const getStatusClass = () => {
|
||||||
if (isCompleted) return styles.statusGenerated
|
if (isCompleted) return styles.statusGenerated
|
||||||
if (isProcessing) return styles.statusProcessing
|
if (isProcessing) return styles.statusProcessing
|
||||||
@@ -49,7 +51,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
|||||||
|
|
||||||
const getStatusLabel = () => {
|
const getStatusLabel = () => {
|
||||||
if (isCompleted) return "Завершено"
|
if (isCompleted) return "Завершено"
|
||||||
if (isProcessing) return "В процессе" // Or more specific state
|
if (isProcessing) return "В процессе"
|
||||||
if (isDraft) return "Черновик"
|
if (isDraft) return "Черновик"
|
||||||
if (isFailed) return "Ошибка"
|
if (isFailed) return "Ошибка"
|
||||||
return status
|
return status
|
||||||
@@ -112,19 +114,15 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownContent align="end">
|
<DropdownContent align="end">
|
||||||
<DropdownItem
|
<DropdownItem onSelect={() => onEdit?.()}>
|
||||||
onSelect={() => console.log("Edit", project.id)}
|
|
||||||
>
|
|
||||||
Изменить
|
Изменить
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem
|
<DropdownItem onSelect={() => onRename?.()}>
|
||||||
onSelect={() => console.log("Rename", project.id)}
|
|
||||||
>
|
|
||||||
Переименовать
|
Переименовать
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
className="text-red-500"
|
className="text-red-500"
|
||||||
onSelect={() => console.log("Delete", project.id)}
|
onSelect={() => onDelete?.()}
|
||||||
>
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
@@ -133,7 +131,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.date}>
|
<span className={styles.date}>
|
||||||
Создано {moment(updated_at).fromNow()}
|
Создано {formatRelativeTime(updated_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
.root {
|
.root {
|
||||||
display: flex
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
@include typography.font-body-16(500);
|
@include typography.font-body-14(500);
|
||||||
color: variables.$text-primary;
|
color: variables.$text-primary;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -12,14 +12,21 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px ;
|
padding: 6px 12px 6px 6px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
|
background-color: variables.$bg-surface;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeItem {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,60 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import Cookies from "js-cookie"
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react"
|
||||||
import { FunctionComponent } from "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 { Avatar } from "@shared/ui/Avatar"
|
||||||
import {
|
import {
|
||||||
Dropdown,
|
Dropdown,
|
||||||
DropdownContent,
|
DropdownContent,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
DropdownRadioGroup,
|
||||||
|
DropdownRadioItem,
|
||||||
DropdownSeparator,
|
DropdownSeparator,
|
||||||
|
DropdownSub,
|
||||||
|
DropdownSubContent,
|
||||||
|
DropdownSubTrigger,
|
||||||
DropdownTrigger,
|
DropdownTrigger,
|
||||||
} from "@shared/ui/Dropdown"
|
} from "@shared/ui/Dropdown"
|
||||||
|
|
||||||
import { userDropdownValues } from "./constants"
|
import { navItems } from "./constants"
|
||||||
import { IUserDropdownProps } from "./UserDropdown.d"
|
import { IUserDropdownProps } from "./UserDropdown.d"
|
||||||
import styles from "./UserDropdown.module.scss"
|
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> = ({
|
export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
|
||||||
user,
|
user,
|
||||||
}): JSX.Element => {
|
}): 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 (
|
return (
|
||||||
<div className={styles.root} data-testid="UserDropdown">
|
<div className={styles.root} data-testid="UserDropdown">
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
@@ -28,15 +65,49 @@ export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</DropdownTrigger>
|
</DropdownTrigger>
|
||||||
<DropdownContent>
|
<DropdownContent>
|
||||||
{userDropdownValues.map((item) => (
|
{navItems.map((item) => (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key={item.acton}
|
key={item.action}
|
||||||
className={styles.item}
|
className={styles.item}
|
||||||
onSelect={() => console.log(`${item.acton} selected`)}
|
onSelect={() => router.push(item.path)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</DropdownItem>
|
</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>
|
</DropdownContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
export const userDropdownValues = [
|
export const navItems = [
|
||||||
{
|
{
|
||||||
label: "Профиль",
|
label: "Профиль",
|
||||||
acton: "profile",
|
action: "profile",
|
||||||
path: "/profile",
|
path: "/profile",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Настройки",
|
label: "Настройки",
|
||||||
acton: "settings",
|
action: "settings",
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Выйти",
|
|
||||||
acton: "logout",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { NotificationBell } from "./NotificationBell"
|
||||||
|
export { NotificationPopup } from "./NotificationPopup"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
+2
-2
@@ -20,6 +20,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selectLabel {
|
.selectLabel {
|
||||||
font-size: 14px;
|
@include typography.font-body-14(500);
|
||||||
font-weight: 500;
|
color: variables.$text-primary;
|
||||||
}
|
}
|
||||||
+4
-49
@@ -1,6 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { ProjectCreateBody } from "./useCreateProject"
|
|
||||||
import type { ICreateProjectModalProps } from "./CreateProjectModal.d"
|
import type { ICreateProjectModalProps } from "./CreateProjectModal.d"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
@@ -12,27 +11,16 @@ import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
|
|||||||
import { useCreateProject } from "./useCreateProject"
|
import { useCreateProject } from "./useCreateProject"
|
||||||
import styles from "./CreateProjectModal.module.scss"
|
import styles from "./CreateProjectModal.module.scss"
|
||||||
|
|
||||||
type ProjectStatus = ProjectCreateBody["status"]
|
|
||||||
|
|
||||||
interface ICreateProjectFormData {
|
interface ICreateProjectFormData {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
language: 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 }> = [
|
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||||
{ value: "auto", label: "Auto" },
|
{ value: "auto", label: "Авто" },
|
||||||
{ value: "ru", label: "Russian" },
|
{ value: "ru", label: "Русский" },
|
||||||
{ value: "en", label: "English" },
|
{ value: "en", label: "Английский" },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const CreateProjectModal: FunctionComponent<
|
export const CreateProjectModal: FunctionComponent<
|
||||||
@@ -43,9 +31,7 @@ export const CreateProjectModal: FunctionComponent<
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
folder: "",
|
|
||||||
language: "auto",
|
language: "auto",
|
||||||
status: "DRAFT",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -66,15 +52,12 @@ export const CreateProjectModal: FunctionComponent<
|
|||||||
const onSubmit = (data: ICreateProjectFormData): void => {
|
const onSubmit = (data: ICreateProjectFormData): void => {
|
||||||
const name = data.name.trim()
|
const name = data.name.trim()
|
||||||
const description = data.description?.trim()
|
const description = data.description?.trim()
|
||||||
const folder = data.folder?.trim()
|
|
||||||
|
|
||||||
mutate({
|
mutate({
|
||||||
body: {
|
body: {
|
||||||
name,
|
name,
|
||||||
description: description?.length ? description : undefined,
|
description: description?.length ? description : undefined,
|
||||||
folder: folder?.length ? folder : undefined,
|
|
||||||
language: data.language,
|
language: data.language,
|
||||||
status: data.status,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -109,13 +92,6 @@ export const CreateProjectModal: FunctionComponent<
|
|||||||
{...register("description")}
|
{...register("description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
|
||||||
id="project_folder"
|
|
||||||
label="Папка"
|
|
||||||
placeholder="Например: /projects/my-project (необязательно)"
|
|
||||||
{...register("folder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.selectField}>
|
<div className={styles.selectField}>
|
||||||
<div className={styles.selectLabel}>Язык</div>
|
<div className={styles.selectLabel}>Язык</div>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -136,33 +112,12 @@ export const CreateProjectModal: FunctionComponent<
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => onOpenChange?.(false)}
|
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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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
Reference in New Issue
Block a user