iter 2
This commit is contained in:
@@ -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
@@ -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,6 +1,6 @@
|
||||
"use server"
|
||||
|
||||
import { fetchClient } from "."
|
||||
import { fetchClient } from "./fetchClient"
|
||||
|
||||
export const pingServer = async (): Promise<boolean> => {
|
||||
try {
|
||||
|
||||
@@ -26,8 +26,8 @@ export const AppProviders = ({
|
||||
<ThemeSync />
|
||||
<BreadcrumbsProvider>
|
||||
<Theme
|
||||
accentColor="iris"
|
||||
grayColor="slate"
|
||||
accentColor="violet"
|
||||
grayColor="sand"
|
||||
radius="medium"
|
||||
scaling="100%"
|
||||
appearance="inherit"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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%));
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -16,5 +16,6 @@ export interface IButtonProps extends Omit<
|
||||
> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
loading?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,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>
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Vendored
+5
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
export * from "./Modal"
|
||||
export { Modal } from "./Modal"
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export { Skeleton, StatsGridSkeleton, ProjectCardSkeleton, RecentProjectsSkeleton } from "./Skeleton"
|
||||
Vendored
+11
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Slider"
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
export interface StepDefinition {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface IStepperProps {
|
||||
steps: StepDefinition[]
|
||||
currentStep: string
|
||||
completedSteps: string[]
|
||||
className?: string
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Stepper"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user