This commit is contained in:
Daniil
2026-04-07 13:42:23 +03:00
parent d648678c68
commit 46f34bdcac
59 changed files with 2708 additions and 1312 deletions
+9 -330
View File
@@ -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,56 +95,59 @@ 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) => {
<div const presentation = getNotificationPresentation(item)
key={item.notification_id || `${item.job_id}-${idx}`}
className={cs(styles.item, { return (
[styles.itemUnread]: !item.is_read, <div
})} key={item.notification_id || `${item.job_id}-${idx}`}
onClick={() => handleItemClick(item)} className={cs(styles.item, {
> [styles.itemUnread]: !item.is_read,
<div className={styles.itemContent}> })}
<div className={styles.itemTitle}> onClick={() => handleItemClick(item)}
<span> >
{item.job_type <div className={styles.itemContent}>
? (JOB_TYPE_LABELS[item.job_type] || <div className={styles.itemHeader}>
item.title) <div className={styles.itemHeadline}>
: item.title} <div className={styles.itemTitle}>
</span> <span className={styles.itemTitleText}>
{item.status && ( {presentation.title}
<span </span>
className={cs( </div>
styles.statusBadge, {presentation.statusText &&
getStatusClass(item.status), presentation.statusVariant && (
)} <Badge
> variant={presentation.statusVariant}
{STATUS_LABELS[item.status] || className={styles.itemStatusBadge}
item.status} >
{presentation.statusText}
</Badge>
)}
</div>
<span className={styles.itemTime}>
{formatNotificationRelativeTime(item.created_at)}
</span> </span>
)}
</div>
{item.message && (
<div className={styles.itemMessage}>
{item.message}
</div> </div>
)} {presentation.detailText && (
{item.status === "RUNNING" && <div className={styles.itemMessage}>
item.progress_pct != null && ( {presentation.detailText}
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{
width: `${item.progress_pct}%`,
}}
/>
</div> </div>
)} )}
<div className={styles.itemMeta}> {item.status === "RUNNING" &&
{formatRelativeTime(item.created_at)} item.progress_pct != null && (
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{
width: `${item.progress_pct}%`,
}}
/>
</div>
)}
</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,39 +137,90 @@ 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>
<div className={styles.playerWrapper}> {/* Content area */}
{videoUrl ? ( <div className={styles.content}>
<MediaPlayer {/* Success header */}
src={videoUrl} <div className={styles.header}>
crossOrigin="" <Badge variant="success">
playsInline <CheckCircle size={14} />
className={styles.player} Готово
> </Badge>
<MediaProvider /> <h2 className={styles.title}>Результат</h2>
<DefaultVideoLayout icons={defaultLayoutIcons} /> <p className={styles.subtitle}>
</MediaPlayer> Видео с субтитрами готово к скачиванию
) : ( </p>
<div className={styles.placeholder}>Видео недоступно</div> </div>
{/* Video player */}
<div className={styles.playerContainer}>
<div className={styles.playerWrapper}>
{videoUrl ? (
<MediaPlayer
src={videoUrl}
crossOrigin=""
playsInline
className={styles.player}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
) : (
<div className={styles.placeholder}>Видео недоступно</div>
)}
</div>
</div>
{/* File info bar */}
{fileInfo?.filename && (
<div className={styles.fileInfoContainer}>
<div className={styles.fileInfoBar}>
<FileVideo size={18} className={styles.fileIcon} />
<span className={styles.fileName}>{fileInfo.filename}</span>
{typeof fileInfo.file_size === "number" && (
<span className={styles.fileSize}>
{formatFileSize(fileInfo.file_size)}
</span>
)}
</div>
</div>
)} )}
</div> </div>
{fileInfo?.filename && ( {/* Footer */}
<p className={styles.filename}>{fileInfo.filename}</p>
)}
<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"
> >
<Plus size={32} className={styles.createIcon} /> <div className={styles.createPreview} style={{ aspectRatio }}>
<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>
Удалить пресет &laquo;{deleteTarget.name}&raquo;? Это Удалить пресет &laquo;{deleteTarget.name}&raquo;? Это действие
действие нельзя отменить. нельзя отменить.
</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") {
setStatus(STATUS_FAILED) if (resolvedProgress.status === "FAILED") {
setErrorMessage(notifMessage ?? ERROR_CONVERT_FAILED) setStatus(STATUS_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,9 +116,10 @@ export const ProcessingStep: FunctionComponent<IProcessingStepProps> = ({
})} })}
> >
{isFailed {isFailed
? (notification?.message ?? "Произошла ошибка при обработке") ? (taskProgress.errorMessage ??
"Произошла ошибка при обработке")
: statusMessage} : statusMessage}
</p> </p>
<div className={styles.infoCard}> <div className={styles.infoCard}>
<Info size={16} className={styles.infoIcon} /> <Info size={16} className={styles.infoIcon} />
@@ -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,89 +169,110 @@ const SubtitleRevisionContent: FunctionComponent<{
transcriptionArtifactId={transcriptionArtifactId} transcriptionArtifactId={transcriptionArtifactId}
/> />
<MediaPlayer <div className={styles.workspaceShell}>
src={videoUrl ?? ""}
crossOrigin=""
playsInline
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}> <div className={styles.mainGrid}>
{/* Left column: video player */} <section className={cs(styles.panel, styles.editorPanel)}>
<div className={styles.playerColumn}> <div className={styles.panelHeader}>
{videoUrl ? ( <h2 className={styles.panelTitle}>Текст</h2>
<> </div>
<MediaProvider /> <div
<DefaultVideoLayout className={styles.editorColumn}
icons={defaultLayoutIcons} aria-busy={isArtifactResolving}
disableTimeSlider >
slots={{ {isArtifactResolving ? (
timeSlider: null, <div
currentTime: null, className={styles.placeholder}
timeDivider: null, role="status"
endTime: null, aria-live="polite"
startDuration: null, aria-atomic="true"
seekBackwardButton: null, >
seekForwardButton: null, Загружаем субтитры...
captionButton: null, </div>
settingsMenu: null, ) : transcriptionArtifactId ? (
pipButton: null, <TranscriptionEditor artifactId={transcriptionArtifactId} />
airPlayButton: null, ) : (
googleCastButton: null, <div
downloadButton: null, className={styles.placeholder}
}} role="status"
/> aria-live="polite"
</> aria-atomic="true"
) : ( >
<div className={styles.placeholder}> Транскрипция не найдена
Видео недоступно </div>
</div> )}
)} </div>
</div> </section>
{/* Right column: transcription editor */} <section className={cs(styles.panel, styles.playerPanel)}>
<div className={styles.editorColumn}> <div className={styles.panelHeader}>
{transcriptionArtifactId ? ( <h2 className={styles.panelTitle}>Видео</h2>
<TranscriptionEditor </div>
artifactId={transcriptionArtifactId} <div className={styles.playerColumn}>
/> {videoUrl ? (
) : ( <MediaPlayer
<div className={styles.placeholder}> ref={playerRef}
Транскрипция не найдена src={videoUrl}
</div> crossOrigin=""
)} playsInline
</div> className={styles.mediaPlayer}
>
<MediaProvider />
<DefaultVideoLayout
icons={defaultLayoutIcons}
disableTimeSlider
slots={{
timeSlider: null,
currentTime: null,
timeDivider: null,
endTime: null,
startDuration: null,
seekBackwardButton: null,
seekForwardButton: null,
captionButton: null,
settingsMenu: null,
pipButton: null,
airPlayButton: null,
googleCastButton: null,
downloadButton: null,
}}
/>
</MediaPlayer>
) : (
<div className={styles.placeholder}>
Видео недоступно
</div>
)}
</div>
</section>
</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 { .segmentCount {
opacity: 0.9; color: variables.$text-secondary;
} @include typography.font-body-14(500);
}
&.disabled { .headerStatus {
background: variables.$border-default; 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(() => {
const lastEnd = setSegmentRows((prev) => {
segments.length > 0 ? segments[segments.length - 1].endTime : "00:00.000" const lastEnd =
setSegments((prev) => [ prev.length > 0 ? prev[prev.length - 1].segment.endTime : "00:00.000"
...prev, return [
{ startTime: lastEnd, endTime: lastEnd, text: "" }, ...prev,
]) 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,89 +218,138 @@ 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
<div className={styles.timesGroup}>
<label className={styles.timeLabel}> return (
<span className={styles.timeLabelText}>Начало</span> <div
<input key={row.id}
className={styles.timeInput} className={styles.segment}
type="text" data-segment-index={idx}
value={seg.startTime} >
onChange={(e) => <div className={styles.segmentNumber} aria-hidden="true">
updateSegment(idx, "startTime", e.target.value) {String(idx + 1).padStart(2, "0")}
}
placeholder="00:00.000"
/>
</label>
<label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Конец</span>
<input
className={styles.timeInput}
type="text"
value={seg.endTime}
onChange={(e) =>
updateSegment(idx, "endTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
</div> </div>
<div className={styles.actionsGroup}> <div className={styles.segmentMain}>
<button <div className={styles.segmentMetaRow}>
className={styles.splitButton} <div className={styles.timesGroup}>
onClick={() => setSplittingIdx(idx)} <label className={styles.timeField}>
title={ <span className={styles.timeLabelText}>Начало</span>
!seg.words || seg.words.length < 2 <input
? "Нет данных о словах для разделения" className={styles.timeInput}
: "Разделить сегмент" type="text"
} value={row.segment.startTime}
disabled={!seg.words || seg.words.length < 2} onChange={(e) =>
> updateSegment(row.id, "startTime", e.target.value)
<Scissors size={14} /> }
</button> placeholder="00:00.000"
<button />
className={styles.removeButton} </label>
onClick={() => removeSegment(idx)} <label className={styles.timeField}>
title="Удалить сегмент" <span className={styles.timeLabelText}>Конец</span>
> <input
<Trash2 size={14} /> className={styles.timeInput}
</button> type="text"
value={row.segment.endTime}
onChange={(e) =>
updateSegment(row.id, "endTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
</div>
<div className={styles.actionsGroup}>
<button
className={styles.splitButton}
onClick={() => setSplittingRowId(row.id)}
aria-label={
splitDisabled
? `Разделить сегмент ${idx + 1}: недоступно без разбивки по словам`
: `Разделить сегмент ${idx + 1}`
}
title={
splitDisabled
? "Нет данных о словах для разделения"
: "Разделить сегмент"
}
disabled={splitDisabled}
type="button"
>
<Scissors size={14} />
</button>
<button
className={styles.removeButton}
onClick={() => removeSegment(row.id)}
aria-label={`Удалить сегмент ${idx + 1}`}
title="Удалить сегмент"
type="button"
>
<Trash2 size={14} />
</button>
</div>
</div>
<label className={styles.srOnly} htmlFor={textareaId}>
Текст сегмента {idx + 1}
</label>
{splittingRowId === row.id ? (
<SegmentSplitter
segment={row.segment}
onSplit={(newSegs) => handleSplit(row.id, newSegs)}
onCancel={() => setSplittingRowId(null)}
/>
) : (
<textarea
id={textareaId}
className={styles.textArea}
value={row.segment.text}
onChange={(e) => updateSegment(row.id, "text", e.target.value)}
rows={2}
placeholder="Текст сегмента..."
/>
)}
</div> </div>
</div> </div>
{splittingIdx === idx ? ( )
<SegmentSplitter })}
segment={seg}
onSplit={(newSegs) => handleSplit(idx, newSegs)}
onCancel={() => setSplittingIdx(null)}
/>
) : (
<textarea
className={styles.textArea}
value={seg.text}
onChange={(e) => updateSegment(idx, "text", e.target.value)}
rows={2}
placeholder="Текст сегмента..."
/>
)}
</div>
))}
</div> </div>
{/* Add segment */}
<button className={styles.addButton} onClick={addSegment}>
<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 {
+19 -17
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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"
+102 -26
View File
@@ -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,
+52
View File
@@ -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,
}
}
+45
View File
@@ -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нед")
})
})
+30
View File
@@ -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)}нед`
}
+12
View File
@@ -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]
]
}
+50
View File
@@ -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: "Подождите, идёт обработка...",
})
})
})
+54
View File
@@ -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,
}
}
+5
View File
@@ -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);
+169 -81
View File
@@ -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%); color: var(--text-primary);
background-attachment: fixed; }
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,61 +69,69 @@ 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;
--radius-sm: 8px; --shadow-sm: 0 1px 2px rgba(76, 79, 105, 0.06), 0 2px 8px rgba(76, 79, 105, 0.04);
--radius-md: 12px; --shadow-md: 0 4px 6px -1px rgba(76, 79, 105, 0.08), 0 24px 48px -12px rgba(76, 79, 105, 0.1);
--radius-lg: 16px; --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-md: 12px;
--radius-lg: 16px;
--header-height: 56px; --header-height: 56px;
@@ -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) {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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" },
+6 -7
View File
@@ -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);
} }
} }
+1 -1
View File
@@ -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>
+2
View File
@@ -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 {
+159 -76
View File
@@ -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;
}
} }
+159 -71
View File
@@ -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,57 +157,83 @@ 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 (
<div <Fragment key={phase.label}>
key={step.key} <div
className={styles.stepContainer} className={cs(styles.phase, {
data-step-container [styles.phaseCompleted]: phase.isCompleted,
> [styles.phaseActive]: phase.isActive,
<div [styles.phaseUpcoming]: phase.isUpcoming,
className={cs(styles.step, { })}
[styles.stepCompleted]: isCompleted, aria-current={phase.isActive ? "step" : undefined}
[styles.stepActive]: isActive, {...(phase.isActive ? { "data-step-active": true } : {})}
[styles.stepUpcoming]: !isActive && !isCompleted, >
})} <span
{...(isActive ? { "data-step-active": true } : {})} className={cs(styles.indicator, {
> [styles.indicatorCompleted]: phase.isCompleted,
<span className={styles.number}> [styles.indicatorActive]: phase.isActive,
{isCompleted ? ( [styles.indicatorUpcoming]: phase.isUpcoming,
<Check size={14} strokeWidth={3} /> })}
) : ( >
index + 1 {phase.isCompleted ? (
<Check size={12} strokeWidth={2.5} />
) : (
phase.phaseIndex + 1
)}
</span>
<span className={styles.phaseLabel}>{phase.label}</span>
{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>
)} )}
</span> </div>
<span className={styles.label}>{step.label}</span>
</div> {connectorClass && (
{!isLast && <ChevronSeparator />} <div className={cs(styles.connector, connectorClass)} />
</div> )}
) </Fragment>
})} )
})}
</div>
</div> </div>
<motion.div <motion.div
@@ -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 {
+1
View File
@@ -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;
}
} }
+4
View File
@@ -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;
+5 -5
View File
@@ -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 =
+10 -10
View File
@@ -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",
}), }),