688 lines
17 KiB
Markdown
688 lines
17 KiB
Markdown
# Subtitle Preset Grid Redesign Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Redesign preset preview cards to match uploaded video aspect ratio with modern visual refresh and style characteristics display
|
||
|
||
**Architecture:** Fetch video metadata to calculate aspect ratio, pass to preset cards via props. Update StylePreview for dynamic sizing. Add loading skeleton state. Use Catppuccin Mocha color palette matching the project theme.
|
||
|
||
**Tech Stack:** React, TypeScript, SCSS Modules, TanStack Query, openapi-react-query
|
||
|
||
**Design Spec:** `docs/superpowers/specs/2026-04-06-subtitle-preset-grid-redesign.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `src/features/project/CaptionSettingsStep/useVideoMetadata.ts` | New hook to fetch video metadata and calculate aspect ratio |
|
||
| `src/features/project/CaptionSettingsStep/PresetGrid.tsx` | Modified - adds aspect ratio fetching, loading state, passes ratio to cards |
|
||
| `src/features/project/CaptionSettingsStep/PresetGrid.module.scss` | Modified - grid layout, responsive styles |
|
||
| `src/features/project/CaptionSettingsStep/PresetCard.tsx` | Modified - adds style characteristics display, checkmark indicator, updated styling |
|
||
| `src/features/project/CaptionSettingsStep/PresetCard.module.scss` | Modified - new card design with Catppuccin Mocha colors |
|
||
| `src/features/project/CaptionSettingsStep/StylePreview.tsx` | Modified - accepts aspectRatio prop for dynamic sizing |
|
||
| `src/features/project/CaptionSettingsStep/StylePreview.module.scss` | Modified - dynamic aspect-ratio container |
|
||
| `src/features/project/CaptionSettingsStep/PresetCardSkeleton.tsx` | New - skeleton loading component for preset cards |
|
||
| `src/features/project/CaptionSettingsStep/PresetCardSkeleton.module.scss` | New - skeleton styles with shimmer animation |
|
||
|
||
---
|
||
|
||
## Task 1: Create useVideoMetadata Hook
|
||
|
||
**Files:**
|
||
- Create: `src/features/project/CaptionSettingsStep/useVideoMetadata.ts`
|
||
|
||
**Context:** This hook fetches video metadata from the API and calculates the aspect ratio. It uses the existing `api` from `@shared/api` which is openapi-react-query.
|
||
|
||
- [ ] **Step 1: Write the hook implementation**
|
||
|
||
```typescript
|
||
import { useMemo } from "react"
|
||
import api from "@shared/api"
|
||
|
||
interface UseVideoMetadataResult {
|
||
aspectRatio: number
|
||
isLoading: boolean
|
||
isError: boolean
|
||
}
|
||
|
||
const DEFAULT_ASPECT_RATIO = 16 / 9
|
||
|
||
export function useVideoMetadata(fileId: string | null): UseVideoMetadataResult {
|
||
const { data: mediaFile, isLoading, isError } = api.useQuery(
|
||
"get",
|
||
"/api/media/mediafiles/{media_file_id}/",
|
||
{
|
||
params: {
|
||
path: {
|
||
media_file_id: fileId ?? "",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
enabled: !!fileId,
|
||
}
|
||
)
|
||
|
||
const aspectRatio = useMemo(() => {
|
||
if (!mediaFile?.width || !mediaFile?.height) {
|
||
return DEFAULT_ASPECT_RATIO
|
||
}
|
||
return mediaFile.width / mediaFile.height
|
||
}, [mediaFile])
|
||
|
||
return {
|
||
aspectRatio,
|
||
isLoading,
|
||
isError,
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/features/project/CaptionSettingsStep/useVideoMetadata.ts
|
||
git commit -m "feat: add useVideoMetadata hook for aspect ratio calculation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Create PresetCardSkeleton Component
|
||
|
||
**Files:**
|
||
- Create: `src/features/project/CaptionSettingsStep/PresetCardSkeleton.tsx`
|
||
- Create: `src/features/project/CaptionSettingsStep/PresetCardSkeleton.module.scss`
|
||
|
||
- [ ] **Step 1: Write the SCSS module**
|
||
|
||
```scss
|
||
// PresetCardSkeleton.module.scss
|
||
.skeletonCard {
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--bg-default);
|
||
border: 1px solid var(--border-subtle);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.skeletonPreview {
|
||
aspect-ratio: 16 / 9;
|
||
background: var(--bg-surface);
|
||
position: relative;
|
||
overflow: hidden;
|
||
|
||
&::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(
|
||
90deg,
|
||
transparent 0%,
|
||
rgba(203, 166, 247, 0.08) 50%,
|
||
transparent 100%
|
||
);
|
||
animation: shimmer 1.5s infinite;
|
||
}
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% {
|
||
transform: translateX(-100%);
|
||
}
|
||
100% {
|
||
transform: translateX(100%);
|
||
}
|
||
}
|
||
|
||
.skeletonFooter {
|
||
padding: 14px 16px;
|
||
background: linear-gradient(to top, var(--bg-surface), var(--bg-default));
|
||
border-top: 1px solid var(--border-subtle);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.skeletonLine {
|
||
height: 14px;
|
||
background: var(--bg-hover);
|
||
border-radius: 4px;
|
||
width: 60%;
|
||
position: relative;
|
||
overflow: hidden;
|
||
|
||
&::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(
|
||
90deg,
|
||
transparent 0%,
|
||
rgba(203, 166, 247, 0.06) 50%,
|
||
transparent 100%
|
||
);
|
||
animation: shimmer 1.5s infinite;
|
||
}
|
||
}
|
||
|
||
.skeletonLineShort {
|
||
composes: skeletonLine;
|
||
width: 40%;
|
||
height: 10px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write the component**
|
||
|
||
```typescript
|
||
// PresetCardSkeleton.tsx
|
||
import type { FunctionComponent } from "react"
|
||
import type { JSX } from "react"
|
||
|
||
import styles from "./PresetCardSkeleton.module.scss"
|
||
|
||
interface IPresetCardSkeletonProps {
|
||
aspectRatio?: number
|
||
}
|
||
|
||
export const PresetCardSkeleton: FunctionComponent<IPresetCardSkeletonProps> = ({
|
||
aspectRatio = 16 / 9,
|
||
}): JSX.Element => {
|
||
return (
|
||
<div className={styles.skeletonCard}>
|
||
<div
|
||
className={styles.skeletonPreview}
|
||
style={{ aspectRatio }}
|
||
/>
|
||
<div className={styles.skeletonFooter}>
|
||
<div className={styles.skeletonLine} />
|
||
<div className={styles.skeletonLineShort} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add barrel export**
|
||
|
||
Add to `src/features/project/CaptionSettingsStep/index.ts`:
|
||
|
||
```typescript
|
||
export { PresetCardSkeleton } from "./PresetCardSkeleton"
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/features/project/CaptionSettingsStep/PresetCardSkeleton.tsx
|
||
git add src/features/project/CaptionSettingsStep/PresetCardSkeleton.module.scss
|
||
git add src/features/project/CaptionSettingsStep/index.ts
|
||
git commit -m "feat: add PresetCardSkeleton component with shimmer animation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Update StylePreview for Dynamic Aspect Ratio
|
||
|
||
**Files:**
|
||
- Modify: `src/features/project/CaptionSettingsStep/StylePreview.tsx`
|
||
- Modify: `src/features/project/CaptionSettingsStep/StylePreview.module.scss`
|
||
|
||
- [ ] **Step 1: Read existing StylePreview files**
|
||
|
||
```bash
|
||
cat src/features/project/CaptionSettingsStep/StylePreview.tsx
|
||
cat src/features/project/CaptionSettingsStep/StylePreview.module.scss
|
||
```
|
||
|
||
- [ ] **Step 2: Update StylePreview.module.scss**
|
||
|
||
Add or modify the preview container to accept dynamic aspect-ratio:
|
||
|
||
```scss
|
||
// Add to existing StylePreview.module.scss
|
||
.previewContainer {
|
||
position: relative;
|
||
width: 100%;
|
||
overflow: hidden;
|
||
background: #0c0a1a;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update StylePreview.tsx**
|
||
|
||
Add `aspectRatio` prop and apply it to the container:
|
||
|
||
```typescript
|
||
// Add to existing imports
|
||
import type { CSSProperties } from "react"
|
||
|
||
// Update interface to include aspectRatio
|
||
interface IStylePreviewProps {
|
||
preset: CaptionPresetRead
|
||
aspectRatio?: number
|
||
className?: string
|
||
}
|
||
|
||
// In component, apply aspect ratio
|
||
export const StylePreview: FunctionComponent<IStylePreviewProps> = ({
|
||
preset,
|
||
aspectRatio = 9 / 16, // Default to vertical (original behavior)
|
||
className,
|
||
}): JSX.Element => {
|
||
// ... existing logic ...
|
||
|
||
const containerStyle: CSSProperties = {
|
||
aspectRatio: String(aspectRatio),
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cs(styles.previewContainer, className)}
|
||
style={containerStyle}
|
||
>
|
||
{/* ... existing preview content ... */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/features/project/CaptionSettingsStep/StylePreview.tsx
|
||
git add src/features/project/CaptionSettingsStep/StylePreview.module.scss
|
||
git commit -m "feat: add aspectRatio prop to StylePreview for dynamic sizing"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Update PresetCard with New Design
|
||
|
||
**Files:**
|
||
- Modify: `src/features/project/CaptionSettingsStep/PresetCard.tsx`
|
||
- Modify: `src/features/project/CaptionSettingsStep/PresetCard.module.scss`
|
||
|
||
- [ ] **Step 1: Read existing PresetCard files**
|
||
|
||
```bash
|
||
cat src/features/project/CaptionSettingsStep/PresetCard.tsx
|
||
cat src/features/project/CaptionSettingsStep/PresetCard.module.scss
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite PresetCard.module.scss with new design**
|
||
|
||
```scss
|
||
// PresetCard.module.scss
|
||
.presetCard {
|
||
position: relative;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--bg-default);
|
||
border: 1px solid var(--border-subtle);
|
||
cursor: pointer;
|
||
transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
&:hover {
|
||
border-color: var(--purple-400);
|
||
transform: translateY(-2px);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
}
|
||
|
||
.selected {
|
||
border-color: var(--purple-400);
|
||
box-shadow:
|
||
0 0 0 1px var(--purple-400),
|
||
0 0 20px rgba(203, 166, 247, 0.25),
|
||
var(--shadow-lg);
|
||
|
||
&::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: -1px;
|
||
border-radius: 12px;
|
||
padding: 1px;
|
||
background: linear-gradient(135deg, var(--purple-400), var(--purple-600));
|
||
-webkit-mask:
|
||
linear-gradient(#fff 0 0) content-box,
|
||
linear-gradient(#fff 0 0);
|
||
-webkit-mask-composite: xor;
|
||
mask-composite: exclude;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.previewArea {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.selectedIndicator {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: var(--purple-400);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 2px 8px rgba(203, 166, 247, 0.4);
|
||
|
||
svg {
|
||
width: 12px;
|
||
height: 12px;
|
||
color: var(--bg-canvas);
|
||
}
|
||
}
|
||
|
||
.cardFooter {
|
||
padding: 14px 16px;
|
||
background: linear-gradient(to top, var(--bg-surface), var(--bg-default));
|
||
border-top: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.presetName {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.systemBadge {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
padding: 2px 8px;
|
||
background: var(--purple-100);
|
||
color: var(--purple-400);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.styleChars {
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.colorDot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.divider {
|
||
color: var(--border-default);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update PresetCard.tsx with style characteristics**
|
||
|
||
```typescript
|
||
// Add helper to extract style characteristics
|
||
function getStyleCharacteristics(preset: CaptionPresetRead): {
|
||
fontFamily: string
|
||
accentColor: string | null
|
||
accentName: string | null
|
||
} {
|
||
const style = preset.style_config
|
||
const fontFamily = style?.text?.font_family ?? "Inter"
|
||
|
||
// Extract accent color from highlight or text color
|
||
const highlightColor = style?.highlight?.color
|
||
const textColor = style?.text?.color
|
||
|
||
// Simple color name mapping (expand as needed)
|
||
const colorMap: Record<string, string> = {
|
||
"#FFD700": "Золотой",
|
||
"#00ffff": "Неоновый",
|
||
"#ffffff": "Белый",
|
||
"#ff006e": "Розовый",
|
||
"#cba6f7": "Пурпурный",
|
||
"#f38ba8": "Розовый",
|
||
"#a6e3a1": "Зеленый",
|
||
"#f9e2af": "Желтый",
|
||
"#89dceb": "Голубой",
|
||
}
|
||
|
||
const accentColor = highlightColor || textColor
|
||
const accentName = accentColor ? (colorMap[accentColor] ?? null) : null
|
||
|
||
return {
|
||
fontFamily,
|
||
accentColor,
|
||
accentName,
|
||
}
|
||
}
|
||
|
||
// Update component to render characteristics
|
||
export const PresetCard: FunctionComponent<IPresetCardProps> = ({
|
||
preset,
|
||
isSelected,
|
||
aspectRatio,
|
||
onSelect,
|
||
onEdit,
|
||
onDelete,
|
||
}): JSX.Element => {
|
||
const { fontFamily, accentColor, accentName } = getStyleCharacteristics(preset)
|
||
|
||
return (
|
||
<div
|
||
className={cs(styles.presetCard, { [styles.selected]: isSelected })}
|
||
onClick={onSelect}
|
||
>
|
||
<div className={styles.previewArea}>
|
||
<StylePreview preset={preset} 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>
|
||
{/* Context menu for edit/delete - preserve existing */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/features/project/CaptionSettingsStep/PresetCard.tsx
|
||
git add src/features/project/CaptionSettingsStep/PresetCard.module.scss
|
||
git commit -m "feat: redesign PresetCard with style characteristics and checkmark indicator"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Update PresetGrid with Aspect Ratio and Loading State
|
||
|
||
**Files:**
|
||
- Modify: `src/features/project/CaptionSettingsStep/PresetGrid.tsx`
|
||
- Modify: `src/features/project/CaptionSettingsStep/PresetGrid.module.scss`
|
||
|
||
- [ ] **Step 1: Read existing PresetGrid files**
|
||
|
||
```bash
|
||
cat src/features/project/CaptionSettingsStep/PresetGrid.tsx
|
||
cat src/features/project/CaptionSettingsStep/PresetGrid.module.scss
|
||
```
|
||
|
||
- [ ] **Step 2: Update PresetGrid.module.scss**
|
||
|
||
```scss
|
||
// PresetGrid.module.scss
|
||
.presetGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 20px;
|
||
|
||
@media (max-width: 768px) {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 12px;
|
||
}
|
||
}
|
||
|
||
// Optional: Add fade-in animation for cards
|
||
@keyframes fadeInUp {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(10px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.cardWrapper {
|
||
animation: fadeInUp 0.3s ease forwards;
|
||
|
||
// Staggered animation delay
|
||
@for $i from 1 through 10 {
|
||
&:nth-child(#{$i}) {
|
||
animation-delay: #{$i * 50}ms;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update PresetGrid.tsx**
|
||
|
||
```typescript
|
||
// Add imports
|
||
import { useVideoMetadata } from "./useVideoMetadata"
|
||
import { PresetCardSkeleton } from "./PresetCardSkeleton"
|
||
import { useWizard } from "../WizardContext"
|
||
|
||
// In component
|
||
export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
|
||
presets,
|
||
selectedPresetId,
|
||
onSelect,
|
||
onEdit,
|
||
onDelete,
|
||
onCreate,
|
||
}): JSX.Element => {
|
||
const { primaryFileId } = useWizard()
|
||
const { aspectRatio, isLoading: isLoadingMetadata } = useVideoMetadata(primaryFileId)
|
||
|
||
if (isLoadingMetadata) {
|
||
return (
|
||
<div className={styles.presetGrid}>
|
||
{Array.from({ length: 4 }).map((_, i) => (
|
||
<PresetCardSkeleton key={i} aspectRatio={aspectRatio} />
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className={styles.presetGrid}>
|
||
{presets.map((preset, index) => (
|
||
<div
|
||
key={preset.id}
|
||
className={styles.cardWrapper}
|
||
style={{ animationDelay: `${index * 50}ms` }}
|
||
>
|
||
<PresetCard
|
||
preset={preset}
|
||
isSelected={preset.id === selectedPresetId}
|
||
aspectRatio={aspectRatio}
|
||
onSelect={() => onSelect(preset.id)}
|
||
onEdit={() => onEdit(preset.id)}
|
||
onDelete={() => onDelete(preset.id)}
|
||
/>
|
||
</div>
|
||
))}
|
||
{/* Create new card - preserve existing */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/features/project/CaptionSettingsStep/PresetGrid.tsx
|
||
git add src/features/project/CaptionSettingsStep/PresetGrid.module.scss
|
||
git commit -m "feat: add aspect ratio and loading state to PresetGrid"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Type Check and Verify
|
||
|
||
- [ ] **Step 1: Run type check**
|
||
|
||
```bash
|
||
cd cofee_frontend && bunx tsc --noEmit
|
||
```
|
||
|
||
Expected: No TypeScript errors
|
||
|
||
- [ ] **Step 2: Run lint check**
|
||
|
||
```bash
|
||
cd cofee_frontend && bun run lint 2>/dev/null || echo "Lint not configured, using type check only"
|
||
```
|
||
|
||
- [ ] **Step 3: Final commit**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "feat: complete subtitle preset grid redesign with dynamic aspect ratio"
|
||
```
|
||
|
||
---
|
||
|
||
## Verification Checklist
|
||
|
||
- [ ] Preset cards display with correct aspect ratio based on uploaded video
|
||
- [ ] Loading state shows skeleton cards with shimmer animation
|
||
- [ ] Style characteristics (font, color) visible on card footers
|
||
- [ ] Selected card shows checkmark indicator and purple glow border
|
||
- [ ] Grid is responsive (2 columns on mobile, more on desktop)
|
||
- [ ] Hover effects work smoothly
|
||
- [ ] Falls back to 16:9 when no video is available
|
||
- [ ] All existing functionality preserved (select, edit, delete, create)
|