rev 4
This commit is contained in:
@@ -1,336 +1,15 @@
|
|||||||
# AGENTS.md — Coffee Project Frontend
|
# AGENTS.md — Coffee Project Frontend
|
||||||
|
|
||||||
Primary Codex reference: [`../.codex/services/frontend.md`](/Users/daniilrakityansky/Documents/Work/Cofee/.codex/services/frontend.md).
|
Primary workflow guidance lives in `../AGENTS.md`.
|
||||||
If this file conflicts with the `.codex` guide, prefer the `.codex` guide. `CLAUDE.md` remains for Claude-specific tooling.
|
|
||||||
|
|
||||||
## Project Overview
|
Use `./CLAUDE.md` as the service-specific source of truth for:
|
||||||
|
|
||||||
Next.js 16 application using **Feature-Sliced Design (FSD)** architecture, powered by **Bun** runtime and package manager.
|
- frontend commands
|
||||||
|
- FSD architecture and boundaries
|
||||||
|
- frontend conventions and gotchas
|
||||||
|
|
||||||
---
|
OpenCode/Codex notes:
|
||||||
|
|
||||||
## Tech Stack
|
- Keep `../AGENTS.md` as the workflow and delegation source of truth.
|
||||||
|
- Treat `CLAUDE.md` as architecture, commands, and conventions only.
|
||||||
| Category | Technology |
|
- Do not rely on `.claude/` directory contents.
|
||||||
| ------------- | ------------------------------------------ |
|
|
||||||
| Runtime | Bun 1.3.5 |
|
|
||||||
| Framework | Next.js 16.1.1 (App Router) |
|
|
||||||
| Language | TypeScript 5.9 |
|
|
||||||
| UI Library | React 19 |
|
|
||||||
| Styling | SCSS Modules, normalize.css |
|
|
||||||
| State/Fetch | TanStack React Query 5, Axios, Xior |
|
|
||||||
| Animation | Framer Motion |
|
|
||||||
| Utilities | Lodash, date-fns, 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
|
|
||||||
5. **Features are module-aware** — group features by domain inside module folders (see below)
|
|
||||||
|
|
||||||
### When to Split Files
|
|
||||||
|
|
||||||
Split into separate files **only when**:
|
|
||||||
|
|
||||||
- Hook/API is reused by multiple components
|
|
||||||
- File exceeds ~200 lines
|
|
||||||
- Props interface is shared across 3+ components
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features Layer — Module-Aware Structure
|
|
||||||
|
|
||||||
Features **must be grouped by domain module**. Never place feature folders flat at the top of `src/features/`.
|
|
||||||
|
|
||||||
```
|
|
||||||
src/features/
|
|
||||||
├── profile/ # Profile domain module
|
|
||||||
│ ├── index.ts # Barrel export for all features in this module
|
|
||||||
│ ├── AvatarUpload/
|
|
||||||
│ ├── EditProfileForm/
|
|
||||||
│ └── LogoutButton/
|
|
||||||
└── project/ # Project domain module
|
|
||||||
├── index.ts
|
|
||||||
├── CreateProjectModal/
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
- Each module folder has an `index.ts` barrel that re-exports all its features
|
|
||||||
- Import via the module barrel: `import { AvatarUpload } from "@features/profile"`
|
|
||||||
- When creating a new feature, place it inside the relevant domain folder
|
|
||||||
- After running `bun run gc feature <Name>`, move the generated folder into the correct module
|
|
||||||
- Create a new module folder + barrel if the domain doesn't exist yet
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Shared Utilities
|
|
||||||
|
|
||||||
Reusable operations should live in `src/shared/` — **do not inline shared logic inside feature components**.
|
|
||||||
|
|
||||||
### File Upload
|
|
||||||
|
|
||||||
Use `uploadFile()` from `@shared/api/uploadFile` for any file upload:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { uploadFile } from "@shared/api/uploadFile"
|
|
||||||
|
|
||||||
const result = await uploadFile(file, "avatars")
|
|
||||||
// result.file_url — URL of the uploaded file
|
|
||||||
// result.file_path — storage path
|
|
||||||
```
|
|
||||||
|
|
||||||
This handles FormData construction, Content-Type header override, and JWT auth automatically.
|
|
||||||
|
|
||||||
### Date Formatting
|
|
||||||
|
|
||||||
Use `date-fns` with Russian locale via shared utilities in `src/shared/lib/dates.ts`. **Never use `moment.js` or inline `Date` formatting in components.**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { formatDate, formatRelativeTime } from "@shared/lib/dates"
|
|
||||||
|
|
||||||
formatDate(user.date_joined) // "21.02.2026" (default: "dd.MM.yyyy")
|
|
||||||
formatDate(date, "dd MMM yyyy") // "21 февр. 2026"
|
|
||||||
formatRelativeTime(project.updated_at) // "2 дня назад"
|
|
||||||
```
|
|
||||||
|
|
||||||
Add new date helpers to `src/shared/lib/dates.ts`, not to individual components.
|
|
||||||
|
|
||||||
### API Client
|
|
||||||
|
|
||||||
- **In React components**: use `api.useQuery()` / `api.useMutation()` from `@shared/api`
|
|
||||||
- **Outside React** (utilities, event handlers): use `fetchClient` from `@shared/api`
|
|
||||||
- **File uploads**: use `uploadFile()` from `@shared/api/uploadFile`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Icons Workflow
|
|
||||||
|
|
||||||
1. Place raw SVG in `src/shared/assets/raw-icons/`
|
|
||||||
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) |
|
|
||||||
|
|
||||||
## Localization
|
|
||||||
|
|
||||||
All user-facing UI text **must be in Russian**. This includes: labels, headings, buttons, placeholders, tooltips, aria-labels, error messages, breadcrumbs, and any other text visible to the user. The only exception is the brand name "Coffee Project" / "Cofee Project" — it stays in English.
|
|
||||||
|
|
||||||
## Implementation sentiments
|
|
||||||
|
|
||||||
Write less complicated code, simple but readable code
|
|
||||||
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>`
|
|
||||||
To test is project have no errors use
|
|
||||||
`bunx tsc --noEmit`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Mistakes to Avoid
|
|
||||||
|
|
||||||
1. **Flat features folder** — never place feature component folders directly in `src/features/`. Always group them inside a domain module folder (`profile/`, `project/`, etc.).
|
|
||||||
2. **Inlining reusable logic** — if an operation (file upload, date formatting, etc.) could be used by multiple features, extract it to `src/shared/`. Features should be thin wrappers around shared utilities.
|
|
||||||
3. **Wrong StaticLoader import** — it lives at `@shared/ui/Loader`, not `@shared/ui/Loader/StaticLoader`. There is no subdirectory.
|
|
||||||
4. **multipart/form-data with fetchClient** — the default `fetchClient` sets `Content-Type: application/json`. For file uploads you must override headers and body serializer. Use the shared `uploadFile()` utility instead.
|
|
||||||
5. **Broken lint scripts** — `bun run lint` calls `lint:es` and `lint:prettier` which are not defined in `package.json`. Use `bunx tsc --noEmit` for type checking until lint is fixed.
|
|
||||||
6. **Generator output needs moving** — `bun run gc feature <Name>` creates the folder flat in `src/features/`. You must manually move it into the correct domain module folder afterward.
|
|
||||||
7. **Raw `fetch` / `useEffect` for API calls** — never use plain `fetch` or `useEffect`-based polling for API requests. Always use `api.useQuery()` / `api.useMutation()` from `@shared/api` which wraps TanStack Query + openapi-fetch. For polling, use `refetchInterval`. Raw `fetch` bypasses typed routes, auth middleware, and query caching.
|
|
||||||
|
|||||||
@@ -103,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +261,8 @@
|
|||||||
left: 10px;
|
left: 10px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: color-mix(in srgb, variables.$bg-default 88%, transparent);
|
||||||
backdrop-filter: blur(8px);
|
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;
|
font-weight: 600;
|
||||||
color: variables.$text-primary;
|
color: variables.$text-primary;
|
||||||
|
|||||||
@@ -38,14 +38,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.accent {
|
&.accent {
|
||||||
background: linear-gradient(135deg, variables.$purple-400 0%, variables.$purple-600 100%);
|
background: linear-gradient(135deg, variables.$accent-solid-start 0%, variables.$accent-solid-end 100%);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: variables.$color-white;
|
color: variables.$accent-foreground;
|
||||||
box-shadow: 0 4px 14px hsla(262, 75%, 48%, 0.25);
|
box-shadow: 0 4px 14px variables.$accent-shadow;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: linear-gradient(135deg, variables.$purple-500 0%, variables.$purple-700 100%);
|
filter: brightness(1.06);
|
||||||
box-shadow: 0 6px 20px hsla(262, 75%, 48%, 0.4);
|
box-shadow: 0 6px 20px variables.$accent-shadow-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,55 +83,51 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemTitle {
|
.itemHeader {
|
||||||
@include typography.font-body-14(600);
|
display: flex;
|
||||||
color: variables.$text-primary;
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemHeadline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemMessage {
|
.itemTitle {
|
||||||
@include typography.font-caption-m;
|
@include typography.font-body-14(600);
|
||||||
color: variables.$text-secondary;
|
color: variables.$text-primary;
|
||||||
margin-top: 2px;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTitleText {
|
||||||
|
display: block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemMeta {
|
.itemStatusBadge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTime {
|
||||||
@include typography.font-caption-m;
|
@include typography.font-caption-m;
|
||||||
color: variables.$text-tertiary;
|
color: variables.$text-tertiary;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMessage {
|
||||||
|
@include typography.font-caption-m;
|
||||||
|
color: variables.$text-secondary;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
align-items: center;
|
overflow: hidden;
|
||||||
gap: 8px;
|
text-overflow: ellipsis;
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusRunning {
|
|
||||||
background-color: hsl(262, 50%, 94%);
|
|
||||||
color: hsl(262, 72%, 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusDone {
|
|
||||||
background-color: hsl(150, 30%, 92%);
|
|
||||||
color: hsl(150, 50%, 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusFailed {
|
|
||||||
background-color: hsl(0, 80%, 95%);
|
|
||||||
color: hsl(0, 65%, 40%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressBar {
|
.progressBar {
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import { FunctionComponent, useCallback, useEffect, useRef } from "react"
|
|||||||
import { useDispatch } from "react-redux"
|
import { useDispatch } from "react-redux"
|
||||||
|
|
||||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||||
import { formatRelativeTime } from "@shared/lib/dates"
|
import { formatNotificationRelativeTime } from "@shared/lib/dates"
|
||||||
import { API_URL } from "@shared/lib/constants"
|
import { API_URL } from "@shared/lib/constants"
|
||||||
import {
|
import {
|
||||||
markAllRead,
|
markAllRead,
|
||||||
markRead,
|
markRead,
|
||||||
NotificationItem,
|
NotificationItem,
|
||||||
} from "@shared/store/notifications"
|
} from "@shared/store/notifications"
|
||||||
|
import { Badge } from "@shared/ui"
|
||||||
|
|
||||||
|
import { getNotificationPresentation } from "./presentation"
|
||||||
|
|
||||||
const apiBase = API_URL || "http://localhost:8000"
|
const apiBase = API_URL || "http://localhost:8000"
|
||||||
|
|
||||||
@@ -27,34 +30,6 @@ function authHeaders(): HeadersInit {
|
|||||||
|
|
||||||
import styles from "./NotificationPopup.module.scss"
|
import styles from "./NotificationPopup.module.scss"
|
||||||
|
|
||||||
const JOB_TYPE_LABELS: Record<string, string> = {
|
|
||||||
MEDIA_PROBE: "Анализ медиа",
|
|
||||||
SILENCE_REMOVE: "Удаление тишины",
|
|
||||||
MEDIA_CONVERT: "Конвертация",
|
|
||||||
TRANSCRIPTION_GENERATE: "Транскрипция",
|
|
||||||
CAPTIONS_GENERATE: "Генерация субтитров",
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
PENDING: "Ожидание",
|
|
||||||
RUNNING: "Выполняется",
|
|
||||||
DONE: "Завершено",
|
|
||||||
FAILED: "Ошибка",
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusClass(status: string | null): string {
|
|
||||||
switch (status) {
|
|
||||||
case "RUNNING":
|
|
||||||
return styles.statusRunning
|
|
||||||
case "DONE":
|
|
||||||
return styles.statusDone
|
|
||||||
case "FAILED":
|
|
||||||
return styles.statusFailed
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
|
export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
|
||||||
onClose,
|
onClose,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
@@ -120,7 +95,10 @@ export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className={styles.empty}>Нет уведомлений</div>
|
<div className={styles.empty}>Нет уведомлений</div>
|
||||||
) : (
|
) : (
|
||||||
items.map((item, idx) => (
|
items.map((item, idx) => {
|
||||||
|
const presentation = getNotificationPresentation(item)
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.notification_id || `${item.job_id}-${idx}`}
|
key={item.notification_id || `${item.job_id}-${idx}`}
|
||||||
className={cs(styles.item, {
|
className={cs(styles.item, {
|
||||||
@@ -129,28 +107,30 @@ export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
|
|||||||
onClick={() => handleItemClick(item)}
|
onClick={() => handleItemClick(item)}
|
||||||
>
|
>
|
||||||
<div className={styles.itemContent}>
|
<div className={styles.itemContent}>
|
||||||
|
<div className={styles.itemHeader}>
|
||||||
|
<div className={styles.itemHeadline}>
|
||||||
<div className={styles.itemTitle}>
|
<div className={styles.itemTitle}>
|
||||||
<span>
|
<span className={styles.itemTitleText}>
|
||||||
{item.job_type
|
{presentation.title}
|
||||||
? (JOB_TYPE_LABELS[item.job_type] ||
|
|
||||||
item.title)
|
|
||||||
: item.title}
|
|
||||||
</span>
|
</span>
|
||||||
{item.status && (
|
</div>
|
||||||
<span
|
{presentation.statusText &&
|
||||||
className={cs(
|
presentation.statusVariant && (
|
||||||
styles.statusBadge,
|
<Badge
|
||||||
getStatusClass(item.status),
|
variant={presentation.statusVariant}
|
||||||
)}
|
className={styles.itemStatusBadge}
|
||||||
>
|
>
|
||||||
{STATUS_LABELS[item.status] ||
|
{presentation.statusText}
|
||||||
item.status}
|
</Badge>
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{item.message && (
|
<span className={styles.itemTime}>
|
||||||
|
{formatNotificationRelativeTime(item.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{presentation.detailText && (
|
||||||
<div className={styles.itemMessage}>
|
<div className={styles.itemMessage}>
|
||||||
{item.message}
|
{presentation.detailText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.status === "RUNNING" &&
|
{item.status === "RUNNING" &&
|
||||||
@@ -164,12 +144,10 @@ export const NotificationPopup: FunctionComponent<INotificationPopupProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.itemMeta}>
|
|
||||||
{formatRelativeTime(item.created_at)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,168 @@
|
|||||||
|
/* ── 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 {
|
.root {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
flex: 1;
|
||||||
padding: 24px;
|
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 {
|
.title {
|
||||||
font-size: 20px;
|
@include typography.font-header-l;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--gray-12);
|
color: variables.$text-primary;
|
||||||
margin: 0;
|
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 {
|
.playerWrapper {
|
||||||
border-radius: 12px;
|
position: relative;
|
||||||
|
border-radius: variables.$radius-md;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
max-height: 60vh;
|
box-shadow: variables.$shadow-lg;
|
||||||
aspect-ratio: 16 / 9;
|
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 {
|
.player {
|
||||||
@@ -30,29 +175,92 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
color: var(--gray-9);
|
color: variables.$text-tertiary;
|
||||||
|
@include typography.font-body-14(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filename {
|
/* ── File info bar ── */
|
||||||
font-size: 13px;
|
|
||||||
color: var(--gray-9);
|
.fileInfoContainer {
|
||||||
margin: 0;
|
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 {
|
.loading {
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--gray-9);
|
color: variables.$text-tertiary;
|
||||||
|
@include typography.font-body-14(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-top: 16px;
|
align-items: center;
|
||||||
border-top: 1px solid var(--gray-6);
|
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 {
|
.rightActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Reduced motion ── */
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.header,
|
||||||
|
.playerContainer,
|
||||||
|
.fileInfoContainer,
|
||||||
|
.footer {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkle {
|
||||||
|
animation: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,17 +12,33 @@ import {
|
|||||||
import "@vidstack/react/player/styles/default/theme.css"
|
import "@vidstack/react/player/styles/default/theme.css"
|
||||||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||||||
|
|
||||||
import { Download, RefreshCw } from "lucide-react"
|
import {
|
||||||
|
Check,
|
||||||
|
CheckCircle,
|
||||||
|
Download,
|
||||||
|
FileVideo,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react"
|
||||||
import { FunctionComponent, useMemo } from "react"
|
import { FunctionComponent, useMemo } from "react"
|
||||||
|
|
||||||
import cs from "classnames"
|
import cs from "classnames"
|
||||||
|
|
||||||
import api from "@shared/api"
|
import api from "@shared/api"
|
||||||
import { useWizard } from "@shared/context/WizardContext"
|
import { useWizard } from "@shared/context/WizardContext"
|
||||||
import { Button } from "@shared/ui"
|
import { Badge, Button } from "@shared/ui"
|
||||||
|
|
||||||
import styles from "./CaptionResultStep.module.scss"
|
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> = ({
|
export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
|
||||||
className,
|
className,
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
@@ -73,20 +89,11 @@ export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
|
|||||||
setCaptionedVideoPath(effectivePath)
|
setCaptionedVideoPath(effectivePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: fileRecord } = api.useQuery(
|
|
||||||
"get",
|
|
||||||
"/api/files/files/{file_id}/",
|
|
||||||
{ params: { path: { file_id: effectiveFileId ?? "" } } },
|
|
||||||
{ enabled: !!effectiveFileId },
|
|
||||||
)
|
|
||||||
|
|
||||||
const filePath = fileRecord?.path ?? effectivePath ?? ""
|
|
||||||
|
|
||||||
const { data: fileInfo, isLoading } = api.useQuery(
|
const { data: fileInfo, isLoading } = api.useQuery(
|
||||||
"get",
|
"get",
|
||||||
"/api/files/get_file/",
|
"/api/files/files/{file_id}/resolve/",
|
||||||
{ params: { query: { file_path: filePath } } },
|
{ params: { path: { file_id: effectiveFileId ?? "" } } },
|
||||||
{ enabled: !!filePath },
|
{ enabled: !!effectiveFileId },
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoUrl = fileInfo?.file_url ?? ""
|
const videoUrl = fileInfo?.file_url ?? ""
|
||||||
@@ -107,6 +114,19 @@ export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
|
|||||||
markStepCompleted("caption-result")
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={cs(styles.root, className)}>
|
<div className={cs(styles.root, className)}>
|
||||||
@@ -117,8 +137,44 @@ export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cs(styles.root, className)} data-testid="CaptionResultStep">
|
<div className={cs(styles.root, className)} data-testid="CaptionResultStep">
|
||||||
<h2 className={styles.title}>Результат</h2>
|
{/* 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}>
|
<div className={styles.playerWrapper}>
|
||||||
{videoUrl ? (
|
{videoUrl ? (
|
||||||
<MediaPlayer
|
<MediaPlayer
|
||||||
@@ -134,22 +190,37 @@ export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
|
|||||||
<div className={styles.placeholder}>Видео недоступно</div>
|
<div className={styles.placeholder}>Видео недоступно</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File info bar */}
|
||||||
{fileInfo?.filename && (
|
{fileInfo?.filename && (
|
||||||
<p className={styles.filename}>{fileInfo.filename}</p>
|
<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}>
|
<div className={styles.footer}>
|
||||||
<Button variant="outline" onClick={handleRerender}>
|
<Button variant="ghost" onClick={handleRerender}>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
Перегенерировать
|
Перегенерировать
|
||||||
</Button>
|
</Button>
|
||||||
<div className={styles.rightActions}>
|
<div className={styles.rightActions}>
|
||||||
<Button variant="outline" onClick={handleDownload}>
|
<Button variant="primary" onClick={handleDownload}>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
Скачать
|
Скачать
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={handleFinish}>
|
<Button variant="outline" onClick={handleFinish}>
|
||||||
|
<Check size={16} />
|
||||||
Завершить
|
Завершить
|
||||||
</Button>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,122 +1,46 @@
|
|||||||
.grid {
|
.grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
justify-content: center;
|
|
||||||
align-content: flex-start;
|
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100cqh;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 2px solid var(--gray-6);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
background: var(--gray-2);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--gray-8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected {
|
|
||||||
border-color: var(--accent-9);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--accent-10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardFooter {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardName {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--gray-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.systemBadge {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-11);
|
|
||||||
background: var(--accent-3);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardActions {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
|
|
||||||
.card:hover & {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconButton {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--gray-3);
|
|
||||||
color: var(--gray-11);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--gray-5);
|
|
||||||
color: var(--gray-12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.createCard {
|
.createCard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 100cqh;
|
background: variables.$bg-default;
|
||||||
box-sizing: border-box;
|
border: 1.5px dashed variables.$border-default;
|
||||||
aspect-ratio: 9 / 16;
|
border-radius: variables.$radius-md;
|
||||||
border-style: dashed;
|
cursor: pointer;
|
||||||
border-color: var(--gray-7);
|
transition: border-color var(--duration-normal) var(--ease-out);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--accent-8);
|
border-color: variables.$color-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.createPreview {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.createIcon {
|
.createIcon {
|
||||||
color: var(--gray-9);
|
color: variables.$text-tertiary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.createLabel {
|
.createLabel {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--gray-9);
|
color: variables.$text-tertiary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--gray-9);
|
color: variables.$text-tertiary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deleteActions {
|
.deleteActions {
|
||||||
@@ -125,3 +49,16 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 16px;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,19 +3,22 @@
|
|||||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
import cs from "classnames"
|
import { Plus } from "lucide-react"
|
||||||
import { Pencil, Plus, Trash2 } from "lucide-react"
|
|
||||||
import { FunctionComponent, useState } from "react"
|
import { FunctionComponent, useState } from "react"
|
||||||
|
|
||||||
|
import { useWizard } from "@shared/context/WizardContext"
|
||||||
import { Button, Modal } from "@shared/ui"
|
import { Button, Modal } from "@shared/ui"
|
||||||
|
|
||||||
import { StylePreview } from "./StylePreview"
|
import { PresetCard } from "./PresetCard"
|
||||||
|
import { PresetCardSkeleton } from "./PresetCardSkeleton"
|
||||||
import { useDeletePreset, usePresetsQuery } from "./useCaptionPresets"
|
import { useDeletePreset, usePresetsQuery } from "./useCaptionPresets"
|
||||||
|
import { useVideoMetadata } from "./useVideoMetadata"
|
||||||
import styles from "./PresetGrid.module.scss"
|
import styles from "./PresetGrid.module.scss"
|
||||||
|
|
||||||
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
|
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
|
||||||
|
|
||||||
|
const SKELETON_COUNT = 5
|
||||||
|
|
||||||
interface IPresetGridProps {
|
interface IPresetGridProps {
|
||||||
selectedPresetId: string | null
|
selectedPresetId: string | null
|
||||||
onSelect: (presetId: string) => void
|
onSelect: (presetId: string) => void
|
||||||
@@ -23,65 +26,23 @@ interface IPresetGridProps {
|
|||||||
onCreateNew: () => void
|
onCreateNew: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PresetCard: FunctionComponent<{
|
|
||||||
preset: CaptionPresetRead
|
|
||||||
isSelected: boolean
|
|
||||||
onSelect: () => void
|
|
||||||
onEdit: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
}> = ({ preset, isSelected, onSelect, onEdit, onDelete }) => (
|
|
||||||
<div
|
|
||||||
className={cs(styles.card, { [styles.selected]: isSelected })}
|
|
||||||
onClick={onSelect}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<StylePreview config={preset.style_config} size="small" />
|
|
||||||
<div className={styles.cardFooter}>
|
|
||||||
<span className={styles.cardName}>{preset.name}</span>
|
|
||||||
{preset.is_system && (
|
|
||||||
<span className={styles.systemBadge}>Системный</span>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
|
export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onEdit,
|
onEdit,
|
||||||
onCreateNew,
|
onCreateNew,
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const { data: presets, isLoading } = usePresetsQuery()
|
const { primaryFileId } = useWizard()
|
||||||
|
const { aspectRatio, isLoading: isMetadataLoading } =
|
||||||
|
useVideoMetadata(primaryFileId)
|
||||||
|
const { data: presets, isLoading: isPresetsLoading } = usePresetsQuery()
|
||||||
const deletePreset = useDeletePreset()
|
const deletePreset = useDeletePreset()
|
||||||
const [deleteTarget, setDeleteTarget] = useState<CaptionPresetRead | null>(
|
const [deleteTarget, setDeleteTarget] = useState<CaptionPresetRead | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isLoading = isPresetsLoading
|
||||||
|
|
||||||
const handleConfirmDelete = () => {
|
const handleConfirmDelete = () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
deletePreset.mutate(
|
deletePreset.mutate(
|
||||||
@@ -91,29 +52,39 @@ export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className={styles.loading}>Загрузка пресетов...</div>
|
return (
|
||||||
|
<div className={styles.grid} data-testid="PresetGrid">
|
||||||
|
{Array.from({ length: SKELETON_COUNT }, (_, i) => (
|
||||||
|
<PresetCardSkeleton key={i} aspectRatio={aspectRatio} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid} data-testid="PresetGrid">
|
||||||
{presets?.map((preset) => (
|
{presets?.map((preset) => (
|
||||||
<PresetCard
|
<PresetCard
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
preset={preset}
|
preset={preset}
|
||||||
isSelected={selectedPresetId === preset.id}
|
isSelected={selectedPresetId === preset.id}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
onSelect={() => onSelect(preset.id)}
|
onSelect={() => onSelect(preset.id)}
|
||||||
onEdit={() => onEdit(preset)}
|
onEdit={() => onEdit(preset)}
|
||||||
onDelete={() => setDeleteTarget(preset)}
|
onDelete={() => setDeleteTarget(preset)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div
|
<div
|
||||||
className={cs(styles.card, styles.createCard)}
|
className={styles.createCard}
|
||||||
onClick={onCreateNew}
|
onClick={onCreateNew}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
data-testid="PresetGrid-CreateCard"
|
||||||
>
|
>
|
||||||
|
<div className={styles.createPreview} style={{ aspectRatio }}>
|
||||||
<Plus size={32} className={styles.createIcon} />
|
<Plus size={32} className={styles.createIcon} />
|
||||||
|
</div>
|
||||||
<span className={styles.createLabel}>Создать пресет</span>
|
<span className={styles.createLabel}>Создать пресет</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,14 +96,11 @@ export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
|
|||||||
title="Удаление пресета"
|
title="Удаление пресета"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Удалить пресет «{deleteTarget.name}»? Это
|
Удалить пресет «{deleteTarget.name}»? Это действие
|
||||||
действие нельзя отменить.
|
нельзя отменить.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.deleteActions}>
|
<div className={styles.deleteActions}>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeleteTarget(null)}
|
|
||||||
>
|
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.small {
|
.small {
|
||||||
--preview-h: calc(100cqh - 38px);
|
width: 100%;
|
||||||
height: var(--preview-h);
|
|
||||||
width: calc(var(--preview-h) * 9 / 16);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.large {
|
.large {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface IStylePreviewProps {
|
|||||||
aspectRatio?: number
|
aspectRatio?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const SMALL_SCALE = 0.65
|
const SMALL_SCALE = 0.45
|
||||||
|
|
||||||
const buildContainerStyles = (
|
const buildContainerStyles = (
|
||||||
config: CaptionStyleConfig,
|
config: CaptionStyleConfig,
|
||||||
@@ -107,7 +107,7 @@ export const StylePreview: FunctionComponent<IStylePreviewProps> = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...buildContainerStyles(safeConfig, scale),
|
...buildContainerStyles(safeConfig, scale),
|
||||||
maxWidth: "100%",
|
maxWidth: `${safeConfig.layout?.max_width_pct ?? 90}%`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ interface UseVideoMetadataResult {
|
|||||||
|
|
||||||
const DEFAULT_ASPECT_RATIO = 16 / 9
|
const DEFAULT_ASPECT_RATIO = 16 / 9
|
||||||
|
|
||||||
export function useVideoMetadata(fileId: string | null): UseVideoMetadataResult {
|
export function useVideoMetadata(
|
||||||
const { data: mediaFile, isLoading, isError } = api.useQuery(
|
fileId: string | null,
|
||||||
|
): UseVideoMetadataResult {
|
||||||
|
const {
|
||||||
|
data: mediaFile,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = api.useQuery(
|
||||||
"get",
|
"get",
|
||||||
"/api/media/mediafiles/{media_file_id}/",
|
"/api/media/mediafiles/{media_file_id}/",
|
||||||
{
|
{
|
||||||
@@ -23,7 +29,8 @@ export function useVideoMetadata(fileId: string | null): UseVideoMetadataResult
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!fileId,
|
enabled: !!fileId,
|
||||||
}
|
retry: false,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const aspectRatio = useMemo(() => {
|
const aspectRatio = useMemo(() => {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import type { IConvertMediaViewProps } from "./ConvertMediaView.d"
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
import { CheckCircle, FileVideo } from "lucide-react"
|
import { CheckCircle, FileVideo } from "lucide-react"
|
||||||
import { FunctionComponent, useCallback, useState } from "react"
|
import { FunctionComponent, useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
import api from "@shared/api"
|
import api from "@shared/api"
|
||||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
import { useTaskProgressState } from "@shared/hooks/useTaskProgressState"
|
||||||
import { Button } from "@shared/ui"
|
import { Button } from "@shared/ui"
|
||||||
|
|
||||||
import styles from "./ConvertMediaView.module.scss"
|
import styles from "./ConvertMediaView.module.scss"
|
||||||
@@ -37,24 +37,25 @@ export const ConvertMediaView: FunctionComponent<
|
|||||||
const [jobId, setJobId] = useState<string | null>(null)
|
const [jobId, setJobId] = useState<string | null>(null)
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
const notification = useAppSelector((state) =>
|
const resolvedProgress = useTaskProgressState({
|
||||||
jobId
|
jobId,
|
||||||
? state.notifications.items.find((n) => n.job_id === jobId)
|
enabled: !!jobId && status === STATUS_CONVERTING,
|
||||||
: null,
|
defaultMessage: "Конвертация...",
|
||||||
)
|
})
|
||||||
|
|
||||||
const progressPct = notification?.progress_pct ?? 0
|
useEffect(() => {
|
||||||
const notifStatus = notification?.status
|
if (status !== STATUS_CONVERTING) return
|
||||||
const notifMessage = notification?.message
|
|
||||||
|
|
||||||
// Update status from notification
|
if (resolvedProgress.status === "DONE") {
|
||||||
if (status === STATUS_CONVERTING && notifStatus === "DONE") {
|
|
||||||
setStatus(STATUS_DONE)
|
setStatus(STATUS_DONE)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (status === STATUS_CONVERTING && notifStatus === "FAILED") {
|
|
||||||
|
if (resolvedProgress.status === "FAILED") {
|
||||||
setStatus(STATUS_FAILED)
|
setStatus(STATUS_FAILED)
|
||||||
setErrorMessage(notifMessage ?? ERROR_CONVERT_FAILED)
|
setErrorMessage(resolvedProgress.errorMessage ?? ERROR_CONVERT_FAILED)
|
||||||
}
|
}
|
||||||
|
}, [resolvedProgress.errorMessage, resolvedProgress.status, status])
|
||||||
|
|
||||||
const { mutate, isPending } = api.useMutation(
|
const { mutate, isPending } = api.useMutation(
|
||||||
"post",
|
"post",
|
||||||
@@ -103,16 +104,16 @@ export const ConvertMediaView: FunctionComponent<
|
|||||||
<div className={styles.root} data-testid="ConvertMediaView">
|
<div className={styles.root} data-testid="ConvertMediaView">
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<FileVideo size={48} className={styles.icon} />
|
<FileVideo size={48} className={styles.icon} />
|
||||||
<p className={styles.message}>
|
<p className={styles.message}>{resolvedProgress.message}</p>
|
||||||
{notifMessage ?? "Конвертация..."}
|
|
||||||
</p>
|
|
||||||
<div className={styles.progressTrack}>
|
<div className={styles.progressTrack}>
|
||||||
<div
|
<div
|
||||||
className={styles.progressBar}
|
className={styles.progressBar}
|
||||||
style={{ width: `${progressPct}%` }}
|
style={{ width: `${resolvedProgress.progressPct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.progressLabel}>{Math.round(progressPct)}%</p>
|
<p className={styles.progressLabel}>
|
||||||
|
{Math.round(resolvedProgress.progressPct)}%
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const FragmentsStep: FunctionComponent<IFragmentsStepProps> = ({
|
|||||||
const {
|
const {
|
||||||
projectId,
|
projectId,
|
||||||
silenceJobId,
|
silenceJobId,
|
||||||
|
primaryFileId,
|
||||||
primaryFileKey,
|
primaryFileKey,
|
||||||
startProcessingJob,
|
startProcessingJob,
|
||||||
goBack,
|
goBack,
|
||||||
@@ -106,9 +107,9 @@ export const FragmentsStep: FunctionComponent<IFragmentsStepProps> = ({
|
|||||||
|
|
||||||
const { data: fileInfo } = api.useQuery(
|
const { data: fileInfo } = api.useQuery(
|
||||||
"get",
|
"get",
|
||||||
"/api/files/get_file/",
|
"/api/files/files/{file_id}/resolve/",
|
||||||
{ params: { query: { file_path: fileKey } } },
|
{ params: { path: { file_id: primaryFileId ?? "" } } },
|
||||||
{ enabled: !!fileKey },
|
{ enabled: !!primaryFileId },
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoUrl = fileInfo?.file_url ?? null
|
const videoUrl = fileInfo?.file_url ?? null
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Info } from "lucide-react"
|
|||||||
import { FunctionComponent } from "react"
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
import { useWizard } from "@shared/context/WizardContext"
|
import { useWizard } from "@shared/context/WizardContext"
|
||||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
import { useTaskProgressState } from "@shared/hooks/useTaskProgressState"
|
||||||
import { Button, CircularProgress } from "@shared/ui"
|
import { Button, CircularProgress } from "@shared/ui"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -51,20 +51,18 @@ export const ProcessingStep: FunctionComponent<IProcessingStepProps> = ({
|
|||||||
goBack()
|
goBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
const notification = useAppSelector((state) =>
|
const taskProgress = useTaskProgressState({
|
||||||
activeJobId
|
jobId: activeJobId,
|
||||||
? state.notifications.items.find(
|
enabled: !!activeJobId,
|
||||||
(n) => n.job_id === activeJobId,
|
defaultMessage: "Подождите, идёт обработка...",
|
||||||
)
|
})
|
||||||
: null,
|
|
||||||
)
|
|
||||||
|
|
||||||
const progressPct = notification?.progress_pct ?? 0
|
const progressPct = taskProgress.progressPct
|
||||||
const statusLabel = activeJobType
|
const statusLabel = activeJobType
|
||||||
? (JOB_TYPE_LABELS[activeJobType] ?? "ОБРАБОТКА")
|
? (JOB_TYPE_LABELS[activeJobType] ?? "ОБРАБОТКА")
|
||||||
: "ОБРАБОТКА"
|
: "ОБРАБОТКА"
|
||||||
const statusMessage = notification?.message ?? "Подождите, идёт обработка..."
|
const statusMessage = taskProgress.message
|
||||||
const isFailed = notification?.status === "FAILED"
|
const isFailed = taskProgress.status === "FAILED"
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
if (!activeJobId || isCancelling) return
|
if (!activeJobId || isCancelling) return
|
||||||
@@ -118,7 +116,8 @@ export const ProcessingStep: FunctionComponent<IProcessingStepProps> = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isFailed
|
{isFailed
|
||||||
? (notification?.message ?? "Произошла ошибка при обработке")
|
? (taskProgress.errorMessage ??
|
||||||
|
"Произошла ошибка при обработке")
|
||||||
: statusMessage}
|
: statusMessage}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: variables.$purple-400;
|
background: variables.$color-primary;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: variables.$purple-400;
|
background: variables.$color-primary;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
@@ -121,8 +121,8 @@
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: variables.$purple-400;
|
border-color: variables.$color-primary;
|
||||||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.15);
|
box-shadow: var(--focus-ring);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,11 +95,22 @@ export const SilenceResultModal: FunctionComponent<ISilenceResultModalProps> = (
|
|||||||
const outputData = taskStatus?.output_data as Record<string, unknown> | null
|
const outputData = taskStatus?.output_data as Record<string, unknown> | null
|
||||||
const fileKey = (outputData?.file_key as string) ?? ""
|
const fileKey = (outputData?.file_key as string) ?? ""
|
||||||
|
|
||||||
|
const { data: project } = api.useQuery(
|
||||||
|
"get",
|
||||||
|
"/api/projects/{project_id}/",
|
||||||
|
{ params: { path: { project_id: projectId } } },
|
||||||
|
{ enabled: open },
|
||||||
|
)
|
||||||
|
|
||||||
|
const primaryFileId =
|
||||||
|
(project?.workspace_state as { wizard?: { primary_file_id?: string | null } } | null)
|
||||||
|
?.wizard?.primary_file_id ?? null
|
||||||
|
|
||||||
const { data: fileInfo } = api.useQuery(
|
const { data: fileInfo } = api.useQuery(
|
||||||
"get",
|
"get",
|
||||||
"/api/files/get_file/",
|
"/api/files/files/{file_id}/resolve/",
|
||||||
{ params: { query: { file_path: fileKey } } },
|
{ params: { path: { file_id: primaryFileId ?? "" } } },
|
||||||
{ enabled: open && !!fileKey },
|
{ enabled: open && !!primaryFileId },
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoUrl = fileInfo?.file_url ?? null
|
const videoUrl = fileInfo?.file_url ?? null
|
||||||
|
|||||||
@@ -3,38 +3,118 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mediaPlayer {
|
.mediaPlayer {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
// Reset vidstack player defaults
|
// Reset vidstack player defaults
|
||||||
aspect-ratio: unset !important;
|
aspect-ratio: unset !important;
|
||||||
width: 100% !important;
|
}
|
||||||
height: auto !important;
|
|
||||||
|
.workspaceShell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid variables.$border-default;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: variables.$bg-default;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mainGrid {
|
.mainGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.95fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 16px 24px;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-content: start;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid variables.$border-default;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: variables.$bg-default;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid variables.$border-default;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: variables.$bg-default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTitle {
|
||||||
|
margin: 0;
|
||||||
|
color: variables.$text-primary;
|
||||||
|
@include typography.font-body-16(600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorPanel {
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerPanel {
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.playerColumn {
|
.playerColumn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: variables.$radius-md;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
min-height: 0;
|
min-height: 280px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
:global([data-media-player]) {
|
:global([data-media-player]) {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
@@ -55,11 +135,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editorColumn {
|
.editorColumn {
|
||||||
overflow-y: auto;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid variables.$border-subtle;
|
min-width: 0;
|
||||||
border-radius: variables.$radius-md;
|
background: variables.$bg-default;
|
||||||
background: variables.$bg-surface;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
@@ -67,28 +151,41 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 24px;
|
||||||
color: variables.$text-tertiary;
|
color: variables.$text-tertiary;
|
||||||
@include typography.font-body-14(500);
|
@include typography.font-body-14(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timelineWrapper {
|
.timelineWrapper {
|
||||||
border-top: 1px solid variables.$border-subtle;
|
width: 100%;
|
||||||
padding: 0 24px;
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 16px 16px;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 24px;
|
width: 100%;
|
||||||
border-top: 1px solid variables.$border-subtle;
|
max-width: 100%;
|
||||||
background: variables.$bg-surface;
|
padding: 0 16px 16px;
|
||||||
|
background: transparent;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import type { ISubtitleRevisionStepProps } from "./SubtitleRevisionStep.d"
|
import type { ISubtitleRevisionStepProps } from "./SubtitleRevisionStep.d"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
import { MediaPlayer, MediaProvider } from "@vidstack/react"
|
import {
|
||||||
|
MediaPlayer,
|
||||||
|
MediaProvider,
|
||||||
|
type MediaPlayerInstance,
|
||||||
|
} from "@vidstack/react"
|
||||||
import {
|
import {
|
||||||
DefaultVideoLayout,
|
DefaultVideoLayout,
|
||||||
defaultLayoutIcons,
|
defaultLayoutIcons,
|
||||||
@@ -90,7 +94,7 @@ const SubtitleRevisionContent: FunctionComponent<{
|
|||||||
markStepCompleted,
|
markStepCompleted,
|
||||||
} = useWizard()
|
} = useWizard()
|
||||||
|
|
||||||
const { data: artifacts } = api.useQuery(
|
const { data: artifacts, isLoading: isArtifactsLoading } = api.useQuery(
|
||||||
"get",
|
"get",
|
||||||
"/api/media/artifacts/",
|
"/api/media/artifacts/",
|
||||||
{},
|
{},
|
||||||
@@ -108,6 +112,10 @@ const SubtitleRevisionContent: FunctionComponent<{
|
|||||||
)
|
)
|
||||||
return match?.id ?? null
|
return match?.id ?? null
|
||||||
}, [contextArtifactId, artifacts, projectId])
|
}, [contextArtifactId, artifacts, projectId])
|
||||||
|
const isArtifactResolving = !contextArtifactId && isArtifactsLoading
|
||||||
|
const isTranscriptionReady = Boolean(transcriptionArtifactId)
|
||||||
|
const isTranscriptionUnavailable =
|
||||||
|
!isTranscriptionReady && !isArtifactResolving
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -130,6 +138,7 @@ const SubtitleRevisionContent: FunctionComponent<{
|
|||||||
"/api/tasks/frame-extract/",
|
"/api/tasks/frame-extract/",
|
||||||
)
|
)
|
||||||
const extractTriggeredRef = useRef(false)
|
const extractTriggeredRef = useRef(false)
|
||||||
|
const playerRef = useRef<MediaPlayerInstance | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!primaryFileKey || !projectId || extractTriggeredRef.current) return
|
if (!primaryFileKey || !projectId || extractTriggeredRef.current) return
|
||||||
@@ -144,6 +153,8 @@ const SubtitleRevisionContent: FunctionComponent<{
|
|||||||
}, [primaryFileKey, projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [primaryFileKey, projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handleFinish = () => {
|
const handleFinish = () => {
|
||||||
|
if (!isTranscriptionReady) return
|
||||||
|
|
||||||
markStepCompleted("subtitle-revision")
|
markStepCompleted("subtitle-revision")
|
||||||
goToStep("caption-settings")
|
goToStep("caption-settings")
|
||||||
}
|
}
|
||||||
@@ -158,28 +169,53 @@ const SubtitleRevisionContent: FunctionComponent<{
|
|||||||
transcriptionArtifactId={transcriptionArtifactId}
|
transcriptionArtifactId={transcriptionArtifactId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className={styles.workspaceShell}>
|
||||||
|
<div className={styles.mainGrid}>
|
||||||
|
<section className={cs(styles.panel, styles.editorPanel)}>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<h2 className={styles.panelTitle}>Текст</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={styles.editorColumn}
|
||||||
|
aria-busy={isArtifactResolving}
|
||||||
|
>
|
||||||
|
{isArtifactResolving ? (
|
||||||
|
<div
|
||||||
|
className={styles.placeholder}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
Загружаем субтитры...
|
||||||
|
</div>
|
||||||
|
) : transcriptionArtifactId ? (
|
||||||
|
<TranscriptionEditor artifactId={transcriptionArtifactId} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={styles.placeholder}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
Транскрипция не найдена
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={cs(styles.panel, styles.playerPanel)}>
|
||||||
|
<div className={styles.panelHeader}>
|
||||||
|
<h2 className={styles.panelTitle}>Видео</h2>
|
||||||
|
</div>
|
||||||
|
<div className={styles.playerColumn}>
|
||||||
|
{videoUrl ? (
|
||||||
<MediaPlayer
|
<MediaPlayer
|
||||||
src={videoUrl ?? ""}
|
ref={playerRef}
|
||||||
|
src={videoUrl}
|
||||||
crossOrigin=""
|
crossOrigin=""
|
||||||
playsInline
|
playsInline
|
||||||
className={styles.mediaPlayer}
|
className={styles.mediaPlayer}
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
flex: 1,
|
|
||||||
aspectRatio: "unset",
|
|
||||||
width: "100%",
|
|
||||||
height: "auto",
|
|
||||||
minHeight: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Main content: video + editor */}
|
|
||||||
<div className={styles.mainGrid}>
|
|
||||||
{/* Left column: video player */}
|
|
||||||
<div className={styles.playerColumn}>
|
|
||||||
{videoUrl ? (
|
|
||||||
<>
|
|
||||||
<MediaProvider />
|
<MediaProvider />
|
||||||
<DefaultVideoLayout
|
<DefaultVideoLayout
|
||||||
icons={defaultLayoutIcons}
|
icons={defaultLayoutIcons}
|
||||||
@@ -200,47 +236,43 @@ const SubtitleRevisionContent: FunctionComponent<{
|
|||||||
downloadButton: null,
|
downloadButton: null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</MediaPlayer>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.placeholder}>
|
<div className={styles.placeholder}>
|
||||||
Видео недоступно
|
Видео недоступно
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{/* Right column: transcription editor */}
|
|
||||||
<div className={styles.editorColumn}>
|
|
||||||
{transcriptionArtifactId ? (
|
|
||||||
<TranscriptionEditor
|
|
||||||
artifactId={transcriptionArtifactId}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={styles.placeholder}>
|
|
||||||
Транскрипция не найдена
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom: timeline */}
|
|
||||||
<div className={styles.timelineWrapper}>
|
<div className={styles.timelineWrapper}>
|
||||||
<TimelinePanel
|
<TimelinePanel
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
audioUrl={videoUrl}
|
audioUrl={videoUrl}
|
||||||
className={styles.timeline}
|
className={styles.timeline}
|
||||||
|
playerRef={playerRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<Button variant="outline" onClick={goBack}>
|
<Button variant="outline" onClick={goBack}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={handleFinish}>
|
<Button
|
||||||
Далее
|
variant="primary"
|
||||||
|
onClick={handleFinish}
|
||||||
|
loading={isArtifactResolving}
|
||||||
|
disabled={!isTranscriptionReady}
|
||||||
|
>
|
||||||
|
{isArtifactResolving
|
||||||
|
? "Проверяем..."
|
||||||
|
: isTranscriptionUnavailable
|
||||||
|
? "Субтитры не найдены"
|
||||||
|
: "Далее"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</MediaPlayer>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
top: 4px;
|
top: 4px;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
border-radius: variables.$radius-sm;
|
border-radius: variables.$radius-sm;
|
||||||
background: rgba(139, 92, 246, 0.3);
|
background: color-mix(in srgb, variables.$color-primary 28%, transparent);
|
||||||
border: 1px solid rgba(139, 92, 246, 0.7);
|
border: 1px solid color-mix(in srgb, variables.$color-primary 62%, transparent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(139, 92, 246, 0.45);
|
background: color-mix(in srgb, variables.$color-primary 42%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -40,15 +40,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
background: rgba(139, 92, 246, 0.6);
|
background: color-mix(in srgb, variables.$color-primary 56%, transparent);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(139, 92, 246, 0.65);
|
background: color-mix(in srgb, variables.$color-primary 62%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.resizing {
|
.resizing {
|
||||||
background: rgba(139, 92, 246, 0.5);
|
background: color-mix(in srgb, variables.$color-primary 48%, transparent);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(139, 92, 246, 0.5);
|
background: color-mix(in srgb, variables.$color-primary 48%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loader {
|
.loader {
|
||||||
@@ -10,12 +12,27 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
gap: 8px;
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
@include typography.font-caption-m;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
@@ -28,126 +45,168 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
gap: 16px;
|
||||||
|
padding: 16px 18px 14px;
|
||||||
border-bottom: 1px solid variables.$border-default;
|
border-bottom: 1px solid variables.$border-default;
|
||||||
|
background: variables.$bg-default;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.toolbarMeta {
|
||||||
font-size: 14px;
|
display: flex;
|
||||||
font-weight: 600;
|
flex-direction: column;
|
||||||
color: variables.$text-primary;
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
color: variables.$text-primary;
|
||||||
|
@include typography.font-body-16(600);
|
||||||
}
|
}
|
||||||
|
|
||||||
.saveButton {
|
.toolbarSummary {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
flex-wrap: wrap;
|
||||||
padding: 6px 12px;
|
gap: 10px;
|
||||||
border-radius: variables.$radius-sm;
|
|
||||||
border: none;
|
|
||||||
background: variables.$color-primary;
|
|
||||||
color: variables.$color-white;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled {
|
.segmentCount {
|
||||||
background: variables.$border-default;
|
color: variables.$text-secondary;
|
||||||
|
@include typography.font-body-14(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStatus {
|
||||||
|
margin: 0;
|
||||||
color: variables.$text-tertiary;
|
color: variables.$text-tertiary;
|
||||||
cursor: default;
|
@include typography.font-caption-m;
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmentsList {
|
.segmentsList {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px 16px;
|
padding: 14px 18px 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment {
|
.segment {
|
||||||
border: 1px solid variables.$border-subtle;
|
display: grid;
|
||||||
border-radius: variables.$radius-md;
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
padding: 12px 16px;
|
gap: 12px;
|
||||||
background: variables.$bg-surface;
|
border: 1px solid variables.$border-default;
|
||||||
transition: all 0.3s ease;
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: variables.$bg-default;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: variables.$border-default;
|
border-color: variables.$border-default;
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.04);
|
background: variables.$bg-surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.highlight {
|
&.highlight {
|
||||||
border-color: variables.$color-primary;
|
border-color: variables.$color-primary;
|
||||||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmentTimes {
|
.segmentNumber {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 7px;
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
@include typography.font-caption-m;
|
||||||
|
@include typography.font-numeric;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmentMain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmentMetaRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timesGroup {
|
.timesGroup {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionsGroup {
|
.actionsGroup {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeLabel {
|
.timeField {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 8px;
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: variables.$bg-surface;
|
||||||
|
border: 1px solid variables.$border-default;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
background: variables.$bg-default;
|
||||||
|
border-color: variables.$color-primary;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeLabelText {
|
.timeLabelText {
|
||||||
font-size: 11px;
|
color: variables.$text-secondary;
|
||||||
color: variables.$text-tertiary;
|
white-space: nowrap;
|
||||||
font-weight: 600;
|
@include typography.font-caption-m;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeInput {
|
.timeInput {
|
||||||
width: 84px;
|
width: 96px;
|
||||||
padding: 4px 8px;
|
padding: 0;
|
||||||
border: 1px solid transparent;
|
border: none;
|
||||||
border-radius: variables.$radius-sm;
|
border-radius: 0;
|
||||||
font-size: 12px;
|
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: variables.$text-secondary;
|
color: variables.$text-primary;
|
||||||
background: variables.$bg-hover;
|
background: transparent;
|
||||||
transition: all 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
|
@include typography.font-caption-m;
|
||||||
|
@include typography.font-numeric;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
background: variables.$bg-surface;
|
|
||||||
border-color: variables.$color-primary;
|
|
||||||
color: variables.$text-primary;
|
|
||||||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,19 +214,23 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 6px;
|
width: 32px;
|
||||||
border: none;
|
height: 32px;
|
||||||
background: transparent;
|
padding: 0;
|
||||||
|
border: 1px solid variables.$border-default;
|
||||||
|
background: variables.$bg-default;
|
||||||
color: variables.$text-tertiary;
|
color: variables.$text-tertiary;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: variables.$radius-sm;
|
border-radius: 8px;
|
||||||
transition: all 0.2s ease;
|
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitButton {
|
.splitButton {
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
color: variables.$color-primary;
|
color: variables.$color-primary;
|
||||||
background: variables.$bg-hover;
|
background: variables.$bg-surface;
|
||||||
|
border-color: variables.$border-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -179,22 +242,23 @@
|
|||||||
.removeButton {
|
.removeButton {
|
||||||
&:hover {
|
&:hover {
|
||||||
color: variables.$color-danger;
|
color: variables.$color-danger;
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: variables.$bg-surface;
|
||||||
|
border-color: variables.$border-default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.textArea {
|
.textArea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
min-height: 96px;
|
||||||
border: 1px solid transparent;
|
padding: 12px 14px;
|
||||||
border-radius: variables.$radius-sm;
|
border: 1px solid variables.$border-default;
|
||||||
font-size: 14px;
|
border-radius: 8px;
|
||||||
line-height: 1.5;
|
|
||||||
color: variables.$text-primary;
|
color: variables.$text-primary;
|
||||||
background: variables.$bg-hover;
|
background: variables.$bg-surface;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
transition: all 0.2s ease;
|
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
@include typography.font-body-14(400);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: variables.$bg-hover;
|
background: variables.$bg-hover;
|
||||||
@@ -202,29 +266,41 @@
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
background: variables.$bg-surface;
|
background: variables.$bg-default;
|
||||||
border-color: variables.$color-primary;
|
border-color: variables.$color-primary;
|
||||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: variables.$text-tertiary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.addButton {
|
.addButton {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
justify-content: center;
|
||||||
margin: 0 16px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px dashed variables.$border-default;
|
border: 1px solid variables.$border-default;
|
||||||
border-radius: variables.$radius-md;
|
border-radius: 8px;
|
||||||
background: none;
|
background: variables.$bg-default;
|
||||||
color: variables.$text-secondary;
|
color: variables.$text-secondary;
|
||||||
font-size: 13px;
|
@include typography.font-body-14(500);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: variables.$bg-hover;
|
background: variables.$bg-surface;
|
||||||
border-color: variables.$color-primary;
|
border-color: variables.$border-default;
|
||||||
color: variables.$color-primary;
|
color: variables.$color-primary;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FunctionComponent, useCallback, useEffect, useRef, useState } from "rea
|
|||||||
import api from "@shared/api"
|
import api from "@shared/api"
|
||||||
import { fetchClient } from "@shared/api"
|
import { fetchClient } from "@shared/api"
|
||||||
import { useWorkspaceFiles } from "@shared/context/WorkspaceContext"
|
import { useWorkspaceFiles } from "@shared/context/WorkspaceContext"
|
||||||
|
import { declOfNum } from "@shared/lib/russianNoun"
|
||||||
import {
|
import {
|
||||||
type EditorSegment,
|
type EditorSegment,
|
||||||
documentToSegments,
|
documentToSegments,
|
||||||
@@ -23,12 +24,18 @@ import styles from "./TranscriptionEditor.module.scss"
|
|||||||
/* Component */
|
/* Component */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type SegmentRow = {
|
||||||
|
id: string
|
||||||
|
segment: EditorSegment
|
||||||
|
}
|
||||||
|
|
||||||
export const TranscriptionEditor: FunctionComponent<
|
export const TranscriptionEditor: FunctionComponent<
|
||||||
ITranscriptionEditorProps
|
ITranscriptionEditorProps
|
||||||
> = ({ artifactId }): JSX.Element => {
|
> = ({ artifactId }): JSX.Element => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { selectedFile, setSelectedFile } = useWorkspaceFiles()
|
const { selectedFile, setSelectedFile } = useWorkspaceFiles()
|
||||||
const segmentsListRef = useRef<HTMLDivElement>(null)
|
const segmentsListRef = useRef<HTMLDivElement>(null)
|
||||||
|
const rowIdRef = useRef(0)
|
||||||
|
|
||||||
const { data: transcription, isLoading } = api.useQuery(
|
const { data: transcription, isLoading } = api.useQuery(
|
||||||
"get",
|
"get",
|
||||||
@@ -36,22 +43,41 @@ export const TranscriptionEditor: FunctionComponent<
|
|||||||
{ params: { path: { artifact_id: artifactId } } },
|
{ params: { path: { artifact_id: artifactId } } },
|
||||||
)
|
)
|
||||||
|
|
||||||
const [segments, setSegments] = useState<EditorSegment[]>([])
|
const [segmentRows, setSegmentRows] = useState<SegmentRow[]>([])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
const [splittingIdx, setSplittingIdx] = useState<number | null>(null)
|
const [splittingRowId, setSplittingRowId] = useState<string | null>(null)
|
||||||
|
const visibleStatus = saving
|
||||||
|
? "Сохраняем изменения"
|
||||||
|
: dirty
|
||||||
|
? "Изменения будут сохранены автоматически"
|
||||||
|
: null
|
||||||
|
|
||||||
|
const createSegmentRow = useCallback(
|
||||||
|
(segment: EditorSegment): SegmentRow => ({
|
||||||
|
id: `segment-row-${rowIdRef.current++}`,
|
||||||
|
segment,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (transcription?.document) {
|
if (transcription?.document) {
|
||||||
setSegments(documentToSegments(transcription.document))
|
rowIdRef.current = 0
|
||||||
|
setSegmentRows(
|
||||||
|
documentToSegments(transcription.document).map((segment) =>
|
||||||
|
createSegmentRow(segment),
|
||||||
|
),
|
||||||
|
)
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
|
setSplittingRowId(null)
|
||||||
}
|
}
|
||||||
}, [transcription])
|
}, [transcription, createSegmentRow])
|
||||||
|
|
||||||
// Scroll to segment when navigated from SubtitlesTrack
|
// Scroll to segment when navigated from SubtitlesTrack
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFile || selectedFile.scrollToSegmentIndex == null) return
|
if (!selectedFile || selectedFile.scrollToSegmentIndex == null) return
|
||||||
if (segments.length === 0) return
|
if (segmentRows.length === 0) return
|
||||||
|
|
||||||
const targetIdx = selectedFile.scrollToSegmentIndex
|
const targetIdx = selectedFile.scrollToSegmentIndex
|
||||||
const container = segmentsListRef.current
|
const container = segmentsListRef.current
|
||||||
@@ -76,16 +102,16 @@ export const TranscriptionEditor: FunctionComponent<
|
|||||||
source: selectedFile.source,
|
source: selectedFile.source,
|
||||||
artifactType: selectedFile.artifactType,
|
artifactType: selectedFile.artifactType,
|
||||||
})
|
})
|
||||||
}, [selectedFile?.scrollToSegmentIndex, segments.length])
|
}, [selectedFile?.scrollToSegmentIndex, segmentRows.length, setSelectedFile])
|
||||||
|
|
||||||
const updateSegment = useCallback(
|
const updateSegment = useCallback(
|
||||||
(idx: number, field: keyof EditorSegment, value: string) => {
|
(rowId: string, field: keyof EditorSegment, value: string) => {
|
||||||
setSegments((prev) =>
|
setSegmentRows((prev) =>
|
||||||
prev.map((seg, i) => {
|
prev.map((row) => {
|
||||||
if (i !== idx) return seg
|
if (row.id !== rowId) return row
|
||||||
const updated = { ...seg, [field]: value }
|
const updated = { ...row.segment, [field]: value }
|
||||||
if (field === "text") updated.words = undefined
|
if (field === "text") updated.words = undefined
|
||||||
return updated
|
return { ...row, segment: updated }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setDirty(true)
|
setDirty(true)
|
||||||
@@ -94,30 +120,38 @@ export const TranscriptionEditor: FunctionComponent<
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleSplit = useCallback(
|
const handleSplit = useCallback(
|
||||||
(idx: number, newSegments: EditorSegment[]) => {
|
(rowId: string, newSegments: EditorSegment[]) => {
|
||||||
setSegments((prev) => [
|
setSegmentRows((prev) => {
|
||||||
...prev.slice(0, idx),
|
const targetIndex = prev.findIndex((row) => row.id === rowId)
|
||||||
...newSegments,
|
if (targetIndex === -1) return prev
|
||||||
...prev.slice(idx + 1),
|
const nextRows = newSegments.map((segment) => createSegmentRow(segment))
|
||||||
])
|
return [
|
||||||
setSplittingIdx(null)
|
...prev.slice(0, targetIndex),
|
||||||
|
...nextRows,
|
||||||
|
...prev.slice(targetIndex + 1),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
setSplittingRowId(null)
|
||||||
setDirty(true)
|
setDirty(true)
|
||||||
},
|
},
|
||||||
[],
|
[createSegmentRow],
|
||||||
)
|
)
|
||||||
|
|
||||||
const addSegment = useCallback(() => {
|
const addSegment = useCallback(() => {
|
||||||
|
setSegmentRows((prev) => {
|
||||||
const lastEnd =
|
const lastEnd =
|
||||||
segments.length > 0 ? segments[segments.length - 1].endTime : "00:00.000"
|
prev.length > 0 ? prev[prev.length - 1].segment.endTime : "00:00.000"
|
||||||
setSegments((prev) => [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{ startTime: lastEnd, endTime: lastEnd, text: "" },
|
createSegmentRow({ startTime: lastEnd, endTime: lastEnd, text: "" }),
|
||||||
])
|
]
|
||||||
|
})
|
||||||
setDirty(true)
|
setDirty(true)
|
||||||
}, [segments])
|
}, [createSegmentRow])
|
||||||
|
|
||||||
const removeSegment = useCallback((idx: number) => {
|
const removeSegment = useCallback((rowId: string) => {
|
||||||
setSegments((prev) => prev.filter((_, i) => i !== idx))
|
setSegmentRows((prev) => prev.filter((row) => row.id !== rowId))
|
||||||
|
setSplittingRowId((prev) => (prev === rowId ? null : prev))
|
||||||
setDirty(true)
|
setDirty(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -129,7 +163,11 @@ export const TranscriptionEditor: FunctionComponent<
|
|||||||
"/api/transcribe/transcriptions/{transcription_id}/",
|
"/api/transcribe/transcriptions/{transcription_id}/",
|
||||||
{
|
{
|
||||||
params: { path: { transcription_id: transcription.id } },
|
params: { path: { transcription_id: transcription.id } },
|
||||||
body: { document: segmentsToDocument(segments) },
|
body: {
|
||||||
|
document: segmentsToDocument(
|
||||||
|
segmentRows.map((row) => row.segment),
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
@@ -143,7 +181,7 @@ export const TranscriptionEditor: FunctionComponent<
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}, [transcription, segments, artifactId, queryClient])
|
}, [transcription, segmentRows, artifactId, queryClient])
|
||||||
|
|
||||||
// Auto-save when dirty (debounced)
|
// Auto-save when dirty (debounced)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -157,9 +195,14 @@ export const TranscriptionEditor: FunctionComponent<
|
|||||||
/* Loading */
|
/* Loading */
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.root} data-testid="TranscriptionEditor">
|
<div
|
||||||
<div className={styles.loader}>
|
className={styles.root}
|
||||||
|
data-testid="TranscriptionEditor"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div className={styles.loader} role="status" aria-live="polite">
|
||||||
<LoaderCircle size={24} className={styles.spinner} />
|
<LoaderCircle size={24} className={styles.spinner} />
|
||||||
|
<span className={styles.srOnly}>Загружаем транскрипцию</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -175,38 +218,79 @@ export const TranscriptionEditor: FunctionComponent<
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root} data-testid="TranscriptionEditor">
|
<div
|
||||||
{/* Header */}
|
className={styles.root}
|
||||||
<div className={styles.header}>
|
data-testid="TranscriptionEditor"
|
||||||
<h3 className={styles.title}>Редактор транскрипции</h3>
|
aria-busy={saving ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div className={styles.toolbarMeta}>
|
||||||
|
<p className={styles.toolbarTitle}>Сегменты</p>
|
||||||
|
<div className={styles.toolbarSummary}>
|
||||||
|
<span className={styles.segmentCount}>
|
||||||
|
{segmentRows.length}{" "}
|
||||||
|
{declOfNum(segmentRows.length, [
|
||||||
|
"сегмент",
|
||||||
|
"сегмента",
|
||||||
|
"сегментов",
|
||||||
|
])}
|
||||||
|
</span>
|
||||||
|
{visibleStatus ? (
|
||||||
|
<p className={styles.headerStatus}>{visibleStatus}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className={styles.addButton} onClick={addSegment} type="button">
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Добавить сегмент</span>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={styles.srOnly}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
{visibleStatus ?? ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Segments list */}
|
|
||||||
<div className={styles.segmentsList} ref={segmentsListRef}>
|
<div className={styles.segmentsList} ref={segmentsListRef}>
|
||||||
{segments.map((seg, idx) => (
|
{segmentRows.map((row, idx) => {
|
||||||
<div key={idx} className={styles.segment} data-segment-index={idx}>
|
const textareaId = `${row.id}-text`
|
||||||
<div className={styles.segmentTimes}>
|
const splitDisabled = !row.segment.words || row.segment.words.length < 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className={styles.segment}
|
||||||
|
data-segment-index={idx}
|
||||||
|
>
|
||||||
|
<div className={styles.segmentNumber} aria-hidden="true">
|
||||||
|
{String(idx + 1).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
<div className={styles.segmentMain}>
|
||||||
|
<div className={styles.segmentMetaRow}>
|
||||||
<div className={styles.timesGroup}>
|
<div className={styles.timesGroup}>
|
||||||
<label className={styles.timeLabel}>
|
<label className={styles.timeField}>
|
||||||
<span className={styles.timeLabelText}>Начало</span>
|
<span className={styles.timeLabelText}>Начало</span>
|
||||||
<input
|
<input
|
||||||
className={styles.timeInput}
|
className={styles.timeInput}
|
||||||
type="text"
|
type="text"
|
||||||
value={seg.startTime}
|
value={row.segment.startTime}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateSegment(idx, "startTime", e.target.value)
|
updateSegment(row.id, "startTime", e.target.value)
|
||||||
}
|
}
|
||||||
placeholder="00:00.000"
|
placeholder="00:00.000"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className={styles.timeLabel}>
|
<label className={styles.timeField}>
|
||||||
<span className={styles.timeLabelText}>Конец</span>
|
<span className={styles.timeLabelText}>Конец</span>
|
||||||
<input
|
<input
|
||||||
className={styles.timeInput}
|
className={styles.timeInput}
|
||||||
type="text"
|
type="text"
|
||||||
value={seg.endTime}
|
value={row.segment.endTime}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateSegment(idx, "endTime", e.target.value)
|
updateSegment(row.id, "endTime", e.target.value)
|
||||||
}
|
}
|
||||||
placeholder="00:00.000"
|
placeholder="00:00.000"
|
||||||
/>
|
/>
|
||||||
@@ -215,49 +299,57 @@ export const TranscriptionEditor: FunctionComponent<
|
|||||||
<div className={styles.actionsGroup}>
|
<div className={styles.actionsGroup}>
|
||||||
<button
|
<button
|
||||||
className={styles.splitButton}
|
className={styles.splitButton}
|
||||||
onClick={() => setSplittingIdx(idx)}
|
onClick={() => setSplittingRowId(row.id)}
|
||||||
|
aria-label={
|
||||||
|
splitDisabled
|
||||||
|
? `Разделить сегмент ${idx + 1}: недоступно без разбивки по словам`
|
||||||
|
: `Разделить сегмент ${idx + 1}`
|
||||||
|
}
|
||||||
title={
|
title={
|
||||||
!seg.words || seg.words.length < 2
|
splitDisabled
|
||||||
? "Нет данных о словах для разделения"
|
? "Нет данных о словах для разделения"
|
||||||
: "Разделить сегмент"
|
: "Разделить сегмент"
|
||||||
}
|
}
|
||||||
disabled={!seg.words || seg.words.length < 2}
|
disabled={splitDisabled}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Scissors size={14} />
|
<Scissors size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.removeButton}
|
className={styles.removeButton}
|
||||||
onClick={() => removeSegment(idx)}
|
onClick={() => removeSegment(row.id)}
|
||||||
|
aria-label={`Удалить сегмент ${idx + 1}`}
|
||||||
title="Удалить сегмент"
|
title="Удалить сегмент"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{splittingIdx === idx ? (
|
<label className={styles.srOnly} htmlFor={textareaId}>
|
||||||
|
Текст сегмента {idx + 1}
|
||||||
|
</label>
|
||||||
|
{splittingRowId === row.id ? (
|
||||||
<SegmentSplitter
|
<SegmentSplitter
|
||||||
segment={seg}
|
segment={row.segment}
|
||||||
onSplit={(newSegs) => handleSplit(idx, newSegs)}
|
onSplit={(newSegs) => handleSplit(row.id, newSegs)}
|
||||||
onCancel={() => setSplittingIdx(null)}
|
onCancel={() => setSplittingRowId(null)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<textarea
|
<textarea
|
||||||
|
id={textareaId}
|
||||||
className={styles.textArea}
|
className={styles.textArea}
|
||||||
value={seg.text}
|
value={row.segment.text}
|
||||||
onChange={(e) => updateSegment(idx, "text", e.target.value)}
|
onChange={(e) => updateSegment(row.id, "text", e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="Текст сегмента..."
|
placeholder="Текст сегмента..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
{/* Add segment */}
|
})}
|
||||||
<button className={styles.addButton} onClick={addSegment}>
|
</div>
|
||||||
<Plus size={16} />
|
|
||||||
<span>Добавить сегмент</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
.dropZoneActive {
|
.dropZoneActive {
|
||||||
border-color: variables.$color-primary;
|
border-color: variables.$color-primary;
|
||||||
background: rgba(var(--iris-a3), 0.08);
|
background: color-mix(in srgb, variables.$color-primary 10%, variables.$bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropZoneUploading {
|
.dropZoneUploading {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const UploadStep: FunctionComponent<IUploadStepProps> = ({
|
|||||||
`projects/${projectId}`,
|
`projects/${projectId}`,
|
||||||
setProgress,
|
setProgress,
|
||||||
)
|
)
|
||||||
setFileKey(result.file_path, result.file_url, result.filename ?? null)
|
setFileKey(result.file_path, result.file_id, result.filename ?? null)
|
||||||
markStepCompleted("upload")
|
markStepCompleted("upload")
|
||||||
goNext()
|
goNext()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import cs from "classnames"
|
|||||||
|
|
||||||
import api, { fetchClient } from "@shared/api"
|
import api, { fetchClient } from "@shared/api"
|
||||||
import { useWizard } from "@shared/context/WizardContext"
|
import { useWizard } from "@shared/context/WizardContext"
|
||||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
import { useTaskProgressState } from "@shared/hooks/useTaskProgressState"
|
||||||
import { Badge, Button, CircularProgress } from "@shared/ui"
|
import { Badge, Button, CircularProgress } from "@shared/ui"
|
||||||
import { StaticLoader } from "@shared/ui/Loader"
|
import { StaticLoader } from "@shared/ui/Loader"
|
||||||
|
|
||||||
@@ -135,28 +135,30 @@ export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
|
|||||||
})
|
})
|
||||||
}, [convertMutation, primaryFileKey, projectId])
|
}, [convertMutation, primaryFileKey, projectId])
|
||||||
|
|
||||||
const convertNotification = useAppSelector((state) =>
|
const {
|
||||||
convertJobId
|
progressPct: convertProgressPct,
|
||||||
? state.notifications.items.find((n) => n.job_id === convertJobId)
|
message: convertMessage,
|
||||||
: null,
|
status: convertTaskStatus,
|
||||||
)
|
errorMessage: convertErrorMessage,
|
||||||
|
} = useTaskProgressState({
|
||||||
const convertProgressPct = convertNotification?.progress_pct ?? 0
|
jobId: convertJobId,
|
||||||
const convertMessage = convertNotification?.message ?? "Конвертация видео..."
|
enabled: !!convertJobId && convertStatus === "converting",
|
||||||
|
defaultMessage: "Конвертация видео...",
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!convertJobId || convertStatus !== "converting") return
|
if (!convertJobId || convertStatus !== "converting") return
|
||||||
|
|
||||||
if (convertNotification?.status === "DONE") {
|
if (convertTaskStatus === "DONE") {
|
||||||
fetchConvertedFileFromJob(convertJobId)
|
fetchConvertedFileFromJob(convertJobId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (convertNotification?.status === "FAILED") {
|
if (convertTaskStatus === "FAILED") {
|
||||||
setActiveJob(null)
|
setActiveJob(null)
|
||||||
setConvertError(convertNotification?.message ?? "Ошибка конвертации")
|
setConvertError(convertErrorMessage ?? "Ошибка конвертации")
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [convertNotification, convertJobId, convertStatus])
|
}, [convertErrorMessage, convertJobId, convertStatus, convertTaskStatus])
|
||||||
|
|
||||||
const fetchConvertedFileFromJob = useCallback(
|
const fetchConvertedFileFromJob = useCallback(
|
||||||
async (jobId: string) => {
|
async (jobId: string) => {
|
||||||
@@ -165,13 +167,13 @@ export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
|
|||||||
{ params: { path: { job_id: jobId } } },
|
{ params: { path: { job_id: jobId } } },
|
||||||
)
|
)
|
||||||
const outputData = taskStatus?.output_data as {
|
const outputData = taskStatus?.output_data as {
|
||||||
|
file_id?: string
|
||||||
file_path?: string
|
file_path?: string
|
||||||
file_url?: string
|
|
||||||
} | null
|
} | null
|
||||||
|
|
||||||
if (outputData?.file_path && outputData?.file_url) {
|
if (outputData?.file_id && outputData?.file_path) {
|
||||||
const convertedName = outputData.file_path.split("/").pop() ?? null
|
const convertedName = outputData.file_path.split("/").pop() ?? null
|
||||||
setFileKey(outputData.file_path, outputData.file_url, convertedName)
|
setFileKey(outputData.file_path, outputData.file_id, convertedName)
|
||||||
setActiveJob(null)
|
setActiveJob(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -181,7 +183,7 @@ export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
|
|||||||
/* ---- Handlers ---- */
|
/* ---- Handlers ---- */
|
||||||
|
|
||||||
const handleReplace = () => {
|
const handleReplace = () => {
|
||||||
setFileKey("", "", null)
|
setFileKey(null, null, null)
|
||||||
goToStep("upload")
|
goToStep("upload")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
.root {
|
.root {
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: calc(100vh - var(--header-height));
|
||||||
|
min-height: calc(100vh - var(--header-height));
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@include breakpoints.respond-to(breakpoints.$mobileMax) {
|
||||||
|
height: calc(100svh - var(--header-height));
|
||||||
|
min-height: calc(100svh - var(--header-height));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { IProjectWizardPageProps } from "./ProjectWizardPage.d"
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
import { useParams } from "next/navigation"
|
import { useParams } from "next/navigation"
|
||||||
import { FunctionComponent } from "react"
|
import { FunctionComponent, useEffect } from "react"
|
||||||
|
|
||||||
import api from "@shared/api"
|
import api from "@shared/api"
|
||||||
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
|
import { useBreadcrumbs } from "@shared/context/BreadcrumbsContext"
|
||||||
@@ -33,6 +33,14 @@ export const ProjectWizardPage: FunctionComponent<
|
|||||||
{ label: project?.name ?? "..." },
|
{ label: project?.name ?? "..." },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.dataset.projectWizardLayout = "true"
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
delete document.body.dataset.projectWizardLayout
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceProvider projectId={projectId}>
|
<WorkspaceProvider projectId={projectId}>
|
||||||
<WizardProvider projectId={projectId}>
|
<WizardProvider projectId={projectId}>
|
||||||
|
|||||||
+161
-2
@@ -21,6 +21,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/health/": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Health
|
||||||
|
* @description Health check for Docker/K8s probes. Verifies DB connectivity.
|
||||||
|
*/
|
||||||
|
get: operations["health_api_health__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/auth/register": {
|
"/auth/register": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -268,6 +288,23 @@ export interface paths {
|
|||||||
patch: operations["patch_file_entry_api_files_files__file_id___patch"];
|
patch: operations["patch_file_entry_api_files_files__file_id___patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/files/files/{file_id}/resolve/": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Resolve File Entry */
|
||||||
|
get: operations["resolve_file_entry_api_files_files__file_id__resolve__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/media/get_meta/": {
|
"/api/media/get_meta/": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -501,6 +538,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/transcribe/salute-speech/": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Salute Speech Transcribe */
|
||||||
|
post: operations["salute_speech_transcribe_api_transcribe_salute_speech__post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/captions/get_video/": {
|
"/api/captions/get_video/": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -997,6 +1051,8 @@ export interface components {
|
|||||||
* @default
|
* @default
|
||||||
*/
|
*/
|
||||||
folder: string;
|
folder: string;
|
||||||
|
/** Project Id */
|
||||||
|
project_id?: string | null;
|
||||||
};
|
};
|
||||||
/** CaptionAnimationStyle */
|
/** CaptionAnimationStyle */
|
||||||
CaptionAnimationStyle: {
|
CaptionAnimationStyle: {
|
||||||
@@ -1277,6 +1333,11 @@ export interface components {
|
|||||||
};
|
};
|
||||||
/** FileInfoResponse */
|
/** FileInfoResponse */
|
||||||
FileInfoResponse: {
|
FileInfoResponse: {
|
||||||
|
/**
|
||||||
|
* File Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
file_id: string;
|
||||||
/** File Path */
|
/** File Path */
|
||||||
file_path: string;
|
file_path: string;
|
||||||
/** File Url */
|
/** File Url */
|
||||||
@@ -1882,6 +1943,18 @@ export interface components {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
/** SaluteSpeechParams */
|
||||||
|
SaluteSpeechParams: {
|
||||||
|
/** File Path */
|
||||||
|
file_path: string;
|
||||||
|
/** Language */
|
||||||
|
language?: string | null;
|
||||||
|
/**
|
||||||
|
* Model
|
||||||
|
* @default general
|
||||||
|
*/
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
/** SegmentNode */
|
/** SegmentNode */
|
||||||
"SegmentNode-Input": {
|
"SegmentNode-Input": {
|
||||||
/** Text */
|
/** Text */
|
||||||
@@ -2161,7 +2234,7 @@ export interface components {
|
|||||||
* @default LOCAL_WHISPER
|
* @default LOCAL_WHISPER
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
engine: "LOCAL_WHISPER" | "GOOGLE_SPEECH_CLOUD";
|
engine: "LOCAL_WHISPER" | "GOOGLE_SPEECH_CLOUD" | "SALUTE_SPEECH";
|
||||||
/** Language */
|
/** Language */
|
||||||
language?: string | null;
|
language?: string | null;
|
||||||
/** Document */
|
/** Document */
|
||||||
@@ -2227,7 +2300,7 @@ export interface components {
|
|||||||
* Engine
|
* Engine
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
engine: "LOCAL_WHISPER" | "GOOGLE_SPEECH_CLOUD";
|
engine: "LOCAL_WHISPER" | "GOOGLE_SPEECH_CLOUD" | "SALUTE_SPEECH";
|
||||||
/** Language */
|
/** Language */
|
||||||
language: string | null;
|
language: string | null;
|
||||||
/** Document */
|
/** Document */
|
||||||
@@ -2500,6 +2573,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
health_api_health__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
register_auth_register_post: {
|
register_auth_register_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3203,6 +3298,37 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
resolve_file_entry_api_files_files__file_id__resolve__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
file_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["FileInfoResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
get_meta_api_media_get_meta__get: {
|
get_meta_api_media_get_meta__get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
@@ -3877,6 +4003,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
salute_speech_transcribe_api_transcribe_salute_speech__post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SaluteSpeechParams"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Document-Output"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
get_video_api_captions_get_video__post: {
|
get_video_api_captions_get_video__post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export const AppProviders = ({
|
|||||||
<ThemeSync />
|
<ThemeSync />
|
||||||
<BreadcrumbsProvider>
|
<BreadcrumbsProvider>
|
||||||
<Theme
|
<Theme
|
||||||
accentColor="violet"
|
accentColor="plum"
|
||||||
grayColor="sand"
|
grayColor="mauve"
|
||||||
radius="medium"
|
radius="medium"
|
||||||
scaling="100%"
|
scaling="100%"
|
||||||
appearance="inherit"
|
appearance="inherit"
|
||||||
|
|||||||
@@ -21,18 +21,78 @@ import { useDebounce } from "@shared/hooks/useDebounce"
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export const WIZARD_STEPS = [
|
export const WIZARD_STEPS = [
|
||||||
{ key: "upload", label: "Загрузка" },
|
{
|
||||||
{ key: "verify", label: "Проверка" },
|
key: "upload",
|
||||||
{ key: "silence-settings", label: "Настройка удаления тишины" },
|
label: "Загрузка",
|
||||||
{ key: "processing", label: "Обработка" },
|
shortLabel: "Загрузка",
|
||||||
{ key: "fragments", label: "Редактирование фрагментов" },
|
phaseLabel: "Загрузка файла",
|
||||||
{ key: "silence-apply-processing", label: "Обработка" },
|
},
|
||||||
{ key: "transcription-settings", label: "Настройка транскрибации" },
|
{
|
||||||
{ key: "transcription-processing", label: "Обработка" },
|
key: "verify",
|
||||||
{ key: "subtitle-revision", label: "Редактирование транскрибации" },
|
label: "Проверка",
|
||||||
{ key: "caption-settings", label: "Настройка субтитров" },
|
shortLabel: "Проверка",
|
||||||
{ key: "caption-processing", label: "Обработка" },
|
phaseLabel: "Загрузка файла",
|
||||||
{ key: "caption-result", label: "Результат" },
|
},
|
||||||
|
{
|
||||||
|
key: "silence-settings",
|
||||||
|
label: "Настройка",
|
||||||
|
shortLabel: "Настройка",
|
||||||
|
phaseLabel: "Удаление тишины",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "processing",
|
||||||
|
label: "Обработка",
|
||||||
|
shortLabel: "Обработка",
|
||||||
|
phaseLabel: "Удаление тишины",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fragments",
|
||||||
|
label: "Настройка вырезок",
|
||||||
|
shortLabel: "Вырезки",
|
||||||
|
phaseLabel: "Удаление тишины",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "silence-apply-processing",
|
||||||
|
label: "Применение вырезок",
|
||||||
|
shortLabel: "Применение",
|
||||||
|
phaseLabel: "Удаление тишины",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "transcription-settings",
|
||||||
|
label: "Настройка",
|
||||||
|
shortLabel: "Настройка",
|
||||||
|
phaseLabel: "Транскрибация",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "transcription-processing",
|
||||||
|
label: "Обработка",
|
||||||
|
shortLabel: "Обработка",
|
||||||
|
phaseLabel: "Транскрибация",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "subtitle-revision",
|
||||||
|
label: "Правка текста",
|
||||||
|
shortLabel: "Правка",
|
||||||
|
phaseLabel: "Транскрибация",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "caption-settings",
|
||||||
|
label: "Выбор стиля субтитров",
|
||||||
|
shortLabel: "Стиль",
|
||||||
|
phaseLabel: "Рендер",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "caption-processing",
|
||||||
|
label: "Рендер",
|
||||||
|
shortLabel: "Рендер",
|
||||||
|
phaseLabel: "Рендер",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "caption-result",
|
||||||
|
label: "Результат",
|
||||||
|
shortLabel: "Результат",
|
||||||
|
phaseLabel: "Рендер",
|
||||||
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type WizardStepKey = (typeof WIZARD_STEPS)[number]["key"]
|
export type WizardStepKey = (typeof WIZARD_STEPS)[number]["key"]
|
||||||
@@ -97,6 +157,7 @@ interface WizardContextValue {
|
|||||||
currentStep: WizardStepKey
|
currentStep: WizardStepKey
|
||||||
stepIndex: number
|
stepIndex: number
|
||||||
completedSteps: WizardStepKey[]
|
completedSteps: WizardStepKey[]
|
||||||
|
primaryFileId: string | null
|
||||||
primaryFileKey: string | null
|
primaryFileKey: string | null
|
||||||
videoUrl: string | null
|
videoUrl: string | null
|
||||||
originalFileName: string | null
|
originalFileName: string | null
|
||||||
@@ -113,8 +174,8 @@ interface WizardContextValue {
|
|||||||
goNext: () => void
|
goNext: () => void
|
||||||
goBack: () => void
|
goBack: () => void
|
||||||
setFileKey: (
|
setFileKey: (
|
||||||
key: string,
|
key: string | null,
|
||||||
url: string,
|
fileId: string | null,
|
||||||
originalFileName?: string | null,
|
originalFileName?: string | null,
|
||||||
) => void
|
) => void
|
||||||
setSilenceSettings: (settings: SilenceSettings) => void
|
setSilenceSettings: (settings: SilenceSettings) => void
|
||||||
@@ -143,8 +204,8 @@ const WizardContext = createContext<WizardContextValue | null>(null)
|
|||||||
interface PersistedWizardState {
|
interface PersistedWizardState {
|
||||||
current_step: WizardStepKey
|
current_step: WizardStepKey
|
||||||
completed_steps: WizardStepKey[]
|
completed_steps: WizardStepKey[]
|
||||||
|
primary_file_id: string | null
|
||||||
primary_file_key: string | null
|
primary_file_key: string | null
|
||||||
video_url: string | null
|
|
||||||
original_file_name: string | null
|
original_file_name: string | null
|
||||||
silence_settings: SilenceSettings
|
silence_settings: SilenceSettings
|
||||||
active_job_id: string | null
|
active_job_id: string | null
|
||||||
@@ -175,8 +236,8 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
}> = ({ projectId, children }) => {
|
}> = ({ projectId, children }) => {
|
||||||
const [currentStep, setCurrentStep] = useState<WizardStepKey>("upload")
|
const [currentStep, setCurrentStep] = useState<WizardStepKey>("upload")
|
||||||
const [completedSteps, setCompletedSteps] = useState<WizardStepKey[]>([])
|
const [completedSteps, setCompletedSteps] = useState<WizardStepKey[]>([])
|
||||||
|
const [primaryFileId, setPrimaryFileId] = useState<string | null>(null)
|
||||||
const [primaryFileKey, setPrimaryFileKey] = useState<string | null>(null)
|
const [primaryFileKey, setPrimaryFileKey] = useState<string | null>(null)
|
||||||
const [videoUrl, setVideoUrl] = useState<string | null>(null)
|
|
||||||
const [originalFileName, setOriginalFileName] = useState<string | null>(null)
|
const [originalFileName, setOriginalFileName] = useState<string | null>(null)
|
||||||
const [silenceSettings, setSilenceSettingsState] = useState<SilenceSettings>(
|
const [silenceSettings, setSilenceSettingsState] = useState<SilenceSettings>(
|
||||||
DEFAULT_SILENCE_SETTINGS,
|
DEFAULT_SILENCE_SETTINGS,
|
||||||
@@ -231,8 +292,8 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
setCompletedSteps(wizard.completed_steps ?? [])
|
setCompletedSteps(wizard.completed_steps ?? [])
|
||||||
|
setPrimaryFileId(wizard.primary_file_id ?? null)
|
||||||
setPrimaryFileKey(wizard.primary_file_key ?? null)
|
setPrimaryFileKey(wizard.primary_file_key ?? null)
|
||||||
setVideoUrl(wizard.video_url ?? null)
|
|
||||||
setOriginalFileName(wizard.original_file_name ?? null)
|
setOriginalFileName(wizard.original_file_name ?? null)
|
||||||
setSilenceSettingsState(
|
setSilenceSettingsState(
|
||||||
wizard.silence_settings ?? DEFAULT_SILENCE_SETTINGS,
|
wizard.silence_settings ?? DEFAULT_SILENCE_SETTINGS,
|
||||||
@@ -251,14 +312,23 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
isInitializedRef.current = true
|
isInitializedRef.current = true
|
||||||
}, [isSuccess, project])
|
}, [isSuccess, project])
|
||||||
|
|
||||||
|
const { data: primaryFileInfo } = api.useQuery(
|
||||||
|
"get",
|
||||||
|
"/api/files/files/{file_id}/resolve/",
|
||||||
|
{ params: { path: { file_id: primaryFileId ?? "" } } },
|
||||||
|
{ enabled: !!primaryFileId },
|
||||||
|
)
|
||||||
|
|
||||||
|
const videoUrl = primaryFileInfo?.file_url ?? null
|
||||||
|
|
||||||
/* ---- Save to server (debounced) ---- */
|
/* ---- Save to server (debounced) ---- */
|
||||||
|
|
||||||
const stateToSave = useMemo<PersistedWizardState>(
|
const stateToSave = useMemo<PersistedWizardState>(
|
||||||
() => ({
|
() => ({
|
||||||
current_step: currentStep,
|
current_step: currentStep,
|
||||||
completed_steps: completedSteps,
|
completed_steps: completedSteps,
|
||||||
|
primary_file_id: primaryFileId,
|
||||||
primary_file_key: primaryFileKey,
|
primary_file_key: primaryFileKey,
|
||||||
video_url: videoUrl,
|
|
||||||
original_file_name: originalFileName,
|
original_file_name: originalFileName,
|
||||||
silence_settings: silenceSettings,
|
silence_settings: silenceSettings,
|
||||||
active_job_id: activeJobId,
|
active_job_id: activeJobId,
|
||||||
@@ -273,8 +343,8 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
[
|
[
|
||||||
currentStep,
|
currentStep,
|
||||||
completedSteps,
|
completedSteps,
|
||||||
|
primaryFileId,
|
||||||
primaryFileKey,
|
primaryFileKey,
|
||||||
videoUrl,
|
|
||||||
originalFileName,
|
originalFileName,
|
||||||
silenceSettings,
|
silenceSettings,
|
||||||
activeJobId,
|
activeJobId,
|
||||||
@@ -296,8 +366,8 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
(overrides: Partial<PersistedWizardState> = {}): PersistedWizardState => ({
|
(overrides: Partial<PersistedWizardState> = {}): PersistedWizardState => ({
|
||||||
current_step: overrides.current_step ?? currentStep,
|
current_step: overrides.current_step ?? currentStep,
|
||||||
completed_steps: overrides.completed_steps ?? completedSteps,
|
completed_steps: overrides.completed_steps ?? completedSteps,
|
||||||
|
primary_file_id: overrides.primary_file_id ?? primaryFileId,
|
||||||
primary_file_key: overrides.primary_file_key ?? primaryFileKey,
|
primary_file_key: overrides.primary_file_key ?? primaryFileKey,
|
||||||
video_url: overrides.video_url ?? videoUrl,
|
|
||||||
original_file_name: overrides.original_file_name ?? originalFileName,
|
original_file_name: overrides.original_file_name ?? originalFileName,
|
||||||
silence_settings: overrides.silence_settings ?? silenceSettings,
|
silence_settings: overrides.silence_settings ?? silenceSettings,
|
||||||
active_job_id: overrides.active_job_id ?? activeJobId,
|
active_job_id: overrides.active_job_id ?? activeJobId,
|
||||||
@@ -323,11 +393,11 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
completedSteps,
|
completedSteps,
|
||||||
currentStep,
|
currentStep,
|
||||||
originalFileName,
|
originalFileName,
|
||||||
|
primaryFileId,
|
||||||
primaryFileKey,
|
primaryFileKey,
|
||||||
silenceJobId,
|
silenceJobId,
|
||||||
silenceSettings,
|
silenceSettings,
|
||||||
transcriptionArtifactId,
|
transcriptionArtifactId,
|
||||||
videoUrl,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -422,16 +492,20 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
setCurrentStep("fragments")
|
setCurrentStep("fragments")
|
||||||
} else if (activeJobType === "SILENCE_APPLY") {
|
} else if (activeJobType === "SILENCE_APPLY") {
|
||||||
const outputData = taskStatus?.output_data as
|
const outputData = taskStatus?.output_data as
|
||||||
| { file_path?: string; file_url?: string }
|
| { file_id?: string; file_path?: string }
|
||||||
| null
|
| null
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
if (taskStatus?.status !== "DONE" || !outputData?.file_path || !outputData?.file_url) {
|
if (
|
||||||
|
taskStatus?.status !== "DONE" ||
|
||||||
|
!outputData?.file_id ||
|
||||||
|
!outputData?.file_path
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPrimaryFileId(outputData.file_id)
|
||||||
setPrimaryFileKey(outputData.file_path)
|
setPrimaryFileKey(outputData.file_path)
|
||||||
setVideoUrl(outputData.file_url)
|
|
||||||
setOriginalFileName(outputData.file_path.split("/").pop() ?? null)
|
setOriginalFileName(outputData.file_path.split("/").pop() ?? null)
|
||||||
setSilenceJobIdState(null)
|
setSilenceJobIdState(null)
|
||||||
setActiveJobId(null)
|
setActiveJobId(null)
|
||||||
@@ -499,9 +573,9 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
}, [currentStep])
|
}, [currentStep])
|
||||||
|
|
||||||
const setFileKey = useCallback(
|
const setFileKey = useCallback(
|
||||||
(key: string, url: string, fileName?: string | null) => {
|
(key: string | null, fileId: string | null, fileName?: string | null) => {
|
||||||
|
setPrimaryFileId(fileId)
|
||||||
setPrimaryFileKey(key)
|
setPrimaryFileKey(key)
|
||||||
setVideoUrl(url)
|
|
||||||
if (fileName !== undefined) {
|
if (fileName !== undefined) {
|
||||||
setOriginalFileName(fileName)
|
setOriginalFileName(fileName)
|
||||||
}
|
}
|
||||||
@@ -589,6 +663,7 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
currentStep,
|
currentStep,
|
||||||
stepIndex,
|
stepIndex,
|
||||||
completedSteps,
|
completedSteps,
|
||||||
|
primaryFileId,
|
||||||
primaryFileKey,
|
primaryFileKey,
|
||||||
videoUrl,
|
videoUrl,
|
||||||
originalFileName,
|
originalFileName,
|
||||||
@@ -621,6 +696,7 @@ export const WizardProvider: FunctionComponent<{
|
|||||||
currentStep,
|
currentStep,
|
||||||
stepIndex,
|
stepIndex,
|
||||||
completedSteps,
|
completedSteps,
|
||||||
|
primaryFileId,
|
||||||
primaryFileKey,
|
primaryFileKey,
|
||||||
videoUrl,
|
videoUrl,
|
||||||
originalFileName,
|
originalFileName,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import api from "@shared/api"
|
||||||
|
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||||
|
import { resolveTaskProgressState } from "@shared/lib/taskProgress"
|
||||||
|
|
||||||
|
interface UseTaskProgressStateArgs {
|
||||||
|
jobId: string | null
|
||||||
|
enabled: boolean
|
||||||
|
defaultMessage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTaskProgressState({
|
||||||
|
jobId,
|
||||||
|
enabled,
|
||||||
|
defaultMessage,
|
||||||
|
}: UseTaskProgressStateArgs): {
|
||||||
|
progressPct: number
|
||||||
|
message: string
|
||||||
|
status: string | null
|
||||||
|
errorMessage: string | null
|
||||||
|
} {
|
||||||
|
const notification = useAppSelector((state) =>
|
||||||
|
jobId
|
||||||
|
? state.notifications.items.find((n) => n.job_id === jobId)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: taskStatus } = api.useQuery(
|
||||||
|
"get",
|
||||||
|
"/api/tasks/status/{job_id}/",
|
||||||
|
{ params: { path: { job_id: jobId ?? "" } } },
|
||||||
|
{
|
||||||
|
enabled,
|
||||||
|
refetchInterval: 2000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvedState = resolveTaskProgressState({
|
||||||
|
notificationMessage: notification?.message,
|
||||||
|
notificationProgressPct: notification?.progress_pct,
|
||||||
|
taskMessage: taskStatus?.current_message,
|
||||||
|
taskProgressPct: taskStatus?.progress_pct,
|
||||||
|
defaultMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
progressPct: resolvedState.progressPct,
|
||||||
|
message: resolvedState.message,
|
||||||
|
status: notification?.status ?? taskStatus?.status ?? null,
|
||||||
|
errorMessage:
|
||||||
|
notification?.message ?? taskStatus?.error_message ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { formatNotificationRelativeTime } from "./dates"
|
||||||
|
|
||||||
|
describe("formatNotificationRelativeTime", () => {
|
||||||
|
test("returns 'только что' for less than a minute", () => {
|
||||||
|
expect(
|
||||||
|
formatNotificationRelativeTime("2026-04-05T11:59:31.000Z", {
|
||||||
|
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
).toBe("только что")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns minutes without suffix", () => {
|
||||||
|
expect(
|
||||||
|
formatNotificationRelativeTime("2026-04-05T11:48:00.000Z", {
|
||||||
|
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
).toBe("12мин")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns hours without suffix", () => {
|
||||||
|
expect(
|
||||||
|
formatNotificationRelativeTime("2026-04-05T08:00:00.000Z", {
|
||||||
|
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
).toBe("4ч")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns days without suffix", () => {
|
||||||
|
expect(
|
||||||
|
formatNotificationRelativeTime("2026-04-02T12:00:00.000Z", {
|
||||||
|
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
).toBe("3д")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns weeks without suffix", () => {
|
||||||
|
expect(
|
||||||
|
formatNotificationRelativeTime("2026-03-22T12:00:00.000Z", {
|
||||||
|
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
).toBe("2нед")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,3 +9,33 @@ export function formatRelativeTime(date: string | Date | null): string {
|
|||||||
if (!date) return ""
|
if (!date) return ""
|
||||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: ru })
|
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: ru })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NotificationRelativeTimeOptions {
|
||||||
|
now?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNotificationRelativeTime(
|
||||||
|
date: string | Date | null,
|
||||||
|
options: NotificationRelativeTimeOptions = {},
|
||||||
|
): string {
|
||||||
|
if (!date) return ""
|
||||||
|
|
||||||
|
const targetDate = new Date(date)
|
||||||
|
|
||||||
|
if (Number.isNaN(targetDate.getTime())) return ""
|
||||||
|
|
||||||
|
const now = options.now ?? new Date()
|
||||||
|
const diffMs = Math.max(0, now.getTime() - targetDate.getTime())
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||||
|
|
||||||
|
if (diffMinutes < 1) return "только что"
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes}мин`
|
||||||
|
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60)
|
||||||
|
if (diffHours < 24) return `${diffHours}ч`
|
||||||
|
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
if (diffDays < 7) return `${diffDays}д`
|
||||||
|
|
||||||
|
return `${Math.floor(diffDays / 7)}нед`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function declOfNum(
|
||||||
|
number: number,
|
||||||
|
titles: [string, string, string],
|
||||||
|
): string {
|
||||||
|
const cases = [2, 0, 1, 1, 1, 2]
|
||||||
|
|
||||||
|
return titles[
|
||||||
|
number % 100 > 4 && number % 100 < 20
|
||||||
|
? 2
|
||||||
|
: cases[number % 10 < 5 ? number % 10 : 5]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { resolveTaskProgressState } from "./taskProgress"
|
||||||
|
|
||||||
|
describe("resolveTaskProgressState", () => {
|
||||||
|
test("prefers the polled task status when it is ahead of notifications", () => {
|
||||||
|
expect(
|
||||||
|
resolveTaskProgressState({
|
||||||
|
notificationMessage: "Конвертация видео",
|
||||||
|
notificationProgressPct: 24,
|
||||||
|
taskMessage: "Загрузка результата",
|
||||||
|
taskProgressPct: 95,
|
||||||
|
defaultMessage: "Конвертация видео...",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
progressPct: 95,
|
||||||
|
message: "Загрузка результата",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to notification state when it is the freshest source", () => {
|
||||||
|
expect(
|
||||||
|
resolveTaskProgressState({
|
||||||
|
notificationMessage: "Применение вырезок",
|
||||||
|
notificationProgressPct: 52.5,
|
||||||
|
taskMessage: "Применение вырезок",
|
||||||
|
taskProgressPct: 10,
|
||||||
|
defaultMessage: "Применение вырезок...",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
progressPct: 52.5,
|
||||||
|
message: "Применение вырезок",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the default message when no progress source is available", () => {
|
||||||
|
expect(
|
||||||
|
resolveTaskProgressState({
|
||||||
|
notificationMessage: null,
|
||||||
|
notificationProgressPct: null,
|
||||||
|
taskMessage: null,
|
||||||
|
taskProgressPct: null,
|
||||||
|
defaultMessage: "Подождите, идёт обработка...",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
progressPct: 0,
|
||||||
|
message: "Подождите, идёт обработка...",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
interface ResolveTaskProgressStateArgs {
|
||||||
|
notificationMessage: string | null | undefined
|
||||||
|
notificationProgressPct: number | null | undefined
|
||||||
|
taskMessage: string | null | undefined
|
||||||
|
taskProgressPct: number | null | undefined
|
||||||
|
defaultMessage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTaskProgressState({
|
||||||
|
notificationMessage,
|
||||||
|
notificationProgressPct,
|
||||||
|
taskMessage,
|
||||||
|
taskProgressPct,
|
||||||
|
defaultMessage,
|
||||||
|
}: ResolveTaskProgressStateArgs): {
|
||||||
|
progressPct: number
|
||||||
|
message: string
|
||||||
|
} {
|
||||||
|
const notificationPct = notificationProgressPct ?? null
|
||||||
|
const statusPct = taskProgressPct ?? null
|
||||||
|
|
||||||
|
if (notificationPct !== null && statusPct !== null) {
|
||||||
|
if (statusPct > notificationPct) {
|
||||||
|
return {
|
||||||
|
progressPct: statusPct,
|
||||||
|
message: taskMessage ?? notificationMessage ?? defaultMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
progressPct: notificationPct,
|
||||||
|
message: notificationMessage ?? taskMessage ?? defaultMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationPct !== null) {
|
||||||
|
return {
|
||||||
|
progressPct: notificationPct,
|
||||||
|
message: notificationMessage ?? taskMessage ?? defaultMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusPct !== null) {
|
||||||
|
return {
|
||||||
|
progressPct: statusPct,
|
||||||
|
message: taskMessage ?? notificationMessage ?? defaultMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
progressPct: 0,
|
||||||
|
message: notificationMessage ?? taskMessage ?? defaultMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ $color-primary: var(--color-primary);
|
|||||||
$color-secondary: var(--color-secondary);
|
$color-secondary: var(--color-secondary);
|
||||||
$color-white: var(--color-white);
|
$color-white: var(--color-white);
|
||||||
$color-black: var(--color-black);
|
$color-black: var(--color-black);
|
||||||
|
$accent-solid-start: var(--accent-solid-start);
|
||||||
|
$accent-solid-end: var(--accent-solid-end);
|
||||||
|
$accent-foreground: var(--accent-foreground);
|
||||||
|
$accent-shadow: var(--accent-shadow);
|
||||||
|
$accent-shadow-hover: var(--accent-shadow-hover);
|
||||||
|
|
||||||
$header-height: var(--header-height);
|
$header-height: var(--header-height);
|
||||||
$text-primary: var(--text-primary);
|
$text-primary: var(--text-primary);
|
||||||
|
|||||||
+165
-77
@@ -13,16 +13,51 @@
|
|||||||
font-variation-settings: "opsz" 10;
|
font-variation-settings: "opsz" 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-default) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-default);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-canvas);
|
background-color: var(--bg-canvas);
|
||||||
background-image: radial-gradient(circle at 50% 0%, rgba(110, 94, 219, 0.05) 0%, transparent 60%);
|
|
||||||
background-attachment: fixed;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image: var(--page-glow);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-project-wizard-layout="true"] {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-project-wizard-layout="true"] #app-root {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background-color: hsl(262, 68%, 52%, 0.15);
|
background-color: var(--selection-bg);
|
||||||
color: var(--text-primary);
|
color: var(--selection-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -34,57 +69,65 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Rich Violet Palette */
|
/* Catppuccin Latte */
|
||||||
--purple-50: hsl(262, 60%, 97%);
|
--purple-50: #f7efff;
|
||||||
--purple-100: hsl(262, 50%, 90%);
|
--purple-100: #eedfff;
|
||||||
--purple-200: hsl(262, 48%, 80%);
|
--purple-200: #dfc8ff;
|
||||||
--purple-300: hsl(262, 55%, 68%);
|
--purple-300: #c8abff;
|
||||||
--purple-400: hsl(262, 70%, 54%);
|
--purple-400: #a777f2;
|
||||||
--purple-500: hsl(262, 75%, 48%);
|
--purple-500: #8839ef;
|
||||||
--purple-600: hsl(262, 78%, 42%);
|
--purple-600: #7430ca;
|
||||||
--purple-700: hsl(262, 80%, 36%);
|
--purple-700: #5f27a5;
|
||||||
--purple-800: hsl(262, 85%, 28%);
|
--purple-800: #4a1f80;
|
||||||
--purple-900: hsl(262, 90%, 20%);
|
--purple-900: #35175c;
|
||||||
|
|
||||||
/* Muted Sage Green Palette */
|
--green-50: #eff8ea;
|
||||||
--green-50: hsl(150, 30%, 94%);
|
--green-100: #dcefd2;
|
||||||
--green-100: hsl(150, 28%, 85%);
|
--green-200: #c7e5b9;
|
||||||
--green-200: hsl(150, 26%, 74%);
|
--green-300: #afd89d;
|
||||||
--green-300: hsl(150, 28%, 62%);
|
--green-400: #7fc16c;
|
||||||
--green-400: hsl(150, 32%, 52%);
|
--green-500: #40a02b;
|
||||||
--green-500: hsl(150, 40%, 42%);
|
--green-600: #348222;
|
||||||
--green-600: hsl(152, 44%, 36%);
|
--green-700: #28651a;
|
||||||
--green-700: hsl(154, 50%, 30%);
|
--green-800: #1d4912;
|
||||||
--green-800: hsl(156, 56%, 24%);
|
--green-900: #122d0a;
|
||||||
--green-900: hsl(158, 64%, 18%);
|
|
||||||
|
|
||||||
--color-success: #16a34a;
|
--color-success: #40a02b;
|
||||||
--color-danger: #dc2626;
|
--color-danger: #d20f39;
|
||||||
--color-warning: #d97706;
|
--color-warning: #df8e1d;
|
||||||
|
|
||||||
--color-primary: var(--purple-500);
|
--color-primary: var(--purple-500);
|
||||||
--color-secondary: var(--purple-400);
|
--color-secondary: var(--purple-400);
|
||||||
--color-white: #ffffff;
|
--color-white: #ffffff;
|
||||||
--color-black: #000000;
|
--color-black: #000000;
|
||||||
--text-primary: #18181b;
|
--text-primary: #4c4f69;
|
||||||
--text-secondary: #71717a;
|
--text-secondary: #5c5f77;
|
||||||
--text-tertiary: #a1a1aa;
|
--text-tertiary: #8c8fa1;
|
||||||
|
|
||||||
--bg-canvas: #fafafa;
|
--bg-canvas: #e6e9ef;
|
||||||
--bg-default: #ffffff;
|
--bg-default: #eff1f5;
|
||||||
--bg-surface: #f4f4f5;
|
--bg-surface: #dce0e8;
|
||||||
--bg-hover: #e4e4e7;
|
--bg-hover: #ccd0da;
|
||||||
--bg-default-invert: #18181b;
|
--bg-default-invert: #1e1e2e;
|
||||||
|
|
||||||
--border-default: #e8e8ec;
|
--border-default: #bcc0cc;
|
||||||
--border-subtle: #f4f4f5;
|
--border-subtle: #dce0e8;
|
||||||
|
|
||||||
--waveform-wave: var(--purple-400);
|
--waveform-wave: #7287fd;
|
||||||
--waveform-progress: var(--purple-600);
|
--waveform-progress: #8839ef;
|
||||||
|
--accent-solid-start: #a777f2;
|
||||||
|
--accent-solid-end: #7430ca;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--accent-shadow: rgba(136, 57, 239, 0.28);
|
||||||
|
--accent-shadow-hover: rgba(136, 57, 239, 0.38);
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.02), 0 2px 8px rgba(0, 0, 0, 0.02);
|
--page-glow: radial-gradient(circle at 50% 0%, rgba(136, 57, 239, 0.08) 0%, transparent 62%);
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.05);
|
--selection-bg: rgba(136, 57, 239, 0.18);
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.04), 0 40px 80px -20px rgba(0, 0, 0, 0.08);
|
--selection-text: #4c4f69;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(76, 79, 105, 0.06), 0 2px 8px rgba(76, 79, 105, 0.04);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(76, 79, 105, 0.08), 0 24px 48px -12px rgba(76, 79, 105, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(76, 79, 105, 0.08), 0 40px 80px -20px rgba(76, 79, 105, 0.12);
|
||||||
|
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--radius-md: 12px;
|
--radius-md: 12px;
|
||||||
@@ -99,51 +142,96 @@ button {
|
|||||||
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
|
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
|
||||||
/* Focus ring */
|
--alert-info-bg: rgba(4, 165, 229, 0.12);
|
||||||
--focus-ring: 0 0 0 2px var(--bg-default), 0 0 0 4px hsla(262, 75%, 48%, 0.3);
|
--alert-info-text: #1e66f5;
|
||||||
|
--alert-info-border: rgba(4, 165, 229, 0.28);
|
||||||
|
--alert-success-bg: rgba(64, 160, 43, 0.12);
|
||||||
|
--alert-success-text: #28651a;
|
||||||
|
--alert-success-border: rgba(64, 160, 43, 0.26);
|
||||||
|
--alert-warning-bg: rgba(223, 142, 29, 0.14);
|
||||||
|
--alert-warning-text: #b06a12;
|
||||||
|
--alert-warning-border: rgba(223, 142, 29, 0.28);
|
||||||
|
--alert-danger-bg: rgba(210, 15, 57, 0.12);
|
||||||
|
--alert-danger-text: #b10f34;
|
||||||
|
--alert-danger-border: rgba(210, 15, 57, 0.24);
|
||||||
|
|
||||||
|
--focus-ring: 0 0 0 2px var(--bg-default), 0 0 0 4px rgba(136, 57, 239, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
|
/* Catppuccin Mocha */
|
||||||
|
--purple-50: #2b253b;
|
||||||
|
--purple-100: #362f4c;
|
||||||
|
--purple-200: #4b4168;
|
||||||
|
--purple-300: #6a5a93;
|
||||||
|
--purple-400: #cba6f7;
|
||||||
|
--purple-500: #d9bcfa;
|
||||||
|
--purple-600: #e4cffc;
|
||||||
|
--purple-700: #eddfff;
|
||||||
|
--purple-800: #f3ebff;
|
||||||
|
--purple-900: #faf6ff;
|
||||||
|
|
||||||
|
--green-50: #1d2b1d;
|
||||||
|
--green-100: #243524;
|
||||||
|
--green-200: #314a31;
|
||||||
|
--green-300: #426542;
|
||||||
|
--green-400: #679d64;
|
||||||
|
--green-500: #8ccf86;
|
||||||
|
--green-600: #a6e3a1;
|
||||||
|
--green-700: #b9ebae;
|
||||||
|
--green-800: #cdf2c8;
|
||||||
|
--green-900: #e0f8e1;
|
||||||
|
|
||||||
|
--color-success: #a6e3a1;
|
||||||
|
--color-danger: #f38ba8;
|
||||||
|
--color-warning: #f9e2af;
|
||||||
|
|
||||||
--color-primary: var(--purple-400);
|
--color-primary: var(--purple-400);
|
||||||
--color-secondary: var(--purple-300);
|
--color-secondary: var(--purple-300);
|
||||||
|
|
||||||
--text-primary: #fdfdfd;
|
--text-primary: #cdd6f4;
|
||||||
--text-secondary: #a1a1aa;
|
--text-secondary: #bac2de;
|
||||||
--text-tertiary: #71717a;
|
--text-tertiary: #9399b2;
|
||||||
|
|
||||||
--bg-canvas: #050505;
|
--bg-canvas: #11111b;
|
||||||
--bg-default: #0a0a0a;
|
--bg-default: #1e1e2e;
|
||||||
--bg-surface: #141414;
|
--bg-surface: #313244;
|
||||||
--bg-hover: #1f1f23;
|
--bg-hover: #45475a;
|
||||||
--bg-default-invert: #fafafa;
|
--bg-default-invert: #eff1f5;
|
||||||
|
|
||||||
--border-default: #27272a;
|
--border-default: #45475a;
|
||||||
--border-subtle: #18181b;
|
--border-subtle: #313244;
|
||||||
|
|
||||||
--waveform-wave: #e4e4e7;
|
--waveform-wave: #6c7086;
|
||||||
--waveform-progress: #a1a1aa;
|
--waveform-progress: #cba6f7;
|
||||||
|
--accent-solid-start: #6a5a93;
|
||||||
|
--accent-solid-end: #362f4c;
|
||||||
|
--accent-foreground: #f5e0dc;
|
||||||
|
--accent-shadow: rgba(203, 166, 247, 0.22);
|
||||||
|
--accent-shadow-hover: rgba(203, 166, 247, 0.3);
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
--page-glow: radial-gradient(circle at 50% 0%, rgba(203, 166, 247, 0.12) 0%, transparent 55%);
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 24px 48px -12px rgba(0, 0, 0, 0.4);
|
--selection-bg: rgba(203, 166, 247, 0.2);
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 40px 80px -20px rgba(0, 0, 0, 0.6);
|
--selection-text: #f5e0dc;
|
||||||
|
|
||||||
/* Alert dark mode overrides */
|
--shadow-sm: 0 1px 2px rgba(17, 17, 27, 0.5);
|
||||||
--alert-info-bg: hsl(204, 50%, 18%);
|
--shadow-md: 0 4px 6px -1px rgba(17, 17, 27, 0.58), 0 24px 48px -12px rgba(17, 17, 27, 0.52);
|
||||||
--alert-info-text: hsl(204, 70%, 72%);
|
--shadow-lg: 0 10px 15px -3px rgba(17, 17, 27, 0.6), 0 40px 80px -20px rgba(17, 17, 27, 0.7);
|
||||||
--alert-info-border: hsl(204, 50%, 28%);
|
|
||||||
--alert-success-bg: hsl(144, 40%, 16%);
|
|
||||||
--alert-success-text: hsl(142, 55%, 68%);
|
|
||||||
--alert-success-border: hsl(142, 40%, 26%);
|
|
||||||
--alert-warning-bg: hsl(45, 50%, 16%);
|
|
||||||
--alert-warning-text: hsl(48, 70%, 68%);
|
|
||||||
--alert-warning-border: hsl(48, 50%, 28%);
|
|
||||||
--alert-danger-bg: hsl(0, 50%, 18%);
|
|
||||||
--alert-danger-text: hsl(0, 60%, 72%);
|
|
||||||
--alert-danger-border: hsl(0, 40%, 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] body {
|
--alert-info-bg: rgba(137, 220, 235, 0.14);
|
||||||
background-image: radial-gradient(circle at 50% 0%, rgba(139, 92, 246, 0.08) 0%, transparent 50%);
|
--alert-info-text: #89dceb;
|
||||||
|
--alert-info-border: rgba(137, 220, 235, 0.28);
|
||||||
|
--alert-success-bg: rgba(166, 227, 161, 0.14);
|
||||||
|
--alert-success-text: #a6e3a1;
|
||||||
|
--alert-success-border: rgba(166, 227, 161, 0.3);
|
||||||
|
--alert-warning-bg: rgba(249, 226, 175, 0.14);
|
||||||
|
--alert-warning-text: #f9e2af;
|
||||||
|
--alert-warning-border: rgba(249, 226, 175, 0.28);
|
||||||
|
--alert-danger-bg: rgba(243, 139, 168, 0.16);
|
||||||
|
--alert-danger-text: #f38ba8;
|
||||||
|
--alert-danger-border: rgba(243, 139, 168, 0.3);
|
||||||
|
|
||||||
|
--focus-ring: 0 0 0 2px var(--bg-default), 0 0 0 4px rgba(203, 166, 247, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Badge as RadixBadge } from "@radix-ui/themes"
|
|||||||
import { forwardRef } from "react"
|
import { forwardRef } from "react"
|
||||||
|
|
||||||
const variantMap = {
|
const variantMap = {
|
||||||
primary: "violet",
|
primary: "plum",
|
||||||
secondary: "gray",
|
secondary: "gray",
|
||||||
success: "green",
|
success: "green",
|
||||||
danger: "red",
|
danger: "red",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
filter: brightness(1.08);
|
filter: brightness(1.08);
|
||||||
box-shadow: inset 0 1px 1px hsla(0,0%,100%,0.2), 0 4px 14px hsla(262, 75%, 48%, 0.35);
|
box-shadow: inset 0 1px 1px hsla(0,0%,100%,0.2), 0 4px 14px var(--accent-shadow-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
&:active:not(:disabled) {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const sizeMap = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
const variantMap = {
|
const variantMap = {
|
||||||
primary: { variant: "solid", color: "violet" },
|
primary: { variant: "solid", color: "plum" },
|
||||||
secondary: { variant: "soft", color: "violet" },
|
secondary: { variant: "soft", color: "plum" },
|
||||||
outline: { variant: "outline", color: "gray" },
|
outline: { variant: "outline", color: "gray" },
|
||||||
ghost: { variant: "ghost", color: "gray" },
|
ghost: { variant: "ghost", color: "gray" },
|
||||||
danger: { variant: "solid", color: "ruby" },
|
danger: { variant: "solid", color: "ruby" },
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
.card {
|
.card {
|
||||||
background-color: hsla(0, 0%, 100%, 0.8);
|
background-color: color-mix(in srgb, var(--bg-default) 92%, transparent);
|
||||||
backdrop-filter: blur(16px) saturate(180%);
|
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
|
|
||||||
[data-theme="dark"] & {
|
[data-theme="dark"] & {
|
||||||
background-color: hsla(240, 2%, 10%, 0.5);
|
background-color: color-mix(in srgb, var(--bg-default) 85%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ export const Modal = ({
|
|||||||
className={CONTENT_CLASS}
|
className={CONTENT_CLASS}
|
||||||
style={maxWidth ? { content: { maxWidth } } : undefined}
|
style={maxWidth ? { content: { maxWidth } } : undefined}
|
||||||
>
|
>
|
||||||
<Theme accentColor="iris" grayColor="slate" appearance="inherit">
|
<Theme accentColor="plum" grayColor="mauve" appearance="inherit">
|
||||||
{title && <h2 className={styles.title}>{title}</h2>}
|
{title && <h2 className={styles.title}>{title}</h2>}
|
||||||
{description && (
|
{description && (
|
||||||
<p className={styles.description}>{description}</p>
|
<p className={styles.description}>{description}</p>
|
||||||
|
|||||||
Vendored
+2
@@ -1,6 +1,8 @@
|
|||||||
export interface StepDefinition {
|
export interface StepDefinition {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
|
shortLabel?: string
|
||||||
|
phaseLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IStepperProps {
|
export interface IStepperProps {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Root & scroll chrome */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: variables.$bg-surface;
|
background: variables.$bg-default;
|
||||||
border-bottom: 1px solid variables.$border-subtle;
|
border-bottom: 1px solid variables.$border-subtle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,25 +15,22 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fadeLeft {
|
.fadeLeft {
|
||||||
left: 0;
|
left: 0;
|
||||||
background: linear-gradient(to right, variables.$bg-surface, transparent);
|
background: linear-gradient(to right, variables.$bg-default, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fadeRight {
|
.fadeRight {
|
||||||
right: 0;
|
right: 0;
|
||||||
background: linear-gradient(to left, variables.$bg-surface, transparent);
|
background: linear-gradient(to left, variables.$bg-default, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollContainer {
|
.scrollContainer {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 16px 48px;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
padding: 12px 20px;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
@@ -37,94 +38,176 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepContainer {
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Phase row */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.phases {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
min-width: max-content;
|
||||||
flex-shrink: 0;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step {
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Phase (shared) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.phase {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 16px 6px 6px;
|
flex-shrink: 0;
|
||||||
border-radius: 32px;
|
padding: 4px 4px;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
border-radius: variables.$radius-sm;
|
||||||
border: 1px solid transparent;
|
white-space: nowrap;
|
||||||
|
transition: background-color var(--duration-normal) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepActive {
|
/* ------------------------------------------------------------------ */
|
||||||
background: variables.$color-primary;
|
/* Indicator circle */
|
||||||
border-color: variables.$color-primary;
|
/* ------------------------------------------------------------------ */
|
||||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.25);
|
|
||||||
|
|
||||||
.number {
|
.indicator {
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepCompleted {
|
|
||||||
background: variables.$bg-hover;
|
|
||||||
border-color: variables.$border-default;
|
|
||||||
|
|
||||||
.number {
|
|
||||||
background: variables.$color-success;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: variables.$text-primary;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stepUpcoming {
|
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
.number {
|
|
||||||
background: variables.$bg-default;
|
|
||||||
border: 1px solid variables.$border-default;
|
|
||||||
color: variables.$text-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: variables.$text-tertiary;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.number {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 26px;
|
width: 28px;
|
||||||
height: 26px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: all 0.3s ease;
|
transition: all var(--duration-normal) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.indicatorCompleted {
|
||||||
font-size: 13px;
|
background: color-mix(in srgb, variables.$color-success 10%, variables.$bg-default);
|
||||||
transition: all 0.3s ease;
|
border: 1.5px solid color-mix(in srgb, variables.$color-success 25%, variables.$border-default);
|
||||||
|
color: variables.$color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicatorActive {
|
||||||
|
background: variables.$color-secondary;
|
||||||
|
color: variables.$color-white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicatorUpcoming {
|
||||||
|
background: variables.$bg-default;
|
||||||
|
border: 1.5px solid variables.$border-default;
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Phase label */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.phaseLabel {
|
||||||
|
@include typography.font-caption-m;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.phaseCompleted .phaseLabel {
|
||||||
|
color: variables.$text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseActive .phaseLabel {
|
||||||
|
@include typography.font-body-14(600);
|
||||||
|
color: variables.$text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseUpcoming .phaseLabel {
|
||||||
|
color: variables.$text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Active phase pill background */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.phaseActive {
|
||||||
|
background: color-mix(in srgb, variables.$color-secondary 4%, transparent);
|
||||||
|
padding: 6px 12px 6px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Substep dots (inline, active phase only) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.substepDots {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 4px;
|
||||||
color: variables.$text-tertiary;
|
margin-left: 2px;
|
||||||
opacity: 0.6;
|
}
|
||||||
margin: 0 4px;
|
|
||||||
padding: 0 4px;
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all var(--duration-normal) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotCompleted {
|
||||||
|
background: variables.$color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotActive {
|
||||||
|
background: variables.$color-secondary;
|
||||||
|
box-shadow: 0 0 0 2.5px color-mix(in srgb, variables.$color-secondary 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotUpcoming {
|
||||||
|
background: transparent;
|
||||||
|
border: 1.5px solid variables.$border-default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Connectors */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.connector {
|
||||||
|
width: 24px;
|
||||||
|
height: 1.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connectorCompleted {
|
||||||
|
background: color-mix(in srgb, variables.$color-success 40%, variables.$border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connectorActive {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, variables.$color-success 40%, variables.$border-default),
|
||||||
|
color-mix(in srgb, variables.$color-secondary 40%, variables.$border-default)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connectorUpcoming {
|
||||||
|
background: none;
|
||||||
|
border-top: 1.5px dashed variables.$border-default;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Mobile overrides */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
@include breakpoints.respond-to(breakpoints.$mobileMax) {
|
||||||
|
.fadeLeft,
|
||||||
|
.fadeRight {
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollContainer {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector {
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,97 @@
|
|||||||
import type { IStepperProps } from "./Stepper.d"
|
import type { IStepperProps, StepDefinition } from "./Stepper.d"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react"
|
||||||
import {
|
import { Fragment, FunctionComponent, useEffect, useMemo, useRef } from "react"
|
||||||
FunctionComponent,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
} from "react"
|
|
||||||
import {
|
|
||||||
motion,
|
|
||||||
useScroll,
|
|
||||||
useTransform,
|
|
||||||
animate,
|
|
||||||
} from "framer-motion"
|
|
||||||
|
|
||||||
import cs from "classnames"
|
import cs from "classnames"
|
||||||
|
import { animate, motion, useScroll, useTransform } from "framer-motion"
|
||||||
|
|
||||||
import styles from "./Stepper.module.scss"
|
import styles from "./Stepper.module.scss"
|
||||||
|
|
||||||
const ChevronSeparator = () => (
|
/* ------------------------------------------------------------------ */
|
||||||
<div className={styles.separator}>
|
/* Phase grouping */
|
||||||
<svg
|
/* ------------------------------------------------------------------ */
|
||||||
width="16"
|
|
||||||
height="16"
|
interface Phase {
|
||||||
viewBox="0 0 24 24"
|
label: string
|
||||||
fill="none"
|
steps: StepDefinition[]
|
||||||
stroke="currentColor"
|
phaseIndex: number
|
||||||
strokeWidth="2"
|
isCompleted: boolean
|
||||||
strokeLinecap="round"
|
isActive: boolean
|
||||||
strokeLinejoin="round"
|
isUpcoming: boolean
|
||||||
>
|
}
|
||||||
<path d="m9 18 6-6-6-6" />
|
|
||||||
</svg>
|
function groupStepsIntoPhases(
|
||||||
</div>
|
steps: StepDefinition[],
|
||||||
|
currentStep: string,
|
||||||
|
completedSteps: string[],
|
||||||
|
): Phase[] {
|
||||||
|
const phases: Phase[] = []
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
const label = step.phaseLabel ?? step.label
|
||||||
|
const lastPhase = phases[phases.length - 1]
|
||||||
|
|
||||||
|
if (lastPhase && lastPhase.label === label) {
|
||||||
|
lastPhase.steps.push(step)
|
||||||
|
} else {
|
||||||
|
phases.push({
|
||||||
|
label,
|
||||||
|
steps: [step],
|
||||||
|
phaseIndex: phases.length,
|
||||||
|
isCompleted: false,
|
||||||
|
isActive: false,
|
||||||
|
isUpcoming: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const phase of phases) {
|
||||||
|
const allCompleted = phase.steps.every((s) =>
|
||||||
|
completedSteps.includes(s.key),
|
||||||
)
|
)
|
||||||
|
const hasActive = phase.steps.some((s) => s.key === currentStep)
|
||||||
|
|
||||||
|
phase.isCompleted = allCompleted && !hasActive
|
||||||
|
phase.isActive = hasActive
|
||||||
|
phase.isUpcoming = !allCompleted && !hasActive
|
||||||
|
}
|
||||||
|
|
||||||
|
return phases
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Connector between phases */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function getConnectorVariant(leftPhase: Phase, rightPhase: Phase): string {
|
||||||
|
if (leftPhase.isCompleted && rightPhase.isCompleted)
|
||||||
|
return styles.connectorCompleted
|
||||||
|
if (leftPhase.isCompleted && rightPhase.isActive)
|
||||||
|
return styles.connectorActive
|
||||||
|
return styles.connectorUpcoming
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Substep status */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type SubstepStatus = "completed" | "active" | "upcoming"
|
||||||
|
|
||||||
|
function getSubstepStatus(
|
||||||
|
stepKey: string,
|
||||||
|
currentStep: string,
|
||||||
|
completedSteps: string[],
|
||||||
|
): SubstepStatus {
|
||||||
|
if (stepKey === currentStep) return "active"
|
||||||
|
if (completedSteps.includes(stepKey)) return "completed"
|
||||||
|
return "upcoming"
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export const Stepper: FunctionComponent<IStepperProps> = ({
|
export const Stepper: FunctionComponent<IStepperProps> = ({
|
||||||
steps,
|
steps,
|
||||||
@@ -52,15 +110,19 @@ export const Stepper: FunctionComponent<IStepperProps> = ({
|
|||||||
const fadeLeftOpacity = useTransform(scrollXProgress, [0, 0.03], [0, 1])
|
const fadeLeftOpacity = useTransform(scrollXProgress, [0, 0.03], [0, 1])
|
||||||
const fadeRightOpacity = useTransform(scrollXProgress, [0.97, 1], [1, 0])
|
const fadeRightOpacity = useTransform(scrollXProgress, [0.97, 1], [1, 0])
|
||||||
|
|
||||||
// Scroll active step into view
|
const phases = useMemo(
|
||||||
|
() => groupStepsIntoPhases(steps, currentStep, completedSteps),
|
||||||
|
[steps, currentStep, completedSteps],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scroll active phase into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = scrollContainerRef.current
|
const container = scrollContainerRef.current
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
const scrollToActive = () => {
|
const scrollToActive = () => {
|
||||||
const activeEl = container.querySelector<HTMLElement>(
|
const activeEl =
|
||||||
"[data-step-active]",
|
container.querySelector<HTMLElement>("[data-step-active]")
|
||||||
)
|
|
||||||
if (!activeEl) return
|
if (!activeEl) return
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
const containerRect = container.getBoundingClientRect()
|
||||||
@@ -95,58 +157,84 @@ export const Stepper: FunctionComponent<IStepperProps> = ({
|
|||||||
return () => controls.stop()
|
return () => controls.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delay to ensure layout is complete after hydration
|
|
||||||
const frame = requestAnimationFrame(scrollToActive)
|
const frame = requestAnimationFrame(scrollToActive)
|
||||||
return () => cancelAnimationFrame(frame)
|
return () => cancelAnimationFrame(frame)
|
||||||
}, [currentStep])
|
}, [currentStep])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cs(styles.root, className)} data-testid="Stepper">
|
||||||
className={cs(styles.root, className)}
|
|
||||||
data-testid="Stepper"
|
|
||||||
>
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className={styles.fadeLeft}
|
className={styles.fadeLeft}
|
||||||
style={{ opacity: fadeLeftOpacity }}
|
style={{ opacity: fadeLeftOpacity }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div ref={scrollContainerRef} className={styles.scrollContainer}>
|
||||||
ref={scrollContainerRef}
|
<div className={styles.phases}>
|
||||||
className={styles.scrollContainer}
|
{phases.map((phase, idx) => {
|
||||||
>
|
const isLast = idx === phases.length - 1
|
||||||
{steps.map((step, index) => {
|
const connectorClass = !isLast
|
||||||
const isCompleted = completedSteps.includes(step.key)
|
? getConnectorVariant(phase, phases[idx + 1])
|
||||||
const isActive = step.key === currentStep
|
: null
|
||||||
const isLast = index === steps.length - 1
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Fragment key={phase.label}>
|
||||||
<div
|
<div
|
||||||
key={step.key}
|
className={cs(styles.phase, {
|
||||||
className={styles.stepContainer}
|
[styles.phaseCompleted]: phase.isCompleted,
|
||||||
data-step-container
|
[styles.phaseActive]: phase.isActive,
|
||||||
>
|
[styles.phaseUpcoming]: phase.isUpcoming,
|
||||||
<div
|
|
||||||
className={cs(styles.step, {
|
|
||||||
[styles.stepCompleted]: isCompleted,
|
|
||||||
[styles.stepActive]: isActive,
|
|
||||||
[styles.stepUpcoming]: !isActive && !isCompleted,
|
|
||||||
})}
|
})}
|
||||||
{...(isActive ? { "data-step-active": true } : {})}
|
aria-current={phase.isActive ? "step" : undefined}
|
||||||
|
{...(phase.isActive ? { "data-step-active": true } : {})}
|
||||||
>
|
>
|
||||||
<span className={styles.number}>
|
<span
|
||||||
{isCompleted ? (
|
className={cs(styles.indicator, {
|
||||||
<Check size={14} strokeWidth={3} />
|
[styles.indicatorCompleted]: phase.isCompleted,
|
||||||
|
[styles.indicatorActive]: phase.isActive,
|
||||||
|
[styles.indicatorUpcoming]: phase.isUpcoming,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{phase.isCompleted ? (
|
||||||
|
<Check size={12} strokeWidth={2.5} />
|
||||||
) : (
|
) : (
|
||||||
index + 1
|
phase.phaseIndex + 1
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.label}>{step.label}</span>
|
|
||||||
</div>
|
<span className={styles.phaseLabel}>{phase.label}</span>
|
||||||
{!isLast && <ChevronSeparator />}
|
|
||||||
</div>
|
{phase.isActive && (
|
||||||
|
<div className={styles.substepDots}>
|
||||||
|
{phase.steps.map((step) => {
|
||||||
|
const status = getSubstepStatus(
|
||||||
|
step.key,
|
||||||
|
currentStep,
|
||||||
|
completedSteps,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={step.key}
|
||||||
|
className={cs(styles.dot, {
|
||||||
|
[styles.dotCompleted]: status === "completed",
|
||||||
|
[styles.dotActive]: status === "active",
|
||||||
|
[styles.dotUpcoming]: status === "upcoming",
|
||||||
|
})}
|
||||||
|
title={step.label}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connectorClass && (
|
||||||
|
<div className={cs(styles.connector, connectorClass)} />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className={styles.fadeRight}
|
className={styles.fadeRight}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--purple-400);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 4px hsla(262, 75%, 48%, 0.15);
|
box-shadow: var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
will-change: transform;
|
||||||
|
|
||||||
[data-theme="dark"] & {
|
[data-theme="dark"] & {
|
||||||
background-color: rgba(5, 5, 5, 0.65);
|
background-color: rgba(5, 5, 5, 0.65);
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
.root {
|
.root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: calc(100vh - var(--header-height));
|
height: calc(100vh - var(--header-height));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: variables.$bg-canvas;
|
||||||
|
|
||||||
|
@include breakpoints.respond-to(breakpoints.$mobileMax) {
|
||||||
|
height: calc(100svh - var(--header-height));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepper {
|
.stepper {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
background: variables.$bg-canvas;
|
||||||
|
border-top: 1px solid variables.$border-subtle;
|
||||||
|
|
||||||
|
@include breakpoints.respond-to(breakpoints.$mobileMax) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import type { MediaPlayerInstance } from "@vidstack/react"
|
||||||
|
import type { RefObject } from "react"
|
||||||
|
|
||||||
export interface ITimelinePanelProps {
|
export interface ITimelinePanelProps {
|
||||||
className?: string
|
className?: string
|
||||||
projectId: string
|
projectId: string
|
||||||
audioUrl: string | null
|
audioUrl: string | null
|
||||||
|
playerRef?: RefObject<MediaPlayerInstance | null>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: variables.$bg-surface;
|
background: linear-gradient(180deg, variables.$bg-default 0%, variables.$bg-surface 100%);
|
||||||
|
border-top: 1px solid variables.$border-subtle;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -27,9 +28,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
padding: 0 12px;
|
padding: 0 14px;
|
||||||
border-bottom: 1px solid variables.$border-subtle;
|
border-bottom: 1px solid variables.$border-subtle;
|
||||||
|
background: transparent;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,30 +45,43 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid variables.$border-subtle;
|
border: 1px solid transparent;
|
||||||
border-radius: variables.$radius-sm;
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: variables.$text-secondary;
|
color: variables.$text-secondary;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
border-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: variables.$bg-hover;
|
background: variables.$bg-hover;
|
||||||
|
border-color: variables.$border-subtle;
|
||||||
|
color: variables.$text-primary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoomLabel {
|
.zoomLabel {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: variables.$text-tertiary;
|
color: variables.$text-secondary;
|
||||||
|
font-weight: 500;
|
||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeDisplay {
|
.timeDisplay {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: variables.$text-tertiary;
|
color: variables.$text-secondary;
|
||||||
|
font-weight: 500;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +97,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 56px;
|
width: 68px;
|
||||||
|
background: transparent;
|
||||||
border-right: 1px solid variables.$border-subtle;
|
border-right: 1px solid variables.$border-subtle;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.labelCell {
|
.labelCell {
|
||||||
@@ -94,6 +110,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: variables.$text-tertiary;
|
color: variables.$text-tertiary;
|
||||||
|
background: transparent;
|
||||||
border-bottom: 1px solid variables.$border-subtle;
|
border-bottom: 1px solid variables.$border-subtle;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -111,6 +128,11 @@
|
|||||||
color: variables.$text-tertiary;
|
color: variables.$text-tertiary;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: variables.$bg-hover;
|
background: variables.$bg-hover;
|
||||||
color: variables.$text-secondary;
|
color: variables.$text-secondary;
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ function formatTime(seconds: number): string {
|
|||||||
|
|
||||||
export const TimelinePanel: FunctionComponent<
|
export const TimelinePanel: FunctionComponent<
|
||||||
ITimelinePanelProps
|
ITimelinePanelProps
|
||||||
> = ({ className, projectId, audioUrl }): JSX.Element => {
|
> = ({ className, projectId, audioUrl, playerRef }): JSX.Element => {
|
||||||
const { selectedFile, setSelectedFile, usedFiles } = useWorkspaceFiles()
|
const { selectedFile, setSelectedFile, usedFiles } = useWorkspaceFiles()
|
||||||
|
|
||||||
const currentTime = useMediaState("currentTime")
|
const currentTime = useMediaState("currentTime", playerRef)
|
||||||
const duration = useMediaState("duration")
|
const duration = useMediaState("duration", playerRef)
|
||||||
const isPlaying = useMediaState("playing")
|
const isPlaying = useMediaState("playing", playerRef)
|
||||||
const remote = useMediaRemote()
|
const remote = useMediaRemote(playerRef)
|
||||||
|
|
||||||
const tracks = useTimelineTracks(usedFiles, selectedFile)
|
const tracks = useTimelineTracks(usedFiles, selectedFile)
|
||||||
const transcriptionArtifactId =
|
const transcriptionArtifactId =
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const PROJECT_ID = "75df675b-013b-4b1f-ab2d-075dadbcd0d9"
|
|||||||
const DETECT_JOB_ID = "00000000-0000-0000-0000-000000000050"
|
const DETECT_JOB_ID = "00000000-0000-0000-0000-000000000050"
|
||||||
const APPLY_JOB_ID = "00000000-0000-0000-0000-000000000051"
|
const APPLY_JOB_ID = "00000000-0000-0000-0000-000000000051"
|
||||||
const TRANSCRIPTION_JOB_ID = "00000000-0000-0000-0000-000000000052"
|
const TRANSCRIPTION_JOB_ID = "00000000-0000-0000-0000-000000000052"
|
||||||
|
const ORIGINAL_FILE_ID = "00000000-0000-0000-0000-000000000060"
|
||||||
|
const CUT_FILE_ID = "00000000-0000-0000-0000-000000000061"
|
||||||
const ORIGINAL_FILE_KEY = "projects/test/original-video.mp4"
|
const ORIGINAL_FILE_KEY = "projects/test/original-video.mp4"
|
||||||
const ORIGINAL_FILE_URL = "http://localhost:4444/files/original-video.mp4"
|
const ORIGINAL_FILE_URL = "http://localhost:4444/files/original-video.mp4"
|
||||||
const CUT_FILE_KEY = "projects/test/cut-video.mp4"
|
const CUT_FILE_KEY = "projects/test/cut-video.mp4"
|
||||||
@@ -52,8 +54,8 @@ test.describe("Silence Apply Flow", () => {
|
|||||||
"silence-settings",
|
"silence-settings",
|
||||||
"processing",
|
"processing",
|
||||||
],
|
],
|
||||||
|
primary_file_id: ORIGINAL_FILE_ID,
|
||||||
primary_file_key: ORIGINAL_FILE_KEY,
|
primary_file_key: ORIGINAL_FILE_KEY,
|
||||||
video_url: ORIGINAL_FILE_URL,
|
|
||||||
original_file_name: "original-video.mp4",
|
original_file_name: "original-video.mp4",
|
||||||
silence_settings: {
|
silence_settings: {
|
||||||
min_silence_duration_ms: 200,
|
min_silence_duration_ms: 200,
|
||||||
@@ -133,19 +135,17 @@ test.describe("Silence Apply Flow", () => {
|
|||||||
await route.fallback()
|
await route.fallback()
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.route("**/api/files/get_file/**", async (route) => {
|
await page.route("**/api/files/files/*/resolve/", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const fileId = route.request().url().split("/files/")[1]?.split("/")[0]
|
||||||
const filePath = url.searchParams.get("file_path")
|
const isCutFile = fileId === CUT_FILE_ID
|
||||||
|
|
||||||
const fileUrl =
|
|
||||||
filePath === CUT_FILE_KEY ? CUT_FILE_URL : ORIGINAL_FILE_URL
|
|
||||||
|
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
file_url: fileUrl,
|
file_id: isCutFile ? CUT_FILE_ID : ORIGINAL_FILE_ID,
|
||||||
file_path: filePath,
|
file_url: isCutFile ? CUT_FILE_URL : ORIGINAL_FILE_URL,
|
||||||
|
file_path: isCutFile ? CUT_FILE_KEY : ORIGINAL_FILE_KEY,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -181,8 +181,8 @@ test.describe("Silence Apply Flow", () => {
|
|||||||
output_data:
|
output_data:
|
||||||
applyStatus === "DONE"
|
applyStatus === "DONE"
|
||||||
? {
|
? {
|
||||||
|
file_id: CUT_FILE_ID,
|
||||||
file_path: CUT_FILE_KEY,
|
file_path: CUT_FILE_KEY,
|
||||||
file_url: CUT_FILE_URL,
|
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -463,8 +463,8 @@ test.describe("Fragments Step (Integration)", () => {
|
|||||||
job_type: "SILENCE_APPLY",
|
job_type: "SILENCE_APPLY",
|
||||||
progress_pct: 100,
|
progress_pct: 100,
|
||||||
output_data: {
|
output_data: {
|
||||||
|
file_id: "00000000-0000-0000-0000-000000000071",
|
||||||
file_path: processedFilePath,
|
file_path: processedFilePath,
|
||||||
file_url: processedFileUrl,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ test.describe("File Upload (Integration)", () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
file_id: "00000000-0000-0000-0000-000000000101",
|
||||||
file_path: "projects/test/video.mp4",
|
file_path: "projects/test/video.mp4",
|
||||||
file_url: "http://localhost:9000/projects/test/video.mp4",
|
file_url: "http://localhost:9000/projects/test/video.mp4",
|
||||||
}),
|
}),
|
||||||
@@ -254,6 +255,7 @@ test.describe("File Upload (Integration)", () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
file_id: "00000000-0000-0000-0000-000000000101",
|
||||||
file_path: "projects/test/video.mp4",
|
file_path: "projects/test/video.mp4",
|
||||||
file_url: "http://localhost:9000/projects/test/video.mp4",
|
file_url: "http://localhost:9000/projects/test/video.mp4",
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user