diff --git a/src/components/cookie-copy-dialog.tsx b/src/components/cookie-copy-dialog.tsx index 6005d6f..8879be6 100644 --- a/src/components/cookie-copy-dialog.tsx +++ b/src/components/cookie-copy-dialog.tsx @@ -3,14 +3,14 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - LuChevronDown, - LuChevronRight, - LuCookie, - LuSearch, -} from "react-icons/lu"; +import { LuChevronRight, LuCookie, LuSearch } from "react-icons/lu"; import { toast } from "sonner"; import { LoadingButton } from "@/components/loading-button"; +import { + AnimatedDisclosureChevron, + AnimatedDisclosureContent, + AnimatedDisclosureItem, +} from "@/components/ui/animated-disclosure"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, @@ -546,7 +546,7 @@ function DomainRow({ selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected; return ( -
+
- {isExpanded && ( -
- {domain.cookies.map((cookie) => { - const isSelected = - domainSelection?.cookies.has(cookie.name) ?? false; - return ( -
- { - onToggleCookie( - domain.domain, - cookie.name, - domain.cookie_count, - ); - }} - /> - {cookie.name} -
- ); - })} -
- )} -
+ + {domain.cookies.map((cookie) => { + const isSelected = domainSelection?.cookies.has(cookie.name) ?? false; + return ( +
+ { + onToggleCookie( + domain.domain, + cookie.name, + domain.cookie_count, + ); + }} + /> + {cookie.name} +
+ ); + })} +
+ ); } diff --git a/src/components/cookie-management-dialog.tsx b/src/components/cookie-management-dialog.tsx index 3664862..74d0de3 100644 --- a/src/components/cookie-management-dialog.tsx +++ b/src/components/cookie-management-dialog.tsx @@ -5,9 +5,14 @@ import { save } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu"; +import { LuChevronRight, LuUpload } from "react-icons/lu"; import { toast } from "sonner"; import { LoadingButton } from "@/components/loading-button"; +import { + AnimatedDisclosureChevron, + AnimatedDisclosureContent, + AnimatedDisclosureItem, +} from "@/components/ui/animated-disclosure"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, @@ -628,7 +633,7 @@ function ExportDomainRow({ selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected; return ( -
+
- {isExpanded && ( -
- {domain.cookies.map((cookie) => { - const isSelected = - domainSelection?.cookies.has(cookie.name) ?? false; - return ( -
- { - onToggleCookie( - domain.domain, - cookie.name, - domain.cookie_count, - ); - }} - /> - {cookie.name} -
- ); - })} -
- )} -
+ + {domain.cookies.map((cookie) => { + const isSelected = domainSelection?.cookies.has(cookie.name) ?? false; + return ( +
+ { + onToggleCookie( + domain.domain, + cookie.name, + domain.cookie_count, + ); + }} + /> + {cookie.name} +
+ ); + })} +
+ ); } diff --git a/src/components/custom-toast.tsx b/src/components/custom-toast.tsx index 7f9337c..32bb732 100644 --- a/src/components/custom-toast.tsx +++ b/src/components/custom-toast.tsx @@ -49,6 +49,7 @@ */ /** biome-ignore-all lint/suspicious/noExplicitAny: TODO */ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useTranslation } from "react-i18next"; import { LuCheckCheck, @@ -58,6 +59,7 @@ import { LuX, } from "react-icons/lu"; import type { ExternalToast } from "sonner"; +import { MOTION_EASE_OUT } from "@/lib/motion"; import { RippleButton } from "./ui/ripple"; interface BaseToastProps { @@ -169,7 +171,7 @@ function ProgressBar({ return (
@@ -213,169 +215,220 @@ function getToastIcon(type: ToastProps["type"], stage?: string) { export function UnifiedToast(props: ToastProps) { const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); const { title, description, type, action, onCancel } = props; const stage = "stage" in props ? props.stage : undefined; const progress = "progress" in props ? props.progress : undefined; + const stateKey = `${type}:${stage ?? "default"}`; return (
-
{getToastIcon(type, stage)}
-
-
-

{title}

- {onCancel && ( - - )} -
+
+ + + {getToastIcon(type, stage)} + + +
+ + +
+

+ {title} +

+ {onCancel && ( + + )} +
- {/* Download progress */} - {type === "download" && - progress && - "percentage" in progress && - stage === "downloading" && ( -
-
-

- {progress.percentage.toFixed(1)}% - {progress.speed && ` • ${progress.speed} MB/s`} - {progress.eta && - ` • ${t("toasts.progress.remaining", { time: progress.eta })}`} -

+ {/* Download progress */} + {type === "download" && + progress && + "percentage" in progress && + stage === "downloading" && ( +
+
+

+ {progress.percentage.toFixed(1)}% + {progress.speed && ` • ${progress.speed} MB/s`} + {progress.eta && + ` • ${t("toasts.progress.remaining", { time: progress.eta })}`} +

+
+
- -
- )} + )} - {/* Extraction / verification progress. Extraction reports a real + {/* Extraction / verification progress. Extraction reports a real percentage for most archive formats; when none is available yet (or the format can't measure progress) show an indeterminate bar. */} - {type === "download" && - (stage === "extracting" || stage === "verifying") && ( -
- {stage === "extracting" && - progress && - "percentage" in progress && - progress.percentage > 0 ? ( - <> -

- {progress.percentage.toFixed(1)}% -

- - - ) : ( -
-
-
- )} -
- )} - - {/* Version update progress */} - {type === "version-update" && - progress && - "current_browser" in progress && ( -
-

- {progress.current_browser && - t("versionUpdater.toast.lookingForUpdates", { - browser: progress.current_browser, - })} -

-
- - - {progress.current}/{progress.total} - -
-
- )} - - {/* Sync progress */} - {type === "sync-progress" && - progress && - "completed_files" in progress && ( -
-

- {progress.phase === "uploading" - ? t("appUpdate.toast.uploading") - : t("appUpdate.toast.downloading")}{" "} - {t("toasts.progress.filesProgress", { - completed: progress.completed_files, - total: progress.total_files, - })} - {" \u2022 "} - {formatBytesCompact(progress.completed_bytes)} /{" "} - {formatBytesCompact(progress.total_bytes)} - {progress.speed_bytes_per_sec > 0 && ( + {type === "download" && + (stage === "extracting" || stage === "verifying") && ( +

+ {stage === "extracting" && + progress && + "percentage" in progress && + progress.percentage > 0 ? ( <> - {" \u2022 "} - {formatSpeedCompact(progress.speed_bytes_per_sec)} +

+ {progress.percentage.toFixed(1)}% +

+ + ) : ( +
+
+
)} - {progress.eta_seconds > 0 && - progress.completed_files < progress.total_files && - ` \u2022 ${t("toasts.progress.remaining", { - time: `~${formatEtaCompact(progress.eta_seconds)}`, - })}`} -

- {progress.failed_count > 0 && ( -

- {t("toasts.progress.filesFailed", { - count: progress.failed_count, +

+ )} + + {/* Version update progress */} + {type === "version-update" && + progress && + "current_browser" in progress && ( +
+

+ {progress.current_browser && + t("versionUpdater.toast.lookingForUpdates", { + browser: progress.current_browser, + })} +

+
+ + + {progress.current}/{progress.total} + +
+
+ )} + + {/* Sync progress */} + {type === "sync-progress" && + progress && + "completed_files" in progress && ( +
+

+ {progress.phase === "uploading" + ? t("appUpdate.toast.uploading") + : t("appUpdate.toast.downloading")}{" "} + {t("toasts.progress.filesProgress", { + completed: progress.completed_files, + total: progress.total_files, })} + {" \u2022 "} + {formatBytesCompact(progress.completed_bytes)} /{" "} + {formatBytesCompact(progress.total_bytes)} + {progress.speed_bytes_per_sec > 0 && ( + <> + {" \u2022 "} + {formatSpeedCompact(progress.speed_bytes_per_sec)} + + )} + {progress.eta_seconds > 0 && + progress.completed_files < progress.total_files && + ` \u2022 ${t("toasts.progress.remaining", { + time: `~${formatEtaCompact(progress.eta_seconds)}`, + })}`} +

+ {progress.failed_count > 0 && ( +

+ {t("toasts.progress.filesFailed", { + count: progress.failed_count, + })} +

+ )} +
+ )} + + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* Stage-specific descriptions for downloads */} + {type === "download" && !description && ( + <> + {stage === "extracting" && ( +

+ {t("browserDownload.toast.extracting")}

)} -
+ {stage === "verifying" && ( +

+ {t("browserDownload.toast.verifying")} +

+ )} + )} - - {/* Description */} - {description && ( -

- {description} -

- )} - - {/* Stage-specific descriptions for downloads */} - {type === "download" && !description && ( - <> - {stage === "extracting" && ( -

- {t("browserDownload.toast.extracting")} -

+ {action && + "onClick" in (action as { onClick?: () => void; label?: string }) && + "label" in (action as { onClick?: () => void; label?: string }) && ( +
+ void; label: string }).onClick + } + > + {(action as { onClick: () => void; label: string }).label} + +
)} - {stage === "verifying" && ( -

- {t("browserDownload.toast.verifying")} -

- )} - - )} - {action && - "onClick" in (action as { onClick?: () => void; label?: string }) && - "label" in (action as { onClick?: () => void; label?: string }) && ( -
- void; label: string }).onClick - } - > - {(action as { onClick: () => void; label: string }).label} - -
- )} -
+ +
); } diff --git a/src/components/loading-button.tsx b/src/components/loading-button.tsx index aacf58e..1f6f85f 100644 --- a/src/components/loading-button.tsx +++ b/src/components/loading-button.tsx @@ -1,4 +1,6 @@ +import { motion, useReducedMotion } from "motion/react"; import { LuLoaderCircle } from "react-icons/lu"; +import { MOTION_EASE_OUT } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { type RippleButtonProps as ButtonProps, @@ -10,17 +12,42 @@ type Props = ButtonProps & { "aria-label"?: string; }; export const LoadingButton = ({ isLoading, className, ...props }: Props) => { + const reduceMotion = useReducedMotion(); + return ( - {isLoading ? ( - - ) : ( - props.children - )} + + + {props.children} + + + ); }; diff --git a/src/components/proxy-check-button.tsx b/src/components/proxy-check-button.tsx index ab4366f..421d612 100644 --- a/src/components/proxy-check-button.tsx +++ b/src/components/proxy-check-button.tsx @@ -1,6 +1,7 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { FiCheck } from "react-icons/fi"; @@ -13,6 +14,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatRelativeTime } from "@/lib/flag-utils"; +import { MOTION_EASE_OUT } from "@/lib/motion"; import type { ProxyCheckResult, StoredProxy } from "@/types"; interface ProxyCheckButtonProps { @@ -37,6 +39,7 @@ export function ProxyCheckButton({ setCheckingProfileId, }: ProxyCheckButtonProps) { const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); const [localResult, setLocalResult] = React.useState< ProxyCheckResult | undefined >(cachedResult); @@ -111,6 +114,13 @@ export function ProxyCheckButton({ const isCurrentlyChecking = checkingProfileId === profileId; const result = localResult; + const statusKey = isCurrentlyChecking + ? "checking" + : result?.is_valid && result.country_code + ? "valid-location" + : result && !result.is_valid + ? "invalid" + : "idle"; return ( @@ -122,18 +132,45 @@ export function ProxyCheckButton({ onClick={handleCheck} disabled={isCurrentlyChecking || disabled} > - {isCurrentlyChecking ? ( -
- ) : result?.is_valid && result.country_code ? ( - - - - - ) : result && !result.is_valid ? ( - - ) : ( - - )} + + + {isCurrentlyChecking ? ( + + ) : result?.is_valid && result.country_code ? ( + + + + + ) : result && !result.is_valid ? ( + + ) : ( + + )} + + diff --git a/src/components/proxy-import-dialog.tsx b/src/components/proxy-import-dialog.tsx index 2638350..0c96f99 100644 --- a/src/components/proxy-import-dialog.tsx +++ b/src/components/proxy-import-dialog.tsx @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useReducer, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuUpload } from "react-icons/lu"; import { toast } from "sonner"; @@ -18,6 +18,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { StepTransition } from "@/components/ui/step-transition"; import { getCurrentOS } from "@/lib/browser-utils"; import type { ParsedProxyLine, @@ -33,6 +34,25 @@ interface ProxyImportDialogProps { type ImportStep = "dropzone" | "preview" | "ambiguous" | "result"; +const STEP_ORDER: Record = { + dropzone: 0, + ambiguous: 1, + preview: 2, + result: 3, +}; + +interface StepState { + step: ImportStep; + direction: 1 | -1; +} + +function transitionStep(current: StepState, next: ImportStep): StepState { + return { + step: next, + direction: STEP_ORDER[next] >= STEP_ORDER[current.step] ? 1 : -1, + }; +} + interface AmbiguousProxy { line: string; possible_formats: string[]; @@ -41,7 +61,10 @@ interface AmbiguousProxy { export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { const { t } = useTranslation(); - const [step, setStep] = useState("dropzone"); + const [{ step, direction: stepDirection }, setStep] = useReducer( + transitionStep, + { step: "dropzone", direction: 1 }, + ); const [isDragOver, setIsDragOver] = useState(false); const [parsedProxies, setParsedProxies] = useState([]); const [ambiguousProxies, setAmbiguousProxies] = useState( @@ -57,7 +80,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { const [namePrefix, setNamePrefix] = useState( t("proxies.importDialog.namePrefixDefault"), ); - const os = getCurrentOS(); const modKey = os === "macos" ? "⌘" : "Ctrl"; @@ -283,263 +305,279 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { {t("proxies.importDialog.title")} - - {step === "dropzone" && t("proxies.importDialog.descDropzone")} - {step === "preview" && t("proxies.importDialog.descPreview")} - {step === "ambiguous" && t("proxies.importDialog.descAmbiguous")} - {step === "result" && t("proxies.importDialog.descResult")} - + + + {step === "dropzone" && t("proxies.importDialog.descDropzone")} + {step === "preview" && t("proxies.importDialog.descPreview")} + {step === "ambiguous" && t("proxies.importDialog.descAmbiguous")} + {step === "result" && t("proxies.importDialog.descResult")} + + - {step === "dropzone" && ( -
-
+ {step === "dropzone" && ( +
+
- document.getElementById("proxy-file-input")?.click() - } - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - document.getElementById("proxy-file-input")?.click(); + onDrop={handleDrop} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onClick={() => + document.getElementById("proxy-file-input")?.click() } - }} - > - -

- {t("proxies.importDialog.dropzonePrompt")} -
- - {t("proxies.importDialog.dropzoneFormats")} - -

- { - const file = e.target.files?.[0]; - if (file) handleFileRead(file); - e.target.value = ""; + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + document.getElementById("proxy-file-input")?.click(); + } }} - /> -
-

- {t("proxies.importDialog.pasteHint", { modKey })} -

-
- )} - - {step === "preview" && ( -
-
- - { - setNamePrefix(e.target.value); - }} - /> -

- {t("proxies.importDialog.namePrefixHint", { - prefix: - namePrefix || t("proxies.importDialog.namePrefixDefault"), - })} + > + +

+ {t("proxies.importDialog.dropzonePrompt")} +
+ + {t("proxies.importDialog.dropzoneFormats")} + +

+ { + const file = e.target.files?.[0]; + if (file) handleFileRead(file); + e.target.value = ""; + }} + /> +
+

+ {t("proxies.importDialog.pasteHint", { modKey })}

+ )} -
- - -
- {parsedProxies.map((proxy, i) => ( -
- - {proxy.proxy_type}:// - - {proxy.username && ( - - {proxy.username}:***@ - - )} - - {proxy.host}:{proxy.port} - -
- ))} -
-
-
-
- )} - - {step === "ambiguous" && ( -
-

- {t("proxies.importDialog.ambiguousIntro")} -

- -
- {ambiguousProxies.map((proxy, i) => ( -
- - {proxy.line} - -
- {proxy.possible_formats.map((format) => ( - - ))} -
-
- ))} -
-
-
- )} - - {step === "result" && importResult && ( -
-
-
- - {t("proxies.importDialog.imported")} - - - {importResult.imported_count} - -
- {importResult.skipped_count > 0 && ( -
- - {t("proxies.importDialog.skippedDuplicates")} - - - {importResult.skipped_count} - -
- )} - {importResult.errors.length > 0 && ( -
- - {t("proxies.importDialog.errors")} - - - {importResult.errors.length} - -
- )} -
- - {importResult.errors.length > 0 && ( + {step === "preview" && ( +
- - + + { + setNamePrefix(e.target.value); + }} + /> +

+ {t("proxies.importDialog.namePrefixHint", { + prefix: + namePrefix || t("proxies.importDialog.namePrefixDefault"), + })} +

+
+ +
+ +
- {importResult.errors.map((error, i) => ( + {parsedProxies.map((proxy, i) => (
- {error} + + {proxy.proxy_type}:// + + {proxy.username && ( + + {proxy.username}:***@ + + )} + + {proxy.host}:{proxy.port} +
))}
- )} -
- )} - - - {step === "dropzone" && ( - - {t("common.buttons.cancel")} - - )} - - {step === "preview" && ( - <> - - {t("common.buttons.back")} - - void handleImport()} - disabled={parsedProxies.length === 0} - > - {t("proxies.importDialog.importButton", { - count: parsedProxies.length, - })} - - +
)} {step === "ambiguous" && ( - <> - - {t("common.buttons.back")} - - !p.selectedFormat)} - > - {t("proxies.importDialog.continueButton")} - - +
+

+ {t("proxies.importDialog.ambiguousIntro")} +

+ +
+ {ambiguousProxies.map((proxy, i) => ( +
+ + {proxy.line} + +
+ {proxy.possible_formats.map((format) => ( + + ))} +
+
+ ))} +
+
+
)} - {step === "result" && ( - - {t("proxies.importDialog.doneButton")} - + {step === "result" && importResult && ( +
+
+
+ + {t("proxies.importDialog.imported")} + + + {importResult.imported_count} + +
+ {importResult.skipped_count > 0 && ( +
+ + {t("proxies.importDialog.skippedDuplicates")} + + + {importResult.skipped_count} + +
+ )} + {importResult.errors.length > 0 && ( +
+ + {t("proxies.importDialog.errors")} + + + {importResult.errors.length} + +
+ )} +
+ + {importResult.errors.length > 0 && ( +
+ + +
+ {importResult.errors.map((error, i) => ( +
+ {error} +
+ ))} +
+
+
+ )} +
)} - + + + {step === "dropzone" && ( + + {t("common.buttons.cancel")} + + )} + + {step === "preview" && ( + <> + + {t("common.buttons.back")} + + void handleImport()} + disabled={parsedProxies.length === 0} + > + {t("proxies.importDialog.importButton", { + count: parsedProxies.length, + })} + + + )} + + {step === "ambiguous" && ( + <> + + {t("common.buttons.back")} + + !p.selectedFormat)} + > + {t("proxies.importDialog.continueButton")} + + + )} + + {step === "result" && ( + + {t("proxies.importDialog.doneButton")} + + )} + + ); diff --git a/src/components/sync-all-dialog.tsx b/src/components/sync-all-dialog.tsx index 96f213f..290a5da 100644 --- a/src/components/sync-all-dialog.tsx +++ b/src/components/sync-all-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { FiWifi } from "react-icons/fi"; @@ -16,6 +17,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { MOTION_EASE_OUT } from "@/lib/motion"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; interface UnsyncedEntityCounts { @@ -33,6 +35,7 @@ interface SyncAllDialogProps { export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) { const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); const [counts, setCounts] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isEnabling, setIsEnabling] = useState(false); @@ -127,33 +130,61 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) { {t("syncAll.description")} - {isLoading ? ( -
-
-
- ) : ( -
- {items.map(({ key, count, label, Icon }) => ( -
-
- -
-
- {label} -
- + {isLoading ? ( + +
+ + ) : ( + + {items.map(({ key, count, label, Icon }, index) => ( + - {count} - -
- ))} -
- )} +
+ +
+
+ {label} +
+ + {count} + + + ))} + + )} + diff --git a/src/components/vpn-import-dialog.tsx b/src/components/vpn-import-dialog.tsx index fc4ae7b..7e82126 100644 --- a/src/components/vpn-import-dialog.tsx +++ b/src/components/vpn-import-dialog.tsx @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useReducer, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuShield, LuUpload } from "react-icons/lu"; import { toast } from "sonner"; @@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RippleButton } from "@/components/ui/ripple"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { StepTransition } from "@/components/ui/step-transition"; import { getCurrentOS } from "@/lib/browser-utils"; import type { VpnImportResult, VpnType } from "@/types"; @@ -29,6 +30,24 @@ interface VpnImportDialogProps { type ImportStep = "dropzone" | "vpn-preview" | "vpn-result"; +const STEP_ORDER: Record = { + dropzone: 0, + "vpn-preview": 1, + "vpn-result": 2, +}; + +interface StepState { + step: ImportStep; + direction: 1 | -1; +} + +function transitionStep(current: StepState, next: ImportStep): StepState { + return { + step: next, + direction: STEP_ORDER[next] >= STEP_ORDER[current.step] ? 1 : -1, + }; +} + interface VpnPreviewData { content: string; filename: string; @@ -58,14 +77,16 @@ const detectVpnType = ( export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) { const { t } = useTranslation(); - const [step, setStep] = useState("dropzone"); + const [{ step, direction: stepDirection }, setStep] = useReducer( + transitionStep, + { step: "dropzone", direction: 1 }, + ); const [isDragOver, setIsDragOver] = useState(false); const [vpnPreview, setVpnPreview] = useState(null); const [vpnName, setVpnName] = useState(""); const [vpnImportResult, setVpnImportResult] = useState(null); const [isImporting, setIsImporting] = useState(false); - const os = getCurrentOS(); const modKey = os === "macos" ? "⌘" : "Ctrl"; @@ -190,159 +211,179 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) { {t("vpns.import.title")} - - {step === "dropzone" && t("vpns.import.descDropzone")} - {step === "vpn-preview" && t("vpns.import.descPreview")} - {step === "vpn-result" && t("vpns.import.descResult")} - + + + {step === "dropzone" && t("vpns.import.descDropzone")} + {step === "vpn-preview" && t("vpns.import.descPreview")} + {step === "vpn-result" && t("vpns.import.descResult")} + + - {step === "dropzone" && ( -
-
document.getElementById("vpn-file-input")?.click()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - document.getElementById("vpn-file-input")?.click(); + + {step === "dropzone" && ( +
+
+ document.getElementById("vpn-file-input")?.click() } - }} - > - -

- {t("vpns.import.dropzonePrompt")} -

- { - const file = e.target.files?.[0]; - if (file) handleFileRead(file); - e.target.value = ""; + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + document.getElementById("vpn-file-input")?.click(); + } }} - /> + > + +

+ {t("vpns.import.dropzonePrompt")} +

+ { + const file = e.target.files?.[0]; + if (file) handleFileRead(file); + e.target.value = ""; + }} + /> +
+

+ {t("vpns.import.pasteHint", { modKey })} +

-

- {t("vpns.import.pasteHint", { modKey })} -

-
- )} + )} - {step === "vpn-preview" && vpnPreview && ( -
-
- -
-
- {t("vpns.import.configurationLabel", { - type: vpnPreview.detectedType, - })} -
- {vpnPreview.endpoint && ( -
- {t("vpns.import.endpointLabel", { - endpoint: vpnPreview.endpoint, + {step === "vpn-preview" && vpnPreview && ( +
+
+ +
+
+ {t("vpns.import.configurationLabel", { + type: vpnPreview.detectedType, })}
+ {vpnPreview.endpoint && ( +
+ {t("vpns.import.endpointLabel", { + endpoint: vpnPreview.endpoint, + })} +
+ )} +
+
+ +
+ + { + setVpnName(e.target.value); + }} + /> +
+ +
+ + +
+                    {vpnPreview.content.slice(0, 1000)}
+                    {vpnPreview.content.length > 1000 && "..."}
+                  
+
+
+
+ )} + + {step === "vpn-result" && vpnImportResult && ( +
+
+ {vpnImportResult.success ? ( +
+ +
+
+ {t("vpns.import.importedSuccess")} +
+
+ {vpnImportResult.name} ({vpnImportResult.vpn_type}) +
+
+
+ ) : ( +
+
+ {t("vpns.import.importFailed")} +
+
+ {vpnImportResult.error} +
+
)}
- -
- - { - setVpnName(e.target.value); - }} - /> -
- -
- - -
-                  {vpnPreview.content.slice(0, 1000)}
-                  {vpnPreview.content.length > 1000 && "..."}
-                
-
-
-
- )} - - {step === "vpn-result" && vpnImportResult && ( -
-
- {vpnImportResult.success ? ( -
- -
-
- {t("vpns.import.importedSuccess")} -
-
- {vpnImportResult.name} ({vpnImportResult.vpn_type}) -
-
-
- ) : ( -
-
- {t("vpns.import.importFailed")} -
-
- {vpnImportResult.error} -
-
- )} -
-
- )} - - - {step === "dropzone" && ( - - {t("common.buttons.cancel")} - )} - {step === "vpn-preview" && ( - <> - - {t("common.buttons.back")} + + {step === "dropzone" && ( + + {t("common.buttons.cancel")} - void handleImport()} - > - {t("vpns.import.importButton")} - - - )} + )} - {step === "vpn-result" && ( - - {t("vpns.import.doneButton")} - - )} - + {step === "vpn-preview" && ( + <> + + {t("common.buttons.back")} + + void handleImport()} + > + {t("vpns.import.importButton")} + + + )} + + {step === "vpn-result" && ( + + {t("vpns.import.doneButton")} + + )} + + ); diff --git a/src/lib/motion.ts b/src/lib/motion.ts new file mode 100644 index 0000000..4ebec9d --- /dev/null +++ b/src/lib/motion.ts @@ -0,0 +1,10 @@ +export const MOTION_EASE_OUT: [number, number, number, number] = [ + 0.23, 1, 0.32, 1, +]; + +export const MOTION_SPRING_POSITION = { + type: "spring" as const, + stiffness: 500, + damping: 30, + mass: 0.5, +}; diff --git a/src/styles/globals.css b/src/styles/globals.css index 84dd59b..21ddbf3 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -64,6 +64,7 @@ :root { --radius: 0.625rem; + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0);