mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 17:50:13 +00:00
refactor: remove framer-motion dependencies and implement custom active indicator for improved performance and animations
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { m } from "framer-motion";
|
||||
import { createRef, useEffect, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useActiveIndicator } from "@/hooks/use-active-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function GroupSwitch({
|
||||
@@ -17,7 +17,13 @@ export default function GroupSwitch({
|
||||
: undefined;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const tagRefs = useRef(tabs.map(() => createRef<HTMLDivElement>()));
|
||||
const {
|
||||
containerRef,
|
||||
enableIndicatorAnimation,
|
||||
indicator,
|
||||
itemRefs,
|
||||
setItemRef,
|
||||
} = useActiveIndicator(tabs, currentTab);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
@@ -50,16 +56,16 @@ export default function GroupSwitch({
|
||||
}, [tabs, setCurrentTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentTagRef = tagRefs.current[tabs.indexOf(currentTab)];
|
||||
const currentTagRef = itemRefs.current[tabs.indexOf(currentTab)];
|
||||
|
||||
if (currentTagRef?.current) {
|
||||
currentTagRef.current.scrollIntoView({
|
||||
if (currentTagRef) {
|
||||
currentTagRef.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
}
|
||||
}, [currentTab, tabs.indexOf]);
|
||||
}, [currentTab, itemRefs, tabs]);
|
||||
|
||||
if (tabs.length === 1 && tabs[0] === "All") {
|
||||
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]"
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
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,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{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
|
||||
key={tab}
|
||||
ref={tagRefs.current[index]}
|
||||
onClick={() => setCurrentTab(tab)}
|
||||
ref={setItemRef(index)}
|
||||
onClick={() => {
|
||||
if (currentTab !== tab) {
|
||||
enableIndicatorAnimation();
|
||||
}
|
||||
setCurrentTab(tab);
|
||||
}}
|
||||
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",
|
||||
currentTab === tab
|
||||
@@ -90,16 +116,6 @@ export default function GroupSwitch({
|
||||
: "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">
|
||||
<p className="whitespace-nowrap">{tab}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import { ImageMinus } from "lucide-react";
|
||||
import { DateTime } from "luxon";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -252,20 +251,12 @@ export function RefreshToast() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<m.div
|
||||
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)", scale: 0.8 }}
|
||||
transition={{ type: "spring", duration: 0.8 }}
|
||||
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>
|
||||
<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">
|
||||
<section className="flex items-center gap-1.5">
|
||||
<LoadingSpinner />
|
||||
<p className="text-[12.5px] font-medium">{t("refreshing")}...</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AnimatePresence, m } from "framer-motion";
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -12,58 +11,53 @@ const MapTooltip = memo(function MapTooltip() {
|
||||
if (!tooltipData) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<m.div
|
||||
initial={{ opacity: 0, filter: "blur(10px)" }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(10px)" }}
|
||||
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"
|
||||
key={tooltipData.country}
|
||||
<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"
|
||||
key={tooltipData.country}
|
||||
style={{
|
||||
left: tooltipData.centroid[0],
|
||||
top: tooltipData.centroid[1],
|
||||
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={{
|
||||
left: tooltipData.centroid[0],
|
||||
top: tooltipData.centroid[1],
|
||||
transform: "translate(20%, -50%)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.stopPropagation();
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<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={{
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{tooltipData.servers.map((server) => (
|
||||
<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>
|
||||
{tooltipData.servers.map((server) => (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useActiveIndicator } from "@/hooks/use-active-indicator";
|
||||
import {
|
||||
fetchLoginUser,
|
||||
fetchMonitor,
|
||||
@@ -250,11 +250,20 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
||||
const [showPeriodLoading, setShowPeriodLoading] = React.useState(false);
|
||||
const loadingStartedAtRef = React.useRef<number | null>(null);
|
||||
|
||||
const TIME_RANGE_OPTIONS: { value: MonitorPeriod; label: string }[] = [
|
||||
{ value: "1d", label: t("monitor.period1d") },
|
||||
{ value: "7d", label: t("monitor.period7d") },
|
||||
{ value: "30d", label: t("monitor.period30d") },
|
||||
];
|
||||
const TIME_RANGE_OPTIONS = useMemo<{ value: MonitorPeriod; label: string }[]>(
|
||||
() => [
|
||||
{ value: "1d", label: t("monitor.period1d") },
|
||||
{ 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(() => {
|
||||
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 items-center gap-3 sm:-mt-5 -mt-3 flex-wrap">
|
||||
<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">
|
||||
{TIME_RANGE_OPTIONS.map((option) => {
|
||||
<div
|
||||
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 optionItem = (
|
||||
<div
|
||||
ref={setItemRef(index)}
|
||||
onClick={() => {
|
||||
if (!isLocked) {
|
||||
if (period !== option.value) {
|
||||
enableIndicatorAnimation();
|
||||
}
|
||||
onPeriodChange(option.value);
|
||||
}
|
||||
}}
|
||||
@@ -599,17 +628,6 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { m } from "framer-motion";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useActiveIndicator } from "@/hooks/use-active-indicator";
|
||||
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import {
|
||||
@@ -106,17 +106,42 @@ function PeriodSelector({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const periods: { value: ChartPeriod; label: string }[] = [
|
||||
{ value: "realtime", label: t("serverDetailChart.realtime") },
|
||||
{ value: "1d", label: t("serverDetailChart.period1d") },
|
||||
{ value: "7d", label: t("serverDetailChart.period7d") },
|
||||
{ value: "30d", label: t("serverDetailChart.period30d") },
|
||||
];
|
||||
const periods = useMemo<{ value: ChartPeriod; label: string }[]>(
|
||||
() => [
|
||||
{ value: "realtime", label: t("serverDetailChart.realtime") },
|
||||
{ value: "1d", label: t("serverDetailChart.period1d") },
|
||||
{ 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 (
|
||||
<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">
|
||||
{periods.map((period) => {
|
||||
<div
|
||||
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 isLockedByTsdb = !isTsdbEnabled && isHistoryPeriod;
|
||||
// Only realtime and 1d are available for non-logged-in users
|
||||
@@ -129,8 +154,12 @@ function PeriodSelector({
|
||||
|
||||
const periodItem = (
|
||||
<div
|
||||
ref={setItemRef(index)}
|
||||
onClick={() => {
|
||||
if (!isLocked) {
|
||||
if (selectedPeriod !== period.value) {
|
||||
enableIndicatorAnimation();
|
||||
}
|
||||
onPeriodChange(period.value);
|
||||
}
|
||||
}}
|
||||
@@ -142,13 +171,6 @@ function PeriodSelector({
|
||||
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">
|
||||
{period.value === "realtime" && (
|
||||
<span className="inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500 dark:bg-emerald-400"></span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { m } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useActiveIndicator } from "@/hooks/use-active-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function TabSwitch({
|
||||
@@ -12,6 +12,8 @@ export default function TabSwitch({
|
||||
setCurrentTab: (tab: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
|
||||
useActiveIndicator(tabs, currentTab);
|
||||
const customBackgroundImage =
|
||||
(window.CustomBackgroundImage as string) !== ""
|
||||
? window.CustomBackgroundImage
|
||||
@@ -19,17 +21,38 @@ export default function TabSwitch({
|
||||
return (
|
||||
<div className="z-50 flex flex-col items-start rounded-[50px] server-info-tab">
|
||||
<div
|
||||
ref={containerRef}
|
||||
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,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{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
|
||||
key={tab}
|
||||
onClick={() => setCurrentTab(tab)}
|
||||
ref={setItemRef(index)}
|
||||
onClick={() => {
|
||||
if (currentTab !== tab) {
|
||||
enableIndicatorAnimation();
|
||||
}
|
||||
setCurrentTab(tab);
|
||||
}}
|
||||
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",
|
||||
currentTab === tab
|
||||
@@ -37,16 +60,6 @@ export default function TabSwitch({
|
||||
: "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">
|
||||
<p className="whitespace-nowrap">{t(`tabSwitch.${tab}`)}</p>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { domMax as default } from "framer-motion";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -334,6 +334,53 @@
|
||||
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. */
|
||||
[data-issues-count-animation] {
|
||||
display: flex;
|
||||
|
||||
+28
-31
@@ -4,7 +4,6 @@ import ReactDOM from "react-dom/client";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import App from "./App";
|
||||
import { MotionProvider } from "./components/motion/motion-provider";
|
||||
import { ThemeColorManager } from "./components/ThemeColorManager";
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
import { CommandProvider } from "./context/command-provider";
|
||||
@@ -23,34 +22,32 @@ if (!rootElement) {
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<MotionProvider>
|
||||
<ThemeProvider storageKey="vite-ui-theme">
|
||||
<ThemeColorManager />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WebSocketProvider url="/api/v1/ws/server">
|
||||
<CommandProvider>
|
||||
<StatusProvider>
|
||||
<SortProvider>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
duration={1000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
default:
|
||||
"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"
|
||||
className={"flex items-center justify-center"}
|
||||
/>
|
||||
<ReactQueryDevtools />
|
||||
</TooltipProvider>
|
||||
</SortProvider>
|
||||
</StatusProvider>
|
||||
</CommandProvider>
|
||||
</WebSocketProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</MotionProvider>,
|
||||
<ThemeProvider storageKey="vite-ui-theme">
|
||||
<ThemeColorManager />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WebSocketProvider url="/api/v1/ws/server">
|
||||
<CommandProvider>
|
||||
<StatusProvider>
|
||||
<SortProvider>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
duration={1000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
default:
|
||||
"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"
|
||||
className={"flex items-center justify-center"}
|
||||
/>
|
||||
<ReactQueryDevtools />
|
||||
</TooltipProvider>
|
||||
</SortProvider>
|
||||
</StatusProvider>
|
||||
</CommandProvider>
|
||||
</WebSocketProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user