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 |
|
||||
| State/Fetch | TanStack React Query 5, Axios, Xior |
|
||||
| Animation | Framer Motion |
|
||||
| Utilities | Lodash, Moment.js, classnames, usehooks-ts |
|
||||
| Utilities | Lodash, date-fns, classnames, usehooks-ts |
|
||||
| Icons | Lucide React, SVGR (custom icons) |
|
||||
| Notifications | React Toastify |
|
||||
| File Upload | React Dropzone |
|
||||
@@ -174,10 +174,12 @@ export const Button: FC<IButtonProps> = ({ variant, onClick }): JSX.Element => {
|
||||
2. **Public API** — export only through `index.ts`
|
||||
3. **No Cross-Slice Imports** — features cannot import from other features
|
||||
4. **Shared is Agnostic** — no business logic in shared layer
|
||||
5. **Features are module-aware** — group features by domain inside module folders (see below)
|
||||
|
||||
### When to Split Files
|
||||
|
||||
Split into separate files **only when**:
|
||||
|
||||
- Hook/API is reused by multiple components
|
||||
- File exceeds ~200 lines
|
||||
- Props interface is shared across 3+ components
|
||||
@@ -208,6 +210,72 @@ Split into separate files **only when**:
|
||||
|
||||
---
|
||||
|
||||
## Features Layer — Module-Aware Structure
|
||||
|
||||
Features **must be grouped by domain module**. Never place feature folders flat at the top of `src/features/`.
|
||||
|
||||
```
|
||||
src/features/
|
||||
├── profile/ # Profile domain module
|
||||
│ ├── index.ts # Barrel export for all features in this module
|
||||
│ ├── AvatarUpload/
|
||||
│ ├── EditProfileForm/
|
||||
│ └── LogoutButton/
|
||||
└── project/ # Project domain module
|
||||
├── index.ts
|
||||
├── CreateProjectModal/
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Each module folder has an `index.ts` barrel that re-exports all its features
|
||||
- Import via the module barrel: `import { AvatarUpload } from "@features/profile"`
|
||||
- When creating a new feature, place it inside the relevant domain folder
|
||||
- After running `bun run gc feature <Name>`, move the generated folder into the correct module
|
||||
- Create a new module folder + barrel if the domain doesn't exist yet
|
||||
|
||||
---
|
||||
|
||||
## Shared Utilities
|
||||
|
||||
Reusable operations should live in `src/shared/` — **do not inline shared logic inside feature components**.
|
||||
|
||||
### File Upload
|
||||
|
||||
Use `uploadFile()` from `@shared/api/uploadFile` for any file upload:
|
||||
|
||||
```ts
|
||||
import { uploadFile } from "@shared/api/uploadFile"
|
||||
|
||||
const result = await uploadFile(file, "avatars")
|
||||
// result.file_url — URL of the uploaded file
|
||||
// result.file_path — storage path
|
||||
```
|
||||
|
||||
This handles FormData construction, Content-Type header override, and JWT auth automatically.
|
||||
|
||||
### Date Formatting
|
||||
|
||||
Use `date-fns` with Russian locale via shared utilities in `src/shared/lib/dates.ts`. **Never use `moment.js` or inline `Date` formatting in components.**
|
||||
|
||||
```ts
|
||||
import { formatDate, formatRelativeTime } from "@shared/lib/dates"
|
||||
|
||||
formatDate(user.date_joined) // "21.02.2026" (default: "dd.MM.yyyy")
|
||||
formatDate(date, "dd MMM yyyy") // "21 февр. 2026"
|
||||
formatRelativeTime(project.updated_at) // "2 дня назад"
|
||||
```
|
||||
|
||||
Add new date helpers to `src/shared/lib/dates.ts`, not to individual components.
|
||||
|
||||
### API Client
|
||||
|
||||
- **In React components**: use `api.useQuery()` / `api.useMutation()` from `@shared/api`
|
||||
- **Outside React** (utilities, event handlers): use `fetchClient` from `@shared/api`
|
||||
- **File uploads**: use `uploadFile()` from `@shared/api/uploadFile`
|
||||
|
||||
---
|
||||
|
||||
## Icons Workflow
|
||||
|
||||
1. Place raw SVG in `src/shared/assets/raw-icons/`
|
||||
@@ -236,6 +304,10 @@ Split into separate files **only when**:
|
||||
| API client | Use Axios/Xior with React Query |
|
||||
| State management | TanStack Query (server state) |
|
||||
|
||||
## Localization
|
||||
|
||||
All user-facing UI text **must be in Russian**. This includes: labels, headings, buttons, placeholders, tooltips, aria-labels, error messages, breadcrumbs, and any other text visible to the user. The only exception is the brand name "Coffee Project" / "Cofee Project" — it stays in English.
|
||||
|
||||
## Implementation sentiments
|
||||
|
||||
Write less complicated code, simple but readable code
|
||||
@@ -245,3 +317,17 @@ To import classNames lib use
|
||||
`import cs from 'classnames'`
|
||||
Always install packages using
|
||||
`bun install <package>`
|
||||
To test is project have no errors use
|
||||
`bunx tsc --noEmit`
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Flat features folder** — never place feature component folders directly in `src/features/`. Always group them inside a domain module folder (`profile/`, `project/`, etc.).
|
||||
2. **Inlining reusable logic** — if an operation (file upload, date formatting, etc.) could be used by multiple features, extract it to `src/shared/`. Features should be thin wrappers around shared utilities.
|
||||
3. **Wrong StaticLoader import** — it lives at `@shared/ui/Loader`, not `@shared/ui/Loader/StaticLoader`. There is no subdirectory.
|
||||
4. **multipart/form-data with fetchClient** — the default `fetchClient` sets `Content-Type: application/json`. For file uploads you must override headers and body serializer. Use the shared `uploadFile()` utility instead.
|
||||
5. **Broken lint scripts** — `bun run lint` calls `lint:es` and `lint:prettier` which are not defined in `package.json`. Use `bunx tsc --noEmit` for type checking until lint is fixed.
|
||||
6. **Generator output needs moving** — `bun run gc feature <Name>` creates the folder flat in `src/features/`. You must manually move it into the correct domain module folder afterward.
|
||||
7. **Raw `fetch` / `useEffect` for API calls** — never use plain `fetch` or `useEffect`-based polling for API requests. Always use `api.useQuery()` / `api.useMutation()` from `@shared/api` which wraps TanStack Query + openapi-fetch. For polling, use `refetchInterval`. Raw `fetch` bypasses typed routes, auth middleware, and query caching.
|
||||
|
||||
@@ -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
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ru" className={open_sans.variable}>
|
||||
<html lang="ru" className={open_sans.variable} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem("theme");var d;if(t==="light"||t==="dark"){d=t}else{d=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"}document.documentElement.setAttribute("data-theme",d);document.documentElement.classList.add(d)}catch(e){document.documentElement.setAttribute("data-theme","light");document.documentElement.classList.add("light")}})()`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</body>
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { Header } from "@widgets/Header"
|
||||
|
||||
const AUTH_ROUTES = ["/login", "/register", "/under_maintenance"]
|
||||
|
||||
export default function EssentialTemplate({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const isAuthPage = AUTH_ROUTES.includes(pathname)
|
||||
|
||||
if (isAuthPage) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
|
||||
@@ -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",
|
||||
"@tanstack/react-query": "^5.90.14",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"@vidstack/react": "^1",
|
||||
"@wavesurfer/react": "^1.0.12",
|
||||
"axios": "^1.13.2",
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.562.0",
|
||||
"moment": "^2.30.1",
|
||||
"next": "16.1.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"openapi-fetch": "^0.15.0",
|
||||
@@ -34,9 +36,11 @@
|
||||
"react-hook-form": "^7.71.0",
|
||||
"react-modern-drawer": "^1.4.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^4.6.5",
|
||||
"react-toastify": "^11.0.5",
|
||||
"use-mask-input": "^3.6.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"wavesurfer.js": "^7.12.1",
|
||||
"xior": "^0.8.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -785,6 +789,10 @@
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"@vidstack/react": ["@vidstack/react@1.12.13", "", { "dependencies": { "@floating-ui/dom": "^1.6.10", "media-captions": "^1.0.4" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" } }, "sha512-zyNydy1+HtoK6cJ8EmqFNkPPGHIFMrr2KH+ef3654EqXx4IcJ8A5LCNMXBuALQE8IMxtk040JMoR9OKyeXjBOQ=="],
|
||||
|
||||
"@wavesurfer/react": ["@wavesurfer/react@1.0.12", "", { "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "wavesurfer.js": ">=7.7.14" } }, "sha512-BNHpz2ryKNVvJdxB47pCPUsNCsjb2pZRysg82M5djIiw0vsiSJwdlt5jaAfDo3vd5IWrcoK9OiPQKO9ZEVNpDQ=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
@@ -935,6 +943,8 @@
|
||||
|
||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
@@ -1293,6 +1303,8 @@
|
||||
|
||||
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
|
||||
|
||||
"media-captions": ["media-captions@1.0.4", "", {}, "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w=="],
|
||||
|
||||
"meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
@@ -1307,8 +1319,6 @@
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
|
||||
@@ -1445,6 +1455,8 @@
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-resizable-panels": ["react-resizable-panels@4.6.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-pmQP6qv9KmsesNMvWVNvVfVJAwYSOWWbAOAtrPR8Cre20+j1NWIlyft0btjtDQE+OepXmI6g3VPrCXQY0oD7+Q=="],
|
||||
|
||||
"react-stately": ["react-stately@3.43.0", "", { "dependencies": { "@react-stately/calendar": "^3.9.1", "@react-stately/checkbox": "^3.7.3", "@react-stately/collections": "^3.12.8", "@react-stately/color": "^3.9.3", "@react-stately/combobox": "^3.12.1", "@react-stately/data": "^3.15.0", "@react-stately/datepicker": "^3.15.3", "@react-stately/disclosure": "^3.0.9", "@react-stately/dnd": "^3.7.2", "@react-stately/form": "^3.2.2", "@react-stately/list": "^3.13.2", "@react-stately/menu": "^3.9.9", "@react-stately/numberfield": "^3.10.3", "@react-stately/overlays": "^3.6.21", "@react-stately/radio": "^3.11.3", "@react-stately/searchfield": "^3.5.17", "@react-stately/select": "^3.9.0", "@react-stately/selection": "^3.20.7", "@react-stately/slider": "^3.7.3", "@react-stately/table": "^3.15.2", "@react-stately/tabs": "^3.8.7", "@react-stately/toast": "^3.1.2", "@react-stately/toggle": "^3.9.3", "@react-stately/tooltip": "^3.5.9", "@react-stately/tree": "^3.9.4", "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-dScb9fTL1tRtFODPnk/2rP0a9kp1C+7+40RArS0C7j0auAUmnrO/wDILojwQUso7/kkys4fP707fTwGJDeJ7vg=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
@@ -1641,6 +1653,8 @@
|
||||
|
||||
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.12.1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
@@ -7,6 +7,19 @@ const stylesPath = path.join(dirname, "src/shared/styles")
|
||||
console.log("dirname", dirname)
|
||||
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
port: "9000",
|
||||
},
|
||||
],
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: "inline",
|
||||
localPatterns: undefined,
|
||||
unoptimized: process.env.NODE_ENV === "development",
|
||||
},
|
||||
sassOptions: {
|
||||
includePaths: [stylesPath],
|
||||
additionalData: `@use "${path.join(stylesPath, "_variables.scss")}";
|
||||
|
||||
+5
-1
@@ -25,13 +25,15 @@
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tanstack/react-query": "^5.90.14",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"@vidstack/react": "^1",
|
||||
"@wavesurfer/react": "^1.0.12",
|
||||
"axios": "^1.13.2",
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.562.0",
|
||||
"moment": "^2.30.1",
|
||||
"next": "16.1.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"openapi-fetch": "^0.15.0",
|
||||
@@ -43,9 +45,11 @@
|
||||
"react-hook-form": "^7.71.0",
|
||||
"react-modern-drawer": "^1.4.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^4.6.5",
|
||||
"react-toastify": "^11.0.5",
|
||||
"use-mask-input": "^3.6.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"wavesurfer.js": "^7.12.1",
|
||||
"xior": "^0.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,33 +1,62 @@
|
||||
@use "@shared/styles/variables" as *;
|
||||
|
||||
.drawer {
|
||||
background: transparent;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
height: 100%;
|
||||
padding: 16px 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px 12px 0 0;
|
||||
box-shadow: 0 10px 30px rgba(12, 18, 38, 0.08);
|
||||
background: variables.$bg-default;
|
||||
border-radius: 0 variables.$radius-lg variables.$radius-lg 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: #0c1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@include typography.font-body-16(600);
|
||||
color: variables.$text-primary;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
@include mixins.flex-center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: transparent;
|
||||
color: variables.$text-secondary;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: variables.$bg-surface;
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@include mixins.reset-list;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item {
|
||||
@@ -36,40 +65,44 @@
|
||||
|
||||
.link,
|
||||
.button {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: transparent;
|
||||
color: #0c1226;
|
||||
color: variables.$text-secondary;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
@include typography.font-body-14(500);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
.link:hover,
|
||||
.button:hover,
|
||||
.link:focus-visible,
|
||||
.button:focus-visible {
|
||||
outline: none;
|
||||
background-color: #f4f6fb;
|
||||
&:hover {
|
||||
background-color: variables.$bg-surface;
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid variables.$color-secondary;
|
||||
outline-offset: -2px;
|
||||
border-radius: variables.$radius-sm;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: variables.$bg-surface;
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #5a6473;
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@include mixins.text-ellipsis;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { X } from "lucide-react"
|
||||
import { FunctionComponent } from "react"
|
||||
import Drawer from "react-modern-drawer"
|
||||
|
||||
import cs from "classnames"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { INavigationDrawerProps } from "./NavigationDrawer.d"
|
||||
import styles from "./NavigationDrawer.module.scss"
|
||||
@@ -20,6 +22,8 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
|
||||
size = 280,
|
||||
title,
|
||||
}): JSX.Element => {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
@@ -27,14 +31,25 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
|
||||
direction={position}
|
||||
size={size}
|
||||
className={cs(styles.drawer, className)}
|
||||
aria-label="Navigation drawer"
|
||||
aria-label="Навигация"
|
||||
duration={200}
|
||||
>
|
||||
<nav className={styles.root} data-testid="NavigationDrawer">
|
||||
{title ? <div className={styles.header}>{title}</div> : null}
|
||||
<div className={styles.header}>
|
||||
<span className={styles.brand}>{title ?? "Coffee Project"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть навигацию"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<ul className={styles.list}>
|
||||
{buttons.map(({ label, icon: Icon, path, action }, index) => {
|
||||
const key = `${label}-${path ?? index}`
|
||||
const isActive = path ? pathname === path : false
|
||||
|
||||
const content = (
|
||||
<>
|
||||
@@ -53,7 +68,7 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
|
||||
{path ? (
|
||||
<Link
|
||||
href={path}
|
||||
className={styles.link}
|
||||
className={cs(styles.link, isActive && styles.active)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{content}
|
||||
|
||||
+3
@@ -17,4 +17,7 @@ export interface IProjectCardProps {
|
||||
*/
|
||||
imageUrl?: string
|
||||
onClick?: () => void
|
||||
onEdit?: () => void
|
||||
onRename?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
@@ -5,19 +5,22 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-md;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
background: variables.$bg-default;
|
||||
}
|
||||
|
||||
.hero {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background-color: variables.$purple-50;
|
||||
background-color: variables.$bg-surface;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px 12px 0 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -35,24 +38,25 @@
|
||||
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%);
|
||||
|
||||
svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: variables.$purple-300;
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -135,7 +139,7 @@
|
||||
|
||||
.info {
|
||||
@include mixins.flex-column;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -155,7 +159,7 @@
|
||||
|
||||
.date {
|
||||
@include typography.font-caption-m;
|
||||
color: variables.$text-secondary;
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
|
||||
.status {
|
||||
@@ -164,7 +168,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@include typography.font-body-s;
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 500;
|
||||
|
||||
&.statusGenerated {
|
||||
@@ -172,7 +176,7 @@
|
||||
}
|
||||
|
||||
&.statusProcessing, &.statusRendering, &.statusUploading {
|
||||
color: variables.$purple-500;
|
||||
color: variables.$purple-400;
|
||||
}
|
||||
|
||||
&.statusDraft {
|
||||
@@ -185,28 +189,27 @@
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
.menuTrigger {
|
||||
margin-left: auto;
|
||||
background-color: variables.$bg-surface;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: transparent;
|
||||
border-radius: variables.$radius-sm;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@include mixins.flex-center;
|
||||
color: variables.$text-primary;
|
||||
color: variables.$text-secondary;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&[data-state="open"] {
|
||||
background-color: variables.$bg-default;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
background-color: variables.$bg-surface;
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -223,13 +226,15 @@
|
||||
|
||||
.statusBadge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
background: variables.$bg-canvas;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 500;
|
||||
color: variables.$text-primary;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Image as ImageIcon, MoreHorizontal } from "lucide-react"
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
import moment from "moment"
|
||||
|
||||
import { formatRelativeTime } from "@shared/lib/dates"
|
||||
import { Card } from "@shared/ui/Card"
|
||||
import { CircularProgress } from "@shared/ui/CircularProgress"
|
||||
import {
|
||||
@@ -27,6 +27,9 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||
currentAction,
|
||||
imageUrl,
|
||||
onClick,
|
||||
onEdit,
|
||||
onRename,
|
||||
onDelete,
|
||||
}): JSX.Element => {
|
||||
const { name, updated_at, status } = project
|
||||
|
||||
@@ -38,7 +41,6 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||
|
||||
const shouldShowProgress = isProcessing
|
||||
|
||||
// Helper to determine status color/class
|
||||
const getStatusClass = () => {
|
||||
if (isCompleted) return styles.statusGenerated
|
||||
if (isProcessing) return styles.statusProcessing
|
||||
@@ -49,7 +51,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||
|
||||
const getStatusLabel = () => {
|
||||
if (isCompleted) return "Завершено"
|
||||
if (isProcessing) return "В процессе" // Or more specific state
|
||||
if (isProcessing) return "В процессе"
|
||||
if (isDraft) return "Черновик"
|
||||
if (isFailed) return "Ошибка"
|
||||
return status
|
||||
@@ -112,19 +114,15 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent align="end">
|
||||
<DropdownItem
|
||||
onSelect={() => console.log("Edit", project.id)}
|
||||
>
|
||||
<DropdownItem onSelect={() => onEdit?.()}>
|
||||
Изменить
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
onSelect={() => console.log("Rename", project.id)}
|
||||
>
|
||||
<DropdownItem onSelect={() => onRename?.()}>
|
||||
Переименовать
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
className="text-red-500"
|
||||
onSelect={() => console.log("Delete", project.id)}
|
||||
onSelect={() => onDelete?.()}
|
||||
>
|
||||
Удалить
|
||||
</DropdownItem>
|
||||
@@ -133,7 +131,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.date}>
|
||||
Создано {moment(updated_at).fromNow()}
|
||||
Создано {formatRelativeTime(updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.root {
|
||||
display: flex
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.username {
|
||||
@include typography.font-body-16(500);
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-primary;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -12,14 +12,21 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px ;
|
||||
padding: 6px 12px 6px 6px;
|
||||
border-radius: 24px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
|
||||
background-color: variables.$bg-surface;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.themeItem {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,60 @@
|
||||
import type { JSX } from "react"
|
||||
|
||||
import Cookies from "js-cookie"
|
||||
import { Monitor, Moon, Sun } from "lucide-react"
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
|
||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||
import {
|
||||
ACCESS_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
} from "@shared/lib/constants"
|
||||
import { setThemePreference } from "@shared/store/appState"
|
||||
import type { ThemePreference } from "@shared/store/appState/types"
|
||||
import { resetUser } from "@shared/store/user"
|
||||
import { Avatar } from "@shared/ui/Avatar"
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownItem,
|
||||
DropdownRadioGroup,
|
||||
DropdownRadioItem,
|
||||
DropdownSeparator,
|
||||
DropdownSub,
|
||||
DropdownSubContent,
|
||||
DropdownSubTrigger,
|
||||
DropdownTrigger,
|
||||
} from "@shared/ui/Dropdown"
|
||||
|
||||
import { userDropdownValues } from "./constants"
|
||||
import { navItems } from "./constants"
|
||||
import { IUserDropdownProps } from "./UserDropdown.d"
|
||||
import styles from "./UserDropdown.module.scss"
|
||||
|
||||
const themeOptions: { value: ThemePreference; label: string; icon: typeof Sun }[] = [
|
||||
{ value: "light", label: "Светлая", icon: Sun },
|
||||
{ value: "dark", label: "Тёмная", icon: Moon },
|
||||
{ value: "system", label: "Системная", icon: Monitor },
|
||||
]
|
||||
|
||||
export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
|
||||
user,
|
||||
}): JSX.Element => {
|
||||
const router = useRouter()
|
||||
const dispatch = useAppDispatch()
|
||||
const themePreference = useAppSelector(
|
||||
(state) => state.appState.themePreference,
|
||||
)
|
||||
|
||||
const handleLogout = () => {
|
||||
Cookies.remove(ACCESS_TOKEN_COOKIE)
|
||||
Cookies.remove(REFRESH_TOKEN_COOKIE)
|
||||
dispatch(resetUser())
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} data-testid="UserDropdown">
|
||||
<Dropdown>
|
||||
@@ -28,15 +65,49 @@ export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
|
||||
</div>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent>
|
||||
{userDropdownValues.map((item) => (
|
||||
{navItems.map((item) => (
|
||||
<DropdownItem
|
||||
key={item.acton}
|
||||
key={item.action}
|
||||
className={styles.item}
|
||||
onSelect={() => console.log(`${item.acton} selected`)}
|
||||
onSelect={() => router.push(item.path)}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownItem>
|
||||
))}
|
||||
|
||||
<DropdownSeparator />
|
||||
|
||||
<DropdownSub>
|
||||
<DropdownSubTrigger className={styles.item}>
|
||||
<Sun size={16} />
|
||||
Тема
|
||||
</DropdownSubTrigger>
|
||||
<DropdownSubContent>
|
||||
<DropdownRadioGroup
|
||||
value={themePreference}
|
||||
onValueChange={(v) =>
|
||||
dispatch(setThemePreference(v as ThemePreference))
|
||||
}
|
||||
>
|
||||
{themeOptions.map((opt) => (
|
||||
<DropdownRadioItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className={styles.themeItem}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
</DropdownRadioItem>
|
||||
))}
|
||||
</DropdownRadioGroup>
|
||||
</DropdownSubContent>
|
||||
</DropdownSub>
|
||||
|
||||
<DropdownSeparator />
|
||||
|
||||
<DropdownItem className={styles.item} onSelect={handleLogout}>
|
||||
Выйти
|
||||
</DropdownItem>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
export const userDropdownValues = [
|
||||
export const navItems = [
|
||||
{
|
||||
label: "Профиль",
|
||||
acton: "profile",
|
||||
action: "profile",
|
||||
path: "/profile",
|
||||
},
|
||||
{
|
||||
label: "Настройки",
|
||||
acton: "settings",
|
||||
action: "settings",
|
||||
path: "/settings",
|
||||
},
|
||||
{
|
||||
label: "Выйти",
|
||||
acton: "logout",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
+4
-49
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import type { ProjectCreateBody } from "./useCreateProject"
|
||||
import type { ICreateProjectModalProps } from "./CreateProjectModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
@@ -12,27 +11,16 @@ import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
|
||||
import { useCreateProject } from "./useCreateProject"
|
||||
import styles from "./CreateProjectModal.module.scss"
|
||||
|
||||
type ProjectStatus = ProjectCreateBody["status"]
|
||||
|
||||
interface ICreateProjectFormData {
|
||||
name: string
|
||||
description?: string
|
||||
language: string
|
||||
folder?: string
|
||||
status: ProjectStatus
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: ProjectStatus; label: string }> = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "PROCESSING", label: "Processing" },
|
||||
{ value: "DONE", label: "Done" },
|
||||
{ value: "FAILED", label: "Failed" },
|
||||
]
|
||||
|
||||
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "ru", label: "Russian" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "auto", label: "Авто" },
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "en", label: "Английский" },
|
||||
]
|
||||
|
||||
export const CreateProjectModal: FunctionComponent<
|
||||
@@ -43,9 +31,7 @@ export const CreateProjectModal: FunctionComponent<
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
folder: "",
|
||||
language: "auto",
|
||||
status: "DRAFT",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -66,15 +52,12 @@ export const CreateProjectModal: FunctionComponent<
|
||||
const onSubmit = (data: ICreateProjectFormData): void => {
|
||||
const name = data.name.trim()
|
||||
const description = data.description?.trim()
|
||||
const folder = data.folder?.trim()
|
||||
|
||||
mutate({
|
||||
body: {
|
||||
name,
|
||||
description: description?.length ? description : undefined,
|
||||
folder: folder?.length ? folder : undefined,
|
||||
language: data.language,
|
||||
status: data.status,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -109,13 +92,6 @@ export const CreateProjectModal: FunctionComponent<
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
id="project_folder"
|
||||
label="Папка"
|
||||
placeholder="Например: /projects/my-project (необязательно)"
|
||||
{...register("folder")}
|
||||
/>
|
||||
|
||||
<div className={styles.selectField}>
|
||||
<div className={styles.selectLabel}>Язык</div>
|
||||
<Controller
|
||||
@@ -136,33 +112,12 @@ export const CreateProjectModal: FunctionComponent<
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.selectField}>
|
||||
<div className={styles.selectLabel}>Статус</div>
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Выберите статус"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
>
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
export interface IDeleteFileModalProps
|
||||
extends Pick<ComponentProps<typeof Dialog.Root>, "open" | "onOpenChange"> {
|
||||
fileName: string
|
||||
onConfirm: () => void
|
||||
isPending: boolean
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.root {
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
.message {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-secondary;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
@include typography.font-body-14(600);
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import type { IDeleteFileModalProps } from "./DeleteFileModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import { Button, Modal } from "@shared/ui"
|
||||
|
||||
import styles from "./DeleteFileModal.module.scss"
|
||||
|
||||
export const DeleteFileModal: FunctionComponent<IDeleteFileModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Удалить файл"
|
||||
description="Это действие нельзя отменить"
|
||||
>
|
||||
<div className={styles.root} data-testid="DeleteFileModal">
|
||||
<p className={styles.message}>
|
||||
Вы уверены, что хотите удалить файл{" "}
|
||||
<span className={styles.fileName}>{fileName}</span>?
|
||||
</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={isPending}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DeleteFileModal } from "./DeleteFileModal"
|
||||
|
||||
export type { IDeleteFileModalProps } from "./DeleteFileModal.d"
|
||||
@@ -0,0 +1,48 @@
|
||||
import api from "@shared/api"
|
||||
|
||||
interface IUseDeleteFileParams {
|
||||
onSuccess?: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const useDeleteUserFile = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IUseDeleteFileParams = {}) => {
|
||||
return api.useMutation("delete", "/api/files/files/{file_id}/", {
|
||||
onSuccess: () => {
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteArtifact = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IUseDeleteFileParams = {}) => {
|
||||
return api.useMutation("delete", "/api/media/artifacts/{artifact_id}/", {
|
||||
onSuccess: () => {
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteMediaFile = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IUseDeleteFileParams = {}) => {
|
||||
return api.useMutation("delete", "/api/media/mediafiles/{media_file_id}/", {
|
||||
onSuccess: () => {
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
export interface IDeleteProjectModalProps extends Pick<
|
||||
ComponentProps<typeof Dialog.Root>,
|
||||
"open" | "onOpenChange"
|
||||
> {
|
||||
project: components["schemas"]["ProjectRead"]
|
||||
onDeleted?: () => void | Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.root {
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
.message {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-secondary;
|
||||
}
|
||||
|
||||
.projectName {
|
||||
@include typography.font-body-14(600);
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import type { IDeleteProjectModalProps } from "./DeleteProjectModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import { Button, Modal } from "@shared/ui"
|
||||
|
||||
import { useDeleteProject } from "./useDeleteProject"
|
||||
import styles from "./DeleteProjectModal.module.scss"
|
||||
|
||||
export const DeleteProjectModal: FunctionComponent<
|
||||
IDeleteProjectModalProps
|
||||
> = ({ open, onOpenChange, project, onDeleted }): JSX.Element => {
|
||||
const { mutate, isPending } = useDeleteProject({
|
||||
onSuccess: async () => {
|
||||
await onDeleted?.()
|
||||
onOpenChange?.(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Delete project failed:", error)
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = (): void => {
|
||||
mutate({
|
||||
params: { path: { project_id: project.id } },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Удалить проект"
|
||||
description="Это действие нельзя отменить"
|
||||
>
|
||||
<div className={styles.root} data-testid="DeleteProjectModal">
|
||||
<p className={styles.message}>
|
||||
Вы уверены, что хотите удалить проект{" "}
|
||||
<span className={styles.projectName}>{project.name}</span>?
|
||||
</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DeleteProjectModal } from "./DeleteProjectModal"
|
||||
|
||||
export type { IDeleteProjectModalProps } from "./DeleteProjectModal.d"
|
||||
@@ -0,0 +1,20 @@
|
||||
import api from "@shared/api"
|
||||
|
||||
interface IUseDeleteProjectParams {
|
||||
onSuccess?: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const useDeleteProject = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IUseDeleteProjectParams = {}) => {
|
||||
return api.useMutation("delete", "/api/projects/{project_id}/", {
|
||||
onSuccess: () => {
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
export interface IEditProjectModalProps extends Pick<
|
||||
ComponentProps<typeof Dialog.Root>,
|
||||
"open" | "onOpenChange"
|
||||
> {
|
||||
project: components["schemas"]["ProjectRead"]
|
||||
onUpdated?: () => void | Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
.root {
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.selectField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.selectLabel {
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import type { IEditProjectModalProps } from "./EditProjectModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent, useEffect } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
|
||||
import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
|
||||
|
||||
import { useUpdateProject } from "./useUpdateProject"
|
||||
import styles from "./EditProjectModal.module.scss"
|
||||
|
||||
interface IEditProjectFormData {
|
||||
name: string
|
||||
description?: string
|
||||
language: string
|
||||
}
|
||||
|
||||
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: "auto", label: "Авто" },
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "en", label: "Английский" },
|
||||
]
|
||||
|
||||
export const EditProjectModal: FunctionComponent<IEditProjectModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
project,
|
||||
onUpdated,
|
||||
}): JSX.Element => {
|
||||
const { control, register, handleSubmit, reset, formState } =
|
||||
useForm<IEditProjectFormData>({
|
||||
defaultValues: {
|
||||
name: project.name,
|
||||
description: project.description ?? "",
|
||||
language: project.language,
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate, isPending } = useUpdateProject({
|
||||
onSuccess: async () => {
|
||||
await onUpdated?.()
|
||||
onOpenChange?.(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Update project failed:", error)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset({
|
||||
name: project.name,
|
||||
description: project.description ?? "",
|
||||
language: project.language,
|
||||
})
|
||||
}
|
||||
}, [open, project, reset])
|
||||
|
||||
const onSubmit = (data: IEditProjectFormData): void => {
|
||||
const name = data.name.trim()
|
||||
const description = data.description?.trim()
|
||||
|
||||
mutate({
|
||||
params: { path: { project_id: project.id } },
|
||||
body: {
|
||||
name,
|
||||
description: description?.length ? description : null,
|
||||
language: data.language,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Изменить проект"
|
||||
description="Измените параметры проекта"
|
||||
>
|
||||
<div className={styles.root} data-testid="EditProjectModal">
|
||||
<Form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className={styles.fields}>
|
||||
<TextField
|
||||
id="project_name"
|
||||
label="Название"
|
||||
placeholder="Например: Мой первый проект"
|
||||
error={Boolean(formState.errors.name)}
|
||||
undertitle={formState.errors.name?.message}
|
||||
{...register("name", {
|
||||
required: "Введите название проекта",
|
||||
validate: (v) =>
|
||||
v.trim().length > 0 || "Введите название проекта",
|
||||
})}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
id="project_description"
|
||||
label="Описание"
|
||||
placeholder="Коротко опишите проект (необязательно)"
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<div className={styles.selectField}>
|
||||
<div className={styles.selectLabel}>Язык</div>
|
||||
<Controller
|
||||
name="language"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Выберите язык"
|
||||
>
|
||||
{LANGUAGE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={isPending}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { EditProjectModal } from "./EditProjectModal"
|
||||
|
||||
export type { IEditProjectModalProps } from "./EditProjectModal.d"
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
|
||||
import api from "@shared/api"
|
||||
|
||||
export type ProjectUpdateBody = components["schemas"]["ProjectUpdate"]
|
||||
export type ProjectRead = components["schemas"]["ProjectRead"]
|
||||
|
||||
interface IUseUpdateProjectParams {
|
||||
onSuccess?: (project: ProjectRead) => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const useUpdateProject = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IUseUpdateProjectParams = {}) => {
|
||||
return api.useMutation("patch", "/api/projects/{project_id}/", {
|
||||
onSuccess: (project) => {
|
||||
onSuccess?.(project)
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
export interface IRenameProjectModalProps extends Pick<
|
||||
ComponentProps<typeof Dialog.Root>,
|
||||
"open" | "onOpenChange"
|
||||
> {
|
||||
project: components["schemas"]["ProjectRead"]
|
||||
onRenamed?: () => void | Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.root {
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import type { IRenameProjectModalProps } from "./RenameProjectModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent, useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { Button, Form, Modal, TextField } from "@shared/ui"
|
||||
|
||||
import styles from "./RenameProjectModal.module.scss"
|
||||
|
||||
interface IRenameProjectFormData {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const RenameProjectModal: FunctionComponent<
|
||||
IRenameProjectModalProps
|
||||
> = ({ open, onOpenChange, project, onRenamed }): JSX.Element => {
|
||||
const { register, handleSubmit, reset, formState } =
|
||||
useForm<IRenameProjectFormData>({
|
||||
defaultValues: {
|
||||
name: project.name,
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate, isPending } = api.useMutation(
|
||||
"patch",
|
||||
"/api/projects/{project_id}/",
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await onRenamed?.()
|
||||
onOpenChange?.(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Rename project failed:", error)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset({ name: project.name })
|
||||
}
|
||||
}, [open, project, reset])
|
||||
|
||||
const onSubmit = (data: IRenameProjectFormData): void => {
|
||||
const name = data.name.trim()
|
||||
|
||||
mutate({
|
||||
params: { path: { project_id: project.id } },
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Переименовать проект"
|
||||
description="Введите новое название проекта"
|
||||
>
|
||||
<div className={styles.root} data-testid="RenameProjectModal">
|
||||
<Form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className={styles.fields}>
|
||||
<TextField
|
||||
id="project_name"
|
||||
label="Название"
|
||||
placeholder="Например: Мой первый проект"
|
||||
error={Boolean(formState.errors.name)}
|
||||
undertitle={formState.errors.name?.message}
|
||||
{...register("name", {
|
||||
required: "Введите название проекта",
|
||||
validate: (v) =>
|
||||
v.trim().length > 0 || "Введите название проекта",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={isPending}>
|
||||
Переименовать
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { RenameProjectModal } from "./RenameProjectModal"
|
||||
|
||||
export type { IRenameProjectModalProps } from "./RenameProjectModal.d"
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WordData } from "@shared/lib/transcriptionDocument"
|
||||
|
||||
export interface SegmentEditData {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
words?: WordData[]
|
||||
}
|
||||
|
||||
export interface ISegmentEditModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
videoUrl?: string
|
||||
segment: SegmentEditData
|
||||
onSave: (text: string) => Promise<void>
|
||||
onSplit?: (
|
||||
newSegments: Array<{ start: number; end: number; text: string; words?: WordData[] }>,
|
||||
) => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.player {
|
||||
width: 100%;
|
||||
border-radius: variables.$radius-md;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.timeRange {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.textArea {
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: variables.$bg-surface;
|
||||
color: variables.$text-primary;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: variables.$purple-400;
|
||||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.splitAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.actionsSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import type { ISegmentEditModalProps } from "./SegmentEditModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { MediaPlayer, MediaProvider, useMediaState } from "@vidstack/react"
|
||||
import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default"
|
||||
import "@vidstack/react/player/styles/default/theme.css"
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||
import { LoaderCircle, Scissors } from "lucide-react"
|
||||
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { Button, Modal } from "@shared/ui"
|
||||
import {
|
||||
type EditorSegment,
|
||||
secondsToTimecode,
|
||||
splitSegmentAtMarkers,
|
||||
} from "@shared/lib/transcriptionDocument"
|
||||
import { SegmentSplitter } from "@features/project/SegmentSplitter"
|
||||
|
||||
import styles from "./SegmentEditModal.module.scss"
|
||||
|
||||
const SegmentPlayer = ({
|
||||
videoUrl,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
videoUrl: string
|
||||
start: number
|
||||
end: number
|
||||
}) => {
|
||||
const currentTime = useMediaState("currentTime")
|
||||
const playing = useMediaState("playing")
|
||||
const hasPausedRef = useRef(false)
|
||||
const playerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
hasPausedRef.current = false
|
||||
}, [start, end])
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return
|
||||
if (currentTime >= end && !hasPausedRef.current) {
|
||||
hasPausedRef.current = true
|
||||
const player = playerRef.current as HTMLElement & {
|
||||
pause?: () => void
|
||||
}
|
||||
player?.pause?.()
|
||||
}
|
||||
}, [currentTime, end, playing])
|
||||
|
||||
return (
|
||||
<div className={styles.playerWrapper}>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout
|
||||
icons={defaultLayoutIcons}
|
||||
slots={{
|
||||
settingsMenu: null,
|
||||
pipButton: null,
|
||||
fullscreenButton: null,
|
||||
airPlayButton: null,
|
||||
googleCastButton: null,
|
||||
}}
|
||||
/>
|
||||
<div className={styles.timeRange}>
|
||||
{secondsToTimecode(start)} — {secondsToTimecode(end)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SegmentEditModal: FunctionComponent<
|
||||
ISegmentEditModalProps
|
||||
> = ({ open, onOpenChange, videoUrl, segment, onSave, onSplit }): JSX.Element => {
|
||||
const [text, setText] = useState(segment.text)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [splitMode, setSplitMode] = useState(false)
|
||||
|
||||
const canSplit = !!onSplit && !!segment.words && segment.words.length >= 2
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setText(segment.text)
|
||||
setSplitMode(false)
|
||||
}
|
||||
}, [open, segment.text])
|
||||
|
||||
const editorSegment: EditorSegment = useMemo(
|
||||
() => ({
|
||||
startTime: secondsToTimecode(segment.start),
|
||||
endTime: secondsToTimecode(segment.end),
|
||||
text: segment.text,
|
||||
words: segment.words,
|
||||
}),
|
||||
[segment],
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(text)
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [text, onSave, onOpenChange])
|
||||
|
||||
const handleSplit = useCallback(
|
||||
async (newSegments: EditorSegment[]) => {
|
||||
if (!onSplit) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSplit(
|
||||
newSegments.map((s) => ({
|
||||
start: s.words?.[0]?.start ?? segment.start,
|
||||
end: s.words?.[s.words.length - 1]?.end ?? segment.end,
|
||||
text: s.text,
|
||||
words: s.words,
|
||||
})),
|
||||
)
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[onSplit, onOpenChange, segment],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
},
|
||||
[handleSave],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Редактировать субтитр"
|
||||
>
|
||||
<div className={styles.root} data-testid="SegmentEditModal">
|
||||
{videoUrl && (
|
||||
<MediaPlayer
|
||||
src={videoUrl}
|
||||
currentTime={segment.start}
|
||||
className={styles.player}
|
||||
autoPlay
|
||||
>
|
||||
<SegmentPlayer
|
||||
videoUrl={videoUrl}
|
||||
start={segment.start}
|
||||
end={segment.end}
|
||||
/>
|
||||
</MediaPlayer>
|
||||
)}
|
||||
|
||||
{splitMode ? (
|
||||
<SegmentSplitter
|
||||
segment={editorSegment}
|
||||
onSplit={handleSplit}
|
||||
onCancel={() => setSplitMode(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
className={styles.textArea}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
placeholder="Текст субтитра..."
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{canSplit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setSplitMode(true)}
|
||||
className={styles.splitAction}
|
||||
>
|
||||
<Scissors size={14} />
|
||||
Разделить
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.actionsSpacer} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<LoaderCircle size={16} className={styles.spinner} />
|
||||
) : null}
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SegmentEditModal"
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { EditorSegment } from "@shared/lib/transcriptionDocument"
|
||||
|
||||
export interface ISegmentSplitterProps {
|
||||
segment: EditorSegment
|
||||
onSplit: (newSegments: EditorSegment[]) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wordsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px 0;
|
||||
padding: 8px;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: variables.$bg-default;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.wordGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.word {
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: variables.$text-primary;
|
||||
border-radius: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
margin: 0 1px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-radius: 1px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
background: variables.$color-primary;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&.active::after {
|
||||
background: variables.$color-danger;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px dashed variables.$border-default;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: variables.$bg-surface;
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: variables.$text-tertiary;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.previewSegment {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid variables.$border-default;
|
||||
}
|
||||
}
|
||||
|
||||
.previewTime {
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
color: variables.$text-tertiary;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.previewText {
|
||||
font-size: 13px;
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.splitBtn {
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: variables.$color-primary;
|
||||
color: variables.$color-white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: variables.$border-default;
|
||||
color: variables.$text-tertiary;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: none;
|
||||
color: variables.$text-secondary;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: variables.$bg-hover;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import type { ISegmentSplitterProps } from "./SegmentSplitter.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent, useCallback, useMemo, useState } from "react"
|
||||
|
||||
import {
|
||||
type EditorSegment,
|
||||
splitSegmentAtMarkers,
|
||||
} from "@shared/lib/transcriptionDocument"
|
||||
|
||||
import styles from "./SegmentSplitter.module.scss"
|
||||
|
||||
export const SegmentSplitter: FunctionComponent<ISegmentSplitterProps> = ({
|
||||
segment,
|
||||
onSplit,
|
||||
onCancel,
|
||||
}): JSX.Element => {
|
||||
const [markers, setMarkers] = useState<Set<number>>(new Set())
|
||||
|
||||
const words = segment.words!
|
||||
|
||||
const toggleMarker = useCallback((idx: number) => {
|
||||
setMarkers((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(idx)) {
|
||||
next.delete(idx)
|
||||
} else {
|
||||
next.add(idx)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const preview: EditorSegment[] = useMemo(() => {
|
||||
if (markers.size === 0) return [segment]
|
||||
return splitSegmentAtMarkers(segment, Array.from(markers))
|
||||
}, [segment, markers])
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (markers.size === 0) return
|
||||
onSplit(preview)
|
||||
}, [markers, preview, onSplit])
|
||||
|
||||
return (
|
||||
<div className={styles.root} data-testid="SegmentSplitter">
|
||||
<div className={styles.wordsRow}>
|
||||
{words.map((word, idx) => (
|
||||
<span key={idx} className={styles.wordGroup}>
|
||||
{idx > 0 && (
|
||||
<button
|
||||
className={`${styles.gap} ${markers.has(idx) ? styles.active : ""}`}
|
||||
onClick={() => toggleMarker(idx)}
|
||||
title="Разделить здесь"
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
<span className={styles.word}>{word.text}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{markers.size > 0 && (
|
||||
<div className={styles.preview}>
|
||||
<span className={styles.previewLabel}>Результат:</span>
|
||||
{preview.map((seg, idx) => (
|
||||
<div key={idx} className={styles.previewSegment}>
|
||||
<span className={styles.previewTime}>
|
||||
{seg.startTime} — {seg.endTime}
|
||||
</span>
|
||||
<span className={styles.previewText}>{seg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.splitBtn}
|
||||
onClick={handleSplit}
|
||||
disabled={markers.size === 0}
|
||||
type="button"
|
||||
>
|
||||
Разделить
|
||||
</button>
|
||||
<button className={styles.cancelBtn} onClick={onCancel} type="button">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SegmentSplitter"
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface ISilenceResultModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
projectId: string
|
||||
jobId: string
|
||||
fileKey: string
|
||||
}
|
||||
|
||||
export interface CutRegion {
|
||||
id: string
|
||||
startMs: number
|
||||
endMs: number
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Override Radix Dialog.Content max-width for this wide modal
|
||||
:global(.rt-DialogContent):has([data-testid="SilenceResultModal"]) {
|
||||
max-width: 80vw !important;
|
||||
width: 80vw !important;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 45vh;
|
||||
border-radius: variables.$radius-md;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
|
||||
// Force Vidstack player to fill the container exactly
|
||||
:global([data-media-player]) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
:global(.vds-video-layout) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.timelineSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.zoomControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: variables.$text-secondary;
|
||||
}
|
||||
|
||||
.zoomButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: variables.$bg-default;
|
||||
color: variables.$text-primary;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: variables.$bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.timelineContainer {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-md;
|
||||
background: variables.$bg-surface;
|
||||
}
|
||||
|
||||
.timelineInner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rulerRow {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
}
|
||||
|
||||
.rulerCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.framesRow {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.framesCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.waveformRow {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
}
|
||||
|
||||
.cutRegionsRow {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.infoBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: variables.$text-secondary;
|
||||
}
|
||||
|
||||
.infoTotal {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
// --- Cut region blocks ---
|
||||
|
||||
.cutRegion {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: rgba(255, 152, 0, 0.3);
|
||||
border: 1px solid rgba(255, 152, 0, 0.7);
|
||||
border-radius: 2px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.cutRegionActive {
|
||||
background: rgba(255, 152, 0, 0.5);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.handleLeft,
|
||||
.handleRight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.handleLeft {
|
||||
left: -3px;
|
||||
}
|
||||
|
||||
.handleRight {
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
.contextMenu {
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
background: variables.$bg-surface;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-md;
|
||||
box-shadow: variables.$shadow-md;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.contextMenuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: variables.$radius-sm;
|
||||
background: none;
|
||||
color: variables.$text-primary;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: variables.$bg-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.contextMenuDanger {
|
||||
color: variables.$color-danger;
|
||||
}
|
||||
|
||||
// --- Playhead ---
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: variables.$color-danger;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
"use client"
|
||||
|
||||
import type { CutRegion, ISilenceResultModalProps } from "./SilenceResultModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { MediaPlayer, MediaProvider } from "@vidstack/react"
|
||||
import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default"
|
||||
import "@vidstack/react/player/styles/default/theme.css"
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||
import cs from "classnames"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import WaveSurfer from "wavesurfer.js"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { useSegmentResize } from "@shared/hooks/useSegmentResize"
|
||||
import { Button, Modal } from "@shared/ui"
|
||||
|
||||
import { useSubmitSilenceApply } from "./useSubmitSilenceApply"
|
||||
import styles from "./SilenceResultModal.module.scss"
|
||||
|
||||
const MIN_REGION_MS = 100
|
||||
const DEFAULT_NEW_REGION_MS = 1000
|
||||
const DEFAULT_PPS = 10
|
||||
const MIN_PPS = 2
|
||||
const MAX_PPS = 200
|
||||
const PPS_STEP = 2
|
||||
const FRAMES_HEIGHT = 48
|
||||
const WAVEFORM_HEIGHT = 48
|
||||
const RULER_HEIGHT = 24
|
||||
const MAX_EXTRACTED_FRAMES = 150
|
||||
const CANVAS_OVERSCAN = 300
|
||||
|
||||
let regionIdCounter = 0
|
||||
const nextRegionId = (): string => `region_${++regionIdCounter}`
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const min = Math.floor(totalSec / 60)
|
||||
const sec = totalSec % 60
|
||||
if (min > 0) return `${min}м ${sec}с`
|
||||
return `${sec}с`
|
||||
}
|
||||
|
||||
function resolveWaveformColors(): { wave: string; progress: string } {
|
||||
const root = getComputedStyle(document.documentElement)
|
||||
return {
|
||||
wave:
|
||||
root.getPropertyValue("--waveform-wave").trim() ||
|
||||
"hsl(297, 70%, 44%)",
|
||||
progress:
|
||||
root.getPropertyValue("--waveform-progress").trim() ||
|
||||
"hsl(293, 100%, 34%)",
|
||||
}
|
||||
}
|
||||
|
||||
export const SilenceResultModal: FunctionComponent<ISilenceResultModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
jobId,
|
||||
}): JSX.Element => {
|
||||
const [cutRegions, setCutRegions] = useState<CutRegion[]>([])
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PPS)
|
||||
const [durationMs, setDurationMs] = useState(0)
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
regionId: string | null
|
||||
timeMs: number
|
||||
} | null>(null)
|
||||
const timelineRef = useRef<HTMLDivElement>(null)
|
||||
const playerRef = useRef<any>(null)
|
||||
const waveformRef = useRef<HTMLDivElement>(null)
|
||||
const wsRef = useRef<WaveSurfer | null>(null)
|
||||
|
||||
// --- Data loading ---
|
||||
const { data: taskStatus } = api.useQuery(
|
||||
"get",
|
||||
"/api/tasks/status/{job_id}/",
|
||||
{ params: { path: { job_id: jobId } } },
|
||||
{ enabled: open && !!jobId },
|
||||
)
|
||||
|
||||
const outputData = taskStatus?.output_data as Record<string, unknown> | null
|
||||
const fileKey = (outputData?.file_key as string) ?? ""
|
||||
|
||||
const { data: fileInfo } = api.useQuery(
|
||||
"get",
|
||||
"/api/files/get_file/",
|
||||
{ params: { query: { file_path: fileKey } } },
|
||||
{ enabled: open && !!fileKey },
|
||||
)
|
||||
|
||||
const videoUrl = fileInfo?.file_url ?? null
|
||||
|
||||
// Initialize cut regions from detection results
|
||||
useEffect(() => {
|
||||
if (!outputData) return
|
||||
const segments = outputData.silent_segments as
|
||||
| { start_ms: number; end_ms: number }[]
|
||||
| undefined
|
||||
const dur = outputData.duration_ms as number | undefined
|
||||
|
||||
if (segments && dur) {
|
||||
setDurationMs(dur)
|
||||
setCutRegions(
|
||||
segments.map((s) => ({
|
||||
id: nextRegionId(),
|
||||
startMs: s.start_ms,
|
||||
endMs: s.end_ms,
|
||||
})),
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [outputData])
|
||||
|
||||
// --- Timeline calculations ---
|
||||
const totalWidth = Math.max(1, (durationMs / 1000) * pixelsPerSecond)
|
||||
|
||||
const msToPixels = useCallback(
|
||||
(ms: number) => (ms / 1000) * pixelsPerSecond,
|
||||
[pixelsPerSecond],
|
||||
)
|
||||
|
||||
const pixelsToMs = useCallback(
|
||||
(px: number) => (px / pixelsPerSecond) * 1000,
|
||||
[pixelsPerSecond],
|
||||
)
|
||||
|
||||
// --- Total removed calculation ---
|
||||
const totalRemovedMs = useMemo(
|
||||
() => cutRegions.reduce((sum, r) => sum + (r.endMs - r.startMs), 0),
|
||||
[cutRegions],
|
||||
)
|
||||
|
||||
// --- Region mutations ---
|
||||
const addRegion = useCallback(
|
||||
(atMs: number) => {
|
||||
const startMs = Math.max(0, atMs - DEFAULT_NEW_REGION_MS / 2)
|
||||
const endMs = Math.min(durationMs, startMs + DEFAULT_NEW_REGION_MS)
|
||||
setCutRegions((prev) =>
|
||||
[...prev, { id: nextRegionId(), startMs, endMs }].sort(
|
||||
(a, b) => a.startMs - b.startMs,
|
||||
),
|
||||
)
|
||||
},
|
||||
[durationMs],
|
||||
)
|
||||
|
||||
const removeRegion = useCallback((regionId: string) => {
|
||||
setCutRegions((prev) => prev.filter((r) => r.id !== regionId))
|
||||
}, [])
|
||||
|
||||
// --- Resize handling ---
|
||||
const { handlePointerDown: handleResizePointerDown } = useSegmentResize({
|
||||
pixelsPerSecond,
|
||||
onResize: (index, edge, deltaSec) => {
|
||||
setCutRegions((prev) => {
|
||||
const updated = [...prev]
|
||||
const region = { ...updated[index] }
|
||||
const deltaMs = deltaSec * 1000
|
||||
|
||||
if (edge === "left") {
|
||||
region.startMs = Math.max(
|
||||
0,
|
||||
Math.min(region.endMs - MIN_REGION_MS, region.startMs + deltaMs),
|
||||
)
|
||||
} else {
|
||||
region.endMs = Math.min(
|
||||
durationMs,
|
||||
Math.max(region.startMs + MIN_REGION_MS, region.endMs + deltaMs),
|
||||
)
|
||||
}
|
||||
updated[index] = region
|
||||
return updated
|
||||
})
|
||||
},
|
||||
onResizeEnd: () => {},
|
||||
})
|
||||
|
||||
// --- Drag-to-move handling ---
|
||||
const handleRegionDragStart = useCallback(
|
||||
(e: React.PointerEvent, index: number) => {
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const region = cutRegions[index]
|
||||
const regionDuration = region.endMs - region.startMs
|
||||
|
||||
const onMove = (moveE: PointerEvent) => {
|
||||
const dx = moveE.clientX - startX
|
||||
const deltaMs = pixelsToMs(dx)
|
||||
let newStart = region.startMs + deltaMs
|
||||
newStart = Math.max(0, Math.min(durationMs - regionDuration, newStart))
|
||||
|
||||
setCutRegions((prev) => {
|
||||
const updated = [...prev]
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
startMs: Math.round(newStart),
|
||||
endMs: Math.round(newStart + regionDuration),
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("pointermove", onMove)
|
||||
document.removeEventListener("pointerup", onUp)
|
||||
}
|
||||
|
||||
document.addEventListener("pointermove", onMove)
|
||||
document.addEventListener("pointerup", onUp)
|
||||
},
|
||||
[cutRegions, durationMs, pixelsToMs],
|
||||
)
|
||||
|
||||
// --- Context menu ---
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent, regionId: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const rect = timelineRef.current?.getBoundingClientRect()
|
||||
const scrollLeft = timelineRef.current?.scrollLeft ?? 0
|
||||
const x = e.clientX - (rect?.left ?? 0) + scrollLeft
|
||||
const timeMs = pixelsToMs(x)
|
||||
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
regionId,
|
||||
timeMs,
|
||||
})
|
||||
},
|
||||
[pixelsToMs],
|
||||
)
|
||||
|
||||
// Close context menu on click anywhere
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const close = () => setContextMenu(null)
|
||||
document.addEventListener("click", close)
|
||||
return () => document.removeEventListener("click", close)
|
||||
}, [contextMenu])
|
||||
|
||||
// --- Timeline click to seek ---
|
||||
const handleTimelineClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const rect = timelineRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const scrollLeft = timelineRef.current?.scrollLeft ?? 0
|
||||
const x = e.clientX - rect.left + scrollLeft
|
||||
const timeMs = pixelsToMs(x)
|
||||
const timeSec = timeMs / 1000
|
||||
|
||||
if (playerRef.current) {
|
||||
playerRef.current.currentTime = timeSec
|
||||
}
|
||||
},
|
||||
[pixelsToMs],
|
||||
)
|
||||
|
||||
// --- Canvas drawing functions (stable refs, called from animation loop) ---
|
||||
const rulerRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
const drawRuler = useCallback(() => {
|
||||
const container = timelineRef.current
|
||||
const canvas = rulerRef.current
|
||||
if (!container || !canvas || !durationMs) return
|
||||
|
||||
const sl = container.scrollLeft
|
||||
const vw = container.clientWidth
|
||||
if (!vw) return
|
||||
const canvasW = Math.min(vw + CANVAS_OVERSCAN * 2, totalWidth)
|
||||
const offset = Math.max(
|
||||
0,
|
||||
Math.min(sl - CANVAS_OVERSCAN, totalWidth - canvasW),
|
||||
)
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = canvasW * dpr
|
||||
canvas.height = RULER_HEIGHT * dpr
|
||||
canvas.style.width = `${canvasW}px`
|
||||
canvas.style.height = `${RULER_HEIGHT}px`
|
||||
canvas.style.transform = `translateX(${offset}px)`
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, canvasW, RULER_HEIGHT)
|
||||
|
||||
const rootStyles = getComputedStyle(document.documentElement)
|
||||
const textColor =
|
||||
rootStyles.getPropertyValue("--text-secondary").trim() || "#888"
|
||||
const lineColor =
|
||||
rootStyles.getPropertyValue("--border-subtle").trim() || "#444"
|
||||
|
||||
ctx.strokeStyle = lineColor
|
||||
ctx.fillStyle = textColor
|
||||
ctx.font = "10px monospace"
|
||||
ctx.textAlign = "center"
|
||||
|
||||
const totalSec = durationMs / 1000
|
||||
let tickInterval = 1
|
||||
if (pixelsPerSecond < 5) tickInterval = 30
|
||||
else if (pixelsPerSecond < 10) tickInterval = 15
|
||||
else if (pixelsPerSecond < 20) tickInterval = 10
|
||||
else if (pixelsPerSecond < 50) tickInterval = 5
|
||||
else if (pixelsPerSecond < 150) tickInterval = 1
|
||||
else tickInterval = 0.5
|
||||
|
||||
const majorMultiple = tickInterval >= 1 ? 5 : 1
|
||||
const startSec =
|
||||
Math.floor(offset / pixelsPerSecond / tickInterval) * tickInterval
|
||||
const endSec = Math.min(
|
||||
totalSec,
|
||||
(offset + canvasW) / pixelsPerSecond,
|
||||
)
|
||||
|
||||
for (let sec = startSec; sec <= endSec; sec += tickInterval) {
|
||||
const x = sec * pixelsPerSecond - offset
|
||||
if (x < -20 || x > canvasW + 20) continue
|
||||
const isMajor =
|
||||
Math.round(sec / tickInterval) % majorMultiple === 0
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, isMajor ? 0 : 14)
|
||||
ctx.lineTo(x, RULER_HEIGHT)
|
||||
ctx.stroke()
|
||||
|
||||
if (isMajor) {
|
||||
const min = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
const label = `${min}:${s.toString().padStart(2, "0")}`
|
||||
const labelW = ctx.measureText(label).width
|
||||
const tx = Math.max(labelW / 2, x)
|
||||
ctx.fillText(label, tx, 10)
|
||||
}
|
||||
}
|
||||
}, [durationMs, pixelsPerSecond, totalWidth])
|
||||
|
||||
// --- WaveSurfer ---
|
||||
useEffect(() => {
|
||||
if (!open || !videoUrl || !waveformRef.current || !durationMs) return
|
||||
|
||||
const durationSec = durationMs / 1000
|
||||
const colors = resolveWaveformColors()
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: waveformRef.current,
|
||||
url: videoUrl,
|
||||
duration: durationSec,
|
||||
height: WAVEFORM_HEIGHT,
|
||||
waveColor: colors.wave,
|
||||
progressColor: colors.progress,
|
||||
cursorWidth: 0,
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
normalize: true,
|
||||
interact: false,
|
||||
minPxPerSec: pixelsPerSecond,
|
||||
hideScrollbar: true,
|
||||
fillParent: false,
|
||||
autoCenter: false,
|
||||
autoScroll: false,
|
||||
dragToSeek: false,
|
||||
mediaControls: false,
|
||||
backend: "MediaElement",
|
||||
})
|
||||
|
||||
ws.setVolume(0)
|
||||
wsRef.current = ws
|
||||
|
||||
return () => {
|
||||
ws.destroy()
|
||||
wsRef.current = null
|
||||
}
|
||||
// Only recreate when URL or open state changes, not on every PPS change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, videoUrl, durationMs])
|
||||
|
||||
// Update WaveSurfer zoom when PPS changes
|
||||
useEffect(() => {
|
||||
const ws = wsRef.current
|
||||
if (!ws) return
|
||||
try {
|
||||
ws.zoom(pixelsPerSecond)
|
||||
} catch {
|
||||
// WaveSurfer might not be ready yet
|
||||
}
|
||||
}, [pixelsPerSecond])
|
||||
|
||||
// --- Video frames extraction ---
|
||||
const framesCanvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const framesCacheRef = useRef<{ timeSec: number; bitmap: ImageBitmap }[]>([])
|
||||
const [framesReady, setFramesReady] = useState(false)
|
||||
|
||||
// Extract frames once when video URL is available
|
||||
useEffect(() => {
|
||||
if (!open || !videoUrl || !durationMs) return
|
||||
|
||||
framesCacheRef.current.forEach((f) => f.bitmap.close())
|
||||
framesCacheRef.current = []
|
||||
setFramesReady(false)
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const video = document.createElement("video")
|
||||
video.crossOrigin = "anonymous"
|
||||
video.muted = true
|
||||
video.preload = "auto"
|
||||
video.src = videoUrl
|
||||
|
||||
const extract = async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadedmetadata = () => resolve()
|
||||
video.onerror = () => reject(new Error("video load error"))
|
||||
if (video.readyState >= 1) resolve()
|
||||
})
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
const durationSec = durationMs / 1000
|
||||
const frameCount = Math.min(
|
||||
Math.ceil(durationSec / 2),
|
||||
MAX_EXTRACTED_FRAMES,
|
||||
)
|
||||
const interval = durationSec / frameCount
|
||||
const aspect = video.videoWidth / video.videoHeight || 16 / 9
|
||||
const frameW = Math.round(FRAMES_HEIGHT * aspect)
|
||||
|
||||
const offCanvas = document.createElement("canvas")
|
||||
offCanvas.width = frameW * 2
|
||||
offCanvas.height = FRAMES_HEIGHT * 2
|
||||
const offCtx = offCanvas.getContext("2d")!
|
||||
|
||||
const cache: { timeSec: number; bitmap: ImageBitmap }[] = []
|
||||
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
if (cancelled) return
|
||||
|
||||
const timeSec = i * interval
|
||||
video.currentTime = timeSec
|
||||
await new Promise<void>((r) => {
|
||||
video.onseeked = () => r()
|
||||
})
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
offCtx.drawImage(video, 0, 0, offCanvas.width, offCanvas.height)
|
||||
const bitmap = await createImageBitmap(offCanvas)
|
||||
cache.push({ timeSec, bitmap })
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
framesCacheRef.current = cache
|
||||
setFramesReady(true)
|
||||
}
|
||||
}
|
||||
|
||||
extract().catch(() => {})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
video.src = ""
|
||||
video.load()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, videoUrl, durationMs])
|
||||
|
||||
const drawFrames = useCallback(() => {
|
||||
const container = timelineRef.current
|
||||
const canvas = framesCanvasRef.current
|
||||
if (!container || !canvas || !framesReady) return
|
||||
|
||||
const cache = framesCacheRef.current
|
||||
if (cache.length === 0) return
|
||||
|
||||
const sl = container.scrollLeft
|
||||
const vw = container.clientWidth
|
||||
if (!vw) return
|
||||
const canvasW = Math.min(vw + CANVAS_OVERSCAN * 2, totalWidth)
|
||||
const offset = Math.max(
|
||||
0,
|
||||
Math.min(sl - CANVAS_OVERSCAN, totalWidth - canvasW),
|
||||
)
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = canvasW * dpr
|
||||
canvas.height = FRAMES_HEIGHT * dpr
|
||||
canvas.style.width = `${canvasW}px`
|
||||
canvas.style.height = `${FRAMES_HEIGHT}px`
|
||||
canvas.style.transform = `translateX(${offset}px)`
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.fillStyle = "#111"
|
||||
ctx.fillRect(0, 0, canvasW, FRAMES_HEIGHT)
|
||||
|
||||
for (let i = 0; i < cache.length; i++) {
|
||||
const globalX = cache[i].timeSec * pixelsPerSecond
|
||||
const nextGlobalX =
|
||||
i < cache.length - 1
|
||||
? cache[i + 1].timeSec * pixelsPerSecond
|
||||
: totalWidth
|
||||
|
||||
// Skip frames entirely outside visible canvas
|
||||
if (nextGlobalX < offset) continue
|
||||
if (globalX > offset + canvasW) break
|
||||
|
||||
const x = globalX - offset
|
||||
const tileW = nextGlobalX - globalX
|
||||
ctx.drawImage(cache[i].bitmap, x, 0, tileW, FRAMES_HEIGHT)
|
||||
}
|
||||
}, [framesReady, pixelsPerSecond, totalWidth])
|
||||
|
||||
// --- Animation loop: playhead sync + canvas redraw on scroll ---
|
||||
const [playheadMs, setPlayheadMs] = useState(0)
|
||||
const animRef = useRef<number>(0)
|
||||
const lastScrollRef = useRef(-1)
|
||||
const lastViewportRef = useRef(-1)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const tick = () => {
|
||||
// Sync playhead with video
|
||||
if (playerRef.current) {
|
||||
const timeMs = playerRef.current.currentTime * 1000
|
||||
setPlayheadMs(timeMs)
|
||||
|
||||
const ws = wsRef.current
|
||||
if (ws) {
|
||||
try {
|
||||
ws.setTime(playerRef.current.currentTime)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw canvases when scroll position or viewport size changes
|
||||
const container = timelineRef.current
|
||||
if (container) {
|
||||
const sl = container.scrollLeft
|
||||
const vw = container.clientWidth
|
||||
if (sl !== lastScrollRef.current || vw !== lastViewportRef.current) {
|
||||
lastScrollRef.current = sl
|
||||
lastViewportRef.current = vw
|
||||
drawRuler()
|
||||
drawFrames()
|
||||
}
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
animRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
if (animRef.current) cancelAnimationFrame(animRef.current)
|
||||
lastScrollRef.current = -1
|
||||
lastViewportRef.current = -1
|
||||
}
|
||||
}, [open, drawRuler, drawFrames])
|
||||
|
||||
// --- Apply ---
|
||||
const { mutate: applyMutate, isPending: isApplying } = useSubmitSilenceApply(
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Silence apply failed:", error)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const handleApply = () => {
|
||||
if (!fileKey || cutRegions.length === 0) return
|
||||
|
||||
const fileName = fileKey.split("/").pop() ?? "video.mp4"
|
||||
const outputName = `Без тишины ${fileName}`
|
||||
|
||||
// Body shape matches SilenceApplyRequest — types available after gen:api-types
|
||||
;(applyMutate as (args: { body: Record<string, unknown> }) => void)({
|
||||
body: {
|
||||
file_key: fileKey,
|
||||
out_folder: "",
|
||||
project_id: projectId,
|
||||
output_name: outputName,
|
||||
cuts: cutRegions.map((r) => ({
|
||||
start_ms: Math.round(r.startMs),
|
||||
end_ms: Math.round(r.endMs),
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Удаление тишины"
|
||||
description="Просмотрите и отредактируйте участки для удаления"
|
||||
>
|
||||
<div className={styles.root} data-testid="SilenceResultModal">
|
||||
{/* Video player */}
|
||||
<div className={styles.playerWrapper}>
|
||||
{videoUrl && (
|
||||
<MediaPlayer
|
||||
ref={playerRef}
|
||||
src={videoUrl}
|
||||
crossOrigin=""
|
||||
playsInline
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline section */}
|
||||
<div className={styles.timelineSection}>
|
||||
<div className={styles.zoomControls}>
|
||||
<button
|
||||
className={styles.zoomButton}
|
||||
onClick={() =>
|
||||
setPixelsPerSecond((p) => Math.max(MIN_PPS, p - PPS_STEP))
|
||||
}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span>Масштаб</span>
|
||||
<button
|
||||
className={styles.zoomButton}
|
||||
onClick={() =>
|
||||
setPixelsPerSecond((p) => Math.min(MAX_PPS, p + PPS_STEP))
|
||||
}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className={styles.timelineContainer}
|
||||
onClick={handleTimelineClick}
|
||||
onContextMenu={(e) => handleContextMenu(e, null)}
|
||||
>
|
||||
<div
|
||||
className={styles.timelineInner}
|
||||
style={{ width: `${totalWidth}px` }}
|
||||
>
|
||||
{/* Ruler */}
|
||||
<div className={styles.rulerRow}>
|
||||
<canvas ref={rulerRef} className={styles.rulerCanvas} />
|
||||
</div>
|
||||
|
||||
{/* Video frames */}
|
||||
<div className={styles.framesRow}>
|
||||
<canvas
|
||||
ref={framesCanvasRef}
|
||||
className={styles.framesCanvas}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Waveform */}
|
||||
<div className={styles.waveformRow}>
|
||||
<div ref={waveformRef} style={{ height: WAVEFORM_HEIGHT }} />
|
||||
</div>
|
||||
|
||||
{/* Cut regions */}
|
||||
<div className={styles.cutRegionsRow}>
|
||||
{cutRegions.map((region, index) => {
|
||||
const left = msToPixels(region.startMs)
|
||||
const width = msToPixels(region.endMs - region.startMs)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={region.id}
|
||||
className={styles.cutRegion}
|
||||
style={{ left: `${left}px`, width: `${width}px` }}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button === 0) {
|
||||
handleRegionDragStart(e, index)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(e, region.id)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles.handleLeft}
|
||||
onPointerDown={(e) =>
|
||||
handleResizePointerDown(e, index, "left")
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={styles.handleRight}
|
||||
onPointerDown={(e) =>
|
||||
handleResizePointerDown(e, index, "right")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Playhead */}
|
||||
<div
|
||||
className={styles.playhead}
|
||||
style={{ left: `${msToPixels(playheadMs)}px` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className={styles.infoBar}>
|
||||
<span>Фрагментов: {cutRegions.length}</span>
|
||||
<span className={styles.infoTotal}>
|
||||
Будет удалено: {formatDuration(totalRemovedMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className={styles.contextMenu}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{contextMenu.regionId && (
|
||||
<button
|
||||
className={cs(
|
||||
styles.contextMenuItem,
|
||||
styles.contextMenuDanger,
|
||||
)}
|
||||
onClick={() => {
|
||||
removeRegion(contextMenu.regionId!)
|
||||
setContextMenu(null)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Удалить</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={styles.contextMenuItem}
|
||||
onClick={() => {
|
||||
addRegion(contextMenu.timeMs)
|
||||
setContextMenu(null)
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Добавить новый</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isApplying}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={isApplying || cutRegions.length === 0}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Применить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SilenceResultModal } from "./SilenceResultModal"
|
||||
@@ -0,0 +1,25 @@
|
||||
import api from "@shared/api"
|
||||
|
||||
interface IUseSubmitSilenceApplyParams {
|
||||
onSuccess?: (data: unknown) => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const useSubmitSilenceApply = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IUseSubmitSilenceApplyParams = {}) => {
|
||||
// NOTE: Endpoint types will be available after running `bun run gen:api-types`
|
||||
return api.useMutation(
|
||||
"post",
|
||||
"/api/tasks/silence-apply/" as "/api/tasks/silence-remove/",
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ISilenceSettingsModalProps {
|
||||
projectId: string
|
||||
open: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
.root {
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.selectField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.selectLabel {
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
.rangeField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rangeLabel {
|
||||
@include typography.font-body-14(500);
|
||||
color: variables.$text-primary;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rangeValue {
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-secondary;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rangeInput {
|
||||
width: 100%;
|
||||
accent-color: variables.$color-primary;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import type { ISilenceSettingsModalProps } from "./SilenceSettingsModal.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent, useEffect } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { Button, Form, Modal, Select, SelectItem } from "@shared/ui"
|
||||
|
||||
import { useSubmitSilenceDetect } from "./useSubmitSilenceDetect"
|
||||
import styles from "./SilenceSettingsModal.module.scss"
|
||||
|
||||
interface ISilenceSettingsFormData {
|
||||
file_key: string
|
||||
min_silence_duration_ms: number
|
||||
silence_threshold_db: number
|
||||
padding_ms: number
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_SILENCE_MS = 200
|
||||
const DEFAULT_THRESHOLD_DB = 16
|
||||
const DEFAULT_PADDING_MS = 100
|
||||
|
||||
export const SilenceSettingsModal: FunctionComponent<
|
||||
ISilenceSettingsModalProps
|
||||
> = ({ projectId, open, onOpenChange }): JSX.Element => {
|
||||
const { control, handleSubmit, reset, watch, setValue } =
|
||||
useForm<ISilenceSettingsFormData>({
|
||||
defaultValues: {
|
||||
file_key: "",
|
||||
min_silence_duration_ms: DEFAULT_MIN_SILENCE_MS,
|
||||
silence_threshold_db: DEFAULT_THRESHOLD_DB,
|
||||
padding_ms: DEFAULT_PADDING_MS,
|
||||
},
|
||||
})
|
||||
|
||||
const minSilence = watch("min_silence_duration_ms")
|
||||
const threshold = watch("silence_threshold_db")
|
||||
const padding = watch("padding_ms")
|
||||
|
||||
const { data: files } = api.useQuery("get", "/api/files/files/", {
|
||||
queryKey: ["files", projectId],
|
||||
})
|
||||
|
||||
const projectFiles = (files ?? []).filter(
|
||||
(f) => f.project_id === projectId && !f.is_deleted,
|
||||
)
|
||||
|
||||
const { mutate, isPending } = useSubmitSilenceDetect({
|
||||
onSuccess: () => {
|
||||
onOpenChange?.(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Silence detect submit failed:", error)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) reset()
|
||||
}, [open, reset])
|
||||
|
||||
const onSubmit = (data: ISilenceSettingsFormData): void => {
|
||||
// Body shape matches SilenceDetectRequest — types available after gen:api-types
|
||||
;(mutate as (args: { body: Record<string, unknown> }) => void)({
|
||||
body: {
|
||||
file_key: data.file_key,
|
||||
project_id: projectId,
|
||||
min_silence_duration_ms: data.min_silence_duration_ms,
|
||||
silence_threshold_db: data.silence_threshold_db,
|
||||
padding_ms: data.padding_ms,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Удалить тишину"
|
||||
description="Выберите файл и настройте параметры обнаружения тишины"
|
||||
>
|
||||
<div className={styles.root} data-testid="SilenceSettingsModal">
|
||||
<Form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className={styles.fields}>
|
||||
<div className={styles.selectField}>
|
||||
<div className={styles.selectLabel}>Файл</div>
|
||||
<Controller
|
||||
name="file_key"
|
||||
control={control}
|
||||
rules={{ required: "Выберите файл" }}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Выберите файл"
|
||||
>
|
||||
{projectFiles.map((f) => (
|
||||
<SelectItem key={f.id} value={f.path}>
|
||||
{f.original_filename}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.rangeField}>
|
||||
<div className={styles.rangeLabel}>
|
||||
<span>Мин. длительность тишины</span>
|
||||
<span className={styles.rangeValue}>{minSilence} мс</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.rangeInput}
|
||||
min={100}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={minSilence}
|
||||
onChange={(e) =>
|
||||
setValue(
|
||||
"min_silence_duration_ms",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.rangeField}>
|
||||
<div className={styles.rangeLabel}>
|
||||
<span>Порог тишины</span>
|
||||
<span className={styles.rangeValue}>{threshold} дБ</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.rangeInput}
|
||||
min={6}
|
||||
max={40}
|
||||
step={2}
|
||||
value={threshold}
|
||||
onChange={(e) =>
|
||||
setValue("silence_threshold_db", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.rangeField}>
|
||||
<div className={styles.rangeLabel}>
|
||||
<span>Отступ</span>
|
||||
<span className={styles.rangeValue}>{padding} мс</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.rangeInput}
|
||||
min={0}
|
||||
max={500}
|
||||
step={25}
|
||||
value={padding}
|
||||
onChange={(e) =>
|
||||
setValue("padding_ms", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={isPending}>
|
||||
Запустить
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SilenceSettingsModal } from "./SilenceSettingsModal"
|
||||
@@ -0,0 +1,25 @@
|
||||
import api from "@shared/api"
|
||||
|
||||
interface IUseSubmitSilenceDetectParams {
|
||||
onSuccess?: (data: unknown) => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const useSubmitSilenceDetect = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IUseSubmitSilenceDetectParams = {}) => {
|
||||
// NOTE: Endpoint types will be available after running `bun run gen:api-types`
|
||||
return api.useMutation(
|
||||
"post",
|
||||
"/api/tasks/silence-detect/" as "/api/tasks/silence-remove/",
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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