Compare commits

..

3 Commits

Author SHA1 Message Date
hamster1963
8fbe50fd8d chore: update project dependencies 2025-02-18 18:04:38 +08:00
hamster1963
e2a9357f18 feat: add AnimatedCount to server overview metrics 2025-02-18 18:04:19 +08:00
hamster1963
69ff5365d5 feat: replace NumberFlow with custom AnimatedCount component for time display 2025-02-18 17:57:18 +08:00
7 changed files with 173 additions and 29 deletions

View File

@ -3,6 +3,7 @@
import { useFilter } from "@/app/context/network-filter-context" import { useFilter } from "@/app/context/network-filter-context"
import { useServerData } from "@/app/context/server-data-context" import { useServerData } from "@/app/context/server-data-context"
import { useStatus } from "@/app/context/status-context" import { useStatus } from "@/app/context/status-context"
import AnimateCountClient from "@/components/AnimatedCount"
import { Loader } from "@/components/loading/Loader" import { Loader } from "@/components/loading/Loader"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import getEnv from "@/lib/env-entry" import getEnv from "@/lib/env-entry"
@ -44,12 +45,14 @@ export default function ServerOverviewClient() {
<CardContent className="flex h-full items-center px-6 py-3"> <CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">{t("p_816-881_Totalservers")}</p> <p className="text-sm font-medium md:text-base">{t("p_816-881_Totalservers")}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-h-[28px]">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" /> <span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span> </span>
{data?.result ? ( {data?.result ? (
<div className="text-lg font-semibold">{data?.result.length}</div> <div className="text-lg font-semibold">
<AnimateCountClient count={data?.result.length} />
</div>
) : ( ) : (
<div className="flex h-7 items-center"> <div className="flex h-7 items-center">
<Loader visible={true} /> <Loader visible={true} />
@ -74,13 +77,15 @@ export default function ServerOverviewClient() {
<CardContent className="flex h-full items-center px-6 py-3"> <CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">{t("p_1610-1676_Onlineservers")}</p> <p className="text-sm font-medium md:text-base">{t("p_1610-1676_Onlineservers")}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-h-[28px]">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" /> <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span> </span>
{data?.result ? ( {data?.result ? (
<div className="text-lg font-semibold">{data?.live_servers}</div> <div className="text-lg font-semibold">
<AnimateCountClient count={data?.live_servers} />
</div>
) : ( ) : (
<div className="flex h-7 items-center"> <div className="flex h-7 items-center">
<Loader visible={true} /> <Loader visible={true} />
@ -105,13 +110,15 @@ export default function ServerOverviewClient() {
<CardContent className="flex h-full items-center px-6 py-3"> <CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base">{t("p_2532-2599_Offlineservers")}</p> <p className="text-sm font-medium md:text-base">{t("p_2532-2599_Offlineservers")}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-h-[28px]">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" /> <span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
</span> </span>
{data?.result ? ( {data?.result ? (
<div className="text-lg font-semibold">{data?.offline_servers}</div> <div className="text-lg font-semibold">
<AnimateCountClient count={data?.offline_servers} />
</div>
) : ( ) : (
<div className="flex h-7 items-center"> <div className="flex h-7 items-center">
<Loader visible={true} /> <Loader visible={true} />

View File

@ -1,11 +1,11 @@
"use client" "use client"
import AnimateCountClient from "@/components/AnimatedCount"
import { LanguageSwitcher } from "@/components/LanguageSwitcher" import { LanguageSwitcher } from "@/components/LanguageSwitcher"
import { ModeToggle } from "@/components/ThemeSwitcher" import { ModeToggle } from "@/components/ThemeSwitcher"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import getEnv from "@/lib/env-entry" import getEnv from "@/lib/env-entry"
import NumberFlow, { NumberFlowGroup } from "@number-flow/react"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
@ -75,24 +75,15 @@ const Overview = memo(function Overview() {
return ( return (
<section className={"mt-10 flex flex-col md:mt-16"}> <section className={"mt-10 flex flex-col md:mt-16"}>
<p className="text-base font-semibold">{t("p_2277-2331_Overview")}</p> <p className="text-base font-semibold">{t("p_2277-2331_Overview")}</p>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1">
<p className="text-sm font-medium opacity-50">{t("p_2390-2457_wherethetimeis")}</p> <p className="text-sm font-medium opacity-50">{t("p_2390-2457_wherethetimeis")}</p>
<NumberFlowGroup> <div className="flex items-center text-sm font-medium">
<div <AnimateCountClient count={time.hh} minDigits={2} />
style={{ fontVariantNumeric: "tabular-nums" }} <span className="text-sm mb-[1px] font-medium opacity-50">:</span>
className="flex text-sm font-medium mt-0.5" <AnimateCountClient count={time.mm} minDigits={2} />
> <span className="text-sm mb-[1px] font-medium opacity-50">:</span>
<NumberFlow trend={1} value={time.hh} format={{ minimumIntegerDigits: 2 }} /> <span className="text-sm font-medium">{time.ss.toString().padStart(2, "0")}</span>
<NumberFlow </div>
prefix=":"
trend={1}
value={time.mm}
digits={{ 1: { max: 5 } }}
format={{ minimumIntegerDigits: 2 }}
/>
<p className="mt-[0.5px]">:{time.ss.toString().padStart(2, "0")}</p>
</div>
</NumberFlowGroup>
</div> </div>
</section> </section>
) )

View File

BIN
bun.lockb

Binary file not shown.

View File

@ -0,0 +1,96 @@
import { cn } from "@/lib/utils"
import { useEffect, useState } from "react"
export default function AnimateCountClient({
count,
className,
minDigits,
}: {
count: number
className?: string
minDigits?: number
}) {
const [previousCount, setPreviousCount] = useState(count)
useEffect(() => {
if (count !== previousCount) {
setTimeout(() => {
setPreviousCount(count)
}, 300)
}
}, [count])
return (
<AnimateCount
key={count}
preCount={previousCount}
className={cn("inline-flex items-center leading-none", className)}
minDigits={minDigits}
data-issues-count-animation
>
{count}
</AnimateCount>
)
}
export function AnimateCount({
children: count,
className,
preCount,
minDigits = 1,
...props
}: {
children: number
className?: string
preCount?: number
minDigits?: number
}) {
const currentDigits = count.toString().split("")
const previousDigits = (
preCount !== undefined ? preCount.toString() : count - 1 >= 0 ? (count - 1).toString() : "0"
).split("")
// Ensure both numbers meet the minimum length requirement and maintain the same length for animation
const maxLength = Math.max(previousDigits.length, currentDigits.length, minDigits)
while (previousDigits.length < maxLength) {
previousDigits.unshift("0")
}
while (currentDigits.length < maxLength) {
currentDigits.unshift("0")
}
return (
<div {...props} className={cn("flex h-[1em] items-center", className)}>
{currentDigits.map((digit, index) => {
const hasChanged = digit !== previousDigits[index]
return (
<div
key={`${index}-${digit}`}
className={cn("relative h-full flex items-center min-w-[0.6em] text-center", {
"min-w-[0.2em]": digit === ".",
})}
>
<div
aria-hidden
data-issues-count-exit
className={cn(
"absolute inset-0 flex items-center justify-center",
hasChanged ? "animate" : "opacity-0",
)}
>
{previousDigits[index]}
</div>
<div
data-issues-count-enter
className={cn(
"absolute inset-0 flex items-center justify-center",
hasChanged && "animate",
)}
>
{digit}
</div>
</div>
)
})}
</div>
)
}

View File

@ -15,7 +15,6 @@
"dependencies": { "dependencies": {
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@number-flow/react": "^0.5.5",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
@ -31,7 +30,7 @@
"@types/d3-geo": "^3.1.0", "@types/d3-geo": "^3.1.0",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
"caniuse-lite": "^1.0.30001699", "caniuse-lite": "^1.0.30001700",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
@ -40,7 +39,7 @@
"d3-geo": "^3.1.1", "d3-geo": "^3.1.1",
"d3-selection": "^3.0.0", "d3-selection": "^3.0.0",
"flag-icons": "^7.3.2", "flag-icons": "^7.3.2",
"i18n-iso-countries": "^7.13.0", "i18n-iso-countries": "^7.14.0",
"lucide-react": "^0.474.0", "lucide-react": "^0.474.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"maxmind": "^4.3.24", "maxmind": "^4.3.24",
@ -65,8 +64,8 @@
"@next/bundle-analyzer": "^15.1.7", "@next/bundle-analyzer": "^15.1.7",
"@tailwindcss/postcss": "^4.0.6", "@tailwindcss/postcss": "^4.0.6",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"@types/react": "^19.0.8", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.4",
"postcss": "^8.5.2", "postcss": "^8.5.2",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@ -146,6 +146,7 @@
--chart-8: 252 50% 50%; --chart-8: 252 50% 50%;
--chart-9: 288 50% 50%; --chart-9: 288 50% 50%;
--chart-10: 324 50% 50%; --chart-10: 324 50% 50%;
--timing: cubic-bezier(0.4, 0, 0.2, 1);
} }
.dark { .dark {
@ -334,3 +335,53 @@
filter: blur(0px); filter: blur(0px);
} }
} }
/* Thanks to next.js. */
[data-issues-count-animation] {
display: flex;
justify-content: center;
align-items: center;
}
[data-issues-count-animation] > div {
text-align: center;
}
[data-issues-count-exit].animate {
animation: fadeOut 300ms var(--timing) forwards;
}
[data-issues-count-enter].animate {
animation: fadeIn 300ms var(--timing) forwards;
}
[data-issues-count-plural] {
display: inline-block;
animation: fadeIn 300ms var(--timing) forwards;
}
@keyframes fadeIn {
0% {
opacity: 0;
filter: blur(2px);
transform: translateY(8px);
}
100% {
opacity: 1;
filter: blur(0px);
transform: translateY(0);
}
}
@keyframes fadeOut {
0% {
opacity: 1;
filter: blur(0px);
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-12px);
filter: blur(2px);
}
}