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
+161 -2
View File
@@ -21,6 +21,26 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -268,6 +288,23 @@ export interface paths {
patch: operations["patch_file_entry_api_files_files__file_id___patch"];
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/": {
parameters: {
query?: never;
@@ -501,6 +538,23 @@ export interface paths {
patch?: 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/": {
parameters: {
query?: never;
@@ -997,6 +1051,8 @@ export interface components {
* @default
*/
folder: string;
/** Project Id */
project_id?: string | null;
};
/** CaptionAnimationStyle */
CaptionAnimationStyle: {
@@ -1277,6 +1333,11 @@ export interface components {
};
/** FileInfoResponse */
FileInfoResponse: {
/**
* File Id
* Format: uuid
*/
file_id: string;
/** File Path */
file_path: string;
/** File Url */
@@ -1882,6 +1943,18 @@ export interface components {
[key: string]: unknown;
} | null;
};
/** SaluteSpeechParams */
SaluteSpeechParams: {
/** File Path */
file_path: string;
/** Language */
language?: string | null;
/**
* Model
* @default general
*/
model: string;
};
/** SegmentNode */
"SegmentNode-Input": {
/** Text */
@@ -2161,7 +2234,7 @@ export interface components {
* @default LOCAL_WHISPER
* @enum {string}
*/
engine: "LOCAL_WHISPER" | "GOOGLE_SPEECH_CLOUD";
engine: "LOCAL_WHISPER" | "GOOGLE_SPEECH_CLOUD" | "SALUTE_SPEECH";
/** Language */
language?: string | null;
/** Document */
@@ -2227,7 +2300,7 @@ export interface components {
* Engine
* @enum {string}
*/
engine: "LOCAL_WHISPER" | "GOOGLE_SPEECH_CLOUD";
engine: "LOCAL_WHISPER" | "GOOGLE_SPEECH_CLOUD" | "SALUTE_SPEECH";
/** Language */
language: string | null;
/** 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: {
parameters: {
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: {
parameters: {
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: {
parameters: {
query?: never;
+2 -2
View File
@@ -26,8 +26,8 @@ export const AppProviders = ({
<ThemeSync />
<BreadcrumbsProvider>
<Theme
accentColor="violet"
grayColor="sand"
accentColor="plum"
grayColor="mauve"
radius="medium"
scaling="100%"
appearance="inherit"
+102 -26
View File
@@ -21,18 +21,78 @@ import { useDebounce } from "@shared/hooks/useDebounce"
/* ------------------------------------------------------------------ */
export const WIZARD_STEPS = [
{ key: "upload", label: "Загрузка" },
{ key: "verify", label: "Проверка" },
{ key: "silence-settings", label: "Настройка удаления тишины" },
{ key: "processing", label: "Обработка" },
{ key: "fragments", label: "Редактирование фрагментов" },
{ key: "silence-apply-processing", label: "Обработка" },
{ key: "transcription-settings", label: "Настройка транскрибации" },
{ key: "transcription-processing", label: "Обработка" },
{ key: "subtitle-revision", label: "Редактирование транскрибации" },
{ key: "caption-settings", label: "Настройка субтитров" },
{ key: "caption-processing", label: "Обработка" },
{ key: "caption-result", label: "Результат" },
{
key: "upload",
label: "Загрузка",
shortLabel: "Загрузка",
phaseLabel: "Загрузка файла",
},
{
key: "verify",
label: "Проверка",
shortLabel: "Проверка",
phaseLabel: "Загрузка файла",
},
{
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
export type WizardStepKey = (typeof WIZARD_STEPS)[number]["key"]
@@ -97,6 +157,7 @@ interface WizardContextValue {
currentStep: WizardStepKey
stepIndex: number
completedSteps: WizardStepKey[]
primaryFileId: string | null
primaryFileKey: string | null
videoUrl: string | null
originalFileName: string | null
@@ -113,8 +174,8 @@ interface WizardContextValue {
goNext: () => void
goBack: () => void
setFileKey: (
key: string,
url: string,
key: string | null,
fileId: string | null,
originalFileName?: string | null,
) => void
setSilenceSettings: (settings: SilenceSettings) => void
@@ -143,8 +204,8 @@ const WizardContext = createContext<WizardContextValue | null>(null)
interface PersistedWizardState {
current_step: WizardStepKey
completed_steps: WizardStepKey[]
primary_file_id: string | null
primary_file_key: string | null
video_url: string | null
original_file_name: string | null
silence_settings: SilenceSettings
active_job_id: string | null
@@ -175,8 +236,8 @@ export const WizardProvider: FunctionComponent<{
}> = ({ projectId, children }) => {
const [currentStep, setCurrentStep] = useState<WizardStepKey>("upload")
const [completedSteps, setCompletedSteps] = useState<WizardStepKey[]>([])
const [primaryFileId, setPrimaryFileId] = 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 [silenceSettings, setSilenceSettingsState] = useState<SilenceSettings>(
DEFAULT_SILENCE_SETTINGS,
@@ -231,8 +292,8 @@ export const WizardProvider: FunctionComponent<{
),
)
setCompletedSteps(wizard.completed_steps ?? [])
setPrimaryFileId(wizard.primary_file_id ?? null)
setPrimaryFileKey(wizard.primary_file_key ?? null)
setVideoUrl(wizard.video_url ?? null)
setOriginalFileName(wizard.original_file_name ?? null)
setSilenceSettingsState(
wizard.silence_settings ?? DEFAULT_SILENCE_SETTINGS,
@@ -251,14 +312,23 @@ export const WizardProvider: FunctionComponent<{
isInitializedRef.current = true
}, [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) ---- */
const stateToSave = useMemo<PersistedWizardState>(
() => ({
current_step: currentStep,
completed_steps: completedSteps,
primary_file_id: primaryFileId,
primary_file_key: primaryFileKey,
video_url: videoUrl,
original_file_name: originalFileName,
silence_settings: silenceSettings,
active_job_id: activeJobId,
@@ -273,8 +343,8 @@ export const WizardProvider: FunctionComponent<{
[
currentStep,
completedSteps,
primaryFileId,
primaryFileKey,
videoUrl,
originalFileName,
silenceSettings,
activeJobId,
@@ -296,8 +366,8 @@ export const WizardProvider: FunctionComponent<{
(overrides: Partial<PersistedWizardState> = {}): PersistedWizardState => ({
current_step: overrides.current_step ?? currentStep,
completed_steps: overrides.completed_steps ?? completedSteps,
primary_file_id: overrides.primary_file_id ?? primaryFileId,
primary_file_key: overrides.primary_file_key ?? primaryFileKey,
video_url: overrides.video_url ?? videoUrl,
original_file_name: overrides.original_file_name ?? originalFileName,
silence_settings: overrides.silence_settings ?? silenceSettings,
active_job_id: overrides.active_job_id ?? activeJobId,
@@ -323,11 +393,11 @@ export const WizardProvider: FunctionComponent<{
completedSteps,
currentStep,
originalFileName,
primaryFileId,
primaryFileKey,
silenceJobId,
silenceSettings,
transcriptionArtifactId,
videoUrl,
],
)
@@ -422,16 +492,20 @@ export const WizardProvider: FunctionComponent<{
setCurrentStep("fragments")
} else if (activeJobType === "SILENCE_APPLY") {
const outputData = taskStatus?.output_data as
| { file_path?: string; file_url?: string }
| { file_id?: string; file_path?: string }
| null
| undefined
if (taskStatus?.status !== "DONE" || !outputData?.file_path || !outputData?.file_url) {
if (
taskStatus?.status !== "DONE" ||
!outputData?.file_id ||
!outputData?.file_path
) {
return
}
setPrimaryFileId(outputData.file_id)
setPrimaryFileKey(outputData.file_path)
setVideoUrl(outputData.file_url)
setOriginalFileName(outputData.file_path.split("/").pop() ?? null)
setSilenceJobIdState(null)
setActiveJobId(null)
@@ -499,9 +573,9 @@ export const WizardProvider: FunctionComponent<{
}, [currentStep])
const setFileKey = useCallback(
(key: string, url: string, fileName?: string | null) => {
(key: string | null, fileId: string | null, fileName?: string | null) => {
setPrimaryFileId(fileId)
setPrimaryFileKey(key)
setVideoUrl(url)
if (fileName !== undefined) {
setOriginalFileName(fileName)
}
@@ -589,6 +663,7 @@ export const WizardProvider: FunctionComponent<{
currentStep,
stepIndex,
completedSteps,
primaryFileId,
primaryFileKey,
videoUrl,
originalFileName,
@@ -621,6 +696,7 @@ export const WizardProvider: FunctionComponent<{
currentStep,
stepIndex,
completedSteps,
primaryFileId,
primaryFileKey,
videoUrl,
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 ""
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-white: var(--color-white);
$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);
$text-primary: var(--text-primary);
+169 -81
View File
@@ -13,16 +13,51 @@
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 {
background-color: var(--bg-canvas);
background-image: radial-gradient(circle at 50% 0%, rgba(110, 94, 219, 0.05) 0%, transparent 60%);
background-attachment: fixed;
color: var(--text-primary);
color: var(--text-primary);
}
body::before {
content: "";
position: fixed;
inset: 0;
background-image: var(--page-glow);
pointer-events: none;
z-index: -1;
}
body[data-project-wizard-layout="true"] {
overflow: hidden;
}
body[data-project-wizard-layout="true"] #app-root {
height: 100vh;
overflow: hidden;
}
::selection {
background-color: hsl(262, 68%, 52%, 0.15);
color: var(--text-primary);
background-color: var(--selection-bg);
color: var(--selection-text);
}
a {
@@ -34,61 +69,69 @@ button {
}
:root {
/* Rich Violet Palette */
--purple-50: hsl(262, 60%, 97%);
--purple-100: hsl(262, 50%, 90%);
--purple-200: hsl(262, 48%, 80%);
--purple-300: hsl(262, 55%, 68%);
--purple-400: hsl(262, 70%, 54%);
--purple-500: hsl(262, 75%, 48%);
--purple-600: hsl(262, 78%, 42%);
--purple-700: hsl(262, 80%, 36%);
--purple-800: hsl(262, 85%, 28%);
--purple-900: hsl(262, 90%, 20%);
/* Catppuccin Latte */
--purple-50: #f7efff;
--purple-100: #eedfff;
--purple-200: #dfc8ff;
--purple-300: #c8abff;
--purple-400: #a777f2;
--purple-500: #8839ef;
--purple-600: #7430ca;
--purple-700: #5f27a5;
--purple-800: #4a1f80;
--purple-900: #35175c;
/* Muted Sage Green Palette */
--green-50: hsl(150, 30%, 94%);
--green-100: hsl(150, 28%, 85%);
--green-200: hsl(150, 26%, 74%);
--green-300: hsl(150, 28%, 62%);
--green-400: hsl(150, 32%, 52%);
--green-500: hsl(150, 40%, 42%);
--green-600: hsl(152, 44%, 36%);
--green-700: hsl(154, 50%, 30%);
--green-800: hsl(156, 56%, 24%);
--green-900: hsl(158, 64%, 18%);
--green-50: #eff8ea;
--green-100: #dcefd2;
--green-200: #c7e5b9;
--green-300: #afd89d;
--green-400: #7fc16c;
--green-500: #40a02b;
--green-600: #348222;
--green-700: #28651a;
--green-800: #1d4912;
--green-900: #122d0a;
--color-success: #16a34a;
--color-danger: #dc2626;
--color-warning: #d97706;
--color-success: #40a02b;
--color-danger: #d20f39;
--color-warning: #df8e1d;
--color-primary: var(--purple-500);
--color-secondary: var(--purple-400);
--color-white: #ffffff;
--color-black: #000000;
--text-primary: #18181b;
--text-secondary: #71717a;
--text-tertiary: #a1a1aa;
--text-primary: #4c4f69;
--text-secondary: #5c5f77;
--text-tertiary: #8c8fa1;
--bg-canvas: #fafafa;
--bg-default: #ffffff;
--bg-surface: #f4f4f5;
--bg-hover: #e4e4e7;
--bg-default-invert: #18181b;
--bg-canvas: #e6e9ef;
--bg-default: #eff1f5;
--bg-surface: #dce0e8;
--bg-hover: #ccd0da;
--bg-default-invert: #1e1e2e;
--border-default: #e8e8ec;
--border-subtle: #f4f4f5;
--border-default: #bcc0cc;
--border-subtle: #dce0e8;
--waveform-wave: var(--purple-400);
--waveform-progress: var(--purple-600);
--waveform-wave: #7287fd;
--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);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.04), 0 40px 80px -20px rgba(0, 0, 0, 0.08);
--page-glow: radial-gradient(circle at 50% 0%, rgba(136, 57, 239, 0.08) 0%, transparent 62%);
--selection-bg: rgba(136, 57, 239, 0.18);
--selection-text: #4c4f69;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-sm: 0 1px 2px rgba(76, 79, 105, 0.06), 0 2px 8px rgba(76, 79, 105, 0.04);
--shadow-md: 0 4px 6px -1px rgba(76, 79, 105, 0.08), 0 24px 48px -12px rgba(76, 79, 105, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(76, 79, 105, 0.08), 0 40px 80px -20px rgba(76, 79, 105, 0.12);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--header-height: 56px;
@@ -99,51 +142,96 @@ button {
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
/* Focus ring */
--focus-ring: 0 0 0 2px var(--bg-default), 0 0 0 4px hsla(262, 75%, 48%, 0.3);
--alert-info-bg: rgba(4, 165, 229, 0.12);
--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"] {
/* 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-secondary: var(--purple-300);
--text-primary: #fdfdfd;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--text-primary: #cdd6f4;
--text-secondary: #bac2de;
--text-tertiary: #9399b2;
--bg-canvas: #050505;
--bg-default: #0a0a0a;
--bg-surface: #141414;
--bg-hover: #1f1f23;
--bg-default-invert: #fafafa;
--bg-canvas: #11111b;
--bg-default: #1e1e2e;
--bg-surface: #313244;
--bg-hover: #45475a;
--bg-default-invert: #eff1f5;
--border-default: #27272a;
--border-subtle: #18181b;
--border-default: #45475a;
--border-subtle: #313244;
--waveform-wave: #e4e4e7;
--waveform-progress: #a1a1aa;
--waveform-wave: #6c7086;
--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);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 24px 48px -12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 40px 80px -20px rgba(0, 0, 0, 0.6);
--page-glow: radial-gradient(circle at 50% 0%, rgba(203, 166, 247, 0.12) 0%, transparent 55%);
--selection-bg: rgba(203, 166, 247, 0.2);
--selection-text: #f5e0dc;
/* Alert dark mode overrides */
--alert-info-bg: hsl(204, 50%, 18%);
--alert-info-text: hsl(204, 70%, 72%);
--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%);
}
--shadow-sm: 0 1px 2px rgba(17, 17, 27, 0.5);
--shadow-md: 0 4px 6px -1px rgba(17, 17, 27, 0.58), 0 24px 48px -12px rgba(17, 17, 27, 0.52);
--shadow-lg: 0 10px 15px -3px rgba(17, 17, 27, 0.6), 0 40px 80px -20px rgba(17, 17, 27, 0.7);
[data-theme="dark"] body {
background-image: radial-gradient(circle at 50% 0%, rgba(139, 92, 246, 0.08) 0%, transparent 50%);
--alert-info-bg: rgba(137, 220, 235, 0.14);
--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) {
+1 -1
View File
@@ -7,7 +7,7 @@ import { Badge as RadixBadge } from "@radix-ui/themes"
import { forwardRef } from "react"
const variantMap = {
primary: "violet",
primary: "plum",
secondary: "gray",
success: "green",
danger: "red",
+1 -1
View File
@@ -14,7 +14,7 @@
&:hover:not(:disabled) {
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) {
+2 -2
View File
@@ -21,8 +21,8 @@ const sizeMap = {
} as const
const variantMap = {
primary: { variant: "solid", color: "violet" },
secondary: { variant: "soft", color: "violet" },
primary: { variant: "solid", color: "plum" },
secondary: { variant: "soft", color: "plum" },
outline: { variant: "outline", color: "gray" },
ghost: { variant: "ghost", color: "gray" },
danger: { variant: "solid", color: "ruby" },
+6 -7
View File
@@ -1,12 +1,11 @@
.card {
background-color: hsla(0, 0%, 100%, 0.8);
backdrop-filter: blur(16px) saturate(180%);
background-color: color-mix(in srgb, var(--bg-default) 92%, transparent);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.5rem;
border: 1px solid var(--border-subtle);
border: 1px solid var(--border-subtle);
[data-theme="dark"] & {
background-color: hsla(240, 2%, 10%, 0.5);
}
}
[data-theme="dark"] & {
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}
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>}
{description && (
<p className={styles.description}>{description}</p>
+2
View File
@@ -1,6 +1,8 @@
export interface StepDefinition {
key: string
label: string
shortLabel?: string
phaseLabel?: string
}
export interface IStepperProps {
+159 -76
View File
@@ -1,6 +1,10 @@
/* ------------------------------------------------------------------ */
/* Root & scroll chrome */
/* ------------------------------------------------------------------ */
.root {
position: relative;
background: variables.$bg-surface;
background: variables.$bg-default;
border-bottom: 1px solid variables.$border-subtle;
}
@@ -11,25 +15,22 @@
bottom: 0;
width: 48px;
pointer-events: none;
z-index: 1;
z-index: 2;
}
.fadeLeft {
left: 0;
background: linear-gradient(to right, variables.$bg-surface, transparent);
background: linear-gradient(to right, variables.$bg-default, transparent);
}
.fadeRight {
right: 0;
background: linear-gradient(to left, variables.$bg-surface, transparent);
background: linear-gradient(to left, variables.$bg-default, transparent);
}
.scrollContainer {
display: flex;
align-items: center;
gap: 6px;
padding: 16px 48px;
overflow-x: auto;
padding: 12px 20px;
scrollbar-width: none;
&::-webkit-scrollbar {
@@ -37,94 +38,176 @@
}
}
.stepContainer {
/* ------------------------------------------------------------------ */
/* Phase row */
/* ------------------------------------------------------------------ */
.phases {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
min-width: max-content;
justify-content: center;
}
.step {
/* ------------------------------------------------------------------ */
/* Phase (shared) */
/* ------------------------------------------------------------------ */
.phase {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px 6px 6px;
border-radius: 32px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
flex-shrink: 0;
padding: 4px 4px;
border-radius: variables.$radius-sm;
white-space: nowrap;
transition: background-color var(--duration-normal) var(--ease-out);
}
.stepActive {
background: variables.$color-primary;
border-color: variables.$color-primary;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.25);
/* ------------------------------------------------------------------ */
/* Indicator circle */
/* ------------------------------------------------------------------ */
.number {
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 {
.indicator {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
width: 28px;
height: 28px;
border-radius: 50%;
font-size: 13px;
font-weight: 600;
font-size: 12px;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
transition: all 0.3s ease;
transition: all var(--duration-normal) var(--ease-out);
}
.label {
font-size: 13px;
transition: all 0.3s ease;
.indicatorCompleted {
background: color-mix(in srgb, variables.$color-success 10%, variables.$bg-default);
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;
}
.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;
align-items: center;
justify-content: center;
color: variables.$text-tertiary;
opacity: 0.6;
margin: 0 4px;
padding: 0 4px;
gap: 4px;
margin-left: 2px;
}
.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 { Check } from "lucide-react"
import {
FunctionComponent,
useEffect,
useRef,
} from "react"
import {
motion,
useScroll,
useTransform,
animate,
} from "framer-motion"
import { Fragment, FunctionComponent, useEffect, useMemo, useRef } from "react"
import cs from "classnames"
import { animate, motion, useScroll, useTransform } from "framer-motion"
import styles from "./Stepper.module.scss"
const ChevronSeparator = () => (
<div className={styles.separator}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
</div>
)
/* ------------------------------------------------------------------ */
/* Phase grouping */
/* ------------------------------------------------------------------ */
interface Phase {
label: string
steps: StepDefinition[]
phaseIndex: number
isCompleted: boolean
isActive: boolean
isUpcoming: boolean
}
function groupStepsIntoPhases(
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> = ({
steps,
@@ -52,15 +110,19 @@ export const Stepper: FunctionComponent<IStepperProps> = ({
const fadeLeftOpacity = useTransform(scrollXProgress, [0, 0.03], [0, 1])
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(() => {
const container = scrollContainerRef.current
if (!container) return
const scrollToActive = () => {
const activeEl = container.querySelector<HTMLElement>(
"[data-step-active]",
)
const activeEl =
container.querySelector<HTMLElement>("[data-step-active]")
if (!activeEl) return
const containerRect = container.getBoundingClientRect()
@@ -95,57 +157,83 @@ export const Stepper: FunctionComponent<IStepperProps> = ({
return () => controls.stop()
}
// Delay to ensure layout is complete after hydration
const frame = requestAnimationFrame(scrollToActive)
return () => cancelAnimationFrame(frame)
}, [currentStep])
return (
<div
className={cs(styles.root, className)}
data-testid="Stepper"
>
<div className={cs(styles.root, className)} data-testid="Stepper">
<motion.div
className={styles.fadeLeft}
style={{ opacity: fadeLeftOpacity }}
/>
<div
ref={scrollContainerRef}
className={styles.scrollContainer}
>
{steps.map((step, index) => {
const isCompleted = completedSteps.includes(step.key)
const isActive = step.key === currentStep
const isLast = index === steps.length - 1
<div ref={scrollContainerRef} className={styles.scrollContainer}>
<div className={styles.phases}>
{phases.map((phase, idx) => {
const isLast = idx === phases.length - 1
const connectorClass = !isLast
? getConnectorVariant(phase, phases[idx + 1])
: null
return (
<div
key={step.key}
className={styles.stepContainer}
data-step-container
>
<div
className={cs(styles.step, {
[styles.stepCompleted]: isCompleted,
[styles.stepActive]: isActive,
[styles.stepUpcoming]: !isActive && !isCompleted,
})}
{...(isActive ? { "data-step-active": true } : {})}
>
<span className={styles.number}>
{isCompleted ? (
<Check size={14} strokeWidth={3} />
) : (
index + 1
return (
<Fragment key={phase.label}>
<div
className={cs(styles.phase, {
[styles.phaseCompleted]: phase.isCompleted,
[styles.phaseActive]: phase.isActive,
[styles.phaseUpcoming]: phase.isUpcoming,
})}
aria-current={phase.isActive ? "step" : undefined}
{...(phase.isActive ? { "data-step-active": true } : {})}
>
<span
className={cs(styles.indicator, {
[styles.indicatorCompleted]: phase.isCompleted,
[styles.indicatorActive]: phase.isActive,
[styles.indicatorUpcoming]: phase.isUpcoming,
})}
>
{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>
<span className={styles.label}>{step.label}</span>
</div>
{!isLast && <ChevronSeparator />}
</div>
)
})}
</div>
{connectorClass && (
<div className={cs(styles.connector, connectorClass)} />
)}
</Fragment>
)
})}
</div>
</div>
<motion.div
@@ -31,8 +31,8 @@
&:focus {
outline: none;
border-color: var(--purple-400);
box-shadow: 0 0 0 4px hsla(262, 75%, 48%, 0.15);
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
&:disabled {