Compare commits

...

10 Commits

Author SHA1 Message Date
Daniil 20928e9a60 chore: something changed, commit before reorg 2026-04-27 23:28:28 +03:00
Daniil 46f34bdcac rev 4 2026-04-07 13:42:23 +03:00
Daniil d648678c68 feat: add aspectRatio prop to StylePreview for dynamic sizing 2026-04-06 01:29:12 +03:00
Daniil face8bb811 feat: add PresetCardSkeleton component with shimmer animation 2026-04-06 01:25:16 +03:00
Daniil bca8224f16 feat: add useVideoMetadata hook for aspect ratio calculation 2026-04-06 01:18:09 +03:00
Daniil 0523ef3d72 iter 2 2026-04-04 14:51:40 +03:00
Daniil 10a1d28f77 feat(frontend): add SaluteSpeech engine option to TranscriptionSettingsStep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:11:06 +03:00
Daniil cf8ded79d7 feat(frontend): add SaluteSpeech engine option to TranscriptionModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:11:00 +03:00
Daniil 305e72725c nf 2026-02-28 17:41:14 +03:00
Daniil 71b974903a new features 2026-02-27 23:34:17 +03:00
315 changed files with 24953 additions and 1095 deletions
+36
View File
@@ -0,0 +1,36 @@
# Convert Icons
Convert raw SVG icons to React TSX components using SVGR.
## Usage
```
/convert-icons
```
## Instructions
1. Check that raw SVG files exist in `src/shared/assets/raw-icons/`. If the directory doesn't exist or is empty, inform the user and ask them to place SVG files there first.
2. Run the conversion:
```bash
bun run gicons
```
This executes:
```
npx @svgr/cli --ext tsx --typescript --no-prettier --icon --ref --no-svgo ./src/shared/assets/raw-icons/ --out-dir ./src/shared/ui/Icons/
```
3. Report which icon components were generated in `src/shared/ui/Icons/`.
4. The generated components can be imported as:
```tsx
import { IconName } from "@shared/ui/Icons/IconName"
```
## Notes
- Raw SVGs go in: `src/shared/assets/raw-icons/`
- Generated TSX components output to: `src/shared/ui/Icons/`
- SVGR flags: TypeScript, icon mode (scales with font-size), forwardRef support, no Prettier formatting, no SVGO optimization
- The primary icon library is `lucide-react` — custom SVG icons are for icons not available in Lucide
+55
View File
@@ -0,0 +1,55 @@
# Generate Component (gc)
Generate an FSD component using the project's generator script.
## Usage
```
/gc <layer> <ComponentName>
```
**Arguments:**
- `$ARGUMENTS` — expects `<layer> <ComponentName>`, e.g. `shared Button`, `entity ProjectCard`, `feature CreateProjectModal`
## Layers
| Alias | Path |
|-------|------|
| `shared` | `src/shared/ui/` |
| `entity` / `entities` | `src/entities/` |
| `feature` / `features` | `src/features/` |
| `widget` / `widgets` | `src/widgets/` |
| `page` / `pages` | `src/pages/` |
## Instructions
1. Parse `$ARGUMENTS` to extract `<layer>` and `<ComponentName>`. If arguments are missing or unclear, ask the user.
2. Run the generator:
```bash
bun run gc <layer> <ComponentName>
```
3. This creates 4 files:
- `index.ts` — re-exports the component
- `<ComponentName>.tsx` — component implementation with `FunctionComponent`, `JSX.Element`, SCSS module import, `data-testid`
- `<ComponentName>.d.ts` — props interface `I<ComponentName>Props` with `className?: string`
- `<ComponentName>.module.scss` — empty `.root {}` class
4. After generation, report the created files and the full path.
5. If the user provides additional context about what the component should do or look like, modify the generated files accordingly:
- Add props to the `.d.ts` file
- Implement the component logic in `.tsx`
- Add styles to `.module.scss`
- Use existing project patterns: `classnames` as `cs`, typography/mixin includes from auto-injected SCSS partials, Radix UI primitives or Themes where appropriate, `lucide-react` icons
## Project Conventions
- Props interface: `I<ComponentName>Props` in a `.d.ts` file
- Import types with `import type { ... }`
- Import order: types → react/libs → path aliases (`@shared/`, `@entities/`, etc.) → local
- Use `cs()` from `classnames` for combining classes
- Root element gets `className={styles.root}` and `data-testid="<ComponentName>"`
- SCSS auto-injected namespaces: `variables`, `breakpoints`, `typography`, `mixins`
- Use `"use client"` directive only when component uses hooks or browser APIs
+51
View File
@@ -0,0 +1,51 @@
# Generate API Types
Fetch the OpenAPI schema from the backend and generate TypeScript types.
## Usage
```
/gen-api-types
```
## Instructions
1. Ensure the backend server is running at `http://127.0.0.1:8000`. If the command fails with a connection error, inform the user that the backend must be running first.
2. Run the type generation:
```bash
bun run gen:api-types
```
This executes:
```
openapi-typescript http://127.0.0.1:8000/api/schema/ --output src/shared/api/__generated__/openapi.types.ts
```
3. Report success and note that the generated types are at `src/shared/api/__generated__/openapi.types.ts`.
4. The generated file exports:
- `paths` — all API endpoints with request/response types
- `components` — schema definitions (used as `components["schemas"]["ModelName"]`)
- `operations` — operation-level types
## Usage in Code
```tsx
// Import schema types
import type { components } from "@shared/api/__generated__/openapi.types"
type ProjectRead = components["schemas"]["ProjectRead"]
// Use with the API client
import api from "@shared/api"
api.useQuery("get", "/api/projects/")
api.useMutation("post", "/api/projects/", { onSuccess, onError })
```
## Notes
- Requires the backend running at `http://127.0.0.1:8000`
- Uses `openapi-typescript` to generate types from the `/api/schema/` endpoint
- The API client (`src/shared/api/index.ts`) uses `openapi-fetch` + `openapi-react-query` with these generated types
- After regeneration, check for any TypeScript errors in components that use the API types, as schema changes may break existing code
+3 -1
View File
@@ -1 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:8000/ NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_WS_URL=ws://localhost:8000
NEXT_PUBLIC_MOCK_WS=false
+5
View File
@@ -12,9 +12,13 @@ package.lock
# testing # testing
/coverage /coverage
/test-results/
/playwright-report/
/blob-report/
# next.js # next.js
/.next/ /.next/
/.next-test/
/out/ /out/
# production # production
@@ -30,6 +34,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
.env
.env*.local .env*.local
# vercel # vercel
+9 -241
View File
@@ -1,247 +1,15 @@
# AGENTS.md — Coffee Project Frontend # 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 | - Keep `../AGENTS.md` as the workflow and delegation source of truth.
| ------------- | ------------------------------------------ | - Treat `CLAUDE.md` as architecture, commands, and conventions only.
| Runtime | Bun 1.3.5 | - Do not rely on `.claude/` directory contents.
| 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>`
+137
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import { StaticLoader } from "@shared/ui/Loader"
export default function ProtectedLoading() {
return <StaticLoader block />
}
+29
View File
@@ -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>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { JSX } from "react"
import { ProfilePage } from "@pages/ProfilePage"
export default function Profile(): JSX.Element {
return (
<main>
<ProfilePage />
</main>
)
}
@@ -1,11 +1,11 @@
import { JSX } from "react" import { JSX } from "react"
import { ProjectWorkspacePage } from "@pages/ProjectWorkspacePage" import { ProjectWizardPage } from "@pages/ProjectWizardPage"
export default function Projects(): JSX.Element { export default function Projects(): JSX.Element {
return ( return (
<main> <main>
<ProjectWorkspacePage /> <ProjectWizardPage />
</main> </main>
) )
} }
+18
View File
@@ -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>
)
}
+14 -4
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { Open_Sans } from "next/font/google" import { Manrope } from "next/font/google"
import "@shared/styles/global.scss" import "@shared/styles/global.scss"
@@ -12,10 +12,11 @@ export const metadata: Metadata = {
description: "Standalone Next.js app using FSD structure", description: "Standalone Next.js app using FSD structure",
} }
const open_sans = Open_Sans({ const manrope = Manrope({
subsets: ["latin", "cyrillic"],
preload: true, preload: true,
display: "swap", display: "swap",
variable: "--font-open-sans", variable: "--font-manrope",
}) })
export default function RootLayout({ export default function RootLayout({
@@ -24,9 +25,18 @@ export default function RootLayout({
children: ReactNode children: ReactNode
}>) { }>) {
return ( 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> <body>
<div id="app-root">
<AppProviders>{children}</AppProviders> <AppProviders>{children}</AppProviders>
</div>
</body> </body>
</html> </html>
) )
+21
View File
@@ -1,14 +1,35 @@
"use client"
import { usePathname } from "next/navigation"
import { motion } from "framer-motion"
import { Header } from "@widgets/Header" import { Header } from "@widgets/Header"
const AUTH_ROUTES = ["/login", "/register", "/under_maintenance"]
export default function EssentialTemplate({ export default function EssentialTemplate({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const pathname = usePathname()
const isAuthPage = AUTH_ROUTES.includes(pathname ?? "")
if (isAuthPage) {
return <>{children}</>
}
return ( return (
<div> <div>
<Header /> <Header />
<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} {children}
</motion.div>
</div> </div>
) )
} }
+13
View File
@@ -0,0 +1,13 @@
import { Suspense } from "react"
import { UnderMaintenancePage } from "@pages/UnderMaintenancePage"
export default function UnderMaintenance() {
return (
<main>
<Suspense>
<UnderMaintenancePage />
</Suspense>
</main>
)
}
+99 -4
View File
@@ -16,37 +16,46 @@
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.90.14", "@tanstack/react-query": "^5.90.14",
"@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-query-devtools": "^5.91.2",
"@vidstack/react": "^1",
"@wavesurfer/react": "^1.0.12",
"axios": "^1.13.2", "axios": "^1.13.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"moment": "^2.30.1",
"next": "16.1.1", "next": "16.1.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"openapi-fetch": "^0.15.0", "openapi-fetch": "^0.15.0",
"openapi-react-query": "^0.5.1", "openapi-react-query": "^0.5.1",
"react": "^19.2.3", "react": "^19.2.3",
"react-aria-components": "^1.14.0", "react-aria-components": "^1.14.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.71.0", "react-hook-form": "^7.71.0",
"react-modal": "^3.16.3",
"react-modern-drawer": "^1.4.0", "react-modern-drawer": "^1.4.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-resizable-panels": "^4.6.5",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"use-mask-input": "^3.6.0", "use-mask-input": "^3.6.0",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"wavesurfer.js": "^7.12.1",
"xior": "^0.8.2", "xior": "^0.8.2",
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@playwright/test": "^1.58.2",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@types/bun": "^1.3.5", "@types/bun": "^1.3.5",
"@types/jest": "^30.0.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-modal": "^3.16.3",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
@@ -231,6 +240,18 @@
"@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="], "@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="],
"@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="],
"@jest/expect-utils": ["@jest/expect-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA=="],
"@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="],
"@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="],
"@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="],
"@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -303,6 +324,8 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@radix-ui/colors": ["@radix-ui/colors@3.0.0", "", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="], "@radix-ui/colors": ["@radix-ui/colors@3.0.0", "", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
@@ -653,6 +676,8 @@
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -713,6 +738,14 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
"@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="],
"@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="],
"@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="], "@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -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-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/react-modal": ["@types/react-modal@3.16.3", "", { "dependencies": { "@types/react": "*" } }, "sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/type-utils": "8.50.1", "@typescript-eslint/utils": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/type-utils": "8.50.1", "@typescript-eslint/utils": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.50.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", "@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.50.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", "@typescript-eslint/typescript-estree": "8.50.1", "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg=="],
@@ -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=="], "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@vidstack/react": ["@vidstack/react@1.12.13", "", { "dependencies": { "@floating-ui/dom": "^1.6.10", "media-captions": "^1.0.4" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" } }, "sha512-zyNydy1+HtoK6cJ8EmqFNkPPGHIFMrr2KH+ef3654EqXx4IcJ8A5LCNMXBuALQE8IMxtk040JMoR9OKyeXjBOQ=="],
"@wavesurfer/react": ["@wavesurfer/react@1.0.12", "", { "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "wavesurfer.js": ">=7.7.14" } }, "sha512-BNHpz2ryKNVvJdxB47pCPUsNCsjb2pZRysg82M5djIiw0vsiSJwdlt5jaAfDo3vd5IWrcoK9OiPQKO9ZEVNpDQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -879,6 +924,8 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -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=="], "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@@ -1039,6 +1088,10 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"exenv": ["exenv@1.2.2", "", {}, "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="],
"expect": ["expect@30.2.0", "", { "dependencies": { "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.2.0", "jest-message-util": "30.2.0", "jest-mock": "30.2.0", "jest-util": "30.2.0" } }, "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -1077,6 +1130,8 @@
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
@@ -1117,6 +1172,8 @@
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
@@ -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=="], "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
"jest-diff": ["jest-diff@30.2.0", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.2.0" } }, "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A=="],
"jest-matcher-utils": ["jest-matcher-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.2.0", "pretty-format": "30.2.0" } }, "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg=="],
"jest-message-util": ["jest-message-util@30.2.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw=="],
"jest-mock": ["jest-mock@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "jest-util": "30.2.0" } }, "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw=="],
"jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="],
"jest-util": ["jest-util@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA=="],
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
@@ -1293,6 +1362,8 @@
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"media-captions": ["media-captions@1.0.4", "", {}, "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w=="],
"meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -1307,8 +1378,6 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="], "motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
@@ -1387,6 +1456,10 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@@ -1411,6 +1484,8 @@
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
@@ -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-aria-components": ["react-aria-components@1.14.0", "", { "dependencies": { "@internationalized/date": "^3.10.1", "@internationalized/string": "^3.2.7", "@react-aria/autocomplete": "3.0.0-rc.4", "@react-aria/collections": "^3.0.1", "@react-aria/dnd": "^3.11.4", "@react-aria/focus": "^3.21.3", "@react-aria/interactions": "^3.26.0", "@react-aria/live-announcer": "^3.4.4", "@react-aria/overlays": "^3.31.0", "@react-aria/ssr": "^3.9.10", "@react-aria/textfield": "^3.18.3", "@react-aria/toolbar": "3.0.0-beta.22", "@react-aria/utils": "^3.32.0", "@react-aria/virtualizer": "^4.1.11", "@react-stately/autocomplete": "3.0.0-beta.4", "@react-stately/layout": "^4.5.2", "@react-stately/selection": "^3.20.7", "@react-stately/table": "^3.15.2", "@react-stately/utils": "^3.11.0", "@react-stately/virtualizer": "^4.4.4", "@react-types/form": "^3.7.16", "@react-types/grid": "^3.3.6", "@react-types/shared": "^3.32.1", "@react-types/table": "^3.13.4", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "^3.45.0", "react-stately": "^3.43.0", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-u21N/yS6Ozk9P9oO8wxMNZSFiPk6F3aAE9w6aN7pseGPApkjXqDyPNCnTsTTvMtVL3QRBkVbf7fJ5yi2hksVEg=="],
"react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-dropzone": ["react-dropzone@14.3.8", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug=="], "react-dropzone": ["react-dropzone@14.3.8", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug=="],
"react-hook-form": ["react-hook-form@7.71.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oFDt/iIFMV9ZfV52waONXzg4xuSlbwKUPvXVH2jumL1me5qFhBMc4knZxuXiZ2+j6h546sYe3ZKJcg/900/iHw=="], "react-hook-form": ["react-hook-form@7.71.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oFDt/iIFMV9ZfV52waONXzg4xuSlbwKUPvXVH2jumL1me5qFhBMc4knZxuXiZ2+j6h546sYe3ZKJcg/900/iHw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-lifecycles-compat": ["react-lifecycles-compat@3.0.4", "", {}, "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="],
"react-modal": ["react-modal@3.16.3", "", { "dependencies": { "exenv": "^1.2.0", "prop-types": "^15.7.2", "react-lifecycles-compat": "^3.0.0", "warning": "^4.0.3" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19", "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" } }, "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw=="],
"react-modern-drawer": ["react-modern-drawer@1.4.0", "", { "peerDependencies": { "react": ">16.0.0" } }, "sha512-5OkcUstqUdd/CNW9+BvLkzm36R2G54RFXWF2mWCH13cUsz5SNo9aB9KzPRbJp2LEVfRL/u+MgikOWRe7/6wKEQ=="], "react-modern-drawer": ["react-modern-drawer@1.4.0", "", { "peerDependencies": { "react": ">16.0.0" } }, "sha512-5OkcUstqUdd/CNW9+BvLkzm36R2G54RFXWF2mWCH13cUsz5SNo9aB9KzPRbJp2LEVfRL/u+MgikOWRe7/6wKEQ=="],
@@ -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-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable-panels": ["react-resizable-panels@4.6.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-pmQP6qv9KmsesNMvWVNvVfVJAwYSOWWbAOAtrPR8Cre20+j1NWIlyft0btjtDQE+OepXmI6g3VPrCXQY0oD7+Q=="],
"react-stately": ["react-stately@3.43.0", "", { "dependencies": { "@react-stately/calendar": "^3.9.1", "@react-stately/checkbox": "^3.7.3", "@react-stately/collections": "^3.12.8", "@react-stately/color": "^3.9.3", "@react-stately/combobox": "^3.12.1", "@react-stately/data": "^3.15.0", "@react-stately/datepicker": "^3.15.3", "@react-stately/disclosure": "^3.0.9", "@react-stately/dnd": "^3.7.2", "@react-stately/form": "^3.2.2", "@react-stately/list": "^3.13.2", "@react-stately/menu": "^3.9.9", "@react-stately/numberfield": "^3.10.3", "@react-stately/overlays": "^3.6.21", "@react-stately/radio": "^3.11.3", "@react-stately/searchfield": "^3.5.17", "@react-stately/select": "^3.9.0", "@react-stately/selection": "^3.20.7", "@react-stately/slider": "^3.7.3", "@react-stately/table": "^3.15.2", "@react-stately/tabs": "^3.8.7", "@react-stately/toast": "^3.1.2", "@react-stately/toggle": "^3.9.3", "@react-stately/tooltip": "^3.5.9", "@react-stately/tree": "^3.9.4", "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-dScb9fTL1tRtFODPnk/2rP0a9kp1C+7+40RArS0C7j0auAUmnrO/wDILojwQUso7/kkys4fP707fTwGJDeJ7vg=="], "react-stately": ["react-stately@3.43.0", "", { "dependencies": { "@react-stately/calendar": "^3.9.1", "@react-stately/checkbox": "^3.7.3", "@react-stately/collections": "^3.12.8", "@react-stately/color": "^3.9.3", "@react-stately/combobox": "^3.12.1", "@react-stately/data": "^3.15.0", "@react-stately/datepicker": "^3.15.3", "@react-stately/disclosure": "^3.0.9", "@react-stately/dnd": "^3.7.2", "@react-stately/form": "^3.2.2", "@react-stately/list": "^3.13.2", "@react-stately/menu": "^3.9.9", "@react-stately/numberfield": "^3.10.3", "@react-stately/overlays": "^3.6.21", "@react-stately/radio": "^3.11.3", "@react-stately/searchfield": "^3.5.17", "@react-stately/select": "^3.9.0", "@react-stately/selection": "^3.20.7", "@react-stately/slider": "^3.7.3", "@react-stately/table": "^3.15.2", "@react-stately/tabs": "^3.8.7", "@react-stately/toast": "^3.1.2", "@react-stately/toggle": "^3.9.3", "@react-stately/tooltip": "^3.5.9", "@react-stately/tree": "^3.9.4", "@react-types/shared": "^3.32.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-dScb9fTL1tRtFODPnk/2rP0a9kp1C+7+40RArS0C7j0auAUmnrO/wDILojwQUso7/kkys4fP707fTwGJDeJ7vg=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
@@ -1529,6 +1612,8 @@
"stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1641,6 +1726,10 @@
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="], "v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
"wavesurfer.js": ["wavesurfer.js@7.12.1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -1755,10 +1844,16 @@
"openapi-typescript/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "openapi-typescript/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"stylelint/file-entry-cache": ["file-entry-cache@11.1.1", "", { "dependencies": { "flat-cache": "^6.1.19" } }, "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A=="], "stylelint/file-entry-cache": ["file-entry-cache@11.1.1", "", { "dependencies": { "flat-cache": "^6.1.19" } }, "sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A=="],
+14 -2
View File
@@ -4,9 +4,21 @@ import { fileURLToPath } from "url"
const dirname = path.dirname(fileURLToPath(import.meta.url)) const dirname = path.dirname(fileURLToPath(import.meta.url))
const stylesPath = path.join(dirname, "src/shared/styles") const stylesPath = path.join(dirname, "src/shared/styles")
console.log("dirname", dirname)
const nextConfig = { const nextConfig = {
distDir: process.env.NEXT_TEST_DIR ?? ".next",
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "9000",
},
],
dangerouslyAllowSVG: true,
contentDispositionType: "inline",
localPatterns: undefined,
unoptimized: process.env.NODE_ENV === "development",
},
sassOptions: { sassOptions: {
includePaths: [stylesPath], includePaths: [stylesPath],
additionalData: `@use "${path.join(stylesPath, "_variables.scss")}"; additionalData: `@use "${path.join(stylesPath, "_variables.scss")}";
+16 -2
View File
@@ -1,6 +1,9 @@
{ {
"name": "fsd-nest-template", "name": "fsd-nest-template",
"version": "0.1.0", "version": "0.1.0",
"imports": {
"#tests/*": "./tests/*"
},
"private": true, "private": true,
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"scripts": { "scripts": {
@@ -11,7 +14,9 @@
"create-component": "npx generate-react-cli component", "create-component": "npx generate-react-cli component",
"gc": "bun run .scripts/create-fsd-component.ts", "gc": "bun run .scripts/create-fsd-component.ts",
"gicons": "npx @svgr/cli --ext tsx --typescript --no-prettier --icon --ref --no-svgo ./src/shared/assets/raw-icons/ --out-dir ./src/shared/ui/Icons/", "gicons": "npx @svgr/cli --ext tsx --typescript --no-prettier --icon --ref --no-svgo ./src/shared/assets/raw-icons/ --out-dir ./src/shared/ui/Icons/",
"gen:api-types": "openapi-typescript http://127.0.0.1:8000/api/schema/ --output src/shared/api/__generated__/openapi.types.ts" "gen:api-types": "openapi-typescript http://127.0.0.1:8000/api/schema/ --output src/shared/api/__generated__/openapi.types.ts",
"test:e2e": "bunx playwright test --project=chromium --headed",
"test:integration": "bunx playwright test --project=integration --headed"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -25,37 +30,46 @@
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.90.14", "@tanstack/react-query": "^5.90.14",
"@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-query-devtools": "^5.91.2",
"@vidstack/react": "^1",
"@wavesurfer/react": "^1.0.12",
"axios": "^1.13.2", "axios": "^1.13.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"moment": "^2.30.1",
"next": "16.1.1", "next": "16.1.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"openapi-fetch": "^0.15.0", "openapi-fetch": "^0.15.0",
"openapi-react-query": "^0.5.1", "openapi-react-query": "^0.5.1",
"react": "^19.2.3", "react": "^19.2.3",
"react-aria-components": "^1.14.0", "react-aria-components": "^1.14.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.71.0", "react-hook-form": "^7.71.0",
"react-modal": "^3.16.3",
"react-modern-drawer": "^1.4.0", "react-modern-drawer": "^1.4.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-resizable-panels": "^4.6.5",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"use-mask-input": "^3.6.0", "use-mask-input": "^3.6.0",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"wavesurfer.js": "^7.12.1",
"xior": "^0.8.2" "xior": "^0.8.2"
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@playwright/test": "^1.58.2",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@types/bun": "^1.3.5", "@types/bun": "^1.3.5",
"@types/jest": "^30.0.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-modal": "^3.16.3",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
+58
View File
@@ -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 { .drawer {
background: transparent; background: transparent !important;
} }
.root { .root {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 220px;
height: 100%; height: 100%;
padding: 16px 12px; background: variables.$bg-default;
background: #ffffff; border-radius: 0 variables.$radius-lg variables.$radius-lg 0;
border-radius: 12px 12px 0 0; box-shadow: var(--shadow-lg);
box-shadow: 0 10px 30px rgba(12, 18, 38, 0.08); overflow: hidden;
} }
.header { .header {
margin-bottom: 12px; display: flex;
font-weight: 700; align-items: center;
font-size: 16px; justify-content: space-between;
color: #0c1226; padding: 16px 16px 12px;
border-bottom: 1px solid variables.$border-subtle;
}
.brand {
@include typography.font-body-16(600);
color: variables.$text-primary;
letter-spacing: -0.01em;
}
.closeButton {
@include mixins.flex-center;
width: 32px;
height: 32px;
border: none;
border-radius: variables.$radius-sm;
background: transparent;
color: variables.$text-secondary;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
svg {
width: 18px;
height: 18px;
}
} }
.list { .list {
list-style: none; @include mixins.reset-list;
margin: 0;
padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 2px;
padding: 8px;
flex: 1;
} }
.item { .item {
@@ -36,40 +65,44 @@
.link, .link,
.button { .button {
display: inline-flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
border: none; border: none;
border-radius: 10px; border-radius: variables.$radius-sm;
background: transparent; background: transparent;
color: #0c1226; color: variables.$text-secondary;
text-decoration: none; text-decoration: none;
font-weight: 600; @include typography.font-body-14(500);
font-size: 14px;
line-height: 1.4;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease; transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background-color: variables.$bg-surface;
color: variables.$text-primary;
} }
.link:hover, &:focus-visible {
.button:hover, outline: 2px solid variables.$color-secondary;
.link:focus-visible, outline-offset: -2px;
.button:focus-visible { border-radius: variables.$radius-sm;
outline: none; }
background-color: #f4f6fb;
&.active {
background-color: variables.$bg-surface;
color: variables.$text-primary;
}
} }
.icon { .icon {
display: inline-flex; display: inline-flex;
flex-shrink: 0;
width: 18px; width: 18px;
height: 18px; height: 18px;
color: #5a6473;
} }
.label { .label {
white-space: nowrap; @include mixins.text-ellipsis;
overflow: hidden;
text-overflow: ellipsis;
} }
@@ -1,10 +1,12 @@
import type { JSX } from "react" import type { JSX } from "react"
import { X } from "lucide-react"
import { FunctionComponent } from "react" import { FunctionComponent } from "react"
import Drawer from "react-modern-drawer" import Drawer from "react-modern-drawer"
import cs from "classnames" import cs from "classnames"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation"
import { INavigationDrawerProps } from "./NavigationDrawer.d" import { INavigationDrawerProps } from "./NavigationDrawer.d"
import styles from "./NavigationDrawer.module.scss" import styles from "./NavigationDrawer.module.scss"
@@ -20,6 +22,8 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
size = 280, size = 280,
title, title,
}): JSX.Element => { }): JSX.Element => {
const pathname = usePathname()
return ( return (
<Drawer <Drawer
open={open} open={open}
@@ -27,14 +31,25 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
direction={position} direction={position}
size={size} size={size}
className={cs(styles.drawer, className)} className={cs(styles.drawer, className)}
aria-label="Navigation drawer" aria-label="Навигация"
duration={200} duration={200}
> >
<nav className={styles.root} data-testid="NavigationDrawer"> <nav className={styles.root} data-testid="NavigationDrawer">
{title ? <div className={styles.header}>{title}</div> : null} <div className={styles.header}>
<span className={styles.brand}>{title ?? "Coffee Project"}</span>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Закрыть навигацию"
>
<X />
</button>
</div>
<ul className={styles.list}> <ul className={styles.list}>
{buttons.map(({ label, icon: Icon, path, action }, index) => { {buttons.map(({ label, icon: Icon, path, action }, index) => {
const key = `${label}-${path ?? index}` const key = `${label}-${path ?? index}`
const isActive = path ? pathname === path : false
const content = ( const content = (
<> <>
@@ -53,7 +68,7 @@ export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
{path ? ( {path ? (
<Link <Link
href={path} href={path}
className={styles.link} className={cs(styles.link, isActive && styles.active)}
onClick={handleClick} onClick={handleClick}
> >
{content} {content}
+3
View File
@@ -17,4 +17,7 @@ export interface IProjectCardProps {
*/ */
imageUrl?: string imageUrl?: string
onClick?: () => void onClick?: () => void
onEdit?: () => void
onRename?: () => void
onDelete?: () => void
} }
@@ -3,56 +3,92 @@
@include mixins.flex-column; @include mixins.flex-column;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden;
position: relative; position: relative;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md ;
box-shadow: var(--shadow-sm);
transition: transition:
transform 0.2s ease, transform variables.$duration-normal variables.$ease-out,
box-shadow 0.2s ease; box-shadow variables.$duration-normal variables.$ease-out,
border-color variables.$duration-normal variables.$ease-out;
cursor: pointer; cursor: pointer;
background: variables.$bg-default;
overflow: hidden;
} }
.hero { .hero {
width: 100%;
height: 180px; height: 180px;
background-color: variables.$purple-50; background-color: variables.$bg-surface;
position: relative; position: relative;
overflow: hidden;
border-radius: 12px 12px 0 0;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-left: -24px;
margin-top: -24px;
width: calc(100% + 48px);
overflow: hidden;
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform variables.$duration-slow variables.$ease-out;
} }
.placeholder { .placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
@include mixins.flex-center; @include mixins.flex-center;
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%); background: linear-gradient(135deg, variables.$bg-surface 0%, variables.$bg-default 100%);
transition: scale variables.$duration-normal variables.$ease-out;
&[data-color-index="0"] {
background: linear-gradient(135deg, variables.$purple-100 0%, variables.$purple-300 100%);
svg { color: variables.$purple-600; opacity: 0.6; }
}
&[data-color-index="1"] {
background: linear-gradient(135deg, variables.$green-100 0%, variables.$green-300 100%);
svg { color: variables.$green-700; opacity: 0.6; }
}
&[data-color-index="2"] {
background: radial-gradient(circle at top left, variables.$purple-200 0%, variables.$purple-50 100%);
svg { color: variables.$purple-500; opacity: 0.5; }
}
&[data-color-index="3"] {
background: radial-gradient(circle at bottom right, variables.$green-200 0%, variables.$green-50 100%);
svg { color: variables.$green-600; opacity: 0.5; }
}
svg { svg {
width: 48px; width: 48px;
height: 48px; height: 48px;
color: variables.$purple-300; color: variables.$text-tertiary;
opacity: 0.5; opacity: 0.35;
transition: transform variables.$duration-normal variables.$ease-out;
} }
} }
} }
.root:hover { .root:hover {
transform: translateY(-2px); transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06); 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 { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 16px; padding: 14px 16px;
gap: 8px; gap: 6px;
flex: 1; flex: 1;
} }
@@ -67,8 +103,7 @@
position: absolute; position: absolute;
inset: -6px; inset: -6px;
border-radius: 50%; border-radius: 50%;
background: rgba(0, 0, 0, 0.2); background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(2px);
z-index: 0; z-index: 0;
} }
@@ -103,7 +138,7 @@
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
@include typography.font-caption-m; @include typography.font-caption-m;
font-weight: 600; font-weight: 700;
color: variables.$color-white; color: variables.$color-white;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
z-index: 2; z-index: 2;
@@ -119,7 +154,7 @@
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
pointer-events: none; pointer-events: none;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.18) 100%); background: linear-gradient(180deg, rgba(0, 0, 0, 0.06) 0%, rgba(0, 0, 0, 0.16) 100%);
z-index: 1; z-index: 1;
} }
@@ -135,7 +170,7 @@
.info { .info {
@include mixins.flex-column; @include mixins.flex-column;
gap: 8px; gap: 4px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
@@ -155,7 +190,7 @@
.date { .date {
@include typography.font-caption-m; @include typography.font-caption-m;
color: variables.$text-secondary; color: variables.$text-tertiary;
} }
.status { .status {
@@ -164,15 +199,15 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@include typography.font-body-s; @include typography.font-caption-m;
font-weight: 500; font-weight: 600;
&.statusGenerated { &.statusGenerated {
color: variables.$color-success; color: variables.$color-success;
} }
&.statusProcessing, &.statusRendering, &.statusUploading { &.statusProcessing, &.statusRendering, &.statusUploading {
color: variables.$purple-500; color: variables.$purple-400;
} }
&.statusDraft { &.statusDraft {
@@ -185,28 +220,27 @@
} }
.statusDot { .statusDot {
width: 8px; width: 6px;
height: 8px; height: 6px;
border-radius: 50%; border-radius: 50%;
background-color: currentColor; background-color: currentColor;
} }
.menuTrigger { .menuTrigger {
margin-left: auto; margin-left: auto;
background-color: variables.$bg-surface; background-color: transparent;
border-radius: 8px; border-radius: variables.$radius-sm;
border: 1px solid rgba(0, 0, 0, 0.06); width: 28px;
width: 32px; height: 28px;
height: 32px;
@include mixins.flex-center; @include mixins.flex-center;
color: variables.$text-primary; color: variables.$text-secondary;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease; transition: background-color variables.$duration-normal variables.$ease-out, color variables.$duration-normal variables.$ease-out;
&:hover, &:hover,
&[data-state="open"] { &[data-state="open"] {
background-color: variables.$bg-default; background-color: variables.$bg-surface;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); color: variables.$text-primary;
} }
button { button {
@@ -223,13 +257,15 @@
.statusBadge { .statusBadge {
position: absolute; position: absolute;
top: 12px; top: 10px;
left: 12px; left: 10px;
padding: 6px 10px; padding: 4px 10px;
border-radius: 12px; border-radius: 20px;
background: variables.$bg-canvas; background: 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; @include typography.font-caption-m;
font-weight: 600;
color: variables.$text-primary; color: variables.$text-primary;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); box-shadow: var(--shadow-sm);
z-index: 2; z-index: 2;
} }
+14 -13
View File
@@ -7,8 +7,8 @@ import { Image as ImageIcon, MoreHorizontal } from "lucide-react"
import { FunctionComponent } from "react" import { FunctionComponent } from "react"
import cs from "classnames" import cs from "classnames"
import moment from "moment"
import { formatRelativeTime } from "@shared/lib/dates"
import { Card } from "@shared/ui/Card" import { Card } from "@shared/ui/Card"
import { CircularProgress } from "@shared/ui/CircularProgress" import { CircularProgress } from "@shared/ui/CircularProgress"
import { import {
@@ -27,6 +27,9 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
currentAction, currentAction,
imageUrl, imageUrl,
onClick, onClick,
onEdit,
onRename,
onDelete,
}): JSX.Element => { }): JSX.Element => {
const { name, updated_at, status } = project const { name, updated_at, status } = project
@@ -38,7 +41,6 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
const shouldShowProgress = isProcessing const shouldShowProgress = isProcessing
// Helper to determine status color/class
const getStatusClass = () => { const getStatusClass = () => {
if (isCompleted) return styles.statusGenerated if (isCompleted) return styles.statusGenerated
if (isProcessing) return styles.statusProcessing if (isProcessing) return styles.statusProcessing
@@ -49,7 +51,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
const getStatusLabel = () => { const getStatusLabel = () => {
if (isCompleted) return "Завершено" if (isCompleted) return "Завершено"
if (isProcessing) return "В процессе" // Or more specific state if (isProcessing) return "В процессе"
if (isDraft) return "Черновик" if (isDraft) return "Черновик"
if (isFailed) return "Ошибка" if (isFailed) return "Ошибка"
return status return status
@@ -63,7 +65,10 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
{imageUrl ? ( {imageUrl ? (
<img src={imageUrl} alt={name} loading="lazy" /> <img src={imageUrl} alt={name} loading="lazy" />
) : ( ) : (
<div className={styles.placeholder}> <div
className={styles.placeholder}
data-color-index={name.charCodeAt(0) % 4}
>
<ImageIcon /> <ImageIcon />
</div> </div>
)} )}
@@ -107,24 +112,20 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
> >
<Dropdown> <Dropdown>
<DropdownTrigger asChild> <DropdownTrigger asChild>
<button type="button" aria-label="Project actions"> <button type="button" aria-label="Действия проекта">
<MoreHorizontal size={16} /> <MoreHorizontal size={16} />
</button> </button>
</DropdownTrigger> </DropdownTrigger>
<DropdownContent align="end"> <DropdownContent align="end">
<DropdownItem <DropdownItem onSelect={() => onEdit?.()}>
onSelect={() => console.log("Edit", project.id)}
>
Изменить Изменить
</DropdownItem> </DropdownItem>
<DropdownItem <DropdownItem onSelect={() => onRename?.()}>
onSelect={() => console.log("Rename", project.id)}
>
Переименовать Переименовать
</DropdownItem> </DropdownItem>
<DropdownItem <DropdownItem
className="text-red-500" className="text-red-500"
onSelect={() => console.log("Delete", project.id)} onSelect={() => onDelete?.()}
> >
Удалить Удалить
</DropdownItem> </DropdownItem>
@@ -133,7 +134,7 @@ export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
</div> </div>
</div> </div>
<span className={styles.date}> <span className={styles.date}>
Создано {moment(updated_at).fromNow()} Создано {formatRelativeTime(updated_at)}
</span> </span>
</div> </div>
</div> </div>
@@ -1,9 +1,9 @@
.root { .root {
display: flex display: flex;
} }
.username { .username {
@include typography.font-body-16(500); @include typography.font-body-14(500);
color: variables.$text-primary; color: variables.$text-primary;
user-select: none; user-select: none;
} }
@@ -12,14 +12,21 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px ; padding: 6px 12px 6px 6px;
border-radius: 24px; border-radius: 24px;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
flex-shrink: 0; flex-shrink: 0;
&:hover { &:hover {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent ); background-color: variables.$bg-surface;
} }
} }
.item {
gap: 8px;
}
.themeItem {
gap: 8px;
}
+75 -4
View File
@@ -1,23 +1,60 @@
import type { JSX } from "react" import type { JSX } from "react"
import Cookies from "js-cookie"
import { Monitor, Moon, Sun } from "lucide-react"
import { FunctionComponent } from "react" import { FunctionComponent } from "react"
import { useRouter } from "next/navigation"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
} from "@shared/lib/constants"
import { setThemePreference } from "@shared/store/appState"
import type { ThemePreference } from "@shared/store/appState/types"
import { resetUser } from "@shared/store/user"
import { Avatar } from "@shared/ui/Avatar" import { Avatar } from "@shared/ui/Avatar"
import { import {
Dropdown, Dropdown,
DropdownContent, DropdownContent,
DropdownItem, DropdownItem,
DropdownRadioGroup,
DropdownRadioItem,
DropdownSeparator, DropdownSeparator,
DropdownSub,
DropdownSubContent,
DropdownSubTrigger,
DropdownTrigger, DropdownTrigger,
} from "@shared/ui/Dropdown" } from "@shared/ui/Dropdown"
import { userDropdownValues } from "./constants" import { navItems } from "./constants"
import { IUserDropdownProps } from "./UserDropdown.d" import { IUserDropdownProps } from "./UserDropdown.d"
import styles from "./UserDropdown.module.scss" import styles from "./UserDropdown.module.scss"
const themeOptions: { value: ThemePreference; label: string; icon: typeof Sun }[] = [
{ value: "light", label: "Светлая", icon: Sun },
{ value: "dark", label: "Тёмная", icon: Moon },
{ value: "system", label: "Системная", icon: Monitor },
]
export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({ export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
user, user,
}): JSX.Element => { }): JSX.Element => {
const router = useRouter()
const dispatch = useAppDispatch()
const themePreference = useAppSelector(
(state) => state.appState.themePreference,
)
const handleLogout = () => {
Cookies.remove(ACCESS_TOKEN_COOKIE)
Cookies.remove(REFRESH_TOKEN_COOKIE)
dispatch(resetUser())
router.push("/login")
}
return ( return (
<div className={styles.root} data-testid="UserDropdown"> <div className={styles.root} data-testid="UserDropdown">
<Dropdown> <Dropdown>
@@ -28,15 +65,49 @@ export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
</div> </div>
</DropdownTrigger> </DropdownTrigger>
<DropdownContent> <DropdownContent>
{userDropdownValues.map((item) => ( {navItems.map((item) => (
<DropdownItem <DropdownItem
key={item.acton} key={item.action}
className={styles.item} className={styles.item}
onSelect={() => console.log(`${item.acton} selected`)} onSelect={() => router.push(item.path)}
> >
{item.label} {item.label}
</DropdownItem> </DropdownItem>
))} ))}
<DropdownSeparator />
<DropdownSub>
<DropdownSubTrigger className={styles.item}>
<Sun size={16} />
Тема
</DropdownSubTrigger>
<DropdownSubContent>
<DropdownRadioGroup
value={themePreference}
onValueChange={(v) =>
dispatch(setThemePreference(v as ThemePreference))
}
>
{themeOptions.map((opt) => (
<DropdownRadioItem
key={opt.value}
value={opt.value}
className={styles.themeItem}
>
<opt.icon size={16} />
{opt.label}
</DropdownRadioItem>
))}
</DropdownRadioGroup>
</DropdownSubContent>
</DropdownSub>
<DropdownSeparator />
<DropdownItem className={styles.item} onSelect={handleLogout}>
Выйти
</DropdownItem>
</DropdownContent> </DropdownContent>
</Dropdown> </Dropdown>
</div> </div>
+3 -7
View File
@@ -1,16 +1,12 @@
export const userDropdownValues = [ export const navItems = [
{ {
label: "Профиль", label: "Профиль",
acton: "profile", action: "profile",
path: "/profile", path: "/profile",
}, },
{ {
label: "Настройки", label: "Настройки",
acton: "settings", action: "settings",
path: "/settings", path: "/settings",
}, },
{
label: "Выйти",
acton: "logout",
},
] ]
+9
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
export { ActionCard } from "./ActionCard"
View File
@@ -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,
}
}
+2
View File
@@ -0,0 +1,2 @@
export { NotificationBell } from "./NotificationBell"
export { NotificationPopup } from "./NotificationPopup"
+5
View File
@@ -0,0 +1,5 @@
export interface IAvatarUploadProps {
currentAvatarUrl: string | null
onAvatarChange: (url: string) => void
className?: string
}
@@ -0,0 +1,38 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatarButton {
all: unset;
cursor: pointer;
border-radius: 50%;
overflow: hidden;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.changeLink {
all: unset;
cursor: pointer;
color: var(--accent-9);
font-size: 14px;
&:hover {
text-decoration: underline;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.fileInput {
display: none;
}
@@ -0,0 +1,81 @@
"use client"
import type { IAvatarUploadProps } from "./AvatarUpload.d"
import type { JSX } from "react"
import cs from "classnames"
import { FunctionComponent, useRef, useState } from "react"
import { uploadFileWithProgress } from "@shared/api/uploadFile"
import { Avatar } from "@shared/ui/Avatar"
import styles from "./AvatarUpload.module.scss"
export const AvatarUpload: FunctionComponent<
IAvatarUploadProps
> = ({ currentAvatarUrl, onAvatarChange, className }): JSX.Element => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const handleClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0]
if (!file) return
setUploadProgress(0)
try {
const result = await uploadFileWithProgress(
file,
"avatars",
setUploadProgress,
)
onAvatarChange(result.file_path)
} catch (err) {
console.error("Avatar upload failed:", err)
} finally {
setUploadProgress(null)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
return (
<div
className={cs(styles.root, className)}
data-testid="AvatarUpload"
>
<button
type="button"
className={styles.avatarButton}
onClick={handleClick}
disabled={uploadProgress !== null}
>
<Avatar
size="xlarge"
url={currentAvatarUrl || ""}
/>
</button>
<button
type="button"
className={styles.changeLink}
onClick={handleClick}
disabled={uploadProgress !== null}
>
{uploadProgress !== null
? `Загрузка ${uploadProgress}%`
: "Изменить фото"}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className={styles.fileInput}
onChange={handleFileChange}
/>
</div>
)
}
@@ -0,0 +1 @@
export * from "./AvatarUpload"
@@ -0,0 +1,3 @@
export interface IChangePasswordFormProps {
className?: string
}
@@ -0,0 +1,17 @@
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
}
@@ -0,0 +1,108 @@
"use client"
import type { IChangePasswordFormProps } from "./ChangePasswordForm.d"
import type { JSX } from "react"
import { FunctionComponent, useState } from "react"
import { useForm } from "react-hook-form"
import api from "@shared/api"
import { Alert, Button, Form, TextField } from "@shared/ui"
import styles from "./ChangePasswordForm.module.scss"
interface IPasswordFormData {
current_password: string
new_password: string
confirm_password: string
}
export const ChangePasswordForm: FunctionComponent<
IChangePasswordFormProps
> = ({ className }): JSX.Element => {
const [successMessage, setSuccessMessage] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { register, handleSubmit, reset, formState: { errors } } = useForm<IPasswordFormData>({
defaultValues: {
current_password: "",
new_password: "",
confirm_password: "",
},
})
const { mutate, isPending } = api.useMutation(
"post",
"/api/users/me/change-password/",
{
onSuccess: () => {
setErrorMessage(null)
setSuccessMessage(true)
reset()
setTimeout(() => setSuccessMessage(false), 3000)
},
onError: (error) => {
setSuccessMessage(false)
setErrorMessage(
(error as { detail?: string })?.detail || "Не удалось сменить пароль",
)
},
},
)
const onSubmit = (data: IPasswordFormData) => {
setErrorMessage(null)
mutate({
body: {
current_password: data.current_password,
new_password: data.new_password,
},
})
}
return (
<div className={className} data-testid="ChangePasswordForm">
<h3 className={styles.title}>Смена пароля</h3>
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="current_password"
label="Текущий пароль"
placeholder="Введите текущий пароль"
type="password"
{...register("current_password", { required: "Обязательное поле" })}
/>
<TextField
id="new_password"
label="Новый пароль"
placeholder="Введите новый пароль"
type="password"
{...register("new_password", { required: "Обязательное поле" })}
/>
<TextField
id="confirm_password"
label="Подтверждение пароля"
placeholder="Повторите новый пароль"
type="password"
error={!!errors.confirm_password}
{...register("confirm_password", {
required: "Обязательное поле",
validate: (value, formValues) =>
value === formValues.new_password || "Пароли не совпадают",
})}
/>
{errors.confirm_password && (
<Alert variant="danger">{errors.confirm_password.message}</Alert>
)}
</div>
{successMessage && (
<Alert variant="success">Пароль успешно изменён</Alert>
)}
{errorMessage && <Alert variant="danger">{errorMessage}</Alert>}
<Button type="submit" variant="primary" disabled={isPending}>
{isPending ? "Сохранение..." : "Сменить пароль"}
</Button>
</Form>
</div>
)
}
@@ -0,0 +1 @@
export * from "./ChangePasswordForm"
@@ -0,0 +1,6 @@
import type { components } from "@shared/api/__generated__/openapi.types"
export interface IEditProfileFormProps {
user: components["schemas"]["UserRead"]
className?: string
}
@@ -0,0 +1,17 @@
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
}
@@ -0,0 +1,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"
+3
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
export { AvatarUpload } from "./AvatarUpload"
export { ChangePasswordForm } from "./ChangePasswordForm"
export { EditProfileForm } from "./EditProfileForm"
export { LogoutButton } from "./LogoutButton"
@@ -0,0 +1,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>
Удалить пресет &laquo;{deleteTarget.name}&raquo;? Это действие
нельзя отменить.
</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"
@@ -1,8 +1,7 @@
import type { Dialog } from "@radix-ui/themes" import type { IModalProps } from "@shared/ui/Modal/Modal.d"
import type { ComponentProps } from "react"
export interface ICreateProjectModalProps extends Pick< export interface ICreateProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>, IModalProps,
"open" | "onOpenChange" "open" | "onOpenChange"
> { > {
onCreated?: () => void | Promise<void> onCreated?: () => void | Promise<void>
@@ -1,5 +1,5 @@
.root { .root {
min-width: 520px; width: 100%;
} }
.fields { .fields {
@@ -20,6 +20,6 @@
} }
.selectLabel { .selectLabel {
font-size: 14px; @include typography.font-body-14(500);
font-weight: 500; color: variables.$text-primary;
} }
@@ -1,6 +1,5 @@
"use client" "use client"
import type { ProjectCreateBody } from "./useCreateProject"
import type { ICreateProjectModalProps } from "./CreateProjectModal.d" import type { ICreateProjectModalProps } from "./CreateProjectModal.d"
import type { JSX } from "react" import type { JSX } from "react"
@@ -12,27 +11,16 @@ import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
import { useCreateProject } from "./useCreateProject" import { useCreateProject } from "./useCreateProject"
import styles from "./CreateProjectModal.module.scss" import styles from "./CreateProjectModal.module.scss"
type ProjectStatus = ProjectCreateBody["status"]
interface ICreateProjectFormData { interface ICreateProjectFormData {
name: string name: string
description?: string description?: string
language: string language: string
folder?: string
status: ProjectStatus
} }
const STATUS_OPTIONS: Array<{ value: ProjectStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "PROCESSING", label: "Processing" },
{ value: "DONE", label: "Done" },
{ value: "FAILED", label: "Failed" },
]
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [ const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "auto", label: "Auto" }, { value: "auto", label: "Авто" },
{ value: "ru", label: "Russian" }, { value: "ru", label: "Русский" },
{ value: "en", label: "English" }, { value: "en", label: "Английский" },
] ]
export const CreateProjectModal: FunctionComponent< export const CreateProjectModal: FunctionComponent<
@@ -43,9 +31,7 @@ export const CreateProjectModal: FunctionComponent<
defaultValues: { defaultValues: {
name: "", name: "",
description: "", description: "",
folder: "",
language: "auto", language: "auto",
status: "DRAFT",
}, },
}) })
@@ -66,15 +52,12 @@ export const CreateProjectModal: FunctionComponent<
const onSubmit = (data: ICreateProjectFormData): void => { const onSubmit = (data: ICreateProjectFormData): void => {
const name = data.name.trim() const name = data.name.trim()
const description = data.description?.trim() const description = data.description?.trim()
const folder = data.folder?.trim()
mutate({ mutate({
body: { body: {
name, name,
description: description?.length ? description : undefined, description: description?.length ? description : undefined,
folder: folder?.length ? folder : undefined,
language: data.language, language: data.language,
status: data.status,
}, },
}) })
} }
@@ -109,13 +92,6 @@ export const CreateProjectModal: FunctionComponent<
{...register("description")} {...register("description")}
/> />
<TextField
id="project_folder"
label="Папка"
placeholder="Например: /projects/my-project (необязательно)"
{...register("folder")}
/>
<div className={styles.selectField}> <div className={styles.selectField}>
<div className={styles.selectLabel}>Язык</div> <div className={styles.selectLabel}>Язык</div>
<Controller <Controller
@@ -136,33 +112,12 @@ export const CreateProjectModal: FunctionComponent<
)} )}
/> />
</div> </div>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Статус</div>
<Controller
name="status"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите статус"
>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
<Button <Button
type="button" type="button"
variant="ghost" variant="outline"
disabled={isPending} disabled={isPending}
onClick={() => onOpenChange?.(false)} onClick={() => onOpenChange?.(false)}
> >
@@ -0,0 +1,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