mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-20 03:17:39 +02:00
refactors: animations cleanup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user