Compare commits
10 Commits
42ce5fa0fe
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 20928e9a60 | |||
| 46f34bdcac | |||
| d648678c68 | |||
| face8bb811 | |||
| bca8224f16 | |||
| 0523ef3d72 | |||
| 10a1d28f77 | |||
| cf8ded79d7 | |||
| 305e72725c | |||
| 71b974903a |
@@ -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
|
||||
@@ -12,9 +12,13 @@ package.lock
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-test/
|
||||
/out/
|
||||
|
||||
# production
|
||||
@@ -30,6 +34,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
|
||||
@@ -1,247 +1,15 @@
|
||||
# AGENTS.md — Coffee Project Frontend
|
||||
|
||||
## Project Overview
|
||||
Primary workflow guidance lives in `../AGENTS.md`.
|
||||
|
||||
Next.js 16 application using **Feature-Sliced Design (FSD)** architecture, powered by **Bun** runtime and package manager.
|
||||
Use `./CLAUDE.md` as the service-specific source of truth for:
|
||||
|
||||
---
|
||||
- frontend commands
|
||||
- FSD architecture and boundaries
|
||||
- frontend conventions and gotchas
|
||||
|
||||
## Tech Stack
|
||||
OpenCode/Codex notes:
|
||||
|
||||
| Category | Technology |
|
||||
| ------------- | ------------------------------------------ |
|
||||
| Runtime | Bun 1.3.5 |
|
||||
| Framework | Next.js 16.1.1 (App Router) |
|
||||
| Language | TypeScript 5.9 |
|
||||
| UI Library | React 19 |
|
||||
| Styling | SCSS Modules, normalize.css |
|
||||
| State/Fetch | TanStack React Query 5, Axios, Xior |
|
||||
| Animation | Framer Motion |
|
||||
| Utilities | Lodash, Moment.js, classnames, usehooks-ts |
|
||||
| Icons | Lucide React, SVGR (custom icons) |
|
||||
| Notifications | React Toastify |
|
||||
| File Upload | React Dropzone |
|
||||
| Linting | ESLint 9, Prettier, Stylelint |
|
||||
| Testing | Jest, Testing Library |
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun dev # Start dev server
|
||||
bun run build # Production build
|
||||
bun run start # Start production server
|
||||
bun run lint # Run ESLint + Prettier
|
||||
bun run gc <layer> <ComponentName> # Generate FSD component
|
||||
bun run gicons # Convert SVGs to React components
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure (FSD)
|
||||
|
||||
```
|
||||
app/ # Next.js App Router entry
|
||||
pages/ # Keep empty (Next.js requires it)
|
||||
public/ # Static assets
|
||||
src/
|
||||
├── app/ # App layer: global styles, providers
|
||||
├── pages/ # Page compositions
|
||||
├── widgets/ # Large UI blocks (header, sidebar)
|
||||
├── features/ # User interactions (auth, search)
|
||||
├── entities/ # Business entities (user, product)
|
||||
└── shared/ # Reusable: UI kit, utils, API, assets
|
||||
├── ui/ # Button, Input, Icons...
|
||||
├── api/ # API clients
|
||||
├── lib/ # Utilities
|
||||
└── assets/ # Images, raw-icons
|
||||
```
|
||||
|
||||
### Path Aliases
|
||||
|
||||
```ts
|
||||
@app/* → ./src/app/*
|
||||
@pages/* → ./src/pages/*
|
||||
@widgets/* → ./src/widgets/*
|
||||
@features/* → ./src/features/*
|
||||
@entities/* → ./src/entities/*
|
||||
@shared/* → ./src/shared/*
|
||||
@/* → ./src/*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Structure
|
||||
|
||||
Each component follows **flat FSD structure** (simplified for MVP):
|
||||
|
||||
```
|
||||
ComponentName/
|
||||
├── index.ts # Public API (re-export)
|
||||
├── ComponentName.tsx # Component + imports
|
||||
├── ComponentName.module.scss # Styles
|
||||
├── ComponentName.d.ts # Props interface
|
||||
└── useComponentApi.ts # Optional: hooks/API if needed
|
||||
```
|
||||
|
||||
> **Note:** Old nested structure (`ui/`, `model/`, `api/` folders) has been deprecated.
|
||||
> The backup of the old generator is at `.scripts/create-fsd-component.ts.bak`
|
||||
|
||||
### Component Template
|
||||
|
||||
```tsx
|
||||
import type { IComponentNameProps } from "./ComponentName.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import styles from "./ComponentName.module.scss"
|
||||
|
||||
export const ComponentName: FunctionComponent<
|
||||
IComponentNameProps
|
||||
> = (): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.root} data-testid="ComponentName">
|
||||
ComponentName
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Props Interface Template (ComponentName.d.ts)
|
||||
|
||||
```typescript
|
||||
export interface IComponentNameProps {
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
### Generate Component
|
||||
|
||||
Use one of these commands to generate new project-wide standardized component, don't create new component file by file by yourself
|
||||
|
||||
```bash
|
||||
bun run gc <layer> <ComponentName>
|
||||
# Examples:
|
||||
bun run gc shared Button
|
||||
bun run gc feature AuthForm
|
||||
bun run gc entity UserCard
|
||||
bun run gc page HomePage
|
||||
bun run gc widget Sidebar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Code Style
|
||||
|
||||
1. **Small Functions** — max 20-30 lines; extract helpers
|
||||
2. **Single Responsibility** — one function = one purpose
|
||||
3. **Descriptive Names** — `getUserById` not `getData`
|
||||
4. **Early Returns** — reduce nesting with guard clauses
|
||||
5. **No Magic Values** — use constants: `const MAX_ITEMS = 10`
|
||||
|
||||
### TypeScript
|
||||
|
||||
```ts
|
||||
// ✅ Use interfaces for props
|
||||
interface IButtonProps {
|
||||
variant: "primary" | "secondary"
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
// ✅ Prefer explicit types over `any`
|
||||
// ✅ Use `type` for unions/intersections, `interface` for objects
|
||||
```
|
||||
|
||||
### React Patterns
|
||||
|
||||
```tsx
|
||||
// ✅ Functional components with explicit return type
|
||||
export const Button: FC<IButtonProps> = ({ variant, onClick }): JSX.Element => { ... }
|
||||
|
||||
// ✅ Destructure props
|
||||
// ✅ Use data-testid for testing
|
||||
// ✅ Colocate styles with components (CSS Modules)
|
||||
```
|
||||
|
||||
### FSD Rules
|
||||
|
||||
1. **Import Direction** — only downward: `features → entities → shared`
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
### File Naming
|
||||
|
||||
| Type | Convention | Example |
|
||||
| --------- | ----------------- | ---------------------- |
|
||||
| Component | PascalCase | `UserCard.tsx` |
|
||||
| Module | PascalCase.module | `UserCard.module.scss` |
|
||||
| Types | PascalCase.d | `UserCard.d.ts` |
|
||||
| Hook | camelCase (use-) | `useAuth.ts` |
|
||||
| Utility | camelCase | `formatDate.ts` |
|
||||
| Constant | UPPER_SNAKE_CASE | `API_ENDPOINTS.ts` |
|
||||
|
||||
### Performance
|
||||
|
||||
1. Use `React.memo` for expensive renders
|
||||
2. Use `useMemo` / `useCallback` for derived data and callbacks
|
||||
3. Lazy load pages/heavy components with `next/dynamic`
|
||||
4. Prefer server components where possible (Next.js App Router)
|
||||
|
||||
### Testing
|
||||
|
||||
- Place tests next to components: `ComponentName.test.tsx`
|
||||
- Use `data-testid` attributes for queries
|
||||
- Test behavior, not implementation
|
||||
|
||||
---
|
||||
|
||||
## Icons Workflow
|
||||
|
||||
1. Place raw SVG in `src/shared/assets/raw-icons/`
|
||||
2. Run `bun run gicons`
|
||||
3. Import from `@shared/ui/Icons/IconName`
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Keep `pages/` folder** in root — removing causes Next.js build errors
|
||||
- **Bun only** — use `bun` commands, not npm/yarn
|
||||
- **SCSS Modules** — all styles are scoped via `.module.scss`
|
||||
- **Strict TypeScript** — `strict: true` in tsconfig
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command / Location |
|
||||
| ------------------ | ------------------------------- |
|
||||
| Add dependency | `bun add <package>` |
|
||||
| Add dev dependency | `bun add -d <package>` |
|
||||
| Create component | `bun run gc <layer> <Name>` |
|
||||
| Global styles | `src/app/styles/global.scss` |
|
||||
| API client | Use Axios/Xior with React Query |
|
||||
| State management | TanStack Query (server state) |
|
||||
|
||||
## Implementation sentiments
|
||||
|
||||
Write less complicated code, simple but readable code
|
||||
Less overhead - better
|
||||
Write all components with html semantics in mind
|
||||
To import classNames lib use
|
||||
`import cs from 'classnames'`
|
||||
Always install packages using
|
||||
`bun install <package>`
|
||||
- Keep `../AGENTS.md` as the workflow and delegation source of truth.
|
||||
- Treat `CLAUDE.md` as architecture, commands, and conventions only.
|
||||
- Do not rely on `.claude/` directory contents.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# 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.
|
||||
|
||||
Always use Context7 MCP when I need library/API documentation, code generation, setup or configuration steps without me having to explicitly ask.
|
||||
|
||||
## Testing Standards
|
||||
|
||||
- All E2E tests use Playwright with TypeScript
|
||||
- Test files live in tests/e2e/
|
||||
- Use `getByRole` as primary locator strategy
|
||||
- Every PR must include error-state tests, not just happy paths
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StaticLoader } from "@shared/ui/Loader"
|
||||
|
||||
export default function ProtectedLoading() {
|
||||
return <StaticLoader block />
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Skeleton } from "@shared/ui/Skeleton"
|
||||
|
||||
export default function ProfileLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
padding: "32px 16px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "24px",
|
||||
width: "100%",
|
||||
maxWidth: "640px",
|
||||
}}
|
||||
>
|
||||
<Skeleton width="120px" height="120px" borderRadius="50%" />
|
||||
<Skeleton width="100%" height="200px" borderRadius="10px" />
|
||||
<Skeleton width="100%" height="160px" borderRadius="10px" />
|
||||
<Skeleton width="100%" height="140px" borderRadius="10px" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { JSX } from "react"
|
||||
|
||||
import { ProfilePage } from "@pages/ProfilePage"
|
||||
|
||||
export default function Profile(): JSX.Element {
|
||||
return (
|
||||
<main>
|
||||
<ProfilePage />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { JSX } from "react"
|
||||
|
||||
import { ProjectWorkspacePage } from "@pages/ProjectWorkspacePage"
|
||||
import { ProjectWizardPage } from "@pages/ProjectWizardPage"
|
||||
|
||||
export default function Projects(): JSX.Element {
|
||||
return (
|
||||
<main>
|
||||
<ProjectWorkspacePage />
|
||||
<ProjectWizardPage />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ProjectCardSkeleton } from "@shared/ui/Skeleton"
|
||||
|
||||
export default function ProjectsLoading() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "28px 24px 40px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
||||
gap: "20px",
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<ProjectCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+15
-5
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Open_Sans } from "next/font/google"
|
||||
import { Manrope } from "next/font/google"
|
||||
|
||||
import "@shared/styles/global.scss"
|
||||
|
||||
@@ -12,10 +12,11 @@ export const metadata: Metadata = {
|
||||
description: "Standalone Next.js app using FSD structure",
|
||||
}
|
||||
|
||||
const open_sans = Open_Sans({
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin", "cyrillic"],
|
||||
preload: true,
|
||||
display: "swap",
|
||||
variable: "--font-open-sans",
|
||||
variable: "--font-manrope",
|
||||
})
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,9 +25,18 @@ export default function RootLayout({
|
||||
children: ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ru" className={open_sans.variable}>
|
||||
<html lang="ru" className={manrope.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>
|
||||
<div id="app-root">
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
+22
-1
@@ -1,14 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
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 />
|
||||
{children}
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { UnderMaintenancePage } from "@pages/UnderMaintenancePage"
|
||||
|
||||
export default function UnderMaintenance() {
|
||||
return (
|
||||
<main>
|
||||
<Suspense>
|
||||
<UnderMaintenancePage />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -16,37 +16,46 @@
|
||||
"@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",
|
||||
"openapi-react-query": "^0.5.1",
|
||||
"react": "^19.2.3",
|
||||
"react-aria-components": "^1.14.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.71.0",
|
||||
"react-modal": "^3.16.3",
|
||||
"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": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
@@ -231,6 +240,18 @@
|
||||
|
||||
"@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="],
|
||||
|
||||
"@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="],
|
||||
|
||||
"@jest/expect-utils": ["@jest/expect-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA=="],
|
||||
|
||||
"@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="],
|
||||
|
||||
"@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="],
|
||||
|
||||
"@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="],
|
||||
|
||||
"@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -303,6 +324,8 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
||||
|
||||
"@radix-ui/colors": ["@radix-ui/colors@3.0.0", "", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
@@ -653,6 +676,8 @@
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
@@ -713,6 +738,14 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
||||
|
||||
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
|
||||
|
||||
"@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="],
|
||||
|
||||
"@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="],
|
||||
|
||||
"@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
@@ -725,8 +758,16 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/react-modal": ["@types/react-modal@3.16.3", "", { "dependencies": { "@types/react": "*" } }, "sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg=="],
|
||||
|
||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
|
||||
|
||||
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/type-utils": "8.50.1", "@typescript-eslint/utils": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.50.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", "@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg=="],
|
||||
@@ -785,6 +826,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=="],
|
||||
@@ -879,6 +924,8 @@
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
@@ -935,6 +982,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=="],
|
||||
@@ -1039,6 +1088,10 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"exenv": ["exenv@1.2.2", "", {}, "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="],
|
||||
|
||||
"expect": ["expect@30.2.0", "", { "dependencies": { "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.2.0", "jest-message-util": "30.2.0", "jest-mock": "30.2.0", "jest-util": "30.2.0" } }, "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
@@ -1077,6 +1130,8 @@
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
||||
@@ -1117,6 +1172,8 @@
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
@@ -1231,6 +1288,18 @@
|
||||
|
||||
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
|
||||
|
||||
"jest-diff": ["jest-diff@30.2.0", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.2.0" } }, "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A=="],
|
||||
|
||||
"jest-matcher-utils": ["jest-matcher-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.2.0", "pretty-format": "30.2.0" } }, "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg=="],
|
||||
|
||||
"jest-message-util": ["jest-message-util@30.2.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw=="],
|
||||
|
||||
"jest-mock": ["jest-mock@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "jest-util": "30.2.0" } }, "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw=="],
|
||||
|
||||
"jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="],
|
||||
|
||||
"jest-util": ["jest-util@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA=="],
|
||||
|
||||
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
|
||||
|
||||
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
|
||||
@@ -1293,6 +1362,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 +1378,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=="],
|
||||
@@ -1387,6 +1456,10 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
|
||||
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
@@ -1411,6 +1484,8 @@
|
||||
|
||||
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
|
||||
|
||||
"pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
@@ -1429,13 +1504,19 @@
|
||||
|
||||
"react-aria-components": ["react-aria-components@1.14.0", "", { "dependencies": { "@internationalized/date": "^3.10.1", "@internationalized/string": "^3.2.7", "@react-aria/autocomplete": "3.0.0-rc.4", "@react-aria/collections": "^3.0.1", "@react-aria/dnd": "^3.11.4", "@react-aria/focus": "^3.21.3", "@react-aria/interactions": "^3.26.0", "@react-aria/live-announcer": "^3.4.4", "@react-aria/overlays": "^3.31.0", "@react-aria/ssr": "^3.9.10", "@react-aria/textfield": "^3.18.3", "@react-aria/toolbar": "3.0.0-beta.22", "@react-aria/utils": "^3.32.0", "@react-aria/virtualizer": "^4.1.11", "@react-stately/autocomplete": "3.0.0-beta.4", "@react-stately/layout": "^4.5.2", "@react-stately/selection": "^3.20.7", "@react-stately/table": "^3.15.2", "@react-stately/utils": "^3.11.0", "@react-stately/virtualizer": "^4.4.4", "@react-types/form": "^3.7.16", "@react-types/grid": "^3.3.6", "@react-types/shared": "^3.32.1", "@react-types/table": "^3.13.4", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "^3.45.0", "react-stately": "^3.43.0", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-u21N/yS6Ozk9P9oO8wxMNZSFiPk6F3aAE9w6aN7pseGPApkjXqDyPNCnTsTTvMtVL3QRBkVbf7fJ5yi2hksVEg=="],
|
||||
|
||||
"react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||
|
||||
"react-dropzone": ["react-dropzone@14.3.8", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.71.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oFDt/iIFMV9ZfV52waONXzg4xuSlbwKUPvXVH2jumL1me5qFhBMc4knZxuXiZ2+j6h546sYe3ZKJcg/900/iHw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-lifecycles-compat": ["react-lifecycles-compat@3.0.4", "", {}, "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="],
|
||||
|
||||
"react-modal": ["react-modal@3.16.3", "", { "dependencies": { "exenv": "^1.2.0", "prop-types": "^15.7.2", "react-lifecycles-compat": "^3.0.0", "warning": "^4.0.3" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19", "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" } }, "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw=="],
|
||||
|
||||
"react-modern-drawer": ["react-modern-drawer@1.4.0", "", { "peerDependencies": { "react": ">16.0.0" } }, "sha512-5OkcUstqUdd/CNW9+BvLkzm36R2G54RFXWF2mWCH13cUsz5SNo9aB9KzPRbJp2LEVfRL/u+MgikOWRe7/6wKEQ=="],
|
||||
|
||||
@@ -1445,6 +1526,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=="],
|
||||
@@ -1529,6 +1612,8 @@
|
||||
|
||||
"stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
|
||||
|
||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -1641,6 +1726,10 @@
|
||||
|
||||
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
|
||||
|
||||
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.12.1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1755,10 +1844,16 @@
|
||||
|
||||
"openapi-typescript/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
|
||||
|
||||
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
||||
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"stylelint/file-entry-cache": ["file-entry-cache@11.1.1", "", { "dependencies": { "flat-cache": "^6.1.19" } }, "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A=="],
|
||||
|
||||
+14
-2
@@ -4,9 +4,21 @@ import { fileURLToPath } from "url"
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const stylesPath = path.join(dirname, "src/shared/styles")
|
||||
console.log("dirname", dirname)
|
||||
|
||||
const nextConfig = {
|
||||
distDir: process.env.NEXT_TEST_DIR ?? ".next",
|
||||
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")}";
|
||||
|
||||
+16
-2
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "fsd-nest-template",
|
||||
"version": "0.1.0",
|
||||
"imports": {
|
||||
"#tests/*": "./tests/*"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.5",
|
||||
"scripts": {
|
||||
@@ -11,7 +14,9 @@
|
||||
"create-component": "npx generate-react-cli component",
|
||||
"gc": "bun run .scripts/create-fsd-component.ts",
|
||||
"gicons": "npx @svgr/cli --ext tsx --typescript --no-prettier --icon --ref --no-svgo ./src/shared/assets/raw-icons/ --out-dir ./src/shared/ui/Icons/",
|
||||
"gen:api-types": "openapi-typescript http://127.0.0.1:8000/api/schema/ --output src/shared/api/__generated__/openapi.types.ts"
|
||||
"gen:api-types": "openapi-typescript http://127.0.0.1:8000/api/schema/ --output src/shared/api/__generated__/openapi.types.ts",
|
||||
"test:e2e": "bunx playwright test --project=chromium --headed",
|
||||
"test:integration": "bunx playwright test --project=integration --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -25,37 +30,46 @@
|
||||
"@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",
|
||||
"openapi-react-query": "^0.5.1",
|
||||
"react": "^19.2.3",
|
||||
"react-aria-components": "^1.14.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.71.0",
|
||||
"react-modal": "^3.16.3",
|
||||
"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": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
import {
|
||||
FRONTEND_INTEGRATION_PORT,
|
||||
FRONTEND_INTEGRATION_URL,
|
||||
FRONTEND_MOCK_PORT,
|
||||
FRONTEND_MOCK_URL,
|
||||
MOCK_API_URL,
|
||||
} from "./tests/e2e/support/config"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/specs",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: "html",
|
||||
use: {
|
||||
actionTimeout: 10_000,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: /\.integration\./,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: FRONTEND_MOCK_URL,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "integration",
|
||||
testMatch: /\.integration\./,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: FRONTEND_INTEGRATION_URL,
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
command: "bun --bun tests/e2e/support/mock-api.ts",
|
||||
url: `${MOCK_API_URL}/api/ping/`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
{
|
||||
command: `NEXT_PUBLIC_API_URL=${MOCK_API_URL} NEXT_TEST_DIR=.next-test bun dev --port ${FRONTEND_MOCK_PORT}`,
|
||||
url: FRONTEND_MOCK_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
{
|
||||
command: `bun dev --port ${FRONTEND_INTEGRATION_PORT}`,
|
||||
url: FRONTEND_INTEGRATION_URL,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,56 +3,92 @@
|
||||
@include mixins.flex-column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-md ;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
transform variables.$duration-normal variables.$ease-out,
|
||||
box-shadow variables.$duration-normal variables.$ease-out,
|
||||
border-color variables.$duration-normal variables.$ease-out;
|
||||
cursor: pointer;
|
||||
background: variables.$bg-default;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-left: -24px;
|
||||
margin-top: -24px;
|
||||
width: calc(100% + 48px);
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform variables.$duration-slow variables.$ease-out;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@include mixins.flex-center;
|
||||
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%);
|
||||
background: linear-gradient(135deg, variables.$bg-surface 0%, variables.$bg-default 100%);
|
||||
transition: scale variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&[data-color-index="0"] {
|
||||
background: linear-gradient(135deg, variables.$purple-100 0%, variables.$purple-300 100%);
|
||||
svg { color: variables.$purple-600; opacity: 0.6; }
|
||||
}
|
||||
&[data-color-index="1"] {
|
||||
background: linear-gradient(135deg, variables.$green-100 0%, variables.$green-300 100%);
|
||||
svg { color: variables.$green-700; opacity: 0.6; }
|
||||
}
|
||||
&[data-color-index="2"] {
|
||||
background: radial-gradient(circle at top left, variables.$purple-200 0%, variables.$purple-50 100%);
|
||||
svg { color: variables.$purple-500; opacity: 0.5; }
|
||||
}
|
||||
&[data-color-index="3"] {
|
||||
background: radial-gradient(circle at bottom right, variables.$green-200 0%, variables.$green-50 100%);
|
||||
svg { color: variables.$green-600; opacity: 0.5; }
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: variables.$purple-300;
|
||||
opacity: 0.5;
|
||||
color: variables.$text-tertiary;
|
||||
opacity: 0.35;
|
||||
transition: transform variables.$duration-normal variables.$ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: variables.$purple-200;
|
||||
|
||||
.placeholder {
|
||||
scale: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.root:active {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -67,8 +103,7 @@
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(2px);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -103,7 +138,7 @@
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: variables.$color-white;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
z-index: 2;
|
||||
@@ -119,7 +154,7 @@
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.18) 100%);
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.06) 0%, rgba(0, 0, 0, 0.16) 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -135,7 +170,7 @@
|
||||
|
||||
.info {
|
||||
@include mixins.flex-column;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -155,7 +190,7 @@
|
||||
|
||||
.date {
|
||||
@include typography.font-caption-m;
|
||||
color: variables.$text-secondary;
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
|
||||
.status {
|
||||
@@ -164,15 +199,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@include typography.font-body-s;
|
||||
font-weight: 500;
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 600;
|
||||
|
||||
&.statusGenerated {
|
||||
color: variables.$color-success;
|
||||
}
|
||||
|
||||
&.statusProcessing, &.statusRendering, &.statusUploading {
|
||||
color: variables.$purple-500;
|
||||
color: variables.$purple-400;
|
||||
}
|
||||
|
||||
&.statusDraft {
|
||||
@@ -185,28 +220,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 variables.$duration-normal variables.$ease-out, color variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&: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 +257,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: color-mix(in srgb, variables.$bg-default 88%, transparent);
|
||||
border: 1px solid color-mix(in srgb, variables.$border-default 72%, transparent);
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 600;
|
||||
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
|
||||
@@ -63,7 +65,10 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={name} loading="lazy" />
|
||||
) : (
|
||||
<div className={styles.placeholder}>
|
||||
<div
|
||||
className={styles.placeholder}
|
||||
data-color-index={name.charCodeAt(0) % 4}
|
||||
>
|
||||
<ImageIcon />
|
||||
</div>
|
||||
)}
|
||||
@@ -107,24 +112,20 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||
>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<button type="button" aria-label="Project actions">
|
||||
<button type="button" aria-label="Действия проекта">
|
||||
<MoreHorizontal size={16} />
|
||||
</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 +134,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,9 @@
|
||||
import type { ComponentType } from "react"
|
||||
|
||||
export interface IActionCardProps {
|
||||
icon: ComponentType<{ size?: number; strokeWidth?: number }>
|
||||
label: string
|
||||
onClick: () => void
|
||||
accent?: boolean
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.card {
|
||||
@include mixins.reset-button;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
|
||||
background: linear-gradient(180deg, variables.$bg-default 0%, variables.$bg-surface 100%);
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-lg;
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
color: variables.$text-secondary;
|
||||
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform variables.$duration-normal variables.$ease-out,
|
||||
box-shadow variables.$duration-normal variables.$ease-out,
|
||||
border-color variables.$duration-normal variables.$ease-out,
|
||||
background variables.$duration-normal variables.$ease-out,
|
||||
color variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: variables.$text-primary;
|
||||
border-color: variables.$purple-300;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&.accent {
|
||||
background: linear-gradient(135deg, variables.$accent-solid-start 0%, variables.$accent-solid-end 100%);
|
||||
border-color: transparent;
|
||||
color: variables.$accent-foreground;
|
||||
box-shadow: 0 4px 14px variables.$accent-shadow;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.06);
|
||||
box-shadow: 0 6px 20px variables.$accent-shadow-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
@include typography.font-body-s;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { IActionCardProps } from "./ActionCard.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import styles from "./ActionCard.module.scss"
|
||||
|
||||
export const ActionCard: FunctionComponent<IActionCardProps> = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
accent = false,
|
||||
className,
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cs(styles.card, accent && styles.accent, className)}
|
||||
onClick={onClick}
|
||||
data-testid="ActionCard"
|
||||
>
|
||||
<Icon size={32} strokeWidth={1.5} />
|
||||
<span className={styles.label}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ActionCard"
|
||||
@@ -0,0 +1 @@
|
||||
export { ActionCard } from "./ActionCard"
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface INotificationBellProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
// Rounded hover for ghost icon button
|
||||
:global(.rt-IconButton) {
|
||||
border-radius: variables.$radius-sm;
|
||||
transition: background-color variables.$duration-normal variables.$ease-out,
|
||||
color variables.$duration-normal variables.$ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%);
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--color-danger);
|
||||
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;
|
||||
animation: badgePulse 2s var(--ease-out) infinite;
|
||||
}
|
||||
|
||||
@keyframes badgePulse {
|
||||
0%, 100% { transform: translate(50%, -50%) scale(1); }
|
||||
50% { transform: translate(50%, -50%) scale(1.08); }
|
||||
}
|
||||
@@ -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,165 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 380px;
|
||||
max-height: 480px;
|
||||
background-color: variables.$bg-default;
|
||||
border: 1px solid variables.$border-default;
|
||||
border-radius: variables.$radius-md;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: popupEntrance 0.2s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid variables.$border-default;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include typography.font-body-14(700);
|
||||
letter-spacing: -0.006em;
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
|
||||
.readAllBtn {
|
||||
@include typography.font-caption-m;
|
||||
font-weight: 600;
|
||||
color: variables.$purple-500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: variables.$radius-sm;
|
||||
transition: background-color variables.$duration-normal variables.$ease-out,
|
||||
color variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&:hover {
|
||||
color: variables.$purple-700;
|
||||
background-color: variables.$bg-surface;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color variables.$duration-normal variables.$ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: variables.$bg-surface;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid variables.$border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
.itemUnread {
|
||||
border-left: 3px solid variables.$purple-500;
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.itemHeadline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
@include typography.font-body-14(600);
|
||||
color: variables.$text-primary;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.itemTitleText {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.itemStatusBadge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.itemTime {
|
||||
@include typography.font-caption-m;
|
||||
color: variables.$text-tertiary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.itemMessage {
|
||||
@include typography.font-caption-m;
|
||||
color: variables.$text-secondary;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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.4s var(--ease-out);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
@include typography.font-body-14(400);
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
|
||||
@keyframes popupEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"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 { formatNotificationRelativeTime } from "@shared/lib/dates"
|
||||
import { API_URL } from "@shared/lib/constants"
|
||||
import {
|
||||
markAllRead,
|
||||
markRead,
|
||||
NotificationItem,
|
||||
} from "@shared/store/notifications"
|
||||
import { Badge } from "@shared/ui"
|
||||
|
||||
import { getNotificationPresentation } from "./presentation"
|
||||
|
||||
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"
|
||||
|
||||
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) => {
|
||||
const presentation = getNotificationPresentation(item)
|
||||
|
||||
return (
|
||||
<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.itemHeader}>
|
||||
<div className={styles.itemHeadline}>
|
||||
<div className={styles.itemTitle}>
|
||||
<span className={styles.itemTitleText}>
|
||||
{presentation.title}
|
||||
</span>
|
||||
</div>
|
||||
{presentation.statusText &&
|
||||
presentation.statusVariant && (
|
||||
<Badge
|
||||
variant={presentation.statusVariant}
|
||||
className={styles.itemStatusBadge}
|
||||
>
|
||||
{presentation.statusText}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className={styles.itemTime}>
|
||||
{formatNotificationRelativeTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{presentation.detailText && (
|
||||
<div className={styles.itemMessage}>
|
||||
{presentation.detailText}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "RUNNING" &&
|
||||
item.progress_pct != null && (
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{
|
||||
width: `${item.progress_pct}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NotificationPopup } from "./NotificationPopup"
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { NotificationItem } from "@shared/store/notifications"
|
||||
|
||||
import { getNotificationPresentation } from "./presentation"
|
||||
|
||||
function buildNotification(
|
||||
overrides: Partial<NotificationItem> = {},
|
||||
): NotificationItem {
|
||||
return {
|
||||
event: "task_update",
|
||||
notification_id: "notification-1",
|
||||
job_id: "job-1",
|
||||
project_id: "project-1",
|
||||
job_type: "TRANSCRIPTION_GENERATE",
|
||||
status: "DONE",
|
||||
progress_pct: 100,
|
||||
message: "Завершено",
|
||||
title: "Задача завершена",
|
||||
created_at: "2026-04-05T12:00:00.000Z",
|
||||
is_read: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("getNotificationPresentation", () => {
|
||||
test("uses the task type as the notification title and moves status into secondary text", () => {
|
||||
expect(getNotificationPresentation(buildNotification())).toEqual({
|
||||
title: "Транскрипция",
|
||||
statusText: "Завершено",
|
||||
statusVariant: "success",
|
||||
detailText: null,
|
||||
})
|
||||
})
|
||||
|
||||
test("maps silence apply notifications to the operation title instead of backend status title", () => {
|
||||
expect(
|
||||
getNotificationPresentation(
|
||||
buildNotification({
|
||||
job_type: "SILENCE_APPLY",
|
||||
title: "Задача завершена",
|
||||
message: "Завершено",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: "Применение вырезок",
|
||||
statusText: "Завершено",
|
||||
statusVariant: "success",
|
||||
detailText: null,
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps detailed progress text when it carries more information than the status", () => {
|
||||
expect(
|
||||
getNotificationPresentation(
|
||||
buildNotification({
|
||||
status: "RUNNING",
|
||||
message: "Транскрибирование (whisper)",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: "Транскрипция",
|
||||
statusText: "Выполняется",
|
||||
statusVariant: "info",
|
||||
detailText: "Транскрибирование (whisper)",
|
||||
})
|
||||
})
|
||||
|
||||
test("maps failed notifications to a danger badge", () => {
|
||||
expect(
|
||||
getNotificationPresentation(
|
||||
buildNotification({
|
||||
status: "FAILED",
|
||||
message: "Ошибка",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: "Транскрипция",
|
||||
statusText: "Ошибка",
|
||||
statusVariant: "danger",
|
||||
detailText: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { NotificationItem } from "@shared/store/notifications"
|
||||
import type { BadgeVariant } from "@shared/ui/Badge/Badge.d"
|
||||
|
||||
const JOB_TYPE_LABELS: Record<string, string> = {
|
||||
MEDIA_PROBE: "Анализ медиа",
|
||||
SILENCE_REMOVE: "Удаление тишины",
|
||||
SILENCE_DETECT: "Анализ тишины",
|
||||
SILENCE_APPLY: "Применение вырезок",
|
||||
MEDIA_CONVERT: "Конвертация",
|
||||
TRANSCRIPTION_GENERATE: "Транскрипция",
|
||||
CAPTIONS_GENERATE: "Генерация субтитров",
|
||||
FRAME_EXTRACT: "Извлечение кадров",
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: "Ожидание",
|
||||
RUNNING: "Выполняется",
|
||||
DONE: "Завершено",
|
||||
FAILED: "Ошибка",
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<string, BadgeVariant> = {
|
||||
PENDING: "secondary",
|
||||
RUNNING: "info",
|
||||
DONE: "success",
|
||||
FAILED: "danger",
|
||||
}
|
||||
|
||||
export interface NotificationPresentation {
|
||||
title: string
|
||||
statusText: string | null
|
||||
statusVariant: BadgeVariant | null
|
||||
detailText: string | null
|
||||
}
|
||||
|
||||
function normalizeText(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
export function getNotificationPresentation(
|
||||
item: NotificationItem,
|
||||
): NotificationPresentation {
|
||||
const statusText = item.status ? (STATUS_LABELS[item.status] ?? item.status) : null
|
||||
const statusVariant = item.status ? (STATUS_VARIANTS[item.status] ?? null) : null
|
||||
const rawTitle = normalizeText(item.title)
|
||||
const rawMessage = normalizeText(item.message)
|
||||
const title = item.job_type
|
||||
? (JOB_TYPE_LABELS[item.job_type] ?? rawTitle ?? "Уведомление")
|
||||
: (rawTitle ?? "Уведомление")
|
||||
|
||||
return {
|
||||
title,
|
||||
statusText,
|
||||
statusVariant,
|
||||
detailText:
|
||||
rawMessage && rawMessage !== statusText && rawMessage !== rawTitle
|
||||
? rawMessage
|
||||
: null,
|
||||
}
|
||||
}
|
||||
@@ -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,103 @@
|
||||
"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="Эл. почта"
|
||||
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,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,3 @@
|
||||
export interface ICaptionResultStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/* ── Entrance animations ── */
|
||||
|
||||
@keyframes fadeSlideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sparkleFloat {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.6) translateY(0);
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1) translateY(-6px);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.8) translateY(-12px);
|
||||
}
|
||||
|
||||
80% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1) translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Root ── */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Sparkle particles ── */
|
||||
|
||||
.sparkles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: sparkleFloat 3.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sparkleGreen {
|
||||
background: color-mix(in srgb, variables.$color-success 60%, transparent);
|
||||
}
|
||||
|
||||
.sparklePurple {
|
||||
background: color-mix(in srgb, variables.$purple-400 55%, transparent);
|
||||
}
|
||||
|
||||
/* ── Content area (scrollable) ── */
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
padding: 32px 24px 24px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Success header ── */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
animation: fadeSlideDown 0.6s variables.$ease-out both;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include typography.font-header-l;
|
||||
|
||||
color: variables.$text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@include typography.font-body-14(400);
|
||||
|
||||
color: variables.$text-tertiary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Video player ── */
|
||||
|
||||
.playerContainer {
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
animation: fadeSlideUp 0.7s variables.$ease-out 0.15s both;
|
||||
}
|
||||
|
||||
.playerWrapper {
|
||||
position: relative;
|
||||
border-radius: variables.$radius-md;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
box-shadow: variables.$shadow-lg;
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
: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;
|
||||
}
|
||||
}
|
||||
|
||||
.player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
color: variables.$text-tertiary;
|
||||
@include typography.font-body-14(400);
|
||||
}
|
||||
|
||||
/* ── File info bar ── */
|
||||
|
||||
.fileInfoContainer {
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
animation: fadeSlideUp 0.6s variables.$ease-out 0.35s both;
|
||||
}
|
||||
|
||||
.fileInfoBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: variables.$bg-default;
|
||||
border: 1px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-sm;
|
||||
box-shadow: variables.$shadow-sm;
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
color: variables.$text-tertiary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
@include typography.font-body-14(500);
|
||||
|
||||
color: variables.$text-primary;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fileSize {
|
||||
@include typography.font-caption-m;
|
||||
|
||||
color: variables.$text-tertiary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Loading state ── */
|
||||
|
||||
.loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: variables.$text-tertiary;
|
||||
@include typography.font-body-14(400);
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid variables.$border-subtle;
|
||||
background: variables.$bg-surface;
|
||||
animation: fadeIn 0.5s variables.$ease-out 0.5s both;
|
||||
}
|
||||
|
||||
.rightActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Reduced motion ── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.header,
|
||||
.playerContainer,
|
||||
.fileInfoContainer,
|
||||
.footer {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client"
|
||||
|
||||
import type { ICaptionResultStepProps } from "./CaptionResultStep.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { MediaPlayer, MediaProvider } from "@vidstack/react"
|
||||
import {
|
||||
defaultLayoutIcons,
|
||||
DefaultVideoLayout,
|
||||
} from "@vidstack/react/player/layouts/default"
|
||||
|
||||
import "@vidstack/react/player/styles/default/theme.css"
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||
|
||||
import {
|
||||
Check,
|
||||
CheckCircle,
|
||||
Download,
|
||||
FileVideo,
|
||||
RefreshCw,
|
||||
} from "lucide-react"
|
||||
import { FunctionComponent, useMemo } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { Badge, Button } from "@shared/ui"
|
||||
|
||||
import styles from "./CaptionResultStep.module.scss"
|
||||
|
||||
const SPARKLE_COUNT = 10
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Б`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} ГБ`
|
||||
}
|
||||
|
||||
export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
|
||||
className,
|
||||
}): JSX.Element => {
|
||||
const {
|
||||
captionedVideoFileId,
|
||||
markStepCompleted,
|
||||
reopenCaptionConfig,
|
||||
} = useWizard()
|
||||
|
||||
const { data: fileInfo, isLoading } = api.useQuery(
|
||||
"get",
|
||||
"/api/files/files/{file_id}/resolve/",
|
||||
{ params: { path: { file_id: captionedVideoFileId ?? "" } } },
|
||||
{ enabled: !!captionedVideoFileId },
|
||||
)
|
||||
|
||||
const videoUrl = fileInfo?.file_url ?? ""
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!videoUrl) return
|
||||
const link = document.createElement("a")
|
||||
link.href = videoUrl
|
||||
link.download = fileInfo?.filename ?? "captioned-video.mp4"
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleRerender = () => {
|
||||
void reopenCaptionConfig()
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
markStepCompleted("caption-result")
|
||||
}
|
||||
|
||||
const sparkles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: SPARKLE_COUNT }, (_, i) => ({
|
||||
id: i,
|
||||
variant: i % 2 === 0 ? "green" : "purple",
|
||||
left: `${10 + (i * 80) / SPARKLE_COUNT + Math.sin(i * 1.7) * 5}%`,
|
||||
top: `${15 + Math.sin(i * 2.3) * 30 + 20}%`,
|
||||
delay: `${(i * 0.3) % 2.5}s`,
|
||||
size: 4 + (i % 3) * 2,
|
||||
})),
|
||||
[],
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cs(styles.root, className)}>
|
||||
<p className={styles.loading}>Загрузка видео...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cs(styles.root, className)} data-testid="CaptionResultStep">
|
||||
{/* Sparkle particles */}
|
||||
<div className={styles.sparkles} aria-hidden="true">
|
||||
{sparkles.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={cs(
|
||||
styles.sparkle,
|
||||
s.variant === "green"
|
||||
? styles.sparkleGreen
|
||||
: styles.sparklePurple,
|
||||
)}
|
||||
style={{
|
||||
left: s.left,
|
||||
top: s.top,
|
||||
animationDelay: s.delay,
|
||||
width: s.size,
|
||||
height: s.size,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className={styles.content}>
|
||||
{/* Success header */}
|
||||
<div className={styles.header}>
|
||||
<Badge variant="success">
|
||||
<CheckCircle size={14} />
|
||||
Готово
|
||||
</Badge>
|
||||
<h2 className={styles.title}>Результат</h2>
|
||||
<p className={styles.subtitle}>
|
||||
Видео с субтитрами готово к скачиванию
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Video player */}
|
||||
<div className={styles.playerContainer}>
|
||||
<div className={styles.playerWrapper}>
|
||||
{videoUrl ? (
|
||||
<MediaPlayer
|
||||
src={videoUrl}
|
||||
crossOrigin=""
|
||||
playsInline
|
||||
className={styles.player}
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
) : (
|
||||
<div className={styles.placeholder}>Видео недоступно</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File info bar */}
|
||||
{fileInfo?.filename && (
|
||||
<div className={styles.fileInfoContainer}>
|
||||
<div className={styles.fileInfoBar}>
|
||||
<FileVideo size={18} className={styles.fileIcon} />
|
||||
<span className={styles.fileName}>{fileInfo.filename}</span>
|
||||
{typeof fileInfo.file_size === "number" && (
|
||||
<span className={styles.fileSize}>
|
||||
{formatFileSize(fileInfo.file_size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={styles.footer}>
|
||||
<Button variant="ghost" onClick={handleRerender}>
|
||||
<RefreshCw size={16} />
|
||||
Перегенерировать
|
||||
</Button>
|
||||
<div className={styles.rightActions}>
|
||||
<Button variant="primary" onClick={handleDownload}>
|
||||
<Download size={16} />
|
||||
Скачать
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleFinish}>
|
||||
<Check size={16} />
|
||||
Завершить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CaptionResultStep } from "./CaptionResultStep"
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ICaptionSettingsStepProps {
|
||||
className?: string
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-12);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scrollArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--gray-6);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import type { ICaptionSettingsStepProps } from "./CaptionSettingsStep.d"
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import {
|
||||
FunctionComponent,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { Button } from "@shared/ui"
|
||||
|
||||
import { PresetGrid } from "./PresetGrid"
|
||||
import { StyleEditor } from "./StyleEditor"
|
||||
import styles from "./CaptionSettingsStep.module.scss"
|
||||
|
||||
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
|
||||
|
||||
const ERROR_SUBMIT = "Не удалось запустить генерацию субтитров"
|
||||
|
||||
export const CaptionSettingsStep: FunctionComponent<
|
||||
ICaptionSettingsStepProps
|
||||
> = ({ className }): JSX.Element => {
|
||||
const {
|
||||
captionPresetId,
|
||||
selectCaptionPreset,
|
||||
startCaptionRender,
|
||||
goBack,
|
||||
} = useWizard()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"select" | "editor">("select")
|
||||
const [editingPreset, setEditingPreset] = useState<CaptionPresetRead | null>(
|
||||
null,
|
||||
)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const submitLockRef = useRef(false)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (submitLockRef.current || isSubmitting) return
|
||||
if (!captionPresetId) return
|
||||
|
||||
submitLockRef.current = true
|
||||
setSubmitError(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await startCaptionRender()
|
||||
submitLockRef.current = false
|
||||
} catch {
|
||||
submitLockRef.current = false
|
||||
setSubmitError(ERROR_SUBMIT)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (preset: CaptionPresetRead) => {
|
||||
setEditingPreset(preset)
|
||||
setActiveTab("editor")
|
||||
}
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditingPreset(null)
|
||||
setActiveTab("editor")
|
||||
}
|
||||
|
||||
const handleSaved = (presetId: string) => {
|
||||
void selectCaptionPreset(presetId)
|
||||
setActiveTab("select")
|
||||
}
|
||||
|
||||
const handleSelectPreset = (presetId: string | null) => {
|
||||
void selectCaptionPreset(presetId)
|
||||
}
|
||||
|
||||
if (activeTab === "editor") {
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, className)}
|
||||
data-testid="CaptionSettingsStep"
|
||||
>
|
||||
<h2 className={styles.title}>Редактор стиля</h2>
|
||||
<StyleEditor
|
||||
initialConfig={editingPreset?.style_config}
|
||||
presetId={editingPreset?.id}
|
||||
presetName={editingPreset?.name}
|
||||
onSaved={handleSaved}
|
||||
onCancel={() => setActiveTab("select")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, className)}
|
||||
data-testid="CaptionSettingsStep"
|
||||
>
|
||||
<h2 className={styles.title}>Выбор пресета субтитров</h2>
|
||||
|
||||
<div className={styles.scrollArea}>
|
||||
<PresetGrid
|
||||
selectedPresetId={captionPresetId}
|
||||
onSelect={handleSelectPreset}
|
||||
onEdit={handleEdit}
|
||||
onCreateNew={handleCreateNew}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submitError && <p className={styles.error}>{submitError}</p>}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button variant="outline" onClick={goBack}>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={!captionPresetId || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Запуск..." : "Генерировать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
.presetCard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: variables.$bg-default;
|
||||
border: 1.5px solid variables.$border-subtle;
|
||||
border-radius: variables.$radius-md;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: variables.$shadow-sm;
|
||||
transition: border-color var(--duration-normal) var(--ease-out);
|
||||
|
||||
&:hover {
|
||||
border-color: variables.$color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: variables.$color-primary;
|
||||
box-shadow: variables.$shadow-sm, 0 0 0 1px variables.$color-primary;
|
||||
|
||||
&:hover {
|
||||
border-color: variables.$color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.previewArea {
|
||||
position: relative;
|
||||
background: #0c0a1a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.selectedIndicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: variables.$color-primary;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
color: white;
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.presetName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: variables.$text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.systemBadge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: variables.$color-primary;
|
||||
background: variables.$purple-100;
|
||||
padding: 3px 8px;
|
||||
border-radius: variables.$radius-sm;
|
||||
}
|
||||
|
||||
.styleChars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: variables.$text-tertiary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.colorDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: variables.$border-default;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast);
|
||||
z-index: 3;
|
||||
|
||||
.presetCard:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.selected & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: variables.$bg-surface;
|
||||
color: variables.$text-secondary;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast), color var(--duration-fast);
|
||||
|
||||
&:hover {
|
||||
background: variables.$bg-hover;
|
||||
color: variables.$text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoints.respond-to(breakpoints.$mobileMax) {
|
||||
.styleChars {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react"
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import { StylePreview } from "./StylePreview"
|
||||
import styles from "./PresetCard.module.scss"
|
||||
|
||||
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
|
||||
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
|
||||
|
||||
interface IPresetCardProps {
|
||||
preset: CaptionPresetRead
|
||||
isSelected: boolean
|
||||
aspectRatio?: number
|
||||
onSelect: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
// Helper to extract style characteristics
|
||||
function getStyleCharacteristics(config: CaptionStyleConfig | undefined): {
|
||||
fontFamily: string
|
||||
accentColor: string | null
|
||||
accentName: string | null
|
||||
} {
|
||||
const style = config
|
||||
const fontFamily = style?.text?.font_family ?? "Inter"
|
||||
|
||||
const highlightColor = style?.text?.highlight_color
|
||||
const textColor = style?.text?.text_color
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
"#FFD700": "Золотой",
|
||||
"#00ffff": "Неоновый",
|
||||
"#ffffff": "Белый",
|
||||
"#ff006e": "Розовый",
|
||||
"#cba6f7": "Пурпурный",
|
||||
"#f38ba8": "Розовый",
|
||||
"#a6e3a1": "Зеленый",
|
||||
"#f9e2af": "Желтый",
|
||||
"#89dceb": "Голубой",
|
||||
}
|
||||
|
||||
const accentColor = highlightColor || textColor || null
|
||||
const accentName = accentColor ? (colorMap[accentColor] ?? null) : null
|
||||
|
||||
return {
|
||||
fontFamily,
|
||||
accentColor,
|
||||
accentName,
|
||||
}
|
||||
}
|
||||
|
||||
export const PresetCard: FunctionComponent<IPresetCardProps> = ({
|
||||
preset,
|
||||
isSelected,
|
||||
aspectRatio = 16 / 9,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}): JSX.Element => {
|
||||
const { fontFamily, accentColor, accentName } = getStyleCharacteristics(
|
||||
preset.style_config,
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.presetCard, { [styles.selected]: isSelected })}
|
||||
onClick={onSelect}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={styles.previewArea}>
|
||||
<StylePreview
|
||||
config={preset.style_config}
|
||||
size="small"
|
||||
aspectRatio={aspectRatio}
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className={styles.selectedIndicator}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.cardFooter}>
|
||||
<div className={styles.presetName}>
|
||||
{preset.name}
|
||||
{preset.is_system && (
|
||||
<span className={styles.systemBadge}>Системный</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.styleChars}>
|
||||
{fontFamily}
|
||||
{accentColor && accentName && (
|
||||
<>
|
||||
<span className={styles.divider}>·</span>
|
||||
<span
|
||||
className={styles.colorDot}
|
||||
style={{ background: accentColor }}
|
||||
/>
|
||||
<span style={{ color: accentColor }}>{accentName}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!preset.is_system && (
|
||||
<div className={styles.cardActions}>
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
title="Редактировать"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.skeletonCard {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-default);
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skeletonPreview {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-surface);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(203, 166, 247, 0.08) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.skeletonFooter {
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(to top, var(--bg-surface), var(--bg-default));
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.skeletonLine {
|
||||
height: 14px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 4px;
|
||||
width: 60%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(203, 166, 247, 0.06) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.skeletonLineShort {
|
||||
composes: skeletonLine;
|
||||
width: 40%;
|
||||
height: 10px;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FunctionComponent } from "react"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import styles from "./PresetCardSkeleton.module.scss"
|
||||
|
||||
interface IPresetCardSkeletonProps {
|
||||
aspectRatio?: number
|
||||
}
|
||||
|
||||
export const PresetCardSkeleton: FunctionComponent<IPresetCardSkeletonProps> = ({
|
||||
aspectRatio = 16 / 9,
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.skeletonCard}>
|
||||
<div
|
||||
className={styles.skeletonPreview}
|
||||
style={{ aspectRatio }}
|
||||
/>
|
||||
<div className={styles.skeletonFooter}>
|
||||
<div className={styles.skeletonLine} />
|
||||
<div className={styles.skeletonLineShort} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.createCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: variables.$bg-default;
|
||||
border: 1.5px dashed variables.$border-default;
|
||||
border-radius: variables.$radius-md;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--duration-normal) var(--ease-out);
|
||||
|
||||
&:hover {
|
||||
border-color: variables.$color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.createPreview {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.createIcon {
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
|
||||
.createLabel {
|
||||
font-size: 13px;
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: variables.$text-tertiary;
|
||||
}
|
||||
|
||||
.deleteActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@include breakpoints.respond-to(breakpoints.$tabletMax) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoints.respond-to(breakpoints.$mobileMax) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { Plus } from "lucide-react"
|
||||
import { FunctionComponent, useState } from "react"
|
||||
|
||||
import { useWizard } from "@shared/context/WizardContext"
|
||||
import { Button, Modal } from "@shared/ui"
|
||||
|
||||
import { PresetCard } from "./PresetCard"
|
||||
import { PresetCardSkeleton } from "./PresetCardSkeleton"
|
||||
import { useDeletePreset, usePresetsQuery } from "./useCaptionPresets"
|
||||
import { useVideoMetadata } from "./useVideoMetadata"
|
||||
import styles from "./PresetGrid.module.scss"
|
||||
|
||||
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
|
||||
|
||||
const SKELETON_COUNT = 5
|
||||
|
||||
interface IPresetGridProps {
|
||||
selectedPresetId: string | null
|
||||
onSelect: (presetId: string) => void
|
||||
onEdit: (preset: CaptionPresetRead) => void
|
||||
onCreateNew: () => void
|
||||
}
|
||||
|
||||
export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
|
||||
selectedPresetId,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onCreateNew,
|
||||
}): JSX.Element => {
|
||||
const { primaryFileId } = useWizard()
|
||||
const { aspectRatio, isLoading: isMetadataLoading } =
|
||||
useVideoMetadata(primaryFileId)
|
||||
const { data: presets, isLoading: isPresetsLoading } = usePresetsQuery()
|
||||
const deletePreset = useDeletePreset()
|
||||
const [deleteTarget, setDeleteTarget] = useState<CaptionPresetRead | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const isLoading = isPresetsLoading
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!deleteTarget) return
|
||||
deletePreset.mutate(
|
||||
{ params: { path: { preset_id: deleteTarget.id } } },
|
||||
{ onSettled: () => setDeleteTarget(null) },
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.grid} data-testid="PresetGrid">
|
||||
{Array.from({ length: SKELETON_COUNT }, (_, i) => (
|
||||
<PresetCardSkeleton key={i} aspectRatio={aspectRatio} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.grid} data-testid="PresetGrid">
|
||||
{presets?.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id}
|
||||
aspectRatio={aspectRatio}
|
||||
onSelect={() => onSelect(preset.id)}
|
||||
onEdit={() => onEdit(preset)}
|
||||
onDelete={() => setDeleteTarget(preset)}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={styles.createCard}
|
||||
onClick={onCreateNew}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="PresetGrid-CreateCard"
|
||||
>
|
||||
<div className={styles.createPreview} style={{ aspectRatio }}>
|
||||
<Plus size={32} className={styles.createIcon} />
|
||||
</div>
|
||||
<span className={styles.createLabel}>Создать пресет</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteTarget && (
|
||||
<Modal
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
title="Удаление пресета"
|
||||
>
|
||||
<p>
|
||||
Удалить пресет «{deleteTarget.name}»? Это действие
|
||||
нельзя отменить.
|
||||
</p>
|
||||
<div className={styles.deleteActions}>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deletePreset.isPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nameRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nameField {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.fieldGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--gray-2);
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--gray-4);
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-6);
|
||||
}
|
||||
}
|
||||
|
||||
.sliderField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--gray-2);
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--gray-4);
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-6);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-11);
|
||||
}
|
||||
|
||||
.colorField {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
background: var(--gray-2);
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--gray-4);
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-6);
|
||||
}
|
||||
}
|
||||
|
||||
.colorSwatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.colorPopover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
padding: 16px;
|
||||
background: var(--gray-1);
|
||||
border: 1px solid var(--gray-6);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.colorClose {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-9);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-10);
|
||||
}
|
||||
}
|
||||
|
||||
.editorFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--gray-6);
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
"use client"
|
||||
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
import { FunctionComponent, useCallback, useRef, useState } from "react"
|
||||
import { HexColorPicker } from "react-colorful"
|
||||
import { Controller, useForm, useWatch } from "react-hook-form"
|
||||
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectItem,
|
||||
Slider,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TextField,
|
||||
} from "@shared/ui"
|
||||
|
||||
import { StylePreview } from "./StylePreview"
|
||||
import { useCreatePreset, useUpdatePreset } from "./useCaptionPresets"
|
||||
|
||||
import styles from "./StyleEditor.module.scss"
|
||||
|
||||
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
|
||||
|
||||
interface IStyleEditorProps {
|
||||
initialConfig?: CaptionStyleConfig | null
|
||||
presetId?: string | null
|
||||
presetName?: string
|
||||
onSaved: (presetId: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string
|
||||
text: {
|
||||
font_family: string
|
||||
font_size: number
|
||||
font_weight: number
|
||||
text_color: string
|
||||
highlight_color: string
|
||||
text_shadow: string | null
|
||||
text_stroke_width: number
|
||||
text_stroke_color: string
|
||||
}
|
||||
layout: {
|
||||
vertical_position: "top" | "center" | "bottom"
|
||||
horizontal_alignment: "left" | "center" | "right"
|
||||
padding_px: number
|
||||
max_width_pct: number
|
||||
lines_per_screen: number
|
||||
}
|
||||
animation: {
|
||||
highlight_style: "color" | "scale" | "underline" | "color_scale"
|
||||
highlight_scale: number
|
||||
segment_transition: "fade" | "slide" | "none"
|
||||
fade_duration_frames: number
|
||||
animation_speed: number
|
||||
}
|
||||
background: {
|
||||
bg_color: string
|
||||
bg_blur_px: number
|
||||
bg_glow_color: string | null
|
||||
bg_border_radius_px: number
|
||||
bg_padding_px: number
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: FormValues = {
|
||||
name: "",
|
||||
text: {
|
||||
font_family: "Lobster",
|
||||
font_size: 40,
|
||||
font_weight: 400,
|
||||
text_color: "#FFFFFF",
|
||||
highlight_color: "#FFFF00",
|
||||
text_shadow: "0 2px 4px rgba(0,0,0,0.5)" as string | null,
|
||||
text_stroke_width: 0,
|
||||
text_stroke_color: "#000000",
|
||||
},
|
||||
layout: {
|
||||
vertical_position: "bottom" as const,
|
||||
horizontal_alignment: "center" as const,
|
||||
padding_px: 16,
|
||||
max_width_pct: 90,
|
||||
lines_per_screen: 2,
|
||||
},
|
||||
animation: {
|
||||
highlight_style: "color" as const,
|
||||
highlight_scale: 1.2,
|
||||
segment_transition: "fade" as const,
|
||||
fade_duration_frames: 5,
|
||||
animation_speed: 1.0,
|
||||
},
|
||||
background: {
|
||||
bg_color: "rgba(0,0,0,0.6)",
|
||||
bg_blur_px: 0,
|
||||
bg_glow_color: null as string | null,
|
||||
bg_border_radius_px: 8,
|
||||
bg_padding_px: 12,
|
||||
},
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Color picker field */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ColorField: FunctionComponent<{
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
label: string
|
||||
}> = ({ value, onChange, label }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div className={styles.colorField} ref={ref}>
|
||||
<span className={styles.fieldLabel}>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.colorSwatch}
|
||||
style={{ backgroundColor: value || "transparent" }}
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
{open && (
|
||||
<div className={styles.colorPopover}>
|
||||
<HexColorPicker color={value} onChange={onChange} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.colorClose}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Готово
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-tab: Текст */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TextFields: FunctionComponent<{
|
||||
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||
}> = ({ control }) => (
|
||||
<div className={styles.fields}>
|
||||
<Controller
|
||||
name="text.font_family"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Шрифт</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Шрифт"
|
||||
>
|
||||
{["Lobster", "Inter", "Roboto", "Montserrat", "Open Sans"].map(
|
||||
(f) => (
|
||||
<SelectItem key={f} value={f}>
|
||||
{f}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.font_size"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Размер шрифта"
|
||||
unit="px"
|
||||
min={16}
|
||||
max={96}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.font_weight"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Начертание</span>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(v) => field.onChange(Number(v))}
|
||||
placeholder="Начертание"
|
||||
>
|
||||
<SelectItem value="400">Обычный</SelectItem>
|
||||
<SelectItem value="700">Жирный</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.text_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет текста"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.highlight_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет выделения"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.text_stroke_width"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Обводка текста"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={5}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="text.text_stroke_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет обводки"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-tab: Позиция */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LayoutFields: FunctionComponent<{
|
||||
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||
}> = ({ control }) => (
|
||||
<div className={styles.fields}>
|
||||
<Controller
|
||||
name="layout.vertical_position"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>
|
||||
Вертикальная позиция
|
||||
</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Позиция"
|
||||
>
|
||||
<SelectItem value="top">Сверху</SelectItem>
|
||||
<SelectItem value="center">По центру</SelectItem>
|
||||
<SelectItem value="bottom">Снизу</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="layout.horizontal_alignment"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Выравнивание</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Выравнивание"
|
||||
>
|
||||
<SelectItem value="left">Слева</SelectItem>
|
||||
<SelectItem value="center">По центру</SelectItem>
|
||||
<SelectItem value="right">Справа</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="layout.max_width_pct"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Макс. ширина"
|
||||
unit="%"
|
||||
min={20}
|
||||
max={100}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="layout.padding_px"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Отступы"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={64}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="layout.lines_per_screen"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Строк на экране"
|
||||
min={1}
|
||||
max={4}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-tab: Анимация */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const AnimationFields: FunctionComponent<{
|
||||
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||
}> = ({ control }) => (
|
||||
<div className={styles.fields}>
|
||||
<Controller
|
||||
name="animation.highlight_style"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Стиль выделения</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Стиль"
|
||||
>
|
||||
<SelectItem value="color">Цвет</SelectItem>
|
||||
<SelectItem value="scale">Масштаб</SelectItem>
|
||||
<SelectItem value="underline">Подчёркивание</SelectItem>
|
||||
<SelectItem value="color_scale">
|
||||
Цвет + масштаб
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="animation.highlight_scale"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Масштаб выделения"
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="animation.segment_transition"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.fieldGroup}>
|
||||
<span className={styles.fieldLabel}>Переход</span>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder="Переход"
|
||||
>
|
||||
<SelectItem value="fade">Затухание</SelectItem>
|
||||
<SelectItem value="slide">Сдвиг</SelectItem>
|
||||
<SelectItem value="none">Без перехода</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="animation.fade_duration_frames"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Длительность перехода"
|
||||
unit=" кадров"
|
||||
min={0}
|
||||
max={30}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="animation.animation_speed"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Скорость анимации"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-tab: Фон */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const BackgroundFields: FunctionComponent<{
|
||||
control: ReturnType<typeof useForm<FormValues>>["control"]
|
||||
}> = ({ control }) => (
|
||||
<div className={styles.fields}>
|
||||
<Controller
|
||||
name="background.bg_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет фона"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="background.bg_blur_px"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Размытие фона"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={20}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="background.bg_glow_color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorField
|
||||
label="Цвет свечения"
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="background.bg_border_radius_px"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Скругление углов"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={24}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="background.bg_padding_px"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={styles.sliderField}>
|
||||
<Slider
|
||||
label="Внутренний отступ"
|
||||
unit="px"
|
||||
min={0}
|
||||
max={32}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main editor */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const buildDefaultValues = (
|
||||
config?: CaptionStyleConfig | null,
|
||||
name?: string,
|
||||
): FormValues => ({
|
||||
name: name ?? "",
|
||||
text: { ...DEFAULT_VALUES.text, ...config?.text },
|
||||
layout: { ...DEFAULT_VALUES.layout, ...config?.layout },
|
||||
animation: { ...DEFAULT_VALUES.animation, ...config?.animation },
|
||||
background: { ...DEFAULT_VALUES.background, ...config?.background },
|
||||
})
|
||||
|
||||
export const StyleEditor: FunctionComponent<IStyleEditorProps> = ({
|
||||
initialConfig,
|
||||
presetId,
|
||||
presetName,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}): JSX.Element => {
|
||||
const isEditing = !!presetId
|
||||
|
||||
const { control, handleSubmit, formState } = useForm<FormValues>({
|
||||
defaultValues: buildDefaultValues(initialConfig, presetName),
|
||||
})
|
||||
|
||||
const watchedValues = useWatch({ control })
|
||||
const previewConfig: CaptionStyleConfig = {
|
||||
text: watchedValues.text as CaptionStyleConfig["text"],
|
||||
layout: watchedValues.layout as CaptionStyleConfig["layout"],
|
||||
animation: watchedValues.animation as CaptionStyleConfig["animation"],
|
||||
background:
|
||||
watchedValues.background as CaptionStyleConfig["background"],
|
||||
}
|
||||
|
||||
const createPreset = useCreatePreset()
|
||||
const updatePreset = useUpdatePreset()
|
||||
const isSaving = createPreset.isPending || updatePreset.isPending
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormValues) => {
|
||||
const styleConfig: CaptionStyleConfig = {
|
||||
text: data.text,
|
||||
layout: data.layout,
|
||||
animation: data.animation,
|
||||
background: data.background,
|
||||
}
|
||||
|
||||
if (isEditing && presetId) {
|
||||
updatePreset.mutate(
|
||||
{
|
||||
params: { path: { preset_id: presetId } },
|
||||
body: {
|
||||
name: data.name || undefined,
|
||||
style_config: styleConfig,
|
||||
},
|
||||
},
|
||||
{ onSuccess: () => onSaved(presetId) },
|
||||
)
|
||||
} else {
|
||||
createPreset.mutate(
|
||||
{
|
||||
body: {
|
||||
name: data.name,
|
||||
style_config: styleConfig,
|
||||
},
|
||||
},
|
||||
{ onSuccess: (res) => onSaved(res.id) },
|
||||
)
|
||||
}
|
||||
},
|
||||
[isEditing, presetId, createPreset, updatePreset, onSaved],
|
||||
)
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.editor}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
data-testid="StyleEditor"
|
||||
>
|
||||
<StylePreview config={previewConfig} size="large" />
|
||||
|
||||
<div className={styles.nameRow}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: !isEditing }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
id="preset-name"
|
||||
placeholder="Название пресета"
|
||||
className={styles.nameField}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="text" className={styles.tabs}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="text">Текст</TabsTrigger>
|
||||
<TabsTrigger value="layout">Позиция</TabsTrigger>
|
||||
<TabsTrigger value="animation">Анимация</TabsTrigger>
|
||||
<TabsTrigger value="background">Фон</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="text">
|
||||
<TextFields control={control} />
|
||||
</TabsContent>
|
||||
<TabsContent value="layout">
|
||||
<LayoutFields control={control} />
|
||||
</TabsContent>
|
||||
<TabsContent value="animation">
|
||||
<AnimationFields control={control} />
|
||||
</TabsContent>
|
||||
<TabsContent value="background">
|
||||
<BackgroundFields control={control} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className={styles.editorFooter}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isSaving || (!isEditing && !formState.dirtyFields.name)}
|
||||
>
|
||||
{isSaving
|
||||
? "Сохранение..."
|
||||
: isEditing
|
||||
? "Сохранить"
|
||||
: "Создать пресет"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0c0a1a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.small {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.large {
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 400px;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { FunctionComponent } from "react"
|
||||
|
||||
import cs from "classnames"
|
||||
|
||||
import styles from "./StylePreview.module.scss"
|
||||
|
||||
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
|
||||
|
||||
interface IStylePreviewProps {
|
||||
config?: CaptionStyleConfig | null
|
||||
size?: "small" | "large"
|
||||
className?: string
|
||||
aspectRatio?: number
|
||||
}
|
||||
|
||||
const SMALL_SCALE = 0.45
|
||||
|
||||
const buildContainerStyles = (
|
||||
config: CaptionStyleConfig,
|
||||
scale: number,
|
||||
): React.CSSProperties => {
|
||||
const bg = config.background
|
||||
return {
|
||||
backgroundColor: bg?.bg_color ?? "rgba(0,0,0,0.6)",
|
||||
borderRadius: (bg?.bg_border_radius_px ?? 8) * scale,
|
||||
padding: (bg?.bg_padding_px ?? 12) * scale,
|
||||
...(bg?.bg_blur_px
|
||||
? { backdropFilter: `blur(${bg.bg_blur_px * scale}px)` }
|
||||
: {}),
|
||||
...(bg?.bg_glow_color
|
||||
? { boxShadow: `0 0 ${20 * scale}px ${bg.bg_glow_color}` }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
const buildTextStyles = (
|
||||
config: CaptionStyleConfig,
|
||||
scale: number,
|
||||
): React.CSSProperties => {
|
||||
const text = config.text
|
||||
return {
|
||||
fontFamily: text?.font_family ?? "Lobster",
|
||||
fontSize: (text?.font_size ?? 40) * scale,
|
||||
fontWeight: text?.font_weight ?? 400,
|
||||
color: text?.text_color ?? "#FFFFFF",
|
||||
textAlign:
|
||||
(config.layout?.horizontal_alignment as "left" | "center" | "right") ??
|
||||
"center",
|
||||
...(text?.text_shadow ? { textShadow: text.text_shadow } : {}),
|
||||
...(text?.text_stroke_width
|
||||
? {
|
||||
WebkitTextStroke: `${(text.text_stroke_width ?? 0) * scale}px ${text.text_stroke_color ?? "#000000"}`,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
const VERTICAL_MAP: Record<string, string> = {
|
||||
top: "flex-start",
|
||||
center: "center",
|
||||
bottom: "flex-end",
|
||||
}
|
||||
|
||||
const HORIZONTAL_MAP: Record<string, string> = {
|
||||
left: "flex-start",
|
||||
center: "center",
|
||||
right: "flex-end",
|
||||
}
|
||||
|
||||
const buildPositionStyles = (
|
||||
config: CaptionStyleConfig,
|
||||
scale: number,
|
||||
): React.CSSProperties => {
|
||||
const layout = config.layout
|
||||
const vPos = layout?.vertical_position ?? "bottom"
|
||||
const hAlign = layout?.horizontal_alignment ?? "center"
|
||||
const padding = (layout?.padding_px ?? 20) * scale
|
||||
|
||||
return {
|
||||
justifyContent: VERTICAL_MAP[vPos] ?? "flex-end",
|
||||
alignItems: HORIZONTAL_MAP[hAlign] ?? "center",
|
||||
padding,
|
||||
}
|
||||
}
|
||||
|
||||
export const StylePreview: FunctionComponent<IStylePreviewProps> = ({
|
||||
config,
|
||||
size = "small",
|
||||
className,
|
||||
aspectRatio = 9 / 16,
|
||||
}): JSX.Element => {
|
||||
const safeConfig = config ?? {}
|
||||
const highlightColor = safeConfig.text?.highlight_color ?? "#FFFF00"
|
||||
const scale = size === "small" ? SMALL_SCALE : 1
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cs(styles.root, styles[size], className)}
|
||||
style={{ ...buildPositionStyles(safeConfig, scale), aspectRatio }}
|
||||
data-testid="StylePreview"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...buildContainerStyles(safeConfig, scale),
|
||||
maxWidth: `${safeConfig.layout?.max_width_pct ?? 90}%`,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...buildTextStyles(safeConfig, scale),
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
Пример <span style={{ color: highlightColor }}>субтитров</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CaptionSettingsStep } from "./CaptionSettingsStep"
|
||||
export { PresetCardSkeleton } from "./PresetCardSkeleton"
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import api from "@shared/api"
|
||||
|
||||
const PRESETS_QUERY_KEY = ["get", "/api/captions/presets/"]
|
||||
|
||||
export const usePresetsQuery = () => {
|
||||
return api.useQuery("get", "/api/captions/presets/", {})
|
||||
}
|
||||
|
||||
export const useCreatePreset = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return api.useMutation("post", "/api/captions/presets/", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdatePreset = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return api.useMutation("patch", "/api/captions/presets/{preset_id}/", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeletePreset = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return api.useMutation("delete", "/api/captions/presets/{preset_id}/", {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import api from "@shared/api"
|
||||
|
||||
interface IUseSubmitCaptionGenerateParams {
|
||||
onSuccess?: (data: { job_id: string }) => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export const useSubmitCaptionGenerate = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: IUseSubmitCaptionGenerateParams = {}) => {
|
||||
return api.useMutation("post", "/api/tasks/captions-generate/", {
|
||||
onSuccess: (data) => {
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo } from "react"
|
||||
|
||||
import api from "@shared/api"
|
||||
|
||||
interface UseVideoMetadataResult {
|
||||
aspectRatio: number
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_ASPECT_RATIO = 16 / 9
|
||||
|
||||
export function useVideoMetadata(
|
||||
fileId: string | null,
|
||||
): UseVideoMetadataResult {
|
||||
const {
|
||||
data: mediaFile,
|
||||
isLoading,
|
||||
isError,
|
||||
} = api.useQuery(
|
||||
"get",
|
||||
"/api/media/mediafiles/{media_file_id}/",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
media_file_id: fileId ?? "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!fileId,
|
||||
retry: false,
|
||||
},
|
||||
)
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (!mediaFile?.width || !mediaFile?.height) {
|
||||
return DEFAULT_ASPECT_RATIO
|
||||
}
|
||||
return mediaFile.width / mediaFile.height
|
||||
}, [mediaFile])
|
||||
|
||||
return {
|
||||
aspectRatio,
|
||||
isLoading,
|
||||
isError,
|
||||
}
|
||||
}
|
||||
@@ -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,143 @@
|
||||
"use client"
|
||||
|
||||
import type { IConvertMediaViewProps } from "./ConvertMediaView.d"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { CheckCircle, FileVideo } from "lucide-react"
|
||||
import { FunctionComponent, useCallback, useEffect, useState } from "react"
|
||||
|
||||
import api from "@shared/api"
|
||||
import { useTaskProgressState } from "@shared/hooks/useTaskProgressState"
|
||||
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 resolvedProgress = useTaskProgressState({
|
||||
jobId,
|
||||
enabled: !!jobId && status === STATUS_CONVERTING,
|
||||
defaultMessage: "Конвертация...",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== STATUS_CONVERTING) return
|
||||
|
||||
if (resolvedProgress.status === "DONE") {
|
||||
setStatus(STATUS_DONE)
|
||||
return
|
||||
}
|
||||
|
||||
if (resolvedProgress.status === "FAILED") {
|
||||
setStatus(STATUS_FAILED)
|
||||
setErrorMessage(resolvedProgress.errorMessage ?? ERROR_CONVERT_FAILED)
|
||||
}
|
||||
}, [resolvedProgress.errorMessage, resolvedProgress.status, status])
|
||||
|
||||
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}>{resolvedProgress.message}</p>
|
||||
<div className={styles.progressTrack}>
|
||||
<div
|
||||
className={styles.progressBar}
|
||||
style={{ width: `${resolvedProgress.progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={styles.progressLabel}>
|
||||
{Math.round(resolvedProgress.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
-3
@@ -1,8 +1,7 @@
|
||||
import type { Dialog } from "@radix-ui/themes"
|
||||
import type { ComponentProps } from "react"
|
||||
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
|
||||
|
||||
export interface ICreateProjectModalProps extends Pick<
|
||||
ComponentProps<typeof Dialog.Root>,
|
||||
IModalProps,
|
||||
"open" | "onOpenChange"
|
||||
> {
|
||||
onCreated?: () => void | Promise<void>
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
.root {
|
||||
min-width: 520px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fields {
|
||||
@@ -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,8 @@
|
||||
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
|
||||
|
||||
export interface IDeleteFileModalProps
|
||||
extends Pick<IModalProps, "open" | "onOpenChange"> {
|
||||
fileName: string
|
||||
onConfirm: () => void
|
||||
isPending: boolean
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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,10 @@
|
||||
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
|
||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||
|
||||
export interface IDeleteProjectModalProps extends Pick<
|
||||
IModalProps,
|
||||
"open" | "onOpenChange"
|
||||
> {
|
||||
project: components["schemas"]["ProjectRead"]
|
||||
onDeleted?: () => void | Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user