rev 4
This commit is contained in:
+161
-2
@@ -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;
|
||||
|
||||
@@ -26,8 +26,8 @@ export const AppProviders = ({
|
||||
<ThemeSync />
|
||||
<BreadcrumbsProvider>
|
||||
<Theme
|
||||
accentColor="violet"
|
||||
grayColor="sand"
|
||||
accentColor="plum"
|
||||
grayColor="mauve"
|
||||
radius="medium"
|
||||
scaling="100%"
|
||||
appearance="inherit"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import api from "@shared/api"
|
||||
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||
import { resolveTaskProgressState } from "@shared/lib/taskProgress"
|
||||
|
||||
interface UseTaskProgressStateArgs {
|
||||
jobId: string | null
|
||||
enabled: boolean
|
||||
defaultMessage: string
|
||||
}
|
||||
|
||||
export function useTaskProgressState({
|
||||
jobId,
|
||||
enabled,
|
||||
defaultMessage,
|
||||
}: UseTaskProgressStateArgs): {
|
||||
progressPct: number
|
||||
message: string
|
||||
status: string | null
|
||||
errorMessage: string | null
|
||||
} {
|
||||
const notification = useAppSelector((state) =>
|
||||
jobId
|
||||
? state.notifications.items.find((n) => n.job_id === jobId)
|
||||
: null,
|
||||
)
|
||||
|
||||
const { data: taskStatus } = api.useQuery(
|
||||
"get",
|
||||
"/api/tasks/status/{job_id}/",
|
||||
{ params: { path: { job_id: jobId ?? "" } } },
|
||||
{
|
||||
enabled,
|
||||
refetchInterval: 2000,
|
||||
},
|
||||
)
|
||||
|
||||
const resolvedState = resolveTaskProgressState({
|
||||
notificationMessage: notification?.message,
|
||||
notificationProgressPct: notification?.progress_pct,
|
||||
taskMessage: taskStatus?.current_message,
|
||||
taskProgressPct: taskStatus?.progress_pct,
|
||||
defaultMessage,
|
||||
})
|
||||
|
||||
return {
|
||||
progressPct: resolvedState.progressPct,
|
||||
message: resolvedState.message,
|
||||
status: notification?.status ?? taskStatus?.status ?? null,
|
||||
errorMessage:
|
||||
notification?.message ?? taskStatus?.error_message ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { formatNotificationRelativeTime } from "./dates"
|
||||
|
||||
describe("formatNotificationRelativeTime", () => {
|
||||
test("returns 'только что' for less than a minute", () => {
|
||||
expect(
|
||||
formatNotificationRelativeTime("2026-04-05T11:59:31.000Z", {
|
||||
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||
}),
|
||||
).toBe("только что")
|
||||
})
|
||||
|
||||
test("returns minutes without suffix", () => {
|
||||
expect(
|
||||
formatNotificationRelativeTime("2026-04-05T11:48:00.000Z", {
|
||||
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||
}),
|
||||
).toBe("12мин")
|
||||
})
|
||||
|
||||
test("returns hours without suffix", () => {
|
||||
expect(
|
||||
formatNotificationRelativeTime("2026-04-05T08:00:00.000Z", {
|
||||
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||
}),
|
||||
).toBe("4ч")
|
||||
})
|
||||
|
||||
test("returns days without suffix", () => {
|
||||
expect(
|
||||
formatNotificationRelativeTime("2026-04-02T12:00:00.000Z", {
|
||||
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||
}),
|
||||
).toBe("3д")
|
||||
})
|
||||
|
||||
test("returns weeks without suffix", () => {
|
||||
expect(
|
||||
formatNotificationRelativeTime("2026-03-22T12:00:00.000Z", {
|
||||
now: new Date("2026-04-05T12:00:00.000Z"),
|
||||
}),
|
||||
).toBe("2нед")
|
||||
})
|
||||
})
|
||||
@@ -9,3 +9,33 @@ export function formatRelativeTime(date: string | Date | null): string {
|
||||
if (!date) return ""
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true, locale: ru })
|
||||
}
|
||||
|
||||
interface NotificationRelativeTimeOptions {
|
||||
now?: Date
|
||||
}
|
||||
|
||||
export function formatNotificationRelativeTime(
|
||||
date: string | Date | null,
|
||||
options: NotificationRelativeTimeOptions = {},
|
||||
): string {
|
||||
if (!date) return ""
|
||||
|
||||
const targetDate = new Date(date)
|
||||
|
||||
if (Number.isNaN(targetDate.getTime())) return ""
|
||||
|
||||
const now = options.now ?? new Date()
|
||||
const diffMs = Math.max(0, now.getTime() - targetDate.getTime())
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
|
||||
if (diffMinutes < 1) return "только что"
|
||||
if (diffMinutes < 60) return `${diffMinutes}мин`
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
if (diffHours < 24) return `${diffHours}ч`
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffDays < 7) return `${diffDays}д`
|
||||
|
||||
return `${Math.floor(diffDays / 7)}нед`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function declOfNum(
|
||||
number: number,
|
||||
titles: [string, string, string],
|
||||
): string {
|
||||
const cases = [2, 0, 1, 1, 1, 2]
|
||||
|
||||
return titles[
|
||||
number % 100 > 4 && number % 100 < 20
|
||||
? 2
|
||||
: cases[number % 10 < 5 ? number % 10 : 5]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { resolveTaskProgressState } from "./taskProgress"
|
||||
|
||||
describe("resolveTaskProgressState", () => {
|
||||
test("prefers the polled task status when it is ahead of notifications", () => {
|
||||
expect(
|
||||
resolveTaskProgressState({
|
||||
notificationMessage: "Конвертация видео",
|
||||
notificationProgressPct: 24,
|
||||
taskMessage: "Загрузка результата",
|
||||
taskProgressPct: 95,
|
||||
defaultMessage: "Конвертация видео...",
|
||||
}),
|
||||
).toEqual({
|
||||
progressPct: 95,
|
||||
message: "Загрузка результата",
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to notification state when it is the freshest source", () => {
|
||||
expect(
|
||||
resolveTaskProgressState({
|
||||
notificationMessage: "Применение вырезок",
|
||||
notificationProgressPct: 52.5,
|
||||
taskMessage: "Применение вырезок",
|
||||
taskProgressPct: 10,
|
||||
defaultMessage: "Применение вырезок...",
|
||||
}),
|
||||
).toEqual({
|
||||
progressPct: 52.5,
|
||||
message: "Применение вырезок",
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the default message when no progress source is available", () => {
|
||||
expect(
|
||||
resolveTaskProgressState({
|
||||
notificationMessage: null,
|
||||
notificationProgressPct: null,
|
||||
taskMessage: null,
|
||||
taskProgressPct: null,
|
||||
defaultMessage: "Подождите, идёт обработка...",
|
||||
}),
|
||||
).toEqual({
|
||||
progressPct: 0,
|
||||
message: "Подождите, идёт обработка...",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
interface ResolveTaskProgressStateArgs {
|
||||
notificationMessage: string | null | undefined
|
||||
notificationProgressPct: number | null | undefined
|
||||
taskMessage: string | null | undefined
|
||||
taskProgressPct: number | null | undefined
|
||||
defaultMessage: string
|
||||
}
|
||||
|
||||
export function resolveTaskProgressState({
|
||||
notificationMessage,
|
||||
notificationProgressPct,
|
||||
taskMessage,
|
||||
taskProgressPct,
|
||||
defaultMessage,
|
||||
}: ResolveTaskProgressStateArgs): {
|
||||
progressPct: number
|
||||
message: string
|
||||
} {
|
||||
const notificationPct = notificationProgressPct ?? null
|
||||
const statusPct = taskProgressPct ?? null
|
||||
|
||||
if (notificationPct !== null && statusPct !== null) {
|
||||
if (statusPct > notificationPct) {
|
||||
return {
|
||||
progressPct: statusPct,
|
||||
message: taskMessage ?? notificationMessage ?? defaultMessage,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
progressPct: notificationPct,
|
||||
message: notificationMessage ?? taskMessage ?? defaultMessage,
|
||||
}
|
||||
}
|
||||
|
||||
if (notificationPct !== null) {
|
||||
return {
|
||||
progressPct: notificationPct,
|
||||
message: notificationMessage ?? taskMessage ?? defaultMessage,
|
||||
}
|
||||
}
|
||||
|
||||
if (statusPct !== null) {
|
||||
return {
|
||||
progressPct: statusPct,
|
||||
message: taskMessage ?? notificationMessage ?? defaultMessage,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
progressPct: 0,
|
||||
message: notificationMessage ?? taskMessage ?? defaultMessage,
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ $color-primary: var(--color-primary);
|
||||
$color-secondary: var(--color-secondary);
|
||||
$color-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
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+2
@@ -1,6 +1,8 @@
|
||||
export interface StepDefinition {
|
||||
key: string
|
||||
label: string
|
||||
shortLabel?: string
|
||||
phaseLabel?: string
|
||||
}
|
||||
|
||||
export interface IStepperProps {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user