refactor: remove framer-motion dependencies and implement custom active indicator for improved performance and animations

This commit is contained in:
hamster1963
2026-06-10 13:23:10 +08:00
parent 8e0f7a2fb6
commit eb682dc75f
13 changed files with 359 additions and 217 deletions
-1
View File
@@ -39,7 +39,6 @@
"country-flag-icons": "1.6.17", "country-flag-icons": "1.6.17",
"d3-geo": "3.1.1", "d3-geo": "3.1.1",
"dayjs": "1.11.20", "dayjs": "1.11.20",
"framer-motion": "12.40.0",
"i18n-iso-countries": "7.14.0", "i18n-iso-countries": "7.14.0",
"i18next": "26.2.0", "i18next": "26.2.0",
"lucide-react": "1.16.0", "lucide-react": "1.16.0",
-38
View File
@@ -83,9 +83,6 @@ importers:
dayjs: dayjs:
specifier: 1.11.20 specifier: 1.11.20
version: 1.11.20 version: 1.11.20
framer-motion:
specifier: 12.40.0
version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
i18n-iso-countries: i18n-iso-countries:
specifier: 7.14.0 specifier: 7.14.0
version: 7.14.0 version: 7.14.0
@@ -1157,20 +1154,6 @@ packages:
picomatch: picomatch:
optional: true optional: true
framer-motion@12.40.0:
resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1302,12 +1285,6 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
motion-dom@12.40.0:
resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
nanoid@3.3.12: nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -2440,15 +2417,6 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
framer-motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
motion-dom: 12.40.0
motion-utils: 12.39.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -2537,12 +2505,6 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
motion-dom@12.40.0:
dependencies:
motion-utils: 12.39.0
motion-utils@12.39.0: {}
nanoid@3.3.12: {} nanoid@3.3.12: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
+36 -20
View File
@@ -1,5 +1,5 @@
import { m } from "framer-motion"; import { useEffect, useRef } from "react";
import { createRef, useEffect, useRef } from "react"; import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function GroupSwitch({ export default function GroupSwitch({
@@ -17,7 +17,13 @@ export default function GroupSwitch({
: undefined; : undefined;
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const tagRefs = useRef(tabs.map(() => createRef<HTMLDivElement>())); const {
containerRef,
enableIndicatorAnimation,
indicator,
itemRefs,
setItemRef,
} = useActiveIndicator(tabs, currentTab);
useEffect(() => { useEffect(() => {
const container = scrollRef.current; const container = scrollRef.current;
@@ -50,16 +56,16 @@ export default function GroupSwitch({
}, [tabs, setCurrentTab]); }, [tabs, setCurrentTab]);
useEffect(() => { useEffect(() => {
const currentTagRef = tagRefs.current[tabs.indexOf(currentTab)]; const currentTagRef = itemRefs.current[tabs.indexOf(currentTab)];
if (currentTagRef?.current) { if (currentTagRef) {
currentTagRef.current.scrollIntoView({ currentTagRef.scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "nearest", block: "nearest",
inline: "center", inline: "center",
}); });
} }
}, [currentTab, tabs.indexOf]); }, [currentTab, itemRefs, tabs]);
if (tabs.length === 1 && tabs[0] === "All") { if (tabs.length === 1 && tabs[0] === "All") {
return null; return null;
@@ -71,18 +77,38 @@ export default function GroupSwitch({
className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]" className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]"
> >
<div <div
ref={containerRef}
className={cn( className={cn(
"flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800", "relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
{ {
"bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage, "bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage,
}, },
)} )}
> >
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 46,
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{tabs.map((tab: string, index: number) => ( {tabs.map((tab: string, index: number) => (
<div <div
key={tab} key={tab}
ref={tagRefs.current[index]} ref={setItemRef(index)}
onClick={() => setCurrentTab(tab)} onClick={() => {
if (currentTab !== tab) {
enableIndicatorAnimation();
}
setCurrentTab(tab);
}}
className={cn( className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50", "relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50",
currentTab === tab currentTab === tab
@@ -90,16 +116,6 @@ export default function GroupSwitch({
: "text-stone-400 dark:text-stone-500", : "text-stone-400 dark:text-stone-500",
)} )}
> >
{currentTab === tab && (
<m.div
layoutId="tab-switch"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1"> <div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{tab}</p> <p className="whitespace-nowrap">{tab}</p>
</div> </div>
+6 -15
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { AnimatePresence, m } from "framer-motion";
import { ImageMinus } from "lucide-react"; import { ImageMinus } from "lucide-react";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -252,20 +251,12 @@ export function RefreshToast() {
} }
return ( return (
<AnimatePresence> <div className="refresh-toast-animate fixed left-1/2 -translate-x-1/2 top-8 z-999 flex items-center justify-between gap-4 rounded-[50px] border border-solid bg-white px-2 py-1.5 shadow-xl shadow-black/5 dark:border-stone-700 dark:bg-stone-800 dark:shadow-none">
<m.div <section className="flex items-center gap-1.5">
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }} <LoadingSpinner />
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} <p className="text-[12.5px] font-medium">{t("refreshing")}...</p>
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }} </section>
transition={{ type: "spring", duration: 0.8 }} </div>
className="fixed left-1/2 -translate-x-1/2 top-8 z-999 flex items-center justify-between gap-4 rounded-[50px] border border-solid bg-white px-2 py-1.5 shadow-xl shadow-black/5 dark:border-stone-700 dark:bg-stone-800 dark:shadow-none"
>
<section className="flex items-center gap-1.5">
<LoadingSpinner />
<p className="text-[12.5px] font-medium">{t("refreshing")}...</p>
</section>
</m.div>
</AnimatePresence>
); );
} }
+44 -50
View File
@@ -1,4 +1,3 @@
import { AnimatePresence, m } from "framer-motion";
import { memo } from "react"; import { memo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -12,58 +11,53 @@ const MapTooltip = memo(function MapTooltip() {
if (!tooltipData) return null; if (!tooltipData) return null;
return ( return (
<AnimatePresence mode="wait"> <div
<m.div className="tooltip-animate absolute hidden lg:block bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700 z-50"
initial={{ opacity: 0, filter: "blur(10px)" }} key={tooltipData.country}
animate={{ opacity: 1, filter: "blur(0px)" }} style={{
exit={{ opacity: 0, filter: "blur(10px)" }} left: tooltipData.centroid[0],
className="absolute hidden lg:block bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700 z-50" top: tooltipData.centroid[1],
key={tooltipData.country} transform: "translate(20%, -50%)",
}}
onMouseEnter={(e) => {
e.stopPropagation();
}}
>
<div>
<p className="font-medium">
{tooltipData.country === "China"
? "Mainland China"
: tooltipData.country}
</p>
<p className="text-neutral-600 dark:text-neutral-400 text-xs font-light mb-1">
{tooltipData.count} {t("map.Servers")}
</p>
</div>
<div
className="border-t dark:border-neutral-700 pt-1"
style={{ style={{
left: tooltipData.centroid[0], maxHeight: "200px",
top: tooltipData.centroid[1], overflowY: "auto",
transform: "translate(20%, -50%)",
}}
onMouseEnter={(e) => {
e.stopPropagation();
}} }}
> >
<div> {tooltipData.servers.map((server) => (
<p className="font-medium"> <button
{tooltipData.country === "China" key={server.id}
? "Mainland China" type="button"
: tooltipData.country} className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white"
</p> onClick={() => {
<p className="text-neutral-600 dark:text-neutral-400 text-xs font-light mb-1"> sessionStorage.setItem("fromMainPage", "true");
{tooltipData.count} {t("map.Servers")} navigate(`/server/${server.id}`);
</p> }}
</div> >
<div <span
className="border-t dark:border-neutral-700 pt-1" className={`h-1.5 w-1.5 shrink-0 rounded-full ${server.status ? "bg-green-500" : "bg-red-500"}`}
style={{ />
maxHeight: "200px", <span className="text-xs">{server.name}</span>
overflowY: "auto", </button>
}} ))}
> </div>
{tooltipData.servers.map((server) => ( </div>
<button
key={server.id}
type="button"
className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white"
onClick={() => {
sessionStorage.setItem("fromMainPage", "true");
navigate(`/server/${server.id}`);
}}
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${server.status ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-xs">{server.name}</span>
</button>
))}
</div>
</m.div>
</AnimatePresence>
); );
}); });
+37 -19
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { m } from "framer-motion";
import * as React from "react"; import * as React from "react";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -34,6 +33,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { import {
fetchLoginUser, fetchLoginUser,
fetchMonitor, fetchMonitor,
@@ -250,11 +250,20 @@ export const NetworkChartClient = React.memo(function NetworkChart({
const [showPeriodLoading, setShowPeriodLoading] = React.useState(false); const [showPeriodLoading, setShowPeriodLoading] = React.useState(false);
const loadingStartedAtRef = React.useRef<number | null>(null); const loadingStartedAtRef = React.useRef<number | null>(null);
const TIME_RANGE_OPTIONS: { value: MonitorPeriod; label: string }[] = [ const TIME_RANGE_OPTIONS = useMemo<{ value: MonitorPeriod; label: string }[]>(
{ value: "1d", label: t("monitor.period1d") }, () => [
{ value: "7d", label: t("monitor.period7d") }, { value: "1d", label: t("monitor.period1d") },
{ value: "30d", label: t("monitor.period30d") }, { value: "7d", label: t("monitor.period7d") },
]; { value: "30d", label: t("monitor.period30d") },
],
[t],
);
const timeRangeValues = useMemo(
() => TIME_RANGE_OPTIONS.map((option) => option.value),
[TIME_RANGE_OPTIONS],
);
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(timeRangeValues, period);
React.useEffect(() => { React.useEffect(() => {
let timeoutId: number | undefined; let timeoutId: number | undefined;
@@ -581,13 +590,33 @@ export const NetworkChartClient = React.memo(function NetworkChart({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-3 sm:-mt-5 -mt-3 flex-wrap"> <div className="flex items-center gap-3 sm:-mt-5 -mt-3 flex-wrap">
<TooltipProvider delayDuration={120}> <TooltipProvider delayDuration={120}>
<div className="flex items-center gap-1 rounded-full bg-muted dark:bg-muted/40 p-0.5 border border-border/60 dark:border-border"> <div
{TIME_RANGE_OPTIONS.map((option) => { ref={containerRef}
className="relative flex items-center gap-1 rounded-full bg-muted dark:bg-muted/40 p-0.5 border border-border/60 dark:border-border"
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
style={{
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{TIME_RANGE_OPTIONS.map((option, index) => {
const isLocked = !isLogin && option.value !== "1d"; const isLocked = !isLogin && option.value !== "1d";
const optionItem = ( const optionItem = (
<div <div
ref={setItemRef(index)}
onClick={() => { onClick={() => {
if (!isLocked) { if (!isLocked) {
if (period !== option.value) {
enableIndicatorAnimation();
}
onPeriodChange(option.value); onPeriodChange(option.value);
} }
}} }}
@@ -599,17 +628,6 @@ export const NetworkChartClient = React.memo(function NetworkChart({
isLocked && "cursor-not-allowed opacity-40 grayscale", isLocked && "cursor-not-allowed opacity-40 grayscale",
)} )}
> >
{period === option.value && (
<m.div
layoutId="network-period-selector-active"
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<span className="relative z-20">{option.label}</span> <span className="relative z-20">{option.label}</span>
</div> </div>
); );
+38 -16
View File
@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { m } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -24,6 +23,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { useWebSocketContext } from "@/hooks/use-websocket-context"; import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import { import {
@@ -106,17 +106,42 @@ function PeriodSelector({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const periods: { value: ChartPeriod; label: string }[] = [ const periods = useMemo<{ value: ChartPeriod; label: string }[]>(
{ value: "realtime", label: t("serverDetailChart.realtime") }, () => [
{ value: "1d", label: t("serverDetailChart.period1d") }, { value: "realtime", label: t("serverDetailChart.realtime") },
{ value: "7d", label: t("serverDetailChart.period7d") }, { value: "1d", label: t("serverDetailChart.period1d") },
{ value: "30d", label: t("serverDetailChart.period30d") }, { value: "7d", label: t("serverDetailChart.period7d") },
]; { value: "30d", label: t("serverDetailChart.period30d") },
],
[t],
);
const periodValues = useMemo(
() => periods.map((period) => period.value),
[periods],
);
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(periodValues, selectedPeriod);
return ( return (
<TooltipProvider delayDuration={120}> <TooltipProvider delayDuration={120}>
<div className="flex gap-0.5 mb-3 flex-wrap sm:-mt-5 -mt-3 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border"> <div
{periods.map((period) => { ref={containerRef}
className="relative flex gap-0.5 mb-3 flex-wrap sm:-mt-5 -mt-3 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border"
>
{indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
style={{
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{periods.map((period, index) => {
const isHistoryPeriod = period.value !== "realtime"; const isHistoryPeriod = period.value !== "realtime";
const isLockedByTsdb = !isTsdbEnabled && isHistoryPeriod; const isLockedByTsdb = !isTsdbEnabled && isHistoryPeriod;
// Only realtime and 1d are available for non-logged-in users // Only realtime and 1d are available for non-logged-in users
@@ -129,8 +154,12 @@ function PeriodSelector({
const periodItem = ( const periodItem = (
<div <div
ref={setItemRef(index)}
onClick={() => { onClick={() => {
if (!isLocked) { if (!isLocked) {
if (selectedPeriod !== period.value) {
enableIndicatorAnimation();
}
onPeriodChange(period.value); onPeriodChange(period.value);
} }
}} }}
@@ -142,13 +171,6 @@ function PeriodSelector({
isLocked && "cursor-not-allowed opacity-40 grayscale", isLocked && "cursor-not-allowed opacity-40 grayscale",
)} )}
> >
{selectedPeriod === period.value && (
<m.div
layoutId="period-selector-active"
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
transition={{ type: "spring", stiffness: 250, damping: 30 }}
/>
)}
<div className="relative z-20 flex items-center gap-1.5"> <div className="relative z-20 flex items-center gap-1.5">
{period.value === "realtime" && ( {period.value === "realtime" && (
<span className="inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500 dark:bg-emerald-400"></span> <span className="inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500 dark:bg-emerald-400"></span>
+27 -14
View File
@@ -1,5 +1,5 @@
import { m } from "framer-motion";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function TabSwitch({ export default function TabSwitch({
@@ -12,6 +12,8 @@ export default function TabSwitch({
setCurrentTab: (tab: string) => void; setCurrentTab: (tab: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(tabs, currentTab);
const customBackgroundImage = const customBackgroundImage =
(window.CustomBackgroundImage as string) !== "" (window.CustomBackgroundImage as string) !== ""
? window.CustomBackgroundImage ? window.CustomBackgroundImage
@@ -19,17 +21,38 @@ export default function TabSwitch({
return ( return (
<div className="z-50 flex flex-col items-start rounded-[50px] server-info-tab"> <div className="z-50 flex flex-col items-start rounded-[50px] server-info-tab">
<div <div
ref={containerRef}
className={cn( className={cn(
"flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800", "relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800",
{ {
"bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage, "bg-stone-100/70 dark:bg-stone-800/70": customBackgroundImage,
}, },
)} )}
> >
{tabs.map((tab: string) => ( {indicator && (
<div
className="active-indicator-fade-in absolute left-0 top-0 z-10 content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 46,
height: indicator.height,
transform: `translate(${indicator.x}px, ${indicator.y}px)`,
transition: indicator.shouldAnimate
? "transform 0.5s var(--timing), width 0.5s var(--timing), height 0.5s var(--timing)"
: "none",
width: indicator.width,
}}
/>
)}
{tabs.map((tab: string, index: number) => (
<div <div
key={tab} key={tab}
onClick={() => setCurrentTab(tab)} ref={setItemRef(index)}
onClick={() => {
if (currentTab !== tab) {
enableIndicatorAnimation();
}
setCurrentTab(tab);
}}
className={cn( className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50", "relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50",
currentTab === tab currentTab === tab
@@ -37,16 +60,6 @@ export default function TabSwitch({
: "text-stone-400 dark:text-stone-500", : "text-stone-400 dark:text-stone-500",
)} )}
> >
{currentTab === tab && (
<m.div
layoutId="tab-switch-active"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1"> <div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{t(`tabSwitch.${tab}`)}</p> <p className="whitespace-nowrap">{t(`tabSwitch.${tab}`)}</p>
</div> </div>
@@ -1 +0,0 @@
export { domMax as default } from "framer-motion";
-12
View File
@@ -1,12 +0,0 @@
import { LazyMotion } from "framer-motion";
const loadFeatures = () =>
import("./framer-lazy-feature").then((res) => res.default);
export const MotionProvider = ({ children }: { children: React.ReactNode }) => {
return (
<LazyMotion features={loadFeatures} strict key="framer">
{children}
</LazyMotion>
);
};
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
type ActiveIndicator = {
x: number;
y: number;
width: number;
height: number;
shouldAnimate: boolean;
};
export function useActiveIndicator<T>(items: T[], activeItem: T) {
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const hasMeasuredRef = useRef(false);
const shouldAnimateNextRef = useRef(false);
const [indicator, setIndicator] = useState<ActiveIndicator | null>(null);
const enableIndicatorAnimation = useCallback(() => {
shouldAnimateNextRef.current = true;
}, []);
const setItemRef = useCallback(
(index: number) => (node: HTMLDivElement | null) => {
itemRefs.current[index] = node;
},
[],
);
const updateIndicator = useCallback(() => {
const activeIndex = items.indexOf(activeItem);
const activeElement = itemRefs.current[activeIndex];
if (!activeElement || !containerRef.current) {
hasMeasuredRef.current = false;
shouldAnimateNextRef.current = false;
setIndicator((currentIndicator) =>
currentIndicator === null ? currentIndicator : null,
);
return;
}
const nextIndicator = {
x: activeElement.offsetLeft,
y: activeElement.offsetTop,
width: activeElement.offsetWidth,
height: activeElement.offsetHeight,
shouldAnimate: hasMeasuredRef.current && shouldAnimateNextRef.current,
};
hasMeasuredRef.current = true;
shouldAnimateNextRef.current = false;
setIndicator((currentIndicator) => {
if (
currentIndicator &&
currentIndicator.x === nextIndicator.x &&
currentIndicator.y === nextIndicator.y &&
currentIndicator.width === nextIndicator.width &&
currentIndicator.height === nextIndicator.height
) {
return currentIndicator;
}
return nextIndicator;
});
}, [activeItem, items]);
useLayoutEffect(() => {
updateIndicator();
const container = containerRef.current;
if (!container || typeof ResizeObserver === "undefined") {
return;
}
const resizeObserver = new ResizeObserver(updateIndicator);
resizeObserver.observe(container);
for (const item of itemRefs.current) {
if (item) {
resizeObserver.observe(item);
}
}
return () => {
resizeObserver.disconnect();
};
}, [updateIndicator]);
return {
containerRef,
enableIndicatorAnimation,
indicator,
itemRefs,
setItemRef,
};
}
+47
View File
@@ -334,6 +334,53 @@
display: none; /* Chrome, Safari 和 Opera */ display: none; /* Chrome, Safari 和 Opera */
} }
.tooltip-animate {
opacity: 0;
filter: blur(5px);
animation: tooltip-fade-in 0.1s ease-in-out forwards;
}
.refresh-toast-animate {
animation: refresh-toast-in 0.5s var(--timing) forwards;
}
.active-indicator-fade-in {
animation: active-indicator-fade-in 0.18s var(--timing) forwards;
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
filter: blur(5px);
}
to {
opacity: 1;
filter: blur(0px);
}
}
@keyframes active-indicator-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes refresh-toast-in {
from {
opacity: 0;
filter: blur(10px);
transform: translateX(-50%) scale(0.8);
}
to {
opacity: 1;
filter: blur(0px);
transform: translateX(-50%) scale(1);
}
}
/* Thanks to next.js. */ /* Thanks to next.js. */
[data-issues-count-animation] { [data-issues-count-animation] {
display: flex; display: flex;
+28 -31
View File
@@ -4,7 +4,6 @@ import ReactDOM from "react-dom/client";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import App from "./App"; import App from "./App";
import { MotionProvider } from "./components/motion/motion-provider";
import { ThemeColorManager } from "./components/ThemeColorManager"; import { ThemeColorManager } from "./components/ThemeColorManager";
import { ThemeProvider } from "./components/ThemeProvider"; import { ThemeProvider } from "./components/ThemeProvider";
import { CommandProvider } from "./context/command-provider"; import { CommandProvider } from "./context/command-provider";
@@ -23,34 +22,32 @@ if (!rootElement) {
} }
ReactDOM.createRoot(rootElement).render( ReactDOM.createRoot(rootElement).render(
<MotionProvider> <ThemeProvider storageKey="vite-ui-theme">
<ThemeProvider storageKey="vite-ui-theme"> <ThemeColorManager />
<ThemeColorManager /> <QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}> <WebSocketProvider url="/api/v1/ws/server">
<WebSocketProvider url="/api/v1/ws/server"> <CommandProvider>
<CommandProvider> <StatusProvider>
<StatusProvider> <SortProvider>
<SortProvider> <TooltipProvider>
<TooltipProvider> <App />
<App /> <Toaster
<Toaster duration={1000}
duration={1000} toastOptions={{
toastOptions={{ classNames: {
classNames: { default:
default: "w-fit rounded-full px-2.5 py-1.5 bg-neutral-100 border border-neutral-200 backdrop-blur-xl shadow-none",
"w-fit rounded-full px-2.5 py-1.5 bg-neutral-100 border border-neutral-200 backdrop-blur-xl shadow-none", },
}, }}
}} position="top-center"
position="top-center" className={"flex items-center justify-center"}
className={"flex items-center justify-center"} />
/> <ReactQueryDevtools />
<ReactQueryDevtools /> </TooltipProvider>
</TooltipProvider> </SortProvider>
</SortProvider> </StatusProvider>
</StatusProvider> </CommandProvider>
</CommandProvider> </WebSocketProvider>
</WebSocketProvider> </QueryClientProvider>
</QueryClientProvider> </ThemeProvider>,
</ThemeProvider>
</MotionProvider>,
); );