This commit is contained in:
Daniil
2026-04-04 14:51:40 +03:00
parent 10a1d28f77
commit 0523ef3d72
191 changed files with 12065 additions and 2658 deletions
+51
View File
@@ -0,0 +1,51 @@
import createFetchClient, { Middleware } from "openapi-fetch"
import { ACCESS_TOKEN_REGEXP, API_URL } from "@shared/lib/constants"
import { paths } from "./__generated__/openapi.types"
const isServer = typeof window === "undefined"
const getAccessTokenFromCookieHeader = (
cookieHeader: string | null,
): string | undefined => {
if (!cookieHeader) return
const token = cookieHeader.replace(ACCESS_TOKEN_REGEXP, "$1")
return token.length ? token : undefined
}
export const fetchClient = createFetchClient<paths>({
baseUrl: API_URL,
// credentials: "include",
headers: {
"Content-Type": "application/json",
},
})
const middleware: Middleware = {
async onRequest({ request }) {
if (request.headers.has("Authorization")) return
let token: string | undefined
if (isServer) {
// In middleware/edge runtime there is no `next/headers` request scope.
token = getAccessTokenFromCookieHeader(request.headers.get("cookie"))
if (!token) {
try {
const { cookies } = await import("next/headers")
token = (await cookies()).get("access_token")?.value
} catch {
// Not in a request scope (e.g. middleware/edge or build-time).
}
}
} else {
token = document.cookie.replace(ACCESS_TOKEN_REGEXP, "$1")
}
if (token?.length) request.headers.set("Authorization", `Bearer ${token}`)
},
async onError({ error }) {
return new Error("Oops, fetch failed", { cause: error })
},
}
fetchClient.use(middleware)
+2 -50
View File
@@ -1,56 +1,8 @@
import createClient from "openapi-react-query"
import createFetchClient, { Middleware } from "openapi-fetch"
import { fetchClient } from "./fetchClient"
import { ACCESS_TOKEN_REGEXP, API_URL } from "@shared/lib/constants"
import { paths } from "./__generated__/openapi.types"
const isServer = typeof window === "undefined"
const getAccessTokenFromCookieHeader = (
cookieHeader: string | null,
): string | undefined => {
if (!cookieHeader) return
const token = cookieHeader.replace(ACCESS_TOKEN_REGEXP, "$1")
return token.length ? token : undefined
}
export const fetchClient = createFetchClient<paths>({
baseUrl: API_URL,
// credentials: "include",
headers: {
"Content-Type": "application/json",
},
})
const middleware: Middleware = {
async onRequest({ request }) {
if (request.headers.has("Authorization")) return
let token: string | undefined
if (isServer) {
// In middleware/edge runtime there is no `next/headers` request scope.
token = getAccessTokenFromCookieHeader(request.headers.get("cookie"))
if (!token) {
try {
const { cookies } = await import("next/headers")
token = (await cookies()).get("access_token")?.value
} catch {
// Not in a request scope (e.g. middleware/edge or build-time).
}
}
} else {
token = document.cookie.replace(ACCESS_TOKEN_REGEXP, "$1")
}
if (token?.length) request.headers.set("Authorization", `Bearer ${token}`)
},
async onError({ error }) {
return new Error("Oops, fetch failed", { cause: error })
},
}
fetchClient.use(middleware)
export { fetchClient }
export const api = createClient(fetchClient)
export default api
+1 -1
View File
@@ -1,6 +1,6 @@
"use server"
import { fetchClient } from "."
import { fetchClient } from "./fetchClient"
export const pingServer = async (): Promise<boolean> => {
try {
+2 -2
View File
@@ -26,8 +26,8 @@ export const AppProviders = ({
<ThemeSync />
<BreadcrumbsProvider>
<Theme
accentColor="iris"
grayColor="slate"
accentColor="violet"
grayColor="sand"
radius="medium"
scaling="100%"
appearance="inherit"
+668
View File
@@ -0,0 +1,668 @@
"use client"
import {
createContext,
FunctionComponent,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import api from "@shared/api"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { useDebounce } from "@shared/hooks/useDebounce"
/* ------------------------------------------------------------------ */
/* Step definitions */
/* ------------------------------------------------------------------ */
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: "Результат" },
] as const
export type WizardStepKey = (typeof WIZARD_STEPS)[number]["key"]
const JOB_PROCESSING_STEP_MAP: Record<string, WizardStepKey> = {
MEDIA_CONVERT: "verify",
SILENCE_DETECT: "processing",
SILENCE_APPLY: "silence-apply-processing",
TRANSCRIPTION_GENERATE: "transcription-processing",
CAPTIONS_GENERATE: "caption-processing",
}
const JOB_CANCELLED_STEP_MAP: Record<string, WizardStepKey> = {
MEDIA_CONVERT: "verify",
SILENCE_DETECT: "silence-settings",
SILENCE_APPLY: "fragments",
TRANSCRIPTION_GENERATE: "transcription-settings",
CAPTIONS_GENERATE: "caption-settings",
}
function getProcessingStepForJobType(
jobType: string | null | undefined,
): WizardStepKey | null {
if (!jobType) return null
return JOB_PROCESSING_STEP_MAP[jobType] ?? null
}
function getCancelledStepForJobType(
jobType: string | null | undefined,
): WizardStepKey | null {
if (!jobType) return null
return JOB_CANCELLED_STEP_MAP[jobType] ?? null
}
function normalizeWizardStep(
currentStep: WizardStepKey,
activeJobId: string | null,
activeJobType: string | null,
): WizardStepKey {
if (!activeJobId) return currentStep
const processingStep = getProcessingStepForJobType(activeJobType)
return processingStep ?? currentStep
}
/* ------------------------------------------------------------------ */
/* Silence settings */
/* ------------------------------------------------------------------ */
export interface SilenceSettings {
min_silence_duration_ms: number
silence_threshold_db: number
padding_ms: number
}
/* ------------------------------------------------------------------ */
/* Context shape */
/* ------------------------------------------------------------------ */
interface WizardContextValue {
projectId: string
currentStep: WizardStepKey
stepIndex: number
completedSteps: WizardStepKey[]
primaryFileKey: string | null
videoUrl: string | null
originalFileName: string | null
silenceSettings: SilenceSettings
activeJobId: string | null
activeJobType: string | null
silenceJobId: string | null
transcriptionArtifactId: string | null
captionPresetId: string | null
captionStyleConfig: Record<string, unknown> | null
captionedVideoPath: string | null
captionedVideoFileId: string | null
goToStep: (step: WizardStepKey) => void
goNext: () => void
goBack: () => void
setFileKey: (
key: string,
url: string,
originalFileName?: string | null,
) => void
setSilenceSettings: (settings: SilenceSettings) => void
setActiveJob: (jobId: string | null, jobType?: string | null) => void
startProcessingJob: (
jobId: string,
jobType: string,
processingStep: WizardStepKey,
completedStep?: WizardStepKey,
) => void
setSilenceJobId: (jobId: string | null) => void
setTranscriptionArtifactId: (id: string | null) => void
setCaptionPresetId: (id: string | null) => void
setCaptionStyleConfig: (config: Record<string, unknown> | null) => void
setCaptionedVideoPath: (path: string | null) => void
setCaptionedVideoFileId: (id: string | null) => void
markStepCompleted: (step: WizardStepKey) => void
}
const WizardContext = createContext<WizardContextValue | null>(null)
/* ------------------------------------------------------------------ */
/* Persisted shape */
/* ------------------------------------------------------------------ */
interface PersistedWizardState {
current_step: WizardStepKey
completed_steps: WizardStepKey[]
primary_file_key: string | null
video_url: string | null
original_file_name: string | null
silence_settings: SilenceSettings
active_job_id: string | null
active_job_type: string | null
silence_job_id: string | null
transcription_artifact_id: string | null
caption_preset_id: string | null
caption_style_config: Record<string, unknown> | null
captioned_video_path: string | null
captioned_video_file_id: string | null
}
const DEFAULT_SILENCE_SETTINGS: SilenceSettings = {
min_silence_duration_ms: 200,
silence_threshold_db: 16,
padding_ms: 100,
}
const DEBOUNCE_MS = 1000
/* ------------------------------------------------------------------ */
/* Provider */
/* ------------------------------------------------------------------ */
export const WizardProvider: FunctionComponent<{
projectId: string
children: ReactNode
}> = ({ projectId, children }) => {
const [currentStep, setCurrentStep] = useState<WizardStepKey>("upload")
const [completedSteps, setCompletedSteps] = useState<WizardStepKey[]>([])
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,
)
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const [activeJobType, setActiveJobType] = useState<string | null>(null)
const [silenceJobId, setSilenceJobIdState] = useState<string | null>(null)
const [transcriptionArtifactId, setTranscriptionArtifactIdState] = useState<
string | null
>(null)
const [captionPresetId, setCaptionPresetIdState] = useState<string | null>(
null,
)
const [captionStyleConfig, setCaptionStyleConfigState] = useState<Record<
string,
unknown
> | null>(null)
const [captionedVideoPath, setCaptionedVideoPathState] = useState<
string | null
>(null)
const [captionedVideoFileId, setCaptionedVideoFileIdState] = useState<
string | null
>(null)
const isInitializedRef = useRef(false)
const initialSerializedRef = useRef<string | null>(null)
/* ---- Load from server ---- */
const { data: project, isSuccess } = api.useQuery(
"get",
"/api/projects/{project_id}/",
{ params: { path: { project_id: projectId } } },
{ enabled: !!projectId },
)
useEffect(() => {
if (!isSuccess || isInitializedRef.current) return
const saved = project?.workspace_state as
| { wizard?: PersistedWizardState }
| null
| undefined
const wizard = saved?.wizard
if (wizard) {
setCurrentStep(
normalizeWizardStep(
wizard.current_step ?? "upload",
wizard.active_job_id ?? null,
wizard.active_job_type ?? null,
),
)
setCompletedSteps(wizard.completed_steps ?? [])
setPrimaryFileKey(wizard.primary_file_key ?? null)
setVideoUrl(wizard.video_url ?? null)
setOriginalFileName(wizard.original_file_name ?? null)
setSilenceSettingsState(
wizard.silence_settings ?? DEFAULT_SILENCE_SETTINGS,
)
setActiveJobId(wizard.active_job_id ?? null)
setActiveJobType(wizard.active_job_type ?? null)
setSilenceJobIdState(wizard.silence_job_id ?? null)
setTranscriptionArtifactIdState(wizard.transcription_artifact_id ?? null)
setCaptionPresetIdState(wizard.caption_preset_id ?? null)
setCaptionStyleConfigState(wizard.caption_style_config ?? null)
setCaptionedVideoPathState(wizard.captioned_video_path ?? null)
setCaptionedVideoFileIdState(wizard.captioned_video_file_id ?? null)
}
initialSerializedRef.current = JSON.stringify(wizard ?? null)
isInitializedRef.current = true
}, [isSuccess, project])
/* ---- Save to server (debounced) ---- */
const stateToSave = useMemo<PersistedWizardState>(
() => ({
current_step: currentStep,
completed_steps: completedSteps,
primary_file_key: primaryFileKey,
video_url: videoUrl,
original_file_name: originalFileName,
silence_settings: silenceSettings,
active_job_id: activeJobId,
active_job_type: activeJobType,
silence_job_id: silenceJobId,
transcription_artifact_id: transcriptionArtifactId,
caption_preset_id: captionPresetId,
caption_style_config: captionStyleConfig,
captioned_video_path: captionedVideoPath,
captioned_video_file_id: captionedVideoFileId,
}),
[
currentStep,
completedSteps,
primaryFileKey,
videoUrl,
originalFileName,
silenceSettings,
activeJobId,
activeJobType,
silenceJobId,
transcriptionArtifactId,
captionPresetId,
captionStyleConfig,
captionedVideoPath,
captionedVideoFileId,
],
)
const debouncedState = useDebounce(stateToSave, DEBOUNCE_MS)
const saveMutation = api.useMutation("patch", "/api/projects/{project_id}/")
const buildPersistedState = useCallback(
(overrides: Partial<PersistedWizardState> = {}): PersistedWizardState => ({
current_step: overrides.current_step ?? currentStep,
completed_steps: overrides.completed_steps ?? completedSteps,
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,
active_job_type: overrides.active_job_type ?? activeJobType,
silence_job_id: overrides.silence_job_id ?? silenceJobId,
transcription_artifact_id:
overrides.transcription_artifact_id ?? transcriptionArtifactId,
caption_preset_id: overrides.caption_preset_id ?? captionPresetId,
caption_style_config:
overrides.caption_style_config ?? captionStyleConfig,
captioned_video_path:
overrides.captioned_video_path ?? captionedVideoPath,
captioned_video_file_id:
overrides.captioned_video_file_id ?? captionedVideoFileId,
}),
[
activeJobId,
activeJobType,
captionedVideoFileId,
captionedVideoPath,
captionPresetId,
captionStyleConfig,
completedSteps,
currentStep,
originalFileName,
primaryFileKey,
silenceJobId,
silenceSettings,
transcriptionArtifactId,
videoUrl,
],
)
const persistStateNow = useCallback(
(nextState: PersistedWizardState) => {
if (!isInitializedRef.current) return
const serialized = JSON.stringify(nextState)
initialSerializedRef.current = serialized
saveMutation.mutate({
params: { path: { project_id: projectId } },
body: {
workspace_state: { wizard: nextState },
},
})
},
[projectId, saveMutation],
)
useEffect(() => {
if (!isInitializedRef.current) return
const serialized = JSON.stringify(debouncedState)
if (serialized === initialSerializedRef.current) return
initialSerializedRef.current = serialized
saveMutation.mutate({
params: { path: { project_id: projectId } },
body: {
workspace_state: { wizard: debouncedState },
},
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedState, projectId])
/* ---- Auto-advance from Processing when job completes ---- */
const notifications = useAppSelector((state) => state.notifications.items)
const isJobActive =
!!activeJobId &&
(currentStep === "processing" ||
currentStep === "silence-apply-processing" ||
currentStep === "transcription-processing" ||
currentStep === "caption-processing")
const { data: taskStatus } = api.useQuery(
"get",
"/api/tasks/status/{job_id}/",
{ params: { path: { job_id: activeJobId ?? "" } } },
{
enabled: isJobActive,
refetchInterval: 2000,
},
)
useEffect(() => {
if (!activeJobId) return
if (taskStatus?.status === "CANCELLED") {
const cancelledStep = getCancelledStepForJobType(activeJobType)
setActiveJobId(null)
setActiveJobType(null)
if (cancelledStep) {
setCurrentStep(cancelledStep)
}
return
}
if (
currentStep !== "processing" &&
currentStep !== "silence-apply-processing" &&
currentStep !== "transcription-processing" &&
currentStep !== "transcription-settings" &&
currentStep !== "caption-processing"
)
return
const isDone =
taskStatus?.status === "DONE" ||
notifications.some((n) => n.job_id === activeJobId && n.status === "DONE")
if (!isDone) return
if (activeJobType === "SILENCE_DETECT") {
setSilenceJobIdState(activeJobId)
setActiveJobId(null)
setActiveJobType(null)
setCompletedSteps((prev) =>
prev.includes("processing") ? prev : [...prev, "processing"],
)
setCurrentStep("fragments")
} else if (activeJobType === "SILENCE_APPLY") {
const outputData = taskStatus?.output_data as
| { file_path?: string; file_url?: string }
| null
| undefined
if (taskStatus?.status !== "DONE" || !outputData?.file_path || !outputData?.file_url) {
return
}
setPrimaryFileKey(outputData.file_path)
setVideoUrl(outputData.file_url)
setOriginalFileName(outputData.file_path.split("/").pop() ?? null)
setSilenceJobIdState(null)
setActiveJobId(null)
setActiveJobType(null)
setCompletedSteps((prev) =>
prev.includes("silence-apply-processing")
? prev
: [...prev, "silence-apply-processing"],
)
setCurrentStep("transcription-settings")
} else if (activeJobType === "TRANSCRIPTION_GENERATE") {
setActiveJobId(null)
setActiveJobType(null)
setCompletedSteps((prev) =>
prev.includes("transcription-processing")
? prev
: [...prev, "transcription-processing"],
)
setCurrentStep("subtitle-revision")
} else if (activeJobType === "CAPTIONS_GENERATE") {
// Wait for taskStatus to have output_data (not just notification DONE)
// to avoid race where notification fires before polling has fresh data
const outputData = taskStatus?.output_data as
| { output_path?: string; file_id?: string }
| null
| undefined
if (taskStatus?.status !== "DONE" || !outputData?.file_id) {
return
}
setCaptionedVideoPathState(outputData.output_path ?? null)
setCaptionedVideoFileIdState(outputData.file_id)
setActiveJobId(null)
setActiveJobType(null)
setCompletedSteps((prev) =>
prev.includes("caption-processing")
? prev
: [...prev, "caption-processing"],
)
setCurrentStep("caption-result")
}
}, [notifications, activeJobId, activeJobType, currentStep, taskStatus])
/* ---- Step index ---- */
const stepIndex = WIZARD_STEPS.findIndex((s) => s.key === currentStep)
/* ---- Actions ---- */
const goToStep = useCallback((step: WizardStepKey) => {
setCurrentStep(step)
}, [])
const goNext = useCallback(() => {
const idx = WIZARD_STEPS.findIndex((s) => s.key === currentStep)
if (idx < WIZARD_STEPS.length - 1) {
setCurrentStep(WIZARD_STEPS[idx + 1].key)
}
}, [currentStep])
const goBack = useCallback(() => {
const idx = WIZARD_STEPS.findIndex((s) => s.key === currentStep)
if (idx > 0) {
setCurrentStep(WIZARD_STEPS[idx - 1].key)
}
}, [currentStep])
const setFileKey = useCallback(
(key: string, url: string, fileName?: string | null) => {
setPrimaryFileKey(key)
setVideoUrl(url)
if (fileName !== undefined) {
setOriginalFileName(fileName)
}
},
[],
)
const setSilenceSettings = useCallback((settings: SilenceSettings) => {
setSilenceSettingsState(settings)
}, [])
const setActiveJob = useCallback(
(jobId: string | null, jobType?: string | null) => {
setActiveJobId(jobId)
setActiveJobType(jobType ?? null)
},
[],
)
const startProcessingJob = useCallback(
(
jobId: string,
jobType: string,
processingStep: WizardStepKey,
completedStep?: WizardStepKey,
) => {
const nextCompletedSteps =
completedStep && !completedSteps.includes(completedStep)
? [...completedSteps, completedStep]
: completedSteps
setActiveJobId(jobId)
setActiveJobType(jobType)
setCurrentStep(processingStep)
setCompletedSteps(nextCompletedSteps)
persistStateNow(
buildPersistedState({
active_job_id: jobId,
active_job_type: jobType,
current_step: processingStep,
completed_steps: nextCompletedSteps,
}),
)
},
[buildPersistedState, completedSteps, persistStateNow],
)
const setSilenceJobId = useCallback((jobId: string | null) => {
setSilenceJobIdState(jobId)
}, [])
const setTranscriptionArtifactId = useCallback((id: string | null) => {
setTranscriptionArtifactIdState(id)
}, [])
const setCaptionPresetId = useCallback((id: string | null) => {
setCaptionPresetIdState(id)
}, [])
const setCaptionStyleConfig = useCallback(
(config: Record<string, unknown> | null) => {
setCaptionStyleConfigState(config)
},
[],
)
const setCaptionedVideoPath = useCallback((path: string | null) => {
setCaptionedVideoPathState(path)
}, [])
const setCaptionedVideoFileId = useCallback((id: string | null) => {
setCaptionedVideoFileIdState(id)
}, [])
const markStepCompleted = useCallback((step: WizardStepKey) => {
setCompletedSteps((prev) => (prev.includes(step) ? prev : [...prev, step]))
}, [])
/* ---- Value ---- */
const value = useMemo<WizardContextValue>(
() => ({
projectId,
currentStep,
stepIndex,
completedSteps,
primaryFileKey,
videoUrl,
originalFileName,
silenceSettings,
activeJobId,
activeJobType,
silenceJobId,
transcriptionArtifactId,
captionPresetId,
captionStyleConfig,
captionedVideoPath,
captionedVideoFileId,
goToStep,
goNext,
goBack,
setFileKey,
setSilenceSettings,
setActiveJob,
startProcessingJob,
setSilenceJobId,
setTranscriptionArtifactId,
setCaptionPresetId,
setCaptionStyleConfig,
setCaptionedVideoPath,
setCaptionedVideoFileId,
markStepCompleted,
}),
[
projectId,
currentStep,
stepIndex,
completedSteps,
primaryFileKey,
videoUrl,
originalFileName,
silenceSettings,
activeJobId,
activeJobType,
silenceJobId,
transcriptionArtifactId,
captionPresetId,
captionStyleConfig,
captionedVideoPath,
captionedVideoFileId,
goToStep,
goNext,
goBack,
setFileKey,
setSilenceSettings,
setActiveJob,
startProcessingJob,
setSilenceJobId,
setTranscriptionArtifactId,
setCaptionPresetId,
setCaptionStyleConfig,
setCaptionedVideoPath,
setCaptionedVideoFileId,
markStepCompleted,
],
)
return (
<WizardContext.Provider value={value}>{children}</WizardContext.Provider>
)
}
/* ------------------------------------------------------------------ */
/* Hook */
/* ------------------------------------------------------------------ */
export function useWizard(): WizardContextValue {
const ctx = useContext(WizardContext)
if (!ctx) {
throw new Error("useWizard must be used within WizardProvider")
}
return ctx
}
+56
View File
@@ -160,6 +160,62 @@ export const WorkspaceProvider: FunctionComponent<{
return <FileContext.Provider value={value}>{children}</FileContext.Provider>
}
/* ------------------------------------------------------------------ */
/* Static provider (in-memory only, no server persistence) */
/* ------------------------------------------------------------------ */
export const StaticWorkspaceProvider: FunctionComponent<{
children: ReactNode
}> = ({ children }) => {
const [selectedFile, setSelectedFileState] = useState<SelectedFile | null>(
null,
)
const [usedFiles, setUsedFiles] = useState<UsedFile[]>([])
const setSelectedFile = useCallback(
(file: SelectedFile | null) => setSelectedFileState(file),
[],
)
const addUsedFile = useCallback((file: UsedFile) => {
setUsedFiles((prev) => {
if (prev.some((f) => f.id === file.id)) return prev
return [...prev, file]
})
}, [])
const removeUsedFile = useCallback((id: string) => {
setUsedFiles((prev) => prev.filter((f) => f.id !== id))
}, [])
const isFileUsed = useCallback(
(id: string) => usedFiles.some((f) => f.id === id),
[usedFiles],
)
const value = useMemo<WorkspaceFileContextValue>(
() => ({
selectedFile,
setSelectedFile,
usedFiles,
addUsedFile,
removeUsedFile,
isFileUsed,
isLoaded: true,
}),
[
selectedFile,
setSelectedFile,
usedFiles,
addUsedFile,
removeUsedFile,
isFileUsed,
],
)
return <FileContext.Provider value={value}>{children}</FileContext.Provider>
}
/* ------------------------------------------------------------------ */
/* Hook */
/* ------------------------------------------------------------------ */
+41
View File
@@ -0,0 +1,41 @@
"use client"
import { useEffect, useRef, useState } from "react"
function easeOut(t: number): number {
return 1 - Math.pow(1 - t, 3)
}
export function useCountUp(target: number, duration = 600): number {
const [value, setValue] = useState(0)
const prevTargetRef = useRef(target)
useEffect(() => {
const from = prevTargetRef.current
prevTargetRef.current = target
if (target === from) {
setValue(target)
return
}
let start: number | null = null
let rafId: number
const step = (timestamp: number) => {
if (start === null) start = timestamp
const elapsed = timestamp - start
const progress = Math.min(elapsed / duration, 1)
setValue(Math.round(from + easeOut(progress) * (target - from)))
if (progress < 1) {
rafId = requestAnimationFrame(step)
}
}
rafId = requestAnimationFrame(step)
return () => cancelAnimationFrame(rafId)
}, [target, duration])
return value
}
+29
View File
@@ -0,0 +1,29 @@
import type { Variants, Transition } from "framer-motion"
export const FADE_IN: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
export const SLIDE_UP: Variants = {
initial: { opacity: 0, y: 16 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 16 },
}
export const SCALE_FADE: Variants = {
initial: { opacity: 0, scale: 0.96 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.96 },
}
export const STAGGER_CONTAINER: Variants = {
initial: {},
animate: { transition: { staggerChildren: 0.08 } },
}
export const EASE_OUT_TRANSITION: Transition = {
duration: 0.25,
ease: [0.16, 1, 0.3, 1],
}
+4
View File
@@ -12,5 +12,9 @@ export const store = configureStore({
},
})
if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
;(window as any).__REDUX_STORE__ = store
}
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
+14 -14
View File
@@ -5,35 +5,35 @@
@mixin font-body-16($weight) {
font-weight: $weight;
font-size: 16px;
line-height: 22px;
letter-spacing: 0px;
line-height: 24px;
letter-spacing: -0.015em;
}
@mixin font-body-14($weight) {
font-weight: $weight;
font-size: 14px;
line-height: 20px;
letter-spacing: 0px;
letter-spacing: -0.006em;
}
@mixin font-display {
@include font-numeric;
font-weight: 600;
font-weight: 800;
font-size: 32px;
line-height: 42px;
letter-spacing: -0.65px;
line-height: 40px;
letter-spacing: -0.035em;
}
@mixin font-header-l {
@include font-numeric;
font-weight: 500;
font-weight: 700;
font-size: 20px;
line-height: 26px;
letter-spacing: -0.41px;
line-height: 28px;
letter-spacing: -0.025em;
}
@mixin font-body-m {
@include font-body-16(500);
@include font-body-16(600);
}
@mixin font-body-mr {
@@ -44,12 +44,12 @@
font-weight: 400;
font-size: 14px;
line-height: 20px;
letter-spacing: 0px;
letter-spacing: -0.006em;
}
@mixin font-caption-m {
font-weight: 400;
font-weight: 500;
font-size: 12px;
line-height: 18px;
letter-spacing: 0px;
line-height: 16px;
letter-spacing: 0em;
}
+6
View File
@@ -49,3 +49,9 @@ $shadow-lg: var(--shadow-lg);
$radius-sm: var(--radius-sm);
$radius-md: var(--radius-md);
$radius-lg: var(--radius-lg);
$duration-fast: var(--duration-fast);
$duration-normal: var(--duration-normal);
$duration-slow: var(--duration-slow);
$ease-out: var(--ease-out);
$ease-in-out: var(--ease-in-out);
+112 -61
View File
@@ -6,7 +6,7 @@
margin: 0;
padding: 0;
border: 0;
font-family: var(--font-open-sans);
font-family: var(--font-manrope);
font-variant-numeric: lining-nums proportional-nums;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
@@ -15,94 +15,145 @@
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);
}
::selection {
background-color: hsl(262, 68%, 52%, 0.15);
color: var(--text-primary);
}
a {
transition: color var(--duration-normal) var(--ease-out);
}
button {
font-family: inherit;
}
:root {
/* Dark Fuchsia Palette (HSL) */
--purple-50: hsl(300, 52%, 93%);
--purple-100: hsl(298, 46%, 82%);
--purple-200: hsl(298, 51%, 69%);
--purple-300: hsl(297, 53%, 56%);
--purple-400: hsl(297, 70%, 44%); /* Primary */
--purple-500: hsl(296, 100%, 35%);
--purple-600: hsl(293, 100%, 34%);
--purple-700: hsl(288, 100%, 33%);
--purple-800: hsl(283, 100%, 32%);
--purple-900: hsl(272, 100%, 30%);
/* 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%);
/* Deep Lime Green Palette (HSL) */
--green-50: hsl(84, 50%, 94%);
--green-100: hsl(85, 48%, 83%);
--green-200: hsl(86, 47%, 73%);
--green-300: hsl(85, 47%, 61%);
--green-400: hsl(85, 51%, 53%);
--green-500: hsl(84, 67%, 43%);
--green-600: hsl(87, 71%, 39%);
--green-700: hsl(88, 79%, 33%);
--green-800: hsl(90, 93%, 26%); /* Secondary */
--green-900: hsl(104, 100%, 19%);
--color-success: #22c55e;
--color-danger: #ef4444;
--color-warning: #facc15;
/* 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%);
--color-primary: var(--green-800);
--color-success: #16a34a;
--color-danger: #dc2626;
--color-warning: #d97706;
--color-primary: var(--purple-500);
--color-secondary: var(--purple-400);
--color-white: #ffffff;
--color-black: #000000;
--text-primary: #0f1729;
--text-secondary: #64748b;
--text-tertiary: #94a3b8;
--text-primary: #18181b;
--text-secondary: #71717a;
--text-tertiary: #a1a1aa;
--bg-canvas: #f8fafc;
--bg-canvas: #fafafa;
--bg-default: #ffffff;
--bg-surface: #f1f5f9;
--bg-hover: #e8edf3;
--bg-default-invert: rgba(34, 35, 37, 1);
--bg-surface: #f4f4f5;
--bg-hover: #e4e4e7;
--bg-default-invert: #18181b;
--border-default: #e2e8f0;
--border-subtle: #e8edf3;
--border-default: #e8e8ec;
--border-subtle: #f4f4f5;
--waveform-wave: var(--purple-400);
--waveform-progress: var(--purple-600);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.08);
--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);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--header-height: 56px;
/* Motion tokens */
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 350ms;
--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);
}
[data-theme="dark"] {
--color-primary: var(--green-400);
--color-primary: var(--purple-400);
--color-secondary: var(--purple-300);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--text-primary: #fdfdfd;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--bg-canvas: #0f1219;
--bg-default: #1a1f2e;
--bg-surface: #242938;
--bg-hover: #1e2330;
--bg-default-invert: rgba(241, 245, 249, 1);
--bg-canvas: #050505;
--bg-default: #0a0a0a;
--bg-surface: #141414;
--bg-hover: #1f1f23;
--bg-default-invert: #fafafa;
--border-default: #2e3447;
--border-subtle: #2a3040;
--border-default: #27272a;
--border-subtle: #18181b;
--waveform-wave: #e2e8f0;
--waveform-progress: #94a3b8;
--waveform-wave: #e4e4e7;
--waveform-progress: #a1a1aa;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
--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);
/* 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%);
}
[data-theme="dark"] body {
background-image: radial-gradient(circle at 50% 0%, rgba(139, 92, 246, 0.08) 0%, transparent 50%);
}
@media (prefers-reduced-motion: reduce) {
:root {
--duration-fast: 0ms;
--duration-normal: 0ms;
--duration-slow: 0ms;
}
}
.radix-themes {
--default-font-family: var(--font-open-sans);
}
--default-font-family: var(--font-manrope);
}
+13 -13
View File
@@ -6,25 +6,25 @@
}
.info {
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #7dd3fc;
background-color: var(--alert-info-bg, hsl(204, 94%, 94%));
color: var(--alert-info-text, hsl(204, 95%, 32%));
border: 1px solid var(--alert-info-border, hsl(199, 95%, 74%));
}
.success {
background-color: #dcfce7;
color: #166534;
border: 1px solid #86efac;
background-color: var(--alert-success-bg, hsl(138, 76%, 93%));
color: var(--alert-success-text, hsl(144, 61%, 20%));
border: 1px solid var(--alert-success-border, hsl(142, 69%, 73%));
}
.warning {
background-color: #fef9c3;
color: #854d0e;
border: 1px solid #fde047;
background-color: var(--alert-warning-bg, hsl(53, 98%, 88%));
color: var(--alert-warning-text, hsl(37, 86%, 29%));
border: 1px solid var(--alert-warning-border, hsl(52, 98%, 63%));
}
.danger {
background-color: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
background-color: var(--alert-danger-bg, hsl(0, 94%, 94%));
color: var(--alert-danger-text, hsl(0, 63%, 35%));
border: 1px solid var(--alert-danger-border, hsl(0, 93%, 82%));
}
+1
View File
@@ -16,5 +16,6 @@ export interface IButtonProps extends Omit<
> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
children?: ReactNode
}
+58
View File
@@ -0,0 +1,58 @@
.button {
font-weight: 600;
letter-spacing: -0.01em;
transition: transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out),
filter var(--duration-normal) var(--ease-out),
opacity var(--duration-normal) var(--ease-out),
background var(--duration-normal) var(--ease-out);
&.variant-primary, &.variant-danger {
background-image: linear-gradient(180deg, hsla(0,0%,100%,0.12) 0%, transparent 100%);
box-shadow: inset 0 1px 1px hsla(0,0%,100%,0.2), 0 1px 2px rgba(0,0,0,0.1);
border-top: 1px solid hsla(0,0%,100%,0.1);
&: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);
}
&:active:not(:disabled) {
transform: scale(0.96);
filter: brightness(0.95);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.2), 0 1px 2px rgba(0,0,0,0.1);
}
}
&:not(.variant-primary):not(.variant-danger) {
&:hover:not(:disabled) {
filter: brightness(0.95);
}
&:active:not(:disabled) {
transform: scale(0.96);
filter: brightness(0.92);
}
}
&:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
}
.loading {
position: relative;
pointer-events: none;
opacity: 0.85;
}
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+14 -4
View File
@@ -9,6 +9,10 @@ import {
Button as RadixButton,
IconButton as RadixIconButton,
} from "@radix-ui/themes"
import cs from "classnames"
import { LoaderCircle } from "lucide-react"
import styles from "./Button.module.scss"
const sizeMap = {
sm: "1",
@@ -17,8 +21,8 @@ const sizeMap = {
} as const
const variantMap = {
primary: { variant: "solid", color: "iris" },
secondary: { variant: "soft", color: "iris" },
primary: { variant: "solid", color: "violet" },
secondary: { variant: "soft", color: "violet" },
outline: { variant: "outline", color: "gray" },
ghost: { variant: "ghost", color: "gray" },
danger: { variant: "solid", color: "ruby" },
@@ -27,11 +31,12 @@ const variantMap = {
export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
(
{ variant = "primary", size = "md", children, ...props },
{ variant = "primary", size = "md", loading, children, className, ...props },
ref,
): JSX.Element => {
const visual = variantMap[variant]
const radixSize = sizeMap[size]
const buttonClass = cs(styles.button, styles[`variant-${variant}`], loading && styles.loading, className)
if (variant === "icon") {
return (
@@ -40,10 +45,12 @@ export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
size={radixSize}
variant={visual.variant}
color={visual.color}
className={buttonClass}
disabled={loading || props.disabled}
style={{ cursor: "pointer", ...props.style }}
{...props}
>
{children}
{loading ? <LoaderCircle className={styles.spinner} size={16} /> : children}
</RadixIconButton>
)
}
@@ -54,9 +61,12 @@ export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
size={radixSize}
variant={visual.variant}
color={visual.color}
className={buttonClass}
disabled={loading || props.disabled}
style={{ cursor: "pointer", ...props.style }}
{...props}
>
{loading && <LoaderCircle className={styles.spinner} size={16} />}
{children}
</RadixButton>
)
+8 -2
View File
@@ -1,6 +1,12 @@
.card {
background-color: var(--bg-default);
border-radius: 0.75rem;
background-color: hsla(0, 0%, 100%, 0.8);
backdrop-filter: blur(16px) saturate(180%);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.5rem;
border: 1px solid var(--border-subtle);
[data-theme="dark"] & {
background-color: hsla(240, 2%, 10%, 0.5);
}
}
+5 -2
View File
@@ -5,10 +5,13 @@ import type { JSX } from "react"
import { Card as RadixCard } from "@radix-ui/themes"
import { forwardRef } from "react"
import cs from "classnames"
import styles from "./Card.module.scss"
export const Card = forwardRef<HTMLDivElement, ICardProps>(
({ children, ...props }, ref): JSX.Element => (
<RadixCard ref={ref} {...props}>
({ children, className, ...props }, ref): JSX.Element => (
<RadixCard ref={ref} className={cs(styles.card, className)} {...props}>
{children}
</RadixCard>
),
+28 -8
View File
@@ -10,8 +10,8 @@
cursor: pointer;
&:focus-visible {
outline: 2px solid variables.$color-secondary;
outline-offset: 2px;
outline: none;
box-shadow: var(--focus-ring);
border-radius: variables.$radius-sm;
}
}
@@ -25,7 +25,12 @@
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
box-shadow: var(--shadow-lg);
animation: fadeIn 0.15s ease-out;
&[data-state="open"] {
animation: fadeIn var(--duration-normal) var(--ease-out);
}
&[data-state="closed"] {
animation: fadeOut var(--duration-fast) ease-in;
}
}
.item,
@@ -36,12 +41,13 @@
gap: 8px;
align-items: center;
padding: 8px 10px;
font-size: 14px;
font-size: 13px;
font-weight: 500;
color: variables.$text-primary;
cursor: pointer;
border-radius: variables.$radius-sm;
outline: none;
transition: background-color 0.12s ease;
transition: background-color var(--duration-fast) var(--ease-out);
&[data-highlighted] {
background-color: variables.$bg-surface;
@@ -72,9 +78,11 @@
.label {
padding: 8px 10px 4px;
font-size: 12px;
font-weight: 500;
color: variables.$text-secondary;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: variables.$text-tertiary;
}
.separator {
@@ -94,3 +102,15 @@
transform: scale(1) translateY(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.96) translateY(-4px);
}
}
+37 -104
View File
@@ -1,116 +1,49 @@
.root {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 20px;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 20px;
width: 100%;
height: 100%;
pointer-events: none;
&.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 9999;
background-color: variables.$bg-default;
}
&.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 9999;
background-color: variables.$bg-default;
}
&.block {
width: 100%;
height: 100%;
}
&.block {
width: 100%;
height: 100%;
}
@include breakpoints.respond-to(breakpoints.$mobileMax) {
width: 100%;
height: 100%;
}
@include breakpoints.respond-to(breakpoints.$mobileMax) {
width: 100%;
height: 100%;
}
}
.container {
position: relative;
width: 72px;
height: 72px;
.dots {
display: flex;
align-items: center;
gap: 6px;
}
.loader {
position: absolute;
top: 0;
left: 0;
border: 2px solid color-mix(in srgb, variables.$color-primary 50%, transparent);
width: 64px;
height: 64px;
border-radius: 50%;
border-left-color: transparent;
border-top-color: transparent;
animation: spin 1s linear infinite;
&.blue {
border: 4px solid color-mix(in srgb, variables.$color-secondary 50%, transparent);
border-left-color: transparent;
border-top-color: transparent;
animation: spin 2s linear infinite;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: variables.$color-primary;
}
.description {
@include typography.font-display;
color: variables.$text-primary;
text-align: center;
&.in {
animation: fadeIn 150ms ease-in-out;
}
&.out {
animation: fadeOut 150ms ease-in-out;
}
@include typography.font-display;
color: variables.$text-primary;
text-align: center;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(5px);
}
}
.minLoader {
border: 4px solid color-mix(in srgb, variables.$color-primary 50%, transparent);
width: 40px;
height: 40px;
border-radius: 50%;
border-left-color: transparent;
border-top-color: transparent;
animation: spin 0.5s linear infinite;
}
+25 -5
View File
@@ -1,18 +1,41 @@
"use client"
import type { JSX } from "react"
import { FunctionComponent, memo } from "react"
import { motion } from "framer-motion"
import cs from "classnames"
import { ILoaderProps } from "./Loader.d"
import styles from "./Loader.module.scss"
const DOT_COUNT = 3
const LoaderDots = () => (
<div className={styles.dots}>
{Array.from({ length: DOT_COUNT }).map((_, i) => (
<motion.div
key={i}
className={styles.dot}
animate={{ y: [0, -8, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
/>
))}
</div>
)
export const StaticLoader: FunctionComponent<ILoaderProps> = memo(
({ fullscreen = false, block = false, description }): JSX.Element => {
if (!fullscreen && !block) {
return (
<div className={styles.root}>
<div className={styles.minLoader} />
<LoaderDots />
</div>
)
}
@@ -23,10 +46,7 @@ export const StaticLoader: FunctionComponent<ILoaderProps> = memo(
[styles.block]: block,
})}
>
<div className={styles.container}>
<div className={styles.loader} />
<div className={cs(styles.loader, styles.blue)} />
</div>
<LoaderDots />
<h4 className={styles.description}>{description || "Загрузка"}</h4>
</div>
)
+5 -3
View File
@@ -1,8 +1,10 @@
import type { Dialog } from "@radix-ui/themes"
import type { ComponentProps, ReactNode } from "react"
import type { ReactNode } from "react"
export interface IModalProps extends ComponentProps<typeof Dialog.Root> {
export interface IModalProps {
open: boolean
onOpenChange?: (open: boolean) => void
title?: ReactNode
description?: ReactNode
children?: ReactNode
maxWidth?: string
}
+51 -40
View File
@@ -1,42 +1,69 @@
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
background-color: rgba(0, 0, 0, 0);
z-index: 100;
animation: overlayShow 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(0px);
transition: background-color 0.25s var(--ease-out),
backdrop-filter 0.25s var(--ease-out);
}
.overlayAfterOpen {
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
}
.overlayBeforeClose {
background-color: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
}
.content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
position: relative;
max-width: 32rem;
width: calc(100% - 2rem);
max-height: 85vh;
padding: 1.5rem;
overflow: hidden;
overflow-y: auto;
padding: 1.75rem;
background-color: var(--bg-default);
border-radius: 0.75rem;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: 101;
animation: contentShow 0.15s ease;
outline: none;
opacity: 0;
transform: scale(0.96) translateY(8px);
transition:
opacity 0.25s var(--ease-out),
transform 0.25s var(--ease-out);
}
&:focus {
outline: none;
}
.contentAfterOpen {
opacity: 1;
transform: scale(1) translateY(0);
}
.contentBeforeClose {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
.title {
margin-bottom: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
font-weight: 700;
letter-spacing: -0.017em;
color: var(--text-primary);
}
.description {
margin-bottom: 1rem;
margin-bottom: 1.25rem;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
}
.close {
@@ -46,38 +73,22 @@
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
padding: 0.375rem;
background: transparent;
border-radius: 0.25rem;
color: var(--text-secondary);
border: none;
border-radius: var(--radius-sm);
color: var(--text-tertiary);
cursor: pointer;
transition: background-color var(--duration-normal) var(--ease-out),
color var(--duration-normal) var(--ease-out);
&:hover {
background-color: var(--bg-surface);
color: var(--text-primary);
}
&:focus-visible {
outline: 2px solid var(--purple-400);
outline-offset: 2px;
outline: none;
box-shadow: var(--focus-ring);
}
}
@keyframes overlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes contentShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
+55 -19
View File
@@ -3,26 +3,62 @@
import type { IModalProps } from "./Modal.d"
import type { JSX } from "react"
import { Dialog } from "@radix-ui/themes"
import { forwardRef } from "react"
import ReactModal from "react-modal"
import { Theme } from "@radix-ui/themes"
import { X } from "lucide-react"
export const Modal = forwardRef<HTMLDivElement, IModalProps>(
({ title, description, children, ...props }, ref): JSX.Element => (
<Dialog.Root {...props}>
<Dialog.Content ref={ref}>
{title && <Dialog.Title>{title}</Dialog.Title>}
{description && (
<Dialog.Description size="2" mb="4">
{description}
</Dialog.Description>
)}
{children}
</Dialog.Content>
</Dialog.Root>
),
import styles from "./Modal.module.scss"
if (typeof window !== "undefined") {
ReactModal.setAppElement("#app-root")
}
const OVERLAY_CLASS = {
base: styles.overlay,
afterOpen: styles.overlayAfterOpen,
beforeClose: styles.overlayBeforeClose,
}
const CONTENT_CLASS = {
base: styles.content,
afterOpen: styles.contentAfterOpen,
beforeClose: styles.contentBeforeClose,
}
export const Modal = ({
open,
onOpenChange,
title,
description,
children,
maxWidth,
}: IModalProps): JSX.Element => (
<ReactModal
isOpen={open}
onRequestClose={() => onOpenChange?.(false)}
shouldCloseOnOverlayClick
shouldCloseOnEsc
closeTimeoutMS={250}
overlayClassName={OVERLAY_CLASS}
className={CONTENT_CLASS}
style={maxWidth ? { content: { maxWidth } } : undefined}
>
<Theme accentColor="iris" grayColor="slate" appearance="inherit">
{title && <h2 className={styles.title}>{title}</h2>}
{description && (
<p className={styles.description}>{description}</p>
)}
<button
type="button"
className={styles.close}
onClick={() => onOpenChange?.(false)}
aria-label="Закрыть"
>
<X size={16} />
</button>
{children}
</Theme>
</ReactModal>
)
Modal.displayName = "Modal"
export const ModalTrigger = Dialog.Trigger
ModalTrigger.displayName = "ModalTrigger"
+1 -1
View File
@@ -1 +1 @@
export * from "./Modal"
export { Modal } from "./Modal"
+8
View File
@@ -0,0 +1,8 @@
import type { CSSProperties } from "react"
export interface ISkeletonProps {
width?: CSSProperties["width"]
height?: CSSProperties["height"]
borderRadius?: CSSProperties["borderRadius"]
className?: string
}
@@ -0,0 +1,39 @@
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.statCard {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px 24px;
background: variables.$bg-default;
border-radius: variables.$radius-md;
box-shadow: var(--shadow-sm);
}
.projectCard {
overflow: hidden;
border-radius: variables.$radius-md;
background: variables.$bg-default;
box-shadow: var(--shadow-sm);
}
.heroSkeleton {
width: 100%;
}
.projectCardContent {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
}
.recentGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
+54
View File
@@ -0,0 +1,54 @@
import type { ISkeletonProps } from "./Skeleton.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import { Skeleton as RadixSkeleton } from "@radix-ui/themes"
import styles from "./Skeleton.module.scss"
export const Skeleton: FunctionComponent<ISkeletonProps> = ({
width,
height,
borderRadius,
className,
}): JSX.Element => {
return (
<RadixSkeleton
loading
width={width as string}
height={height as string}
style={{ borderRadius }}
className={className}
/>
)
}
export const StatsGridSkeleton: FunctionComponent = (): JSX.Element => (
<div className={styles.statsGrid}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.statCard}>
<Skeleton width="48px" height="32px" borderRadius="6px" />
<Skeleton width="100px" height="14px" borderRadius="4px" />
</div>
))}
</div>
)
export const ProjectCardSkeleton: FunctionComponent = (): JSX.Element => (
<div className={styles.projectCard}>
<Skeleton height="180px" borderRadius="0" className={styles.heroSkeleton} />
<div className={styles.projectCardContent}>
<Skeleton width="70%" height="16px" borderRadius="4px" />
<Skeleton width="40%" height="12px" borderRadius="4px" />
</div>
</div>
)
export const RecentProjectsSkeleton: FunctionComponent = (): JSX.Element => (
<div className={styles.recentGrid}>
{Array.from({ length: 3 }).map((_, i) => (
<ProjectCardSkeleton key={i} />
))}
</div>
)
+1
View File
@@ -0,0 +1 @@
export { Skeleton, StatsGridSkeleton, ProjectCardSkeleton, RecentProjectsSkeleton } from "./Skeleton"
+11
View File
@@ -0,0 +1,11 @@
export interface ISliderProps {
value: number
min: number
max: number
step?: number
label: string
unit?: string
helpText?: string
onChange: (value: number) => void
className?: string
}
+79
View File
@@ -0,0 +1,79 @@
.root {
display: grid;
gap: 6px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.label {
@include typography.font-body-14(500);
color: variables.$text-primary;
}
.value {
@include typography.font-body-14(400);
color: variables.$text-secondary;
font-variant-numeric: tabular-nums;
}
.input {
width: 100%;
height: 6px;
appearance: none;
border-radius: 3px;
cursor: pointer;
background: linear-gradient(
to right,
variables.$color-success var(--fill-percent, 0%),
variables.$border-default var(--fill-percent, 0%)
);
&::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: variables.$color-success;
border: 2px solid #fff;
box-shadow: variables.$shadow-sm;
cursor: grab;
transition: transform 0.15s ease;
&:active {
cursor: grabbing;
transform: scale(1.15);
}
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: variables.$color-success;
border: 2px solid #fff;
box-shadow: variables.$shadow-sm;
cursor: grab;
&:active {
cursor: grabbing;
transform: scale(1.15);
}
}
&::-moz-range-track {
height: 6px;
border-radius: 3px;
background: transparent;
}
}
.helpText {
font-weight: 400;
font-size: 12px;
line-height: 18px;
color: variables.$text-tertiary;
}
+58
View File
@@ -0,0 +1,58 @@
import type { ISliderProps } from "./Slider.d"
import type { JSX } from "react"
import cs from "classnames"
import { FunctionComponent, useCallback, useMemo } from "react"
import styles from "./Slider.module.scss"
export const Slider: FunctionComponent<ISliderProps> = ({
value,
min,
max,
step = 1,
label,
unit,
helpText,
onChange,
className,
}): JSX.Element => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(Number(e.target.value))
},
[onChange],
)
const fillPercent = useMemo(
() => ((value - min) / (max - min)) * 100,
[value, min, max],
)
return (
<div className={cs(styles.root, className)} data-testid="Slider">
<div className={styles.header}>
<span className={styles.label}>{label}</span>
<span className={styles.value}>
{value}
{unit ? ` ${unit}` : ""}
</span>
</div>
<input
type="range"
className={styles.input}
min={min}
max={max}
step={step}
value={value}
onChange={handleChange}
style={
{
"--fill-percent": `${fillPercent}%`,
} as React.CSSProperties
}
/>
{helpText && <span className={styles.helpText}>{helpText}</span>}
</div>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./Slider"
+11
View File
@@ -0,0 +1,11 @@
export interface StepDefinition {
key: string
label: string
}
export interface IStepperProps {
steps: StepDefinition[]
currentStep: string
completedSteps: string[]
className?: string
}
+130
View File
@@ -0,0 +1,130 @@
.root {
position: relative;
background: variables.$bg-surface;
border-bottom: 1px solid variables.$border-subtle;
}
.fadeLeft,
.fadeRight {
position: absolute;
top: 0;
bottom: 0;
width: 48px;
pointer-events: none;
z-index: 1;
}
.fadeLeft {
left: 0;
background: linear-gradient(to right, variables.$bg-surface, transparent);
}
.fadeRight {
right: 0;
background: linear-gradient(to left, variables.$bg-surface, transparent);
}
.scrollContainer {
display: flex;
align-items: center;
gap: 6px;
padding: 16px 48px;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.stepContainer {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.step {
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;
}
.stepActive {
background: variables.$color-primary;
border-color: variables.$color-primary;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.25);
.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 {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
transition: all 0.3s ease;
}
.label {
font-size: 13px;
transition: all 0.3s ease;
white-space: nowrap;
}
.separator {
display: flex;
align-items: center;
justify-content: center;
color: variables.$text-tertiary;
opacity: 0.6;
margin: 0 4px;
padding: 0 4px;
}
+157
View File
@@ -0,0 +1,157 @@
import type { IStepperProps } 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 cs from "classnames"
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>
)
export const Stepper: FunctionComponent<IStepperProps> = ({
steps,
currentStep,
completedSteps,
className,
}): JSX.Element => {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isFirstScroll = useRef(true)
const { scrollXProgress } = useScroll({
container: scrollContainerRef,
axis: "x",
})
const fadeLeftOpacity = useTransform(scrollXProgress, [0, 0.03], [0, 1])
const fadeRightOpacity = useTransform(scrollXProgress, [0.97, 1], [1, 0])
// Scroll active step into view
useEffect(() => {
const container = scrollContainerRef.current
if (!container) return
const scrollToActive = () => {
const activeEl = container.querySelector<HTMLElement>(
"[data-step-active]",
)
if (!activeEl) return
const containerRect = container.getBoundingClientRect()
const activeRect = activeEl.getBoundingClientRect()
const targetScroll =
activeRect.left -
containerRect.left +
container.scrollLeft -
containerRect.width / 2 +
activeRect.width / 2
const clampedTarget = Math.max(
0,
Math.min(targetScroll, container.scrollWidth - container.clientWidth),
)
if (isFirstScroll.current) {
container.scrollLeft = clampedTarget
isFirstScroll.current = false
return
}
const controls = animate(container.scrollLeft, clampedTarget, {
type: "spring",
stiffness: 300,
damping: 30,
onUpdate: (v) => {
container.scrollLeft = v
},
})
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"
>
<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
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
)}
</span>
<span className={styles.label}>{step.label}</span>
</div>
{!isLast && <ChevronSeparator />}
</div>
)
})}
</div>
<motion.div
className={styles.fadeRight}
style={{ opacity: fadeRightOpacity }}
/>
</div>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./Stepper"
+12 -9
View File
@@ -6,7 +6,7 @@
.list {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid #e5e7eb;
border-bottom: 1px solid var(--border-default);
}
.trigger {
@@ -14,24 +14,27 @@
background: transparent;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
letter-spacing: -0.006em;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.15s ease;
transition: all var(--duration-normal) var(--ease-out);
&:hover {
color: var(--purple-400);
color: var(--purple-500);
}
&:focus-visible {
outline: 2px solid var(--purple-400);
outline-offset: -2px;
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
&[data-state="active"] {
color: var(--purple-400);
border-bottom-color: var(--purple-400);
color: var(--purple-500);
border-bottom-color: var(--purple-500);
font-weight: 600;
}
&[data-disabled] {
@@ -46,4 +49,4 @@
&:focus {
outline: none;
}
}
}
+10 -9
View File
@@ -5,20 +5,21 @@
}
.label {
font-size: 0.875rem;
font-weight: 500;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: -0.006em;
color: var(--text-primary);
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
padding: 0.5625rem 0.75rem;
background-color: var(--bg-default);
border: 1px solid var(--border-default);
border-radius: 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
color: var(--text-primary);
transition: all 0.15s ease;
transition: all var(--duration-normal) var(--ease-out);
&::placeholder {
color: var(--text-tertiary);
@@ -31,7 +32,7 @@
&:focus {
outline: none;
border-color: var(--purple-400);
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.1);
box-shadow: 0 0 0 4px hsla(262, 75%, 48%, 0.15);
}
&:disabled {
@@ -46,11 +47,11 @@
&:focus {
border-color: var(--color-danger);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.15);
}
}
.undertitle {
font-size: 0.75rem;
color: #6b7280;
}
color: var(--text-secondary);
}
+3
View File
@@ -10,5 +10,8 @@ export * from "./Modal"
export * from "./Pagination"
export * from "./Radio"
export * from "./Select"
export * from "./Skeleton"
export * from "./Slider"
export * from "./Stepper"
export * from "./Table"
export * from "./Tabs"