mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-05-06 05:48:41 +00:00
feat: implement animated count component and update header for real-time display
This commit is contained in:
@@ -6,7 +6,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "5.1.1",
|
"@fontsource/inter": "5.1.1",
|
||||||
"@heroicons/react": "2.2.0",
|
"@heroicons/react": "2.2.0",
|
||||||
"@number-flow/react": "0.5.5",
|
|
||||||
"@radix-ui/react-accordion": "1.2.3",
|
"@radix-ui/react-accordion": "1.2.3",
|
||||||
"@radix-ui/react-checkbox": "1.1.4",
|
"@radix-ui/react-checkbox": "1.1.4",
|
||||||
"@radix-ui/react-dialog": "1.1.6",
|
"@radix-ui/react-dialog": "1.1.6",
|
||||||
@@ -44,7 +43,7 @@
|
|||||||
"recharts": "2.15.1",
|
"recharts": "2.15.1",
|
||||||
"sonner": "1.7.4",
|
"sonner": "1.7.4",
|
||||||
"tailwind-merge": "2.6.0",
|
"tailwind-merge": "2.6.0",
|
||||||
"tailwindcss-animate": "1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.20.0",
|
"@eslint/js": "9.20.0",
|
||||||
@@ -190,8 +189,6 @@
|
|||||||
|
|
||||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||||
|
|
||||||
"@number-flow/react": ["@number-flow/react@0.5.5", "", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.5.3" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-Zdju5n0osxrb+7jbcpUJ9L2VJ2+9ptwjz5+A+2wq9Q32hs3PW/noPJjHtLTrtGINM9mEw76DcDg0ac/dx6j1aA=="],
|
|
||||||
|
|
||||||
"@radix-ui/number": ["@radix-ui/number@1.1.0", "", {}, "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="],
|
"@radix-ui/number": ["@radix-ui/number@1.1.0", "", {}, "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="],
|
||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
|
||||||
@@ -530,8 +527,6 @@
|
|||||||
|
|
||||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
|
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
|
||||||
|
|
||||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
|
||||||
|
|
||||||
"espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="],
|
"espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="],
|
||||||
|
|
||||||
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
||||||
@@ -678,8 +673,6 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"number-flow": ["number-flow@0.5.3", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-iLKyssImNWQmJ41rza9K7P5lHRZTyishi/9FarWPLQHYY2Ydtl6eiXINEjZ1fa8dHeY0O7+YOD+Py3ZsJddYkg=="],
|
|
||||||
|
|
||||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|||||||
+1
-2
@@ -14,7 +14,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "5.1.1",
|
"@fontsource/inter": "5.1.1",
|
||||||
"@heroicons/react": "2.2.0",
|
"@heroicons/react": "2.2.0",
|
||||||
"@number-flow/react": "0.5.5",
|
|
||||||
"@radix-ui/react-accordion": "1.2.3",
|
"@radix-ui/react-accordion": "1.2.3",
|
||||||
"@radix-ui/react-checkbox": "1.1.4",
|
"@radix-ui/react-checkbox": "1.1.4",
|
||||||
"@radix-ui/react-dialog": "1.1.6",
|
"@radix-ui/react-dialog": "1.1.6",
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
"recharts": "2.15.1",
|
"recharts": "2.15.1",
|
||||||
"sonner": "1.7.4",
|
"sonner": "1.7.4",
|
||||||
"tailwind-merge": "2.6.0",
|
"tailwind-merge": "2.6.0",
|
||||||
"tailwindcss-animate": "1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.20.0",
|
"@eslint/js": "9.20.0",
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export 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 default AnimateCountClient
|
||||||
|
|
||||||
|
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 flex h-full min-w-[0.6em] items-center 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+47
-22
@@ -5,7 +5,6 @@ import { useBackground } from "@/hooks/use-background"
|
|||||||
import { useWebSocketContext } from "@/hooks/use-websocket-context"
|
import { useWebSocketContext } from "@/hooks/use-websocket-context"
|
||||||
import { fetchLoginUser, fetchSetting } from "@/lib/nezha-api"
|
import { fetchLoginUser, fetchSetting } from "@/lib/nezha-api"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import NumberFlow, { NumberFlowGroup } from "@number-flow/react"
|
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { AnimatePresence, m } from "framer-motion"
|
import { AnimatePresence, m } from "framer-motion"
|
||||||
import { ImageMinus } from "lucide-react"
|
import { ImageMinus } from "lucide-react"
|
||||||
@@ -14,11 +13,41 @@ import { useEffect, useRef, useState } from "react"
|
|||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
import AnimateCountClient from "./AnimatedCount"
|
||||||
import { LanguageSwitcher } from "./LanguageSwitcher"
|
import { LanguageSwitcher } from "./LanguageSwitcher"
|
||||||
import { SearchButton } from "./SearchButton"
|
import { SearchButton } from "./SearchButton"
|
||||||
import { Loader, LoadingSpinner } from "./loading/Loader"
|
import { Loader, LoadingSpinner } from "./loading/Loader"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
|
|
||||||
|
interface TimeState {
|
||||||
|
hh: number
|
||||||
|
mm: number
|
||||||
|
ss: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCurrentTime = () => {
|
||||||
|
const [time, setTime] = useState<TimeState>({
|
||||||
|
hh: DateTime.now().setLocale("en-US").hour,
|
||||||
|
mm: DateTime.now().setLocale("en-US").minute,
|
||||||
|
ss: DateTime.now().setLocale("en-US").second,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
const now = DateTime.now().setLocale("en-US")
|
||||||
|
setTime({
|
||||||
|
hh: now.hour,
|
||||||
|
mm: now.minute,
|
||||||
|
ss: now.second,
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return time
|
||||||
|
}
|
||||||
|
|
||||||
function Header() {
|
function Header() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -269,35 +298,31 @@ function DashboardLink() {
|
|||||||
|
|
||||||
function Overview() {
|
function Overview() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [time, setTime] = useState({
|
const time = useCurrentTime()
|
||||||
hh: DateTime.now().setLocale("en-US").hour,
|
const [mounted, setMounted] = useState(false)
|
||||||
mm: DateTime.now().setLocale("en-US").minute,
|
|
||||||
ss: DateTime.now().setLocale("en-US").second,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
setMounted(true)
|
||||||
setTime({
|
|
||||||
hh: DateTime.now().setLocale("en-US").hour,
|
|
||||||
mm: DateTime.now().setLocale("en-US").minute,
|
|
||||||
ss: DateTime.now().setLocale("en-US").second,
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
return () => clearInterval(timer)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={"mt-10 flex flex-col md:mt-16 header-timer"}>
|
<section className={"mt-10 flex flex-col md:mt-16 header-timer"}>
|
||||||
<p className="text-base font-semibold">👋 {t("overview")}</p>
|
<p className="text-base font-semibold">👋 {t("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("whereTheTimeIs")}</p>
|
<p className="text-sm font-medium opacity-50">{t("whereTheTimeIs")}</p>
|
||||||
<NumberFlowGroup>
|
{mounted ? (
|
||||||
<div style={{ fontVariantNumeric: "tabular-nums" }} className="flex text-sm font-medium mt-0.5">
|
<div className="flex items-center font-medium text-sm">
|
||||||
<NumberFlow trend={1} value={time.hh} format={{ minimumIntegerDigits: 2 }} />
|
<AnimateCountClient count={time.hh} minDigits={2} />
|
||||||
<NumberFlow prefix=":" trend={1} value={time.mm} digits={{ 1: { max: 5 } }} format={{ minimumIntegerDigits: 2 }} />
|
<span className="mb-px font-medium text-sm opacity-50">:</span>
|
||||||
<p className="mt-[0.5px]">:{time.ss.toString().padStart(2, "0")}</p>
|
<AnimateCountClient count={time.mm} minDigits={2} />
|
||||||
|
<span className="mb-px font-medium text-sm opacity-50">:</span>
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
<AnimateCountClient count={time.ss} minDigits={2} />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</NumberFlowGroup>
|
) : (
|
||||||
|
<Skeleton className="h-[21px] w-16 animate-none rounded-[5px] bg-muted-foreground/10" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -150,6 +150,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 {
|
||||||
@@ -321,3 +322,53 @@
|
|||||||
.scrollbar-hidden::-webkit-scrollbar {
|
.scrollbar-hidden::-webkit-scrollbar {
|
||||||
display: none; /* Chrome, Safari 和 Opera */
|
display: none; /* Chrome, Safari 和 Opera */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user