refactors: animations cleanup

This commit is contained in:
zhom
2026-07-16 22:07:51 +04:00
parent cef522649a
commit e1c9ce6525
14 changed files with 1070 additions and 645 deletions
+37 -38
View File
@@ -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 (
<div>
<AnimatedDisclosureItem>
<div className="flex items-center gap-2 rounded p-2 hover:bg-accent/50">
<Checkbox
checked={isAllSelected || isPartial}
@@ -557,48 +557,47 @@ function DomainRow({
/>
<button
type="button"
aria-expanded={isExpanded}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent text-left"
onClick={() => {
onToggleExpand(domain.domain);
}}
>
{isExpanded ? (
<LuChevronDown className="size-4" />
) : (
<AnimatedDisclosureChevron open={isExpanded}>
<LuChevronRight className="size-4" />
)}
</AnimatedDisclosureChevron>
<span className="truncate font-medium">{domain.domain}</span>
<span className="shrink-0 text-xs text-muted-foreground">
({domain.cookie_count})
</span>
</button>
</div>
{isExpanded && (
<div className="ml-8 space-y-1 border-l pl-2">
{domain.cookies.map((cookie) => {
const isSelected =
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
>
<Checkbox
checked={isSelected || isAllSelected}
onCheckedChange={() => {
onToggleCookie(
domain.domain,
cookie.name,
domain.cookie_count,
);
}}
/>
<span className="truncate">{cookie.name}</span>
</div>
);
})}
</div>
)}
</div>
<AnimatedDisclosureContent
open={isExpanded}
className="ml-8 space-y-1 border-l pl-2"
>
{domain.cookies.map((cookie) => {
const isSelected = domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
>
<Checkbox
checked={isSelected || isAllSelected}
onCheckedChange={() => {
onToggleCookie(
domain.domain,
cookie.name,
domain.cookie_count,
);
}}
/>
<span className="truncate">{cookie.name}</span>
</div>
);
})}
</AnimatedDisclosureContent>
</AnimatedDisclosureItem>
);
}
+37 -33
View File
@@ -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 (
<div>
<AnimatedDisclosureItem>
<div className="flex items-center gap-2 rounded p-1.5 hover:bg-accent/50">
<Checkbox
checked={isAllSelected || isPartial}
@@ -639,48 +644,47 @@ function ExportDomainRow({
/>
<button
type="button"
aria-expanded={isExpanded}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent text-left text-sm"
onClick={() => {
onToggleExpand(domain.domain);
}}
>
{isExpanded ? (
<LuChevronDown className="size-3.5" />
) : (
<AnimatedDisclosureChevron open={isExpanded}>
<LuChevronRight className="size-3.5" />
)}
</AnimatedDisclosureChevron>
<span className="truncate font-medium">{domain.domain}</span>
<span className="shrink-0 text-xs text-muted-foreground">
({domain.cookie_count})
</span>
</button>
</div>
{isExpanded && (
<div className="ml-7 space-y-0.5 border-l pl-2">
{domain.cookies.map((cookie) => {
const isSelected =
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
>
<Checkbox
checked={isSelected || isAllSelected}
onCheckedChange={() => {
onToggleCookie(
domain.domain,
cookie.name,
domain.cookie_count,
);
}}
/>
<span className="truncate">{cookie.name}</span>
</div>
);
})}
</div>
)}
</div>
<AnimatedDisclosureContent
open={isExpanded}
className="ml-7 space-y-0.5 border-l pl-2"
>
{domain.cookies.map((cookie) => {
const isSelected = domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
>
<Checkbox
checked={isSelected || isAllSelected}
onCheckedChange={() => {
onToggleCookie(
domain.domain,
cookie.name,
domain.cookie_count,
);
}}
/>
<span className="truncate">{cookie.name}</span>
</div>
);
})}
</AnimatedDisclosureContent>
</AnimatedDisclosureItem>
);
}
+198 -145
View File
@@ -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 (
<div className={`h-1.5 rounded-full bg-muted ${className}`}>
<div
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
className="h-1.5 rounded-full bg-foreground"
style={{ width: `${percentage}%` }}
/>
</div>
@@ -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 (
<div className="flex w-full max-w-md items-start rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg">
<div className="mt-0.5 mr-3">{getToastIcon(type, stage)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm/tight font-semibold text-foreground">{title}</p>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="ml-2 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t("common.buttons.cancel")}
>
<LuX className="size-3" />
</button>
)}
</div>
<div className="mt-0.5 mr-3">
<AnimatePresence initial={false} mode="wait">
<motion.div
key={`icon-${stateKey}`}
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.92 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
},
}}
exit={{
opacity: 0,
scale: reduceMotion ? 1 : 0.92,
transition: {
duration: reduceMotion ? 0.15 : 0.12,
ease: MOTION_EASE_OUT,
},
}}
>
{getToastIcon(type, stage)}
</motion.div>
</AnimatePresence>
</div>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={stateKey}
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
animate={{
opacity: 1,
y: 0,
transition: {
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
},
}}
exit={{
opacity: 0,
y: reduceMotion ? 0 : -4,
transition: {
duration: reduceMotion ? 0.15 : 0.12,
ease: MOTION_EASE_OUT,
},
}}
className="min-w-0 flex-1"
>
<div className="flex items-center justify-between">
<p className="text-sm/tight font-semibold text-foreground">
{title}
</p>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="ml-2 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t("common.buttons.cancel")}
>
<LuX className="size-3" />
</button>
)}
</div>
{/* Download progress */}
{type === "download" &&
progress &&
"percentage" in progress &&
stage === "downloading" && (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between">
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta &&
`${t("toasts.progress.remaining", { time: progress.eta })}`}
</p>
{/* Download progress */}
{type === "download" &&
progress &&
"percentage" in progress &&
stage === "downloading" && (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between">
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{progress.percentage.toFixed(1)}%
{progress.speed && `${progress.speed} MB/s`}
{progress.eta &&
`${t("toasts.progress.remaining", { time: progress.eta })}`}
</p>
</div>
<ProgressBar percentage={progress.percentage} />
</div>
<ProgressBar percentage={progress.percentage} />
</div>
)}
)}
{/* 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") && (
<div className="mt-2 space-y-1">
{stage === "extracting" &&
progress &&
"percentage" in progress &&
progress.percentage > 0 ? (
<>
<p className="text-xs text-muted-foreground">
{progress.percentage.toFixed(1)}%
</p>
<ProgressBar percentage={progress.percentage} />
</>
) : (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-1.5 w-1/3 animate-progress-indeterminate rounded-full bg-foreground" />
</div>
)}
</div>
)}
{/* Version update progress */}
{type === "version-update" &&
progress &&
"current_browser" in progress && (
<div className="mt-2 space-y-1">
<p className="text-xs text-muted-foreground">
{progress.current_browser &&
t("versionUpdater.toast.lookingForUpdates", {
browser: progress.current_browser,
})}
</p>
<div className="flex items-center gap-x-2">
<ProgressBar
percentage={(progress.current / progress.total) * 100}
className="min-w-0 flex-1"
/>
<span className="w-8 shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
{progress.current}/{progress.total}
</span>
</div>
</div>
)}
{/* Sync progress */}
{type === "sync-progress" &&
progress &&
"completed_files" in progress && (
<div className="mt-1">
<p className="text-xs text-muted-foreground">
{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") && (
<div className="mt-2 space-y-1">
{stage === "extracting" &&
progress &&
"percentage" in progress &&
progress.percentage > 0 ? (
<>
{" \u2022 "}
{formatSpeedCompact(progress.speed_bytes_per_sec)}
<p className="text-xs text-muted-foreground">
{progress.percentage.toFixed(1)}%
</p>
<ProgressBar percentage={progress.percentage} />
</>
) : (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-1.5 w-1/3 animate-progress-indeterminate rounded-full bg-foreground" />
</div>
)}
{progress.eta_seconds > 0 &&
progress.completed_files < progress.total_files &&
` \u2022 ${t("toasts.progress.remaining", {
time: `~${formatEtaCompact(progress.eta_seconds)}`,
})}`}
</p>
{progress.failed_count > 0 && (
<p className="mt-0.5 text-xs text-destructive">
{t("toasts.progress.filesFailed", {
count: progress.failed_count,
</div>
)}
{/* Version update progress */}
{type === "version-update" &&
progress &&
"current_browser" in progress && (
<div className="mt-2 space-y-1">
<p className="text-xs text-muted-foreground">
{progress.current_browser &&
t("versionUpdater.toast.lookingForUpdates", {
browser: progress.current_browser,
})}
</p>
<div className="flex items-center gap-x-2">
<ProgressBar
percentage={(progress.current / progress.total) * 100}
className="min-w-0 flex-1"
/>
<span className="w-8 shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
{progress.current}/{progress.total}
</span>
</div>
</div>
)}
{/* Sync progress */}
{type === "sync-progress" &&
progress &&
"completed_files" in progress && (
<div className="mt-1">
<p className="text-xs text-muted-foreground">
{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)}`,
})}`}
</p>
{progress.failed_count > 0 && (
<p className="mt-0.5 text-xs text-destructive">
{t("toasts.progress.filesFailed", {
count: progress.failed_count,
})}
</p>
)}
</div>
)}
{/* Description */}
{description && (
<p className="mt-1 text-xs/tight text-muted-foreground">
{description}
</p>
)}
{/* Stage-specific descriptions for downloads */}
{type === "download" && !description && (
<>
{stage === "extracting" && (
<p className="mt-1 text-xs text-muted-foreground">
{t("browserDownload.toast.extracting")}
</p>
)}
</div>
{stage === "verifying" && (
<p className="mt-1 text-xs text-muted-foreground">
{t("browserDownload.toast.verifying")}
</p>
)}
</>
)}
{/* Description */}
{description && (
<p className="mt-1 text-xs/tight text-muted-foreground">
{description}
</p>
)}
{/* Stage-specific descriptions for downloads */}
{type === "download" && !description && (
<>
{stage === "extracting" && (
<p className="mt-1 text-xs text-muted-foreground">
{t("browserDownload.toast.extracting")}
</p>
{action &&
"onClick" in (action as { onClick?: () => void; label?: string }) &&
"label" in (action as { onClick?: () => void; label?: string }) && (
<div className="mt-2 w-full">
<RippleButton
size="sm"
className="ml-auto"
onClick={
(action as { onClick: () => void; label: string }).onClick
}
>
{(action as { onClick: () => void; label: string }).label}
</RippleButton>
</div>
)}
{stage === "verifying" && (
<p className="mt-1 text-xs text-muted-foreground">
{t("browserDownload.toast.verifying")}
</p>
)}
</>
)}
{action &&
"onClick" in (action as { onClick?: () => void; label?: string }) &&
"label" in (action as { onClick?: () => void; label?: string }) && (
<div className="mt-2 w-full">
<RippleButton
size="sm"
className="ml-auto"
onClick={
(action as { onClick: () => void; label: string }).onClick
}
>
{(action as { onClick: () => void; label: string }).label}
</RippleButton>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
);
}
+32 -5
View File
@@ -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 (
<UIButton
className={cn("inline-flex items-center justify-center", className)}
{...props}
disabled={props.disabled || isLoading}
aria-busy={isLoading}
>
{isLoading ? (
<LuLoaderCircle className="size-4 animate-spin" />
) : (
props.children
)}
<span className="inline-grid items-center justify-items-center">
<motion.span
animate={{
opacity: isLoading ? 0 : 1,
scale: reduceMotion || !isLoading ? 1 : 0.98,
}}
transition={{ duration: 0.1, ease: MOTION_EASE_OUT }}
className="col-start-1 row-start-1 inline-flex items-center justify-center gap-2"
>
{props.children}
</motion.span>
<motion.span
aria-hidden="true"
initial={false}
animate={{
opacity: isLoading ? 1 : 0,
scale: reduceMotion || isLoading ? 1 : 0.9,
}}
transition={{
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
}}
className="pointer-events-none col-start-1 row-start-1 inline-flex items-center justify-center"
>
<LuLoaderCircle className="size-4 animate-spin" />
</motion.span>
</span>
</UIButton>
);
};
+49 -12
View File
@@ -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 (
<Tooltip>
@@ -122,18 +132,45 @@ export function ProxyCheckButton({
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
) : result?.is_valid && result.country_code ? (
<span className="relative inline-flex items-center justify-center">
<FlagIcon countryCode={result.country_code} className="h-2.5" />
<FiCheck className="absolute right-[-4px] bottom-[-6px]" />
</span>
) : result && !result.is_valid ? (
<span className="text-sm text-destructive"></span>
) : (
<FiCheck className="size-3" />
)}
<AnimatePresence initial={false} mode="wait">
<motion.span
key={statusKey}
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.9 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
},
}}
exit={{
opacity: 0,
scale: reduceMotion ? 1 : 0.9,
transition: {
duration: reduceMotion ? 0.15 : 0.1,
ease: MOTION_EASE_OUT,
},
}}
className="inline-flex size-3 items-center justify-center"
>
{isCurrentlyChecking ? (
<span className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
) : result?.is_valid && result.country_code ? (
<span className="relative inline-flex items-center justify-center">
<FlagIcon
countryCode={result.country_code}
className="h-2.5"
/>
<FiCheck className="absolute right-[-4px] bottom-[-6px]" />
</span>
) : result && !result.is_valid ? (
<span className="text-sm text-destructive"></span>
) : (
<FiCheck className="size-3" />
)}
</motion.span>
</AnimatePresence>
</Button>
</TooltipTrigger>
<TooltipContent>
+273 -235
View File
@@ -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<ImportStep, number> = {
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<ImportStep>("dropzone");
const [{ step, direction: stepDirection }, setStep] = useReducer(
transitionStep,
{ step: "dropzone", direction: 1 },
);
const [isDragOver, setIsDragOver] = useState(false);
const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]);
const [ambiguousProxies, setAmbiguousProxies] = useState<AmbiguousProxy[]>(
@@ -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) {
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
<DialogDescription>
{step === "dropzone" && t("proxies.importDialog.descDropzone")}
{step === "preview" && t("proxies.importDialog.descPreview")}
{step === "ambiguous" && t("proxies.importDialog.descAmbiguous")}
{step === "result" && t("proxies.importDialog.descResult")}
</DialogDescription>
<StepTransition
transitionKey={`description-${step}`}
direction={stepDirection}
>
<DialogDescription>
{step === "dropzone" && t("proxies.importDialog.descDropzone")}
{step === "preview" && t("proxies.importDialog.descPreview")}
{step === "ambiguous" && t("proxies.importDialog.descAmbiguous")}
{step === "result" && t("proxies.importDialog.descResult")}
</DialogDescription>
</StepTransition>
</DialogHeader>
{step === "dropzone" && (
<div className="space-y-4">
<div
role="button"
tabIndex={0}
className={`
<StepTransition
transitionKey={step}
direction={stepDirection}
className="grid gap-4"
>
{step === "dropzone" && (
<div className="space-y-4">
<div
role="button"
tabIndex={0}
className={`
flex flex-col items-center justify-center
border-2 border-dashed rounded-lg p-8
transition-colors cursor-pointer
${isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
cursor-pointer transition-[color,background-color,border-color,transform]
duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none
${isDragOver ? "scale-[1.005] border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() =>
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()
}
}}
>
<LuUpload className="mb-4 size-10 text-muted-foreground" />
<p className="text-center text-sm text-muted-foreground">
{t("proxies.importDialog.dropzonePrompt")}
<br />
<span className="text-xs">
{t("proxies.importDialog.dropzoneFormats")}
</span>
</p>
<input
id="proxy-file-input"
type="file"
accept=".json,.txt"
className="hidden"
onChange={(e) => {
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();
}
}}
/>
</div>
<p className="text-center text-xs text-muted-foreground">
{t("proxies.importDialog.pasteHint", { modKey })}
</p>
</div>
)}
{step === "preview" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name-prefix">
{t("proxies.importDialog.namePrefix")}
</Label>
<Input
id="name-prefix"
placeholder={t("proxies.importDialog.namePrefixDefault")}
value={namePrefix}
onChange={(e) => {
setNamePrefix(e.target.value);
}}
/>
<p className="text-xs text-muted-foreground">
{t("proxies.importDialog.namePrefixHint", {
prefix:
namePrefix || t("proxies.importDialog.namePrefixDefault"),
})}
>
<LuUpload
className={`mb-4 size-10 text-muted-foreground transition-transform duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none ${
isDragOver ? "-translate-y-0.5 scale-105" : ""
}`}
/>
<p className="text-center text-sm text-muted-foreground">
{t("proxies.importDialog.dropzonePrompt")}
<br />
<span className="text-xs">
{t("proxies.importDialog.dropzoneFormats")}
</span>
</p>
<input
id="proxy-file-input"
type="file"
accept=".json,.txt"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileRead(file);
e.target.value = "";
}}
/>
</div>
<p className="text-center text-xs text-muted-foreground">
{t("proxies.importDialog.pasteHint", { modKey })}
</p>
</div>
)}
<div className="space-y-2">
<Label>
{t("proxies.importDialog.proxiesToImport", {
count: parsedProxies.length,
})}
{invalidProxies.length > 0 && (
<span className="ml-2 text-muted-foreground">
{t("proxies.importDialog.invalidCount", {
count: invalidProxies.length,
})}
</span>
)}
</Label>
<ScrollArea className="h-[clamp(120px,30vh,400px)] rounded-md border">
<div className="space-y-1 p-2">
{parsedProxies.map((proxy, i) => (
<div
key={`${proxy.original_line}-${i}`}
className="rounded bg-muted/30 p-2 font-mono text-xs break-all"
>
<span className="text-primary">
{proxy.proxy_type}://
</span>
{proxy.username && (
<span className="text-muted-foreground">
{proxy.username}:***@
</span>
)}
<span>
{proxy.host}:{proxy.port}
</span>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
)}
{step === "ambiguous" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("proxies.importDialog.ambiguousIntro")}
</p>
<ScrollArea className="h-[clamp(150px,35vh,450px)] rounded-md border">
<div className="space-y-4 p-3">
{ambiguousProxies.map((proxy, i) => (
<div
key={`${proxy.line}-${i}`}
className="space-y-2 border-b pb-3 last:border-0"
>
<code className="block rounded bg-muted px-2 py-1 text-xs break-all">
{proxy.line}
</code>
<div className="flex flex-col gap-2">
{proxy.possible_formats.map((format) => (
<label
key={format}
className="flex cursor-pointer items-center gap-2"
>
<input
type="radio"
name={`format-${i}`}
checked={proxy.selectedFormat === format}
onChange={() => {
handleAmbiguousFormatSelect(i, format);
}}
className="accent-primary"
/>
<span className="text-xs">{format}</span>
</label>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{step === "result" && importResult && (
<div className="space-y-4">
<div className="space-y-2 rounded-lg bg-muted/30 p-4">
<div className="flex justify-between">
<span className="text-sm">
{t("proxies.importDialog.imported")}
</span>
<span className="text-sm font-medium text-success">
{importResult.imported_count}
</span>
</div>
{importResult.skipped_count > 0 && (
<div className="flex justify-between">
<span className="text-sm">
{t("proxies.importDialog.skippedDuplicates")}
</span>
<span className="text-sm font-medium text-warning">
{importResult.skipped_count}
</span>
</div>
)}
{importResult.errors.length > 0 && (
<div className="flex justify-between">
<span className="text-sm">
{t("proxies.importDialog.errors")}
</span>
<span className="text-sm font-medium text-destructive">
{importResult.errors.length}
</span>
</div>
)}
</div>
{importResult.errors.length > 0 && (
{step === "preview" && (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("proxies.importDialog.errors")}</Label>
<ScrollArea className="h-[100px] rounded-md border">
<Label htmlFor="name-prefix">
{t("proxies.importDialog.namePrefix")}
</Label>
<Input
id="name-prefix"
placeholder={t("proxies.importDialog.namePrefixDefault")}
value={namePrefix}
onChange={(e) => {
setNamePrefix(e.target.value);
}}
/>
<p className="text-xs text-muted-foreground">
{t("proxies.importDialog.namePrefixHint", {
prefix:
namePrefix || t("proxies.importDialog.namePrefixDefault"),
})}
</p>
</div>
<div className="space-y-2">
<Label>
{t("proxies.importDialog.proxiesToImport", {
count: parsedProxies.length,
})}
{invalidProxies.length > 0 && (
<span className="ml-2 text-muted-foreground">
{t("proxies.importDialog.invalidCount", {
count: invalidProxies.length,
})}
</span>
)}
</Label>
<ScrollArea className="h-[clamp(120px,30vh,400px)] rounded-md border">
<div className="space-y-1 p-2">
{importResult.errors.map((error, i) => (
{parsedProxies.map((proxy, i) => (
<div
key={`error-${i}`}
className="text-xs text-destructive"
key={`${proxy.original_line}-${i}`}
className="rounded bg-muted/30 p-2 font-mono text-xs break-all"
>
{error}
<span className="text-primary">
{proxy.proxy_type}://
</span>
{proxy.username && (
<span className="text-muted-foreground">
{proxy.username}:***@
</span>
)}
<span>
{proxy.host}:{proxy.port}
</span>
</div>
))}
</div>
</ScrollArea>
</div>
)}
</div>
)}
<DialogFooter>
{step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
)}
{step === "preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
{t("common.buttons.back")}
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={parsedProxies.length === 0}
>
{t("proxies.importDialog.importButton", {
count: parsedProxies.length,
})}
</LoadingButton>
</>
</div>
)}
{step === "ambiguous" && (
<>
<RippleButton variant="outline" onClick={resetState}>
{t("common.buttons.back")}
</RippleButton>
<RippleButton
onClick={handleResolveAmbiguous}
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
>
{t("proxies.importDialog.continueButton")}
</RippleButton>
</>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("proxies.importDialog.ambiguousIntro")}
</p>
<ScrollArea className="h-[clamp(150px,35vh,450px)] rounded-md border">
<div className="space-y-4 p-3">
{ambiguousProxies.map((proxy, i) => (
<div
key={`${proxy.line}-${i}`}
className="space-y-2 border-b pb-3 last:border-0"
>
<code className="block rounded bg-muted px-2 py-1 text-xs break-all">
{proxy.line}
</code>
<div className="flex flex-col gap-2">
{proxy.possible_formats.map((format) => (
<label
key={format}
className="flex cursor-pointer items-center gap-2"
>
<input
type="radio"
name={`format-${i}`}
checked={proxy.selectedFormat === format}
onChange={() => {
handleAmbiguousFormatSelect(i, format);
}}
className="accent-primary"
/>
<span className="text-xs">{format}</span>
</label>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{step === "result" && (
<RippleButton onClick={handleClose}>
{t("proxies.importDialog.doneButton")}
</RippleButton>
{step === "result" && importResult && (
<div className="space-y-4">
<div className="space-y-2 rounded-lg bg-muted/30 p-4">
<div className="flex justify-between">
<span className="text-sm">
{t("proxies.importDialog.imported")}
</span>
<span className="text-sm font-medium text-success">
{importResult.imported_count}
</span>
</div>
{importResult.skipped_count > 0 && (
<div className="flex justify-between">
<span className="text-sm">
{t("proxies.importDialog.skippedDuplicates")}
</span>
<span className="text-sm font-medium text-warning">
{importResult.skipped_count}
</span>
</div>
)}
{importResult.errors.length > 0 && (
<div className="flex justify-between">
<span className="text-sm">
{t("proxies.importDialog.errors")}
</span>
<span className="text-sm font-medium text-destructive">
{importResult.errors.length}
</span>
</div>
)}
</div>
{importResult.errors.length > 0 && (
<div className="space-y-2">
<Label>{t("proxies.importDialog.errors")}</Label>
<ScrollArea className="h-[100px] rounded-md border">
<div className="space-y-1 p-2">
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-destructive"
>
{error}
</div>
))}
</div>
</ScrollArea>
</div>
)}
</div>
)}
</DialogFooter>
<DialogFooter>
{step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
)}
{step === "preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
{t("common.buttons.back")}
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={parsedProxies.length === 0}
>
{t("proxies.importDialog.importButton", {
count: parsedProxies.length,
})}
</LoadingButton>
</>
)}
{step === "ambiguous" && (
<>
<RippleButton variant="outline" onClick={resetState}>
{t("common.buttons.back")}
</RippleButton>
<RippleButton
onClick={handleResolveAmbiguous}
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
>
{t("proxies.importDialog.continueButton")}
</RippleButton>
</>
)}
{step === "result" && (
<RippleButton onClick={handleClose}>
{t("proxies.importDialog.doneButton")}
</RippleButton>
)}
</DialogFooter>
</StepTransition>
</DialogContent>
</Dialog>
);
+57 -26
View File
@@ -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<UnsyncedEntityCounts | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isEnabling, setIsEnabling] = useState(false);
@@ -127,33 +130,61 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
<DialogDescription>{t("syncAll.description")}</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
</div>
) : (
<div className="grid grid-cols-2 gap-2 py-2">
{items.map(({ key, count, label, Icon }) => (
<div
key={key}
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 p-3 transition-colors hover:bg-card"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1 truncate text-sm font-medium">
{label}
</div>
<Badge
variant="secondary"
className="shrink-0 px-2 tabular-nums"
<AnimatePresence initial={false} mode="wait">
{isLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: reduceMotion ? 0.15 : 0.12,
ease: MOTION_EASE_OUT,
}}
className="flex justify-center py-8"
>
<div className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
</motion.div>
) : (
<motion.div
key="results"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
}}
className="grid grid-cols-2 gap-2 py-2"
>
{items.map(({ key, count, label, Icon }, index) => (
<motion.div
key={key}
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduceMotion ? 0.15 : 0.16,
delay: reduceMotion ? 0 : Math.min(index * 0.03, 0.12),
ease: MOTION_EASE_OUT,
}}
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 p-3 transition-colors hover:bg-card"
>
{count}
</Badge>
</div>
))}
</div>
)}
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1 truncate text-sm font-medium">
{label}
</div>
<Badge
variant="secondary"
className="shrink-0 px-2 tabular-nums"
>
{count}
</Badge>
</motion.div>
))}
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={onClose} disabled={isEnabling}>
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ComponentProps, ReactNode } from "react";
import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion";
import { cn } from "@/lib/utils";
interface AnimatedDisclosureItemProps
extends ComponentProps<typeof motion.div> {
children: ReactNode;
}
export function AnimatedDisclosureItem({
children,
...props
}: AnimatedDisclosureItemProps) {
const reduceMotion = useReducedMotion();
return (
<motion.div
layout={reduceMotion ? false : "position"}
transition={reduceMotion ? { duration: 0 } : MOTION_SPRING_POSITION}
{...props}
>
{children}
</motion.div>
);
}
export function AnimatedDisclosureChevron({
open,
children,
className,
}: {
open: boolean;
children: ReactNode;
className?: string;
}) {
const reduceMotion = useReducedMotion();
return (
<motion.span
aria-hidden="true"
animate={{ rotate: open ? 90 : 0 }}
transition={{
duration: reduceMotion ? 0 : 0.16,
ease: MOTION_EASE_OUT,
}}
className={cn("inline-flex shrink-0", className)}
>
{children}
</motion.span>
);
}
export function AnimatedDisclosureContent({
open,
children,
className,
}: {
open: boolean;
children: ReactNode;
className?: string;
}) {
const reduceMotion = useReducedMotion();
return (
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
transition={{
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
}}
className={cn(className)}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
+7 -1
View File
@@ -5,7 +5,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,transform] duration-[120ms] ease-[var(--ease-out)] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -28,6 +28,12 @@ const buttonVariants = cva(
icon: "size-9",
},
},
compoundVariants: [
{
variant: ["default", "destructive", "outline", "secondary", "ghost"],
className: "active:scale-[0.98] motion-reduce:active:scale-100",
},
],
defaultVariants: {
variant: "default",
size: "default",
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { Key, ReactNode } from "react";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { cn } from "@/lib/utils";
interface StepTransitionProps {
transitionKey: Key;
direction: 1 | -1;
children: ReactNode;
className?: string;
}
export function StepTransition({
transitionKey,
direction,
children,
className,
}: StepTransitionProps) {
const reduceMotion = useReducedMotion();
return (
<AnimatePresence initial={false} mode="wait" custom={direction}>
<motion.div
key={transitionKey}
custom={direction}
variants={{
enter: (customDirection: 1 | -1) => ({
opacity: 0,
x: reduceMotion ? 0 : customDirection * 6,
}),
center: {
opacity: 1,
x: 0,
transition: {
duration: reduceMotion ? 0.16 : 0.18,
ease: MOTION_EASE_OUT,
},
},
exit: (customDirection: 1 | -1) => ({
opacity: 0,
x: reduceMotion ? 0 : customDirection * -6,
transition: {
duration: reduceMotion ? 0.16 : 0.12,
ease: MOTION_EASE_OUT,
},
}),
}}
initial="enter"
animate="center"
exit="exit"
className={cn(className)}
>
{children}
</motion.div>
</AnimatePresence>
);
}
+43 -9
View File
@@ -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";
@@ -12,6 +13,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatRelativeTime } from "@/lib/flag-utils";
import { MOTION_EASE_OUT } from "@/lib/motion";
import type { ProxyCheckResult } from "@/types";
interface VpnCheckButtonProps {
@@ -30,6 +32,7 @@ export function VpnCheckButton({
disabled = false,
}: VpnCheckButtonProps) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const [result, setResult] = React.useState<ProxyCheckResult | undefined>();
const handleCheck = React.useCallback(async () => {
@@ -63,6 +66,13 @@ export function VpnCheckButton({
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId, t]);
const isCurrentlyChecking = checkingVpnId === vpnId;
const statusKey = isCurrentlyChecking
? "checking"
: result?.is_valid
? "valid"
: result && !result.is_valid
? "invalid"
: "idle";
return (
<Tooltip>
@@ -74,15 +84,39 @@ export function VpnCheckButton({
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
) : result?.is_valid ? (
<FiCheck className="size-3 text-success" />
) : result && !result.is_valid ? (
<span className="text-sm text-destructive"></span>
) : (
<FiCheck className="size-3" />
)}
<AnimatePresence initial={false} mode="wait">
<motion.span
key={statusKey}
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.9 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
},
}}
exit={{
opacity: 0,
scale: reduceMotion ? 1 : 0.9,
transition: {
duration: reduceMotion ? 0.15 : 0.1,
ease: MOTION_EASE_OUT,
},
}}
className="inline-flex size-3 items-center justify-center"
>
{isCurrentlyChecking ? (
<span className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
) : result?.is_valid ? (
<FiCheck className="size-3 text-success" />
) : result && !result.is_valid ? (
<span className="text-sm text-destructive"></span>
) : (
<FiCheck className="size-3" />
)}
</motion.span>
</AnimatePresence>
</Button>
</TooltipTrigger>
<TooltipContent>
+182 -141
View File
@@ -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<ImportStep, number> = {
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<ImportStep>("dropzone");
const [{ step, direction: stepDirection }, setStep] = useReducer(
transitionStep,
{ step: "dropzone", direction: 1 },
);
const [isDragOver, setIsDragOver] = useState(false);
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
const [vpnName, setVpnName] = useState("");
const [vpnImportResult, setVpnImportResult] =
useState<VpnImportResult | null>(null);
const [isImporting, setIsImporting] = useState(false);
const os = getCurrentOS();
const modKey = os === "macos" ? "⌘" : "Ctrl";
@@ -190,159 +211,179 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("vpns.import.title")}</DialogTitle>
<DialogDescription>
{step === "dropzone" && t("vpns.import.descDropzone")}
{step === "vpn-preview" && t("vpns.import.descPreview")}
{step === "vpn-result" && t("vpns.import.descResult")}
</DialogDescription>
<StepTransition
transitionKey={`description-${step}`}
direction={stepDirection}
>
<DialogDescription>
{step === "dropzone" && t("vpns.import.descDropzone")}
{step === "vpn-preview" && t("vpns.import.descPreview")}
{step === "vpn-result" && t("vpns.import.descResult")}
</DialogDescription>
</StepTransition>
</DialogHeader>
{step === "dropzone" && (
<div className="space-y-4">
<div
role="button"
tabIndex={0}
className={`
flex flex-col items-center justify-center
border-2 border-dashed rounded-lg p-8
transition-colors cursor-pointer
${isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById("vpn-file-input")?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById("vpn-file-input")?.click();
<StepTransition
transitionKey={step}
direction={stepDirection}
className="grid gap-4"
>
{step === "dropzone" && (
<div className="space-y-4">
<div
role="button"
tabIndex={0}
className={`
flex flex-col items-center justify-center
border-2 border-dashed rounded-lg p-8
cursor-pointer transition-[color,background-color,border-color,transform]
duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none
${isDragOver ? "scale-[1.005] border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() =>
document.getElementById("vpn-file-input")?.click()
}
}}
>
<LuUpload className="mb-4 size-10 text-muted-foreground" />
<p className="text-center text-sm text-muted-foreground">
{t("vpns.import.dropzonePrompt")}
</p>
<input
id="vpn-file-input"
type="file"
accept=".conf"
className="hidden"
onChange={(e) => {
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();
}
}}
/>
>
<LuUpload
className={`mb-4 size-10 text-muted-foreground transition-transform duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none ${
isDragOver ? "-translate-y-0.5 scale-105" : ""
}`}
/>
<p className="text-center text-sm text-muted-foreground">
{t("vpns.import.dropzonePrompt")}
</p>
<input
id="vpn-file-input"
type="file"
accept=".conf"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileRead(file);
e.target.value = "";
}}
/>
</div>
<p className="text-center text-xs text-muted-foreground">
{t("vpns.import.pasteHint", { modKey })}
</p>
</div>
<p className="text-center text-xs text-muted-foreground">
{t("vpns.import.pasteHint", { modKey })}
</p>
</div>
)}
)}
{step === "vpn-preview" && vpnPreview && (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg bg-muted/30 p-4">
<LuShield className="size-8 text-primary" />
<div>
<div className="font-medium">
{t("vpns.import.configurationLabel", {
type: vpnPreview.detectedType,
})}
</div>
{vpnPreview.endpoint && (
<div className="text-sm text-muted-foreground">
{t("vpns.import.endpointLabel", {
endpoint: vpnPreview.endpoint,
{step === "vpn-preview" && vpnPreview && (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg bg-muted/30 p-4">
<LuShield className="size-8 text-primary" />
<div>
<div className="font-medium">
{t("vpns.import.configurationLabel", {
type: vpnPreview.detectedType,
})}
</div>
{vpnPreview.endpoint && (
<div className="text-sm text-muted-foreground">
{t("vpns.import.endpointLabel", {
endpoint: vpnPreview.endpoint,
})}
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="vpn-name">
{t("vpns.import.vpnNameLabel")}
</Label>
<Input
id="vpn-name"
placeholder={t("vpns.import.vpnNamePlaceholder")}
value={vpnName}
onChange={(e) => {
setVpnName(e.target.value);
}}
/>
</div>
<div className="space-y-2">
<Label>{t("vpns.import.configPreview")}</Label>
<ScrollArea className="h-[min(150px,25vh)] rounded-md border">
<pre className="p-2 font-mono text-xs break-all whitespace-pre-wrap">
{vpnPreview.content.slice(0, 1000)}
{vpnPreview.content.length > 1000 && "..."}
</pre>
</ScrollArea>
</div>
</div>
)}
{step === "vpn-result" && vpnImportResult && (
<div className="space-y-4">
<div
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-success/10" : "bg-destructive/10"}`}
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="size-8 text-success" />
<div>
<div className="font-medium text-success">
{t("vpns.import.importedSuccess")}
</div>
<div className="text-sm text-muted-foreground">
{vpnImportResult.name} ({vpnImportResult.vpn_type})
</div>
</div>
</div>
) : (
<div className="space-y-2">
<div className="font-medium text-destructive">
{t("vpns.import.importFailed")}
</div>
<div className="text-sm text-destructive">
{vpnImportResult.error}
</div>
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="vpn-name">{t("vpns.import.vpnNameLabel")}</Label>
<Input
id="vpn-name"
placeholder={t("vpns.import.vpnNamePlaceholder")}
value={vpnName}
onChange={(e) => {
setVpnName(e.target.value);
}}
/>
</div>
<div className="space-y-2">
<Label>{t("vpns.import.configPreview")}</Label>
<ScrollArea className="h-[min(150px,25vh)] rounded-md border">
<pre className="p-2 font-mono text-xs break-all whitespace-pre-wrap">
{vpnPreview.content.slice(0, 1000)}
{vpnPreview.content.length > 1000 && "..."}
</pre>
</ScrollArea>
</div>
</div>
)}
{step === "vpn-result" && vpnImportResult && (
<div className="space-y-4">
<div
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-success/10" : "bg-destructive/10"}`}
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="size-8 text-success" />
<div>
<div className="font-medium text-success">
{t("vpns.import.importedSuccess")}
</div>
<div className="text-sm text-muted-foreground">
{vpnImportResult.name} ({vpnImportResult.vpn_type})
</div>
</div>
</div>
) : (
<div className="space-y-2">
<div className="font-medium text-destructive">
{t("vpns.import.importFailed")}
</div>
<div className="text-sm text-destructive">
{vpnImportResult.error}
</div>
</div>
)}
</div>
</div>
)}
<DialogFooter>
{step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
)}
{step === "vpn-preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
{t("common.buttons.back")}
<DialogFooter>
{step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
>
{t("vpns.import.importButton")}
</LoadingButton>
</>
)}
)}
{step === "vpn-result" && (
<RippleButton onClick={handleClose}>
{t("vpns.import.doneButton")}
</RippleButton>
)}
</DialogFooter>
{step === "vpn-preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
{t("common.buttons.back")}
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
>
{t("vpns.import.importButton")}
</LoadingButton>
</>
)}
{step === "vpn-result" && (
<RippleButton onClick={handleClose}>
{t("vpns.import.doneButton")}
</RippleButton>
)}
</DialogFooter>
</StepTransition>
</DialogContent>
</Dialog>
);
+10
View File
@@ -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,
};
+1
View File
@@ -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);