From 46f34bdcacf53c33865221a37a59983b2deb29c9 Mon Sep 17 00:00:00 2001 From: Daniil Date: Tue, 7 Apr 2026 13:42:23 +0300 Subject: [PATCH] rev 4 --- AGENTS.md | 339 +----------------- .../ProjectCard/ProjectCard.module.scss | 7 +- .../ActionCard/ActionCard.module.scss | 10 +- .../NotificationPopup.module.scss | 68 ++-- .../NotificationPopup/NotificationPopup.tsx | 124 +++---- .../NotificationPopup/presentation.test.ts | 84 +++++ .../NotificationPopup/presentation.ts | 61 ++++ .../CaptionResultStep.module.scss | 238 +++++++++++- .../CaptionResultStep/CaptionResultStep.tsx | 141 ++++++-- .../PresetCard.module.scss | 150 ++++++++ .../CaptionSettingsStep/PresetCard.tsx | 145 ++++++++ .../PresetGrid.module.scss | 125 ++----- .../CaptionSettingsStep/PresetGrid.tsx | 92 ++--- .../StylePreview.module.scss | 4 +- .../CaptionSettingsStep/StylePreview.tsx | 4 +- .../CaptionSettingsStep/useVideoMetadata.ts | 13 +- .../ConvertMediaView/ConvertMediaView.tsx | 47 +-- .../project/FragmentsStep/FragmentsStep.tsx | 7 +- .../project/ProcessingStep/ProcessingStep.tsx | 25 +- .../SegmentEditModal.module.scss | 8 +- .../SilenceResultModal/SilenceResultModal.tsx | 17 +- .../SubtitleRevisionStep.module.scss | 127 ++++++- .../SubtitleRevisionStep.tsx | 166 +++++---- .../SubtitlesTrack/SubtitlesTrack.module.scss | 14 +- .../TranscriptionEditor.module.scss | 246 ++++++++----- .../TranscriptionEditor.tsx | 310 ++++++++++------ .../project/UploadStep/UploadStep.module.scss | 2 +- .../project/UploadStep/UploadStep.tsx | 2 +- .../project/VerifyStep/VerifyStep.tsx | 36 +- .../ProjectWizardPage.module.scss | 10 +- .../ProjectWizardPage/ProjectWizardPage.tsx | 10 +- src/shared/api/__generated__/openapi.types.ts | 163 ++++++++- src/shared/context/AppProviders.tsx | 4 +- src/shared/context/WizardContext.tsx | 128 +++++-- src/shared/hooks/useTaskProgressState.ts | 52 +++ src/shared/lib/dates.test.ts | 45 +++ src/shared/lib/dates.ts | 30 ++ src/shared/lib/russianNoun.ts | 12 + src/shared/lib/taskProgress.test.ts | 50 +++ src/shared/lib/taskProgress.ts | 54 +++ src/shared/styles/_variables.scss | 5 + src/shared/styles/global.scss | 250 ++++++++----- src/shared/ui/Badge/Badge.tsx | 2 +- src/shared/ui/Button/Button.module.scss | 2 +- src/shared/ui/Button/Button.tsx | 4 +- src/shared/ui/Card/Card.module.scss | 13 +- src/shared/ui/Modal/Modal.tsx | 2 +- src/shared/ui/Stepper/Stepper.d.ts | 2 + src/shared/ui/Stepper/Stepper.module.scss | 235 ++++++++---- src/shared/ui/Stepper/Stepper.tsx | 230 ++++++++---- src/shared/ui/TextField/TextField.module.scss | 4 +- src/widgets/Header/Header.module.scss | 1 + .../ProjectWizard/ProjectWizard.module.scss | 18 +- src/widgets/TimelinePanel/TimelinePanel.d.ts | 4 + .../TimelinePanel/TimelinePanel.module.scss | 44 ++- src/widgets/TimelinePanel/TimelinePanel.tsx | 10 +- tests/e2e/specs/project/silence-apply.spec.ts | 20 +- .../silence-fragments.integration.spec.ts | 2 +- .../upload/file-upload.integration.spec.ts | 2 + 59 files changed, 2708 insertions(+), 1312 deletions(-) create mode 100644 src/features/notifications/NotificationPopup/presentation.test.ts create mode 100644 src/features/notifications/NotificationPopup/presentation.ts create mode 100644 src/features/project/CaptionSettingsStep/PresetCard.module.scss create mode 100644 src/features/project/CaptionSettingsStep/PresetCard.tsx create mode 100644 src/shared/hooks/useTaskProgressState.ts create mode 100644 src/shared/lib/dates.test.ts create mode 100644 src/shared/lib/russianNoun.ts create mode 100644 src/shared/lib/taskProgress.test.ts create mode 100644 src/shared/lib/taskProgress.ts diff --git a/AGENTS.md b/AGENTS.md index aae9527..d3e69a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,336 +1,15 @@ # AGENTS.md — Coffee Project Frontend -Primary Codex reference: [`../.codex/services/frontend.md`](/Users/daniilrakityansky/Documents/Work/Cofee/.codex/services/frontend.md). -If this file conflicts with the `.codex` guide, prefer the `.codex` guide. `CLAUDE.md` remains for Claude-specific tooling. +Primary workflow guidance lives in `../AGENTS.md`. -## 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 - -| Category | Technology | -| ------------- | ------------------------------------------ | -| Runtime | Bun 1.3.5 | -| Framework | Next.js 16.1.1 (App Router) | -| Language | TypeScript 5.9 | -| UI Library | React 19 | -| Styling | SCSS Modules, normalize.css | -| State/Fetch | TanStack React Query 5, Axios, Xior | -| Animation | Framer Motion | -| Utilities | Lodash, 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 # 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 ( -
- ComponentName -
- ) -} -``` - -### 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 -# 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 = ({ 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 `, 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 ` | -| Add dev dependency | `bun add -d ` | -| Create component | `bun run gc ` | -| 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 ` -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 ` 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. +- Keep `../AGENTS.md` as the workflow and delegation source of truth. +- Treat `CLAUDE.md` as architecture, commands, and conventions only. +- Do not rely on `.claude/` directory contents. diff --git a/src/entities/ProjectCard/ProjectCard.module.scss b/src/entities/ProjectCard/ProjectCard.module.scss index a174f28..b42f758 100644 --- a/src/entities/ProjectCard/ProjectCard.module.scss +++ b/src/entities/ProjectCard/ProjectCard.module.scss @@ -103,8 +103,7 @@ position: absolute; inset: -6px; border-radius: 50%; - background: rgba(0, 0, 0, 0.2); - backdrop-filter: blur(2px); + background: rgba(0, 0, 0, 0.25); z-index: 0; } @@ -262,8 +261,8 @@ left: 10px; padding: 4px 10px; border-radius: 20px; - background: rgba(255, 255, 255, 0.92); - backdrop-filter: blur(8px); + background: color-mix(in srgb, variables.$bg-default 88%, transparent); + border: 1px solid color-mix(in srgb, variables.$border-default 72%, transparent); @include typography.font-caption-m; font-weight: 600; color: variables.$text-primary; diff --git a/src/entities/dashboard/ActionCard/ActionCard.module.scss b/src/entities/dashboard/ActionCard/ActionCard.module.scss index 675eaff..e2a965e 100644 --- a/src/entities/dashboard/ActionCard/ActionCard.module.scss +++ b/src/entities/dashboard/ActionCard/ActionCard.module.scss @@ -38,14 +38,14 @@ } &.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; - color: variables.$color-white; - box-shadow: 0 4px 14px hsla(262, 75%, 48%, 0.25); + color: variables.$accent-foreground; + box-shadow: 0 4px 14px variables.$accent-shadow; &:hover { - background: linear-gradient(135deg, variables.$purple-500 0%, variables.$purple-700 100%); - box-shadow: 0 6px 20px hsla(262, 75%, 48%, 0.4); + filter: brightness(1.06); + box-shadow: 0 6px 20px variables.$accent-shadow-hover; } } } diff --git a/src/features/notifications/NotificationPopup/NotificationPopup.module.scss b/src/features/notifications/NotificationPopup/NotificationPopup.module.scss index 95679f2..b9efe6a 100644 --- a/src/features/notifications/NotificationPopup/NotificationPopup.module.scss +++ b/src/features/notifications/NotificationPopup/NotificationPopup.module.scss @@ -83,55 +83,51 @@ min-width: 0; } -.itemTitle { - @include typography.font-body-14(600); - color: variables.$text-primary; +.itemHeader { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.itemHeadline { display: flex; align-items: center; gap: 8px; + flex: 1; + min-width: 0; } -.itemMessage { - @include typography.font-caption-m; - color: variables.$text-secondary; - margin-top: 2px; +.itemTitle { + @include typography.font-body-14(600); + color: variables.$text-primary; + min-width: 0; + flex: 1; +} + +.itemTitleText { + display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.itemMeta { +.itemStatusBadge { + flex-shrink: 0; +} + +.itemTime { @include typography.font-caption-m; color: variables.$text-tertiary; + flex-shrink: 0; +} + +.itemMessage { + @include typography.font-caption-m; + color: variables.$text-secondary; margin-top: 4px; - display: flex; - align-items: center; - gap: 8px; -} - -.statusBadge { - display: inline-flex; - align-items: center; - padding: 1px 6px; - border-radius: 9999px; - font-size: 11px; - font-weight: 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%); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .progressBar { diff --git a/src/features/notifications/NotificationPopup/NotificationPopup.tsx b/src/features/notifications/NotificationPopup/NotificationPopup.tsx index 5cd33de..dab795c 100644 --- a/src/features/notifications/NotificationPopup/NotificationPopup.tsx +++ b/src/features/notifications/NotificationPopup/NotificationPopup.tsx @@ -10,13 +10,16 @@ import { FunctionComponent, useCallback, useEffect, useRef } from "react" import { useDispatch } from "react-redux" import { useAppSelector } from "@shared/hooks/useAppSelector" -import { formatRelativeTime } from "@shared/lib/dates" +import { formatNotificationRelativeTime } from "@shared/lib/dates" import { API_URL } from "@shared/lib/constants" import { markAllRead, markRead, NotificationItem, } from "@shared/store/notifications" +import { Badge } from "@shared/ui" + +import { getNotificationPresentation } from "./presentation" const apiBase = API_URL || "http://localhost:8000" @@ -27,34 +30,6 @@ function authHeaders(): HeadersInit { import styles from "./NotificationPopup.module.scss" -const JOB_TYPE_LABELS: Record = { - MEDIA_PROBE: "Анализ медиа", - SILENCE_REMOVE: "Удаление тишины", - MEDIA_CONVERT: "Конвертация", - TRANSCRIPTION_GENERATE: "Транскрипция", - CAPTIONS_GENERATE: "Генерация субтитров", -} - -const STATUS_LABELS: Record = { - 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 = ({ onClose, anchorRef, @@ -120,56 +95,59 @@ export const NotificationPopup: FunctionComponent = ({ {items.length === 0 ? (
Нет уведомлений
) : ( - items.map((item, idx) => ( -
handleItemClick(item)} - > -
-
- - {item.job_type - ? (JOB_TYPE_LABELS[item.job_type] || - item.title) - : item.title} - - {item.status && ( - - {STATUS_LABELS[item.status] || - item.status} + items.map((item, idx) => { + const presentation = getNotificationPresentation(item) + + return ( +
handleItemClick(item)} + > +
+
+
+
+ + {presentation.title} + +
+ {presentation.statusText && + presentation.statusVariant && ( + + {presentation.statusText} + + )} +
+ + {formatNotificationRelativeTime(item.created_at)} - )} -
- {item.message && ( -
- {item.message}
- )} - {item.status === "RUNNING" && - item.progress_pct != null && ( -
-
+ {presentation.detailText && ( +
+ {presentation.detailText}
)} -
- {formatRelativeTime(item.created_at)} + {item.status === "RUNNING" && + item.progress_pct != null && ( +
+
+
+ )}
-
- )) + ) + }) )}
diff --git a/src/features/notifications/NotificationPopup/presentation.test.ts b/src/features/notifications/NotificationPopup/presentation.test.ts new file mode 100644 index 0000000..348725f --- /dev/null +++ b/src/features/notifications/NotificationPopup/presentation.test.ts @@ -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 { + 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, + }) + }) +}) diff --git a/src/features/notifications/NotificationPopup/presentation.ts b/src/features/notifications/NotificationPopup/presentation.ts new file mode 100644 index 0000000..a287bcd --- /dev/null +++ b/src/features/notifications/NotificationPopup/presentation.ts @@ -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 = { + MEDIA_PROBE: "Анализ медиа", + SILENCE_REMOVE: "Удаление тишины", + SILENCE_DETECT: "Анализ тишины", + SILENCE_APPLY: "Применение вырезок", + MEDIA_CONVERT: "Конвертация", + TRANSCRIPTION_GENERATE: "Транскрипция", + CAPTIONS_GENERATE: "Генерация субтитров", + FRAME_EXTRACT: "Извлечение кадров", +} + +const STATUS_LABELS: Record = { + PENDING: "Ожидание", + RUNNING: "Выполняется", + DONE: "Завершено", + FAILED: "Ошибка", +} + +const STATUS_VARIANTS: Record = { + 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, + } +} diff --git a/src/features/project/CaptionResultStep/CaptionResultStep.module.scss b/src/features/project/CaptionResultStep/CaptionResultStep.module.scss index 57e9cd2..1818329 100644 --- a/src/features/project/CaptionResultStep/CaptionResultStep.module.scss +++ b/src/features/project/CaptionResultStep/CaptionResultStep.module.scss @@ -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 { + position: relative; display: flex; flex-direction: column; - gap: 16px; - padding: 24px; + flex: 1; + overflow: hidden; + min-height: 0; +} + +/* ── Sparkle particles ── */ + +.sparkles { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 0; +} + +.sparkle { + position: absolute; + border-radius: 50%; + animation: sparkleFloat 3.5s ease-in-out infinite; +} + +.sparkleGreen { + background: color-mix(in srgb, variables.$color-success 60%, transparent); +} + +.sparklePurple { + background: color-mix(in srgb, variables.$purple-400 55%, transparent); +} + +/* ── Content area (scrollable) ── */ + +.content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + flex: 1; + padding: 32px 24px 24px; + overflow-y: auto; + min-height: 0; +} + +/* ── Success header ── */ + +.header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + animation: fadeSlideDown 0.6s variables.$ease-out both; } .title { - font-size: 20px; - font-weight: 600; - color: var(--gray-12); + @include typography.font-header-l; + + color: variables.$text-primary; margin: 0; } +.subtitle { + @include typography.font-body-14(400); + + color: variables.$text-tertiary; + margin: 0; +} + +/* ── Video player ── */ + +.playerContainer { + width: 100%; + max-width: 780px; + animation: fadeSlideUp 0.7s variables.$ease-out 0.15s both; +} + .playerWrapper { - border-radius: 12px; + position: relative; + border-radius: variables.$radius-md; overflow: hidden; background: #000; - max-height: 60vh; + box-shadow: variables.$shadow-lg; aspect-ratio: 16 / 9; + + :global([data-media-player]) { + width: 100% !important; + height: 100% !important; + } + + :global(.vds-video-layout) { + width: 100%; + height: 100%; + } + + video { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } } .player { @@ -30,29 +175,92 @@ align-items: center; justify-content: center; aspect-ratio: 16 / 9; - color: var(--gray-9); + color: variables.$text-tertiary; + @include typography.font-body-14(400); } -.filename { - font-size: 13px; - color: var(--gray-9); - margin: 0; +/* ── File info bar ── */ + +.fileInfoContainer { + width: 100%; + max-width: 780px; + animation: fadeSlideUp 0.6s variables.$ease-out 0.35s both; } +.fileInfoBar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: variables.$bg-default; + border: 1px solid variables.$border-subtle; + border-radius: variables.$radius-sm; + box-shadow: variables.$shadow-sm; +} + +.fileIcon { + color: variables.$text-tertiary; + flex-shrink: 0; +} + +.fileName { + @include typography.font-body-14(500); + + color: variables.$text-primary; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileSize { + @include typography.font-caption-m; + + color: variables.$text-tertiary; + flex-shrink: 0; +} + +/* ── Loading state ── */ + .loading { padding: 48px; text-align: center; - color: var(--gray-9); + color: variables.$text-tertiary; + @include typography.font-body-14(400); } +/* ── Footer ── */ + .footer { + position: relative; + z-index: 1; display: flex; justify-content: space-between; - padding-top: 16px; - border-top: 1px solid var(--gray-6); + align-items: center; + padding: 16px 24px; + border-top: 1px solid variables.$border-subtle; + background: variables.$bg-surface; + animation: fadeIn 0.5s variables.$ease-out 0.5s both; } .rightActions { display: flex; gap: 8px; } + +/* ── Reduced motion ── */ + +@media (prefers-reduced-motion: reduce) { + .header, + .playerContainer, + .fileInfoContainer, + .footer { + animation: none; + } + + .sparkle { + animation: none; + display: none; + } +} diff --git a/src/features/project/CaptionResultStep/CaptionResultStep.tsx b/src/features/project/CaptionResultStep/CaptionResultStep.tsx index 94d115c..80b8655 100644 --- a/src/features/project/CaptionResultStep/CaptionResultStep.tsx +++ b/src/features/project/CaptionResultStep/CaptionResultStep.tsx @@ -12,17 +12,33 @@ import { import "@vidstack/react/player/styles/default/theme.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 cs from "classnames" import api from "@shared/api" import { useWizard } from "@shared/context/WizardContext" -import { Button } from "@shared/ui" +import { Badge, Button } from "@shared/ui" import styles from "./CaptionResultStep.module.scss" +const SPARKLE_COUNT = 10 + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} Б` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ` + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} МБ` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} ГБ` +} + export const CaptionResultStep: FunctionComponent = ({ className, }): JSX.Element => { @@ -73,20 +89,11 @@ export const CaptionResultStep: FunctionComponent = ({ 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( "get", - "/api/files/get_file/", - { params: { query: { file_path: filePath } } }, - { enabled: !!filePath }, + "/api/files/files/{file_id}/resolve/", + { params: { path: { file_id: effectiveFileId ?? "" } } }, + { enabled: !!effectiveFileId }, ) const videoUrl = fileInfo?.file_url ?? "" @@ -107,6 +114,19 @@ export const CaptionResultStep: FunctionComponent = ({ markStepCompleted("caption-result") } + const sparkles = useMemo( + () => + Array.from({ length: SPARKLE_COUNT }, (_, i) => ({ + id: i, + variant: i % 2 === 0 ? "green" : "purple", + left: `${10 + (i * 80) / SPARKLE_COUNT + Math.sin(i * 1.7) * 5}%`, + top: `${15 + Math.sin(i * 2.3) * 30 + 20}%`, + delay: `${(i * 0.3) % 2.5}s`, + size: 4 + (i % 3) * 2, + })), + [], + ) + if (isLoading) { return (
@@ -117,39 +137,90 @@ export const CaptionResultStep: FunctionComponent = ({ return (
-

Результат

+ {/* Sparkle particles */} + -
- {videoUrl ? ( - - - - - ) : ( -
Видео недоступно
+ {/* Content area */} +
+ {/* Success header */} +
+ + + Готово + +

Результат

+

+ Видео с субтитрами готово к скачиванию +

+
+ + {/* Video player */} +
+
+ {videoUrl ? ( + + + + + ) : ( +
Видео недоступно
+ )} +
+
+ + {/* File info bar */} + {fileInfo?.filename && ( +
+
+ + {fileInfo.filename} + {typeof fileInfo.file_size === "number" && ( + + {formatFileSize(fileInfo.file_size)} + + )} +
+
)}
- {fileInfo?.filename && ( -

{fileInfo.filename}

- )} - + {/* Footer */}
-
- -
diff --git a/src/features/project/CaptionSettingsStep/PresetCard.module.scss b/src/features/project/CaptionSettingsStep/PresetCard.module.scss new file mode 100644 index 0000000..0c8c831 --- /dev/null +++ b/src/features/project/CaptionSettingsStep/PresetCard.module.scss @@ -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; + } +} diff --git a/src/features/project/CaptionSettingsStep/PresetCard.tsx b/src/features/project/CaptionSettingsStep/PresetCard.tsx new file mode 100644 index 0000000..e35cda1 --- /dev/null +++ b/src/features/project/CaptionSettingsStep/PresetCard.tsx @@ -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 = { + "#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 = ({ + preset, + isSelected, + aspectRatio = 16 / 9, + onSelect, + onEdit, + onDelete, +}): JSX.Element => { + const { fontFamily, accentColor, accentName } = getStyleCharacteristics( + preset.style_config, + ) + + return ( +
+
+ + {isSelected && ( +
+ + + +
+ )} +
+
+
+ {preset.name} + {preset.is_system && ( + Системный + )} +
+
+ {fontFamily} + {accentColor && accentName && ( + <> + · + + {accentName} + + )} +
+
+ {!preset.is_system && ( +
+ + +
+ )} +
+ ) +} diff --git a/src/features/project/CaptionSettingsStep/PresetGrid.module.scss b/src/features/project/CaptionSettingsStep/PresetGrid.module.scss index 7f44d11..a17d952 100644 --- a/src/features/project/CaptionSettingsStep/PresetGrid.module.scss +++ b/src/features/project/CaptionSettingsStep/PresetGrid.module.scss @@ -1,122 +1,46 @@ .grid { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-content: flex-start; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); 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 { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; - height: 100cqh; - box-sizing: border-box; - aspect-ratio: 9 / 16; - border-style: dashed; - border-color: var(--gray-7); + background: variables.$bg-default; + border: 1.5px dashed variables.$border-default; + border-radius: variables.$radius-md; + cursor: pointer; + transition: border-color var(--duration-normal) var(--ease-out); &:hover { - border-color: var(--accent-8); + border-color: variables.$color-secondary; } } +.createPreview { + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + .createIcon { - color: var(--gray-9); + color: variables.$text-tertiary; } .createLabel { font-size: 13px; - color: var(--gray-9); + color: variables.$text-tertiary; } .loading { padding: 48px; text-align: center; - color: var(--gray-9); + color: variables.$text-tertiary; } .deleteActions { @@ -125,3 +49,16 @@ gap: 8px; margin-top: 16px; } + +@include breakpoints.respond-to(breakpoints.$tabletMax) { + .grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + } +} + +@include breakpoints.respond-to(breakpoints.$mobileMax) { + .grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/features/project/CaptionSettingsStep/PresetGrid.tsx b/src/features/project/CaptionSettingsStep/PresetGrid.tsx index cdc88ce..f69ff75 100644 --- a/src/features/project/CaptionSettingsStep/PresetGrid.tsx +++ b/src/features/project/CaptionSettingsStep/PresetGrid.tsx @@ -3,19 +3,22 @@ import type { components } from "@shared/api/__generated__/openapi.types" import type { JSX } from "react" -import cs from "classnames" -import { Pencil, Plus, Trash2 } from "lucide-react" +import { Plus } from "lucide-react" import { FunctionComponent, useState } from "react" +import { useWizard } from "@shared/context/WizardContext" import { Button, Modal } from "@shared/ui" -import { StylePreview } from "./StylePreview" +import { PresetCard } from "./PresetCard" +import { PresetCardSkeleton } from "./PresetCardSkeleton" import { useDeletePreset, usePresetsQuery } from "./useCaptionPresets" - +import { useVideoMetadata } from "./useVideoMetadata" import styles from "./PresetGrid.module.scss" type CaptionPresetRead = components["schemas"]["CaptionPresetRead"] +const SKELETON_COUNT = 5 + interface IPresetGridProps { selectedPresetId: string | null onSelect: (presetId: string) => void @@ -23,65 +26,23 @@ interface IPresetGridProps { onCreateNew: () => void } -const PresetCard: FunctionComponent<{ - preset: CaptionPresetRead - isSelected: boolean - onSelect: () => void - onEdit: () => void - onDelete: () => void -}> = ({ preset, isSelected, onSelect, onEdit, onDelete }) => ( -
- -
- {preset.name} - {preset.is_system && ( - Системный - )} -
- {!preset.is_system && ( -
- - -
- )} -
-) - export const PresetGrid: FunctionComponent = ({ selectedPresetId, onSelect, onEdit, onCreateNew, }): 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 [deleteTarget, setDeleteTarget] = useState( null, ) + const isLoading = isPresetsLoading + const handleConfirmDelete = () => { if (!deleteTarget) return deletePreset.mutate( @@ -91,29 +52,39 @@ export const PresetGrid: FunctionComponent = ({ } if (isLoading) { - return
Загрузка пресетов...
+ return ( +
+ {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + ))} +
+ ) } return ( <> -
+
{presets?.map((preset) => ( onSelect(preset.id)} onEdit={() => onEdit(preset)} onDelete={() => setDeleteTarget(preset)} /> ))}
- +
+ +
Создать пресет
@@ -125,14 +96,11 @@ export const PresetGrid: FunctionComponent = ({ title="Удаление пресета" >

- Удалить пресет «{deleteTarget.name}»? Это - действие нельзя отменить. + Удалить пресет «{deleteTarget.name}»? Это действие + нельзя отменить.

- -
- +
) } diff --git a/src/features/project/SubtitlesTrack/SubtitlesTrack.module.scss b/src/features/project/SubtitlesTrack/SubtitlesTrack.module.scss index 69501d1..12c2986 100644 --- a/src/features/project/SubtitlesTrack/SubtitlesTrack.module.scss +++ b/src/features/project/SubtitlesTrack/SubtitlesTrack.module.scss @@ -8,8 +8,8 @@ top: 4px; bottom: 4px; border-radius: variables.$radius-sm; - background: rgba(139, 92, 246, 0.3); - border: 1px solid rgba(139, 92, 246, 0.7); + background: color-mix(in srgb, variables.$color-primary 28%, transparent); + border: 1px solid color-mix(in srgb, variables.$color-primary 62%, transparent); cursor: pointer; user-select: none; display: flex; @@ -18,7 +18,7 @@ transition: background 0.1s; &:hover { - background: rgba(139, 92, 246, 0.45); + background: color-mix(in srgb, variables.$color-primary 42%, transparent); } } @@ -40,15 +40,15 @@ } .active { - background: rgba(139, 92, 246, 0.6); + background: color-mix(in srgb, variables.$color-primary 56%, transparent); &:hover { - background: rgba(139, 92, 246, 0.65); + background: color-mix(in srgb, variables.$color-primary 62%, transparent); } } .resizing { - background: rgba(139, 92, 246, 0.5); + background: color-mix(in srgb, variables.$color-primary 48%, transparent); z-index: 2; } @@ -75,7 +75,7 @@ z-index: 3; &:hover { - background: rgba(139, 92, 246, 0.5); + background: color-mix(in srgb, variables.$color-primary 48%, transparent); } } diff --git a/src/features/project/TranscriptionEditor/TranscriptionEditor.module.scss b/src/features/project/TranscriptionEditor/TranscriptionEditor.module.scss index 4251ffe..2a91c13 100644 --- a/src/features/project/TranscriptionEditor/TranscriptionEditor.module.scss +++ b/src/features/project/TranscriptionEditor/TranscriptionEditor.module.scss @@ -2,7 +2,9 @@ display: flex; flex-direction: column; height: 100%; + min-height: 0; overflow: hidden; + background: transparent; } .loader { @@ -10,12 +12,27 @@ align-items: center; justify-content: center; flex: 1; + gap: 8px; + color: variables.$text-tertiary; + @include typography.font-caption-m; } .spinner { 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 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } @@ -28,126 +45,168 @@ font-size: 14px; } -.header { +.toolbar { display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; + gap: 16px; + padding: 16px 18px 14px; border-bottom: 1px solid variables.$border-default; + background: variables.$bg-default; flex-shrink: 0; + + @media (max-width: 720px) { + flex-direction: column; + align-items: stretch; + } } -.title { - font-size: 14px; - font-weight: 600; - color: variables.$text-primary; +.toolbarMeta { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.toolbarTitle { margin: 0; + color: variables.$text-primary; + @include typography.font-body-16(600); } -.saveButton { - display: inline-flex; +.toolbarSummary { + display: flex; align-items: center; - gap: 6px; - padding: 6px 12px; - 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; + flex-wrap: wrap; + gap: 10px; +} - &:hover { - opacity: 0.9; - } +.segmentCount { + color: variables.$text-secondary; + @include typography.font-body-14(500); +} - &.disabled { - background: variables.$border-default; - color: variables.$text-tertiary; - cursor: default; - pointer-events: none; - } +.headerStatus { + margin: 0; + color: variables.$text-tertiary; + @include typography.font-caption-m; } .segmentsList { flex: 1; overflow-y: auto; - padding: 12px 16px; + padding: 14px 18px 18px; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; } .segment { - border: 1px solid variables.$border-subtle; - border-radius: variables.$radius-md; - padding: 12px 16px; - background: variables.$bg-surface; - transition: all 0.3s ease; + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 12px; + border: 1px solid variables.$border-default; + 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 { border-color: variables.$border-default; - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.04); + background: variables.$bg-surface; } &.highlight { 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; align-items: center; justify-content: space-between; - margin-bottom: 12px; + gap: 12px; + flex-wrap: wrap; } .timesGroup { display: flex; + flex-wrap: wrap; align-items: center; - gap: 16px; + gap: 8px; + flex: 1; + min-width: 0; } .actionsGroup { display: flex; align-items: center; gap: 6px; + flex-shrink: 0; } -.timeLabel { +.timeField { display: flex; - align-items: center; - gap: 8px; + flex-direction: column; + 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 { - font-size: 11px; - color: variables.$text-tertiary; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; + color: variables.$text-secondary; + white-space: nowrap; + @include typography.font-caption-m; } .timeInput { - width: 84px; - padding: 4px 8px; - border: 1px solid transparent; - border-radius: variables.$radius-sm; - font-size: 12px; + width: 96px; + padding: 0; + border: none; + border-radius: 0; font-family: monospace; - color: variables.$text-secondary; - background: variables.$bg-hover; - transition: all 0.2s ease; - text-align: center; + color: variables.$text-primary; + background: transparent; + transition: color 0.2s ease; + text-align: left; + @include typography.font-caption-m; + @include typography.font-numeric; &:focus { 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; align-items: center; justify-content: center; - padding: 6px; - border: none; - background: transparent; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid variables.$border-default; + background: variables.$bg-default; color: variables.$text-tertiary; cursor: pointer; - border-radius: variables.$radius-sm; - transition: all 0.2s ease; + border-radius: 8px; + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease, + box-shadow 0.2s ease; } .splitButton { &:hover:not(:disabled) { color: variables.$color-primary; - background: variables.$bg-hover; + background: variables.$bg-surface; + border-color: variables.$border-default; } &:disabled { @@ -179,22 +242,23 @@ .removeButton { &:hover { color: variables.$color-danger; - background: rgba(239, 68, 68, 0.1); + background: variables.$bg-surface; + border-color: variables.$border-default; } } .textArea { width: 100%; - padding: 10px 12px; - border: 1px solid transparent; - border-radius: variables.$radius-sm; - font-size: 14px; - line-height: 1.5; + min-height: 96px; + padding: 12px 14px; + border: 1px solid variables.$border-default; + border-radius: 8px; color: variables.$text-primary; - background: variables.$bg-hover; + background: variables.$bg-surface; resize: vertical; 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 { background: variables.$bg-hover; @@ -202,29 +266,41 @@ &:focus { outline: none; - background: variables.$bg-surface; + background: variables.$bg-default; 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 { display: inline-flex; align-items: center; - gap: 6px; - padding: 8px 16px; - margin: 0 16px 12px; - border: 1px dashed variables.$border-default; - border-radius: variables.$radius-md; - background: none; + gap: 8px; + justify-content: center; + padding: 10px 12px; + border: 1px solid variables.$border-default; + border-radius: 8px; + background: variables.$bg-default; color: variables.$text-secondary; - font-size: 13px; + @include typography.font-body-14(500); cursor: pointer; 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 { - background: variables.$bg-hover; - border-color: variables.$color-primary; + background: variables.$bg-surface; + border-color: variables.$border-default; color: variables.$color-primary; + box-shadow: var(--shadow-sm); + } + + @media (max-width: 720px) { + width: 100%; } } diff --git a/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx b/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx index 7c4839f..1d260ea 100644 --- a/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx +++ b/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx @@ -10,6 +10,7 @@ import { FunctionComponent, useCallback, useEffect, useRef, useState } from "rea import api from "@shared/api" import { fetchClient } from "@shared/api" import { useWorkspaceFiles } from "@shared/context/WorkspaceContext" +import { declOfNum } from "@shared/lib/russianNoun" import { type EditorSegment, documentToSegments, @@ -23,12 +24,18 @@ import styles from "./TranscriptionEditor.module.scss" /* Component */ /* ------------------------------------------------------------------ */ +type SegmentRow = { + id: string + segment: EditorSegment +} + export const TranscriptionEditor: FunctionComponent< ITranscriptionEditorProps > = ({ artifactId }): JSX.Element => { const queryClient = useQueryClient() const { selectedFile, setSelectedFile } = useWorkspaceFiles() const segmentsListRef = useRef(null) + const rowIdRef = useRef(0) const { data: transcription, isLoading } = api.useQuery( "get", @@ -36,22 +43,41 @@ export const TranscriptionEditor: FunctionComponent< { params: { path: { artifact_id: artifactId } } }, ) - const [segments, setSegments] = useState([]) + const [segmentRows, setSegmentRows] = useState([]) const [saving, setSaving] = useState(false) const [dirty, setDirty] = useState(false) - const [splittingIdx, setSplittingIdx] = useState(null) + const [splittingRowId, setSplittingRowId] = useState(null) + const visibleStatus = saving + ? "Сохраняем изменения" + : dirty + ? "Изменения будут сохранены автоматически" + : null + + const createSegmentRow = useCallback( + (segment: EditorSegment): SegmentRow => ({ + id: `segment-row-${rowIdRef.current++}`, + segment, + }), + [], + ) useEffect(() => { if (transcription?.document) { - setSegments(documentToSegments(transcription.document)) + rowIdRef.current = 0 + setSegmentRows( + documentToSegments(transcription.document).map((segment) => + createSegmentRow(segment), + ), + ) setDirty(false) + setSplittingRowId(null) } - }, [transcription]) + }, [transcription, createSegmentRow]) // Scroll to segment when navigated from SubtitlesTrack useEffect(() => { if (!selectedFile || selectedFile.scrollToSegmentIndex == null) return - if (segments.length === 0) return + if (segmentRows.length === 0) return const targetIdx = selectedFile.scrollToSegmentIndex const container = segmentsListRef.current @@ -76,16 +102,16 @@ export const TranscriptionEditor: FunctionComponent< source: selectedFile.source, artifactType: selectedFile.artifactType, }) - }, [selectedFile?.scrollToSegmentIndex, segments.length]) + }, [selectedFile?.scrollToSegmentIndex, segmentRows.length, setSelectedFile]) const updateSegment = useCallback( - (idx: number, field: keyof EditorSegment, value: string) => { - setSegments((prev) => - prev.map((seg, i) => { - if (i !== idx) return seg - const updated = { ...seg, [field]: value } + (rowId: string, field: keyof EditorSegment, value: string) => { + setSegmentRows((prev) => + prev.map((row) => { + if (row.id !== rowId) return row + const updated = { ...row.segment, [field]: value } if (field === "text") updated.words = undefined - return updated + return { ...row, segment: updated } }), ) setDirty(true) @@ -94,30 +120,38 @@ export const TranscriptionEditor: FunctionComponent< ) const handleSplit = useCallback( - (idx: number, newSegments: EditorSegment[]) => { - setSegments((prev) => [ - ...prev.slice(0, idx), - ...newSegments, - ...prev.slice(idx + 1), - ]) - setSplittingIdx(null) + (rowId: string, newSegments: EditorSegment[]) => { + setSegmentRows((prev) => { + const targetIndex = prev.findIndex((row) => row.id === rowId) + if (targetIndex === -1) return prev + const nextRows = newSegments.map((segment) => createSegmentRow(segment)) + return [ + ...prev.slice(0, targetIndex), + ...nextRows, + ...prev.slice(targetIndex + 1), + ] + }) + setSplittingRowId(null) setDirty(true) }, - [], + [createSegmentRow], ) const addSegment = useCallback(() => { - const lastEnd = - segments.length > 0 ? segments[segments.length - 1].endTime : "00:00.000" - setSegments((prev) => [ - ...prev, - { startTime: lastEnd, endTime: lastEnd, text: "" }, - ]) + setSegmentRows((prev) => { + const lastEnd = + prev.length > 0 ? prev[prev.length - 1].segment.endTime : "00:00.000" + return [ + ...prev, + createSegmentRow({ startTime: lastEnd, endTime: lastEnd, text: "" }), + ] + }) setDirty(true) - }, [segments]) + }, [createSegmentRow]) - const removeSegment = useCallback((idx: number) => { - setSegments((prev) => prev.filter((_, i) => i !== idx)) + const removeSegment = useCallback((rowId: string) => { + setSegmentRows((prev) => prev.filter((row) => row.id !== rowId)) + setSplittingRowId((prev) => (prev === rowId ? null : prev)) setDirty(true) }, []) @@ -129,7 +163,11 @@ export const TranscriptionEditor: FunctionComponent< "/api/transcribe/transcriptions/{transcription_id}/", { params: { path: { transcription_id: transcription.id } }, - body: { document: segmentsToDocument(segments) }, + body: { + document: segmentsToDocument( + segmentRows.map((row) => row.segment), + ), + }, }, ) setDirty(false) @@ -143,7 +181,7 @@ export const TranscriptionEditor: FunctionComponent< } finally { setSaving(false) } - }, [transcription, segments, artifactId, queryClient]) + }, [transcription, segmentRows, artifactId, queryClient]) // Auto-save when dirty (debounced) useEffect(() => { @@ -157,9 +195,14 @@ export const TranscriptionEditor: FunctionComponent< /* Loading */ if (isLoading) { return ( -
-
+
+
+ Загружаем транскрипцию
) @@ -175,89 +218,138 @@ export const TranscriptionEditor: FunctionComponent< } return ( -
- {/* Header */} -
-

Редактор транскрипции

+
+
+
+

Сегменты

+
+ + {segmentRows.length}{" "} + {declOfNum(segmentRows.length, [ + "сегмент", + "сегмента", + "сегментов", + ])} + + {visibleStatus ? ( +

{visibleStatus}

+ ) : null} +
+
+ + + {visibleStatus ?? ""} +
- {/* Segments list */}
- {segments.map((seg, idx) => ( -
-
-
- - + {segmentRows.map((row, idx) => { + const textareaId = `${row.id}-text` + const splitDisabled = !row.segment.words || row.segment.words.length < 2 + + return ( +
+ -
- - +
+
+
+ + +
+
+ + +
+
+ + {splittingRowId === row.id ? ( + handleSplit(row.id, newSegs)} + onCancel={() => setSplittingRowId(null)} + /> + ) : ( +