Compare commits

..

No commits in common. "main" and "v0.1.2-beta.1" have entirely different histories.

138 changed files with 1331 additions and 7613 deletions

View File

@ -1,18 +1,5 @@
NezhaBaseUrl=http://124.XX.XX.XX:8008
NezhaAuth=your-nezha-api-token
DefaultLocale=zh
ForceShowAllServers=false
NEXT_PUBLIC_NezhaFetchInterval=5000
NezhaBaseUrl=http://1.1.1.1:8008
NezhaAuth=nezha-token
NEXT_PUBLIC_NezhaFetchInterval=2000
NEXT_PUBLIC_ShowFlag=true
NEXT_PUBLIC_DisableCartoon=false
NEXT_PUBLIC_ShowTag=true
NEXT_PUBLIC_ShowNetTransfer=false
NEXT_PUBLIC_ForceUseSvgFlag=false
NEXT_PUBLIC_FixedTopServerName=false
NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png
NEXT_PUBLIC_CustomTitle=NezhaDash
NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha.
NEXT_PUBLIC_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕"}]'
NEXT_PUBLIC_DisableIndex=false
NEXT_PUBLIC_ShowTagCount=false
NEXT_PUBLIC_ShowIpInfo=false
NEXT_PUBLIC_DisableCartoon=false

View File

@ -1,19 +0,0 @@
---
name: Bug 提交
about: 提交 bug让面板变得更好。
title: "[BUG]"
labels: ""
assignees: ""
---
**面板版本(二选一)**
V0 | V1
**描述 bug**
在这里描述 bug 的相关信息
**屏幕截图**
有屏幕截图可以帮助更快定位到问题
**额外信息**
可附上其他需要的额外信息

View File

@ -1,14 +0,0 @@
---
name: 功能申请
about: 描述需求
title: "[FEAT]"
labels: ""
assignees: ""
---
**面板版本(二选一)**
V0 | V1
**需要什么?**
**额外信息**

View File

@ -1,14 +0,0 @@
---
name: 改善建议
about: 交流面板需要改进的地方
title: "[SUGGEST]"
labels: ""
assignees: ""
---
**面板版本(二选一)**
V0 | V1
**需要改进的?**
-

BIN
.github/get-token.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
.github/shotOne.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

BIN
.github/shotTwo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

BIN
.github/v2-1.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

BIN
.github/v2-2.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

BIN
.github/v2-3.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

BIN
.github/v2-4.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

BIN
.github/v2-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

View File

@ -1,31 +1,23 @@
name: Build and push Docker image
permissions:
contents: write
on:
push:
tags:
- "v*"
env:
REGISTRY_IMAGE: hamster1963/nezha-dash
ALIYUN_REGISTRY_IMAGE: registry.cn-guangzhou.aliyuncs.com/hamster-home/nezha-dash
jobs:
build-and-push:
name: Build and push Docker image
runs-on: ubuntu-latest
environment: Production
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to Docker Hub
uses: docker/login-action@v3
@ -45,8 +37,8 @@ jobs:
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY_IMAGE }}
${{ env.ALIYUN_REGISTRY_IMAGE }}
hamster1963/nezha-dash
registry.cn-guangzhou.aliyuncs.com/hamster-home/nezha-dash
tags: |
type=raw,value=latest
type=ref,event=tag
@ -59,13 +51,3 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "latest"
- name: Changelog
run: bun x changelogithub
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

View File

@ -1,56 +0,0 @@
name: Auto Fix Lint and Format
permissions:
contents: write
pull-requests: write
on:
pull_request:
types: [opened, synchronize]
jobs:
auto-fix:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "latest"
- name: Install dependencies
run: bun install
- name: Run linter & formatter and fix issues
run: bun run check:fix
- name: Check for changes
id: check_changes
run: |
git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV
- name: Commit and push changes
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: auto-fix linting and formatting issues"
commit_options: "--no-verify"
file_pattern: "."
- name: Add PR comment
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: actions/github-script@v7
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Linting and formatting issues were automatically fixed. Please review the changes.'
});

View File

@ -1,25 +1,43 @@
FROM --platform=$BUILDPLATFORM oven/bun:1 AS base
FROM oven/bun:1 AS base
# Stage 1: Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
# Stage 2: Build the application
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./
RUN bun install
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
# Stage 3: Production image
FROM node:23-alpine AS runner
FROM node:21-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.env.example ./.env
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

201
LICENSE
View File

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,38 +1,28 @@
<div align="center"><img width="600" alt="nezhadash" src="https://github.com/user-attachments/assets/0a5768e1-96f2-4f8a-b77f-01488ed3b237"></div>
<h3 align="center">NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘</h3>
<h1 align="center">NezhaDash</h1>
<strong>NezhaDash 是一个基于 Next.js 和 哪吒监控 的仪表盘</strong>
<br>
</div>
> [!CAUTION]
> 此为 V0 兼容版本,与 V1 内置版本功能上可能有所不同
>
> V0 | V1 版本 issue 请在当前仓库发起
> [!TIP]
> 有关 V1 版本 pr 可移步 https://github.com/hamster1963/nezha-dash-v1
| 一键部署到 Vercel-推荐 | Docker部署 | Cloudflare部署 |
| ------------------------------ | -------------------- | -------------------------------- |
| [部署简易教程](https://buycoffee.top/blog/tech/nezha) | [Docker 部署教程](https://buycoffee.top/blog/tech/nezha-docker) | [Cloudflare 部署教程(编写中)](https://buycoffee.top/blog/tech/) |
### 部署
支持部署环境:
- Vercel
- Cloudflare
- Docker
#### 环境变量
[演示站点](https://nezha-vercel.vercel.app)
[说明文档](https://nezhadash-docs.vercel.app)
| 变量名 | 含义 | 示例 |
| ------------------------------ | -------------------- | -------------------------------- |
| NezhaBaseUrl | nezha 面板地址 | http://120.x.x.x:8008 |
| NezhaAuth | nezha 面板 API Token | 5hAY3QX6Nl9B3Uxxxx26KMvOMyXS1Udi |
| NEXT_PUBLIC_NezhaFetchInterval | 获取数据间隔(毫秒) | **默认**2000 |
| NEXT_PUBLIC_ShowFlag | 是否显示旗帜 | **默认**false |
| NEXT_PUBLIC_DisableCartoon | 是否禁用卡通人物 | **默认**false |
### 如何更新
<br>
[更新教程](https://buycoffee.top/blog/tech/nezha-upgrade)
### 环境变量
[环境变量介绍](https://nezhadash-docs.vercel.app/environment)
![screen](/.github/v2-1.webp)
![screen](/.github/v2-2.webp)
![screen](/.github/v2-3.webp)
![screen](/.github/v2-4.webp)
![screen](/.github/v2-dark.webp)
![screen-shot-one](/.github/shotOne.png)
![screen-shot-two](/.github/shotTwo.png)

View File

@ -0,0 +1,32 @@
"use client";
import { ServerApi } from "@/app/types/nezha-api";
import ServerCard from "@/components/ServerCard";
import { nezhaFetcher } from "@/lib/utils";
import useSWR from "swr";
import getEnv from "@/lib/env-entry";
export default function ServerListClient() {
const { data } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
});
if (!data) return null;
const sortedServers = data.result.sort((a, b) => {
if (a.display_index && b.display_index) {
return b.display_index - a.display_index;
}
if (a.display_index) return -1;
if (b.display_index) return 1;
return a.id - b.id;
});
return (
<section className="grid grid-cols-1 gap-2 md:grid-cols-2">
{sortedServers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
);
}

File diff suppressed because one or more lines are too long

View File

@ -1,398 +0,0 @@
"use client"
import type { NezhaAPIMonitor, ServerMonitorChart } from "@/app/types/nezha-api"
import NetworkChartLoading from "@/components/loading/NetworkChartLoading"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import getEnv from "@/lib/env-entry"
import { formatTime, nezhaFetcher } from "@/lib/utils"
import { useTranslations } from "next-intl"
import * as React from "react"
import { useCallback, useMemo } from "react"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import useSWR from "swr"
interface ResultItem {
created_at: number
[key: string]: number
}
export function NetworkChartClient({
server_id,
show,
}: {
server_id: number
show: boolean
}) {
const t = useTranslations("NetworkChartClient")
const { data, error } = useSWR<NezhaAPIMonitor[]>(
`/api/monitor?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 15000,
isVisible: () => show,
},
)
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("chart_fetch_error_message")}</p>
</div>
<NetworkChartLoading />
</>
)
}
if (!data) return <NetworkChartLoading />
const transformedData = transformData(data)
const formattedData = formatData(data)
const initChartConfig = {
avg_delay: {
label: t("avg_delay"),
},
} satisfies ChartConfig
const chartDataKey = Object.keys(transformedData)
return (
<NetworkChart
chartDataKey={chartDataKey}
chartConfig={initChartConfig}
chartData={transformedData}
serverName={data[0].server_name}
formattedData={formattedData}
/>
)
}
export const NetworkChart = React.memo(function NetworkChart({
chartDataKey,
chartConfig,
chartData,
serverName,
formattedData,
}: {
chartDataKey: string[]
chartConfig: ChartConfig
chartData: ServerMonitorChart
serverName: string
formattedData: ResultItem[]
}) {
const t = useTranslations("NetworkChart")
const defaultChart = "All"
const [activeChart, setActiveChart] = React.useState(defaultChart)
const [isPeakEnabled, setIsPeakEnabled] = React.useState(false)
const handleButtonClick = useCallback(
(chart: string) => {
setActiveChart((prev) => (prev === chart ? defaultChart : chart))
},
[defaultChart],
)
const getColorByIndex = useCallback(
(chart: string) => {
const index = chartDataKey.indexOf(chart)
return `hsl(var(--chart-${(index % 10) + 1}))`
},
[chartDataKey],
)
const chartButtons = useMemo(
() =>
chartDataKey.map((key) => (
<button
type="button"
key={key}
data-active={activeChart === key}
className={
"relative z-30 flex grow basis-0 cursor-pointer flex-col justify-center gap-1 border-neutral-200 border-b px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-t-0 sm:border-l sm:px-6 dark:border-neutral-800"
}
onClick={() => handleButtonClick(key)}
>
<span className="whitespace-nowrap text-muted-foreground text-xs">{key}</span>
<span className="font-bold text-md leading-none sm:text-lg">
{chartData[key][chartData[key].length - 1].avg_delay.toFixed(2)}ms
</span>
</button>
)),
[chartDataKey, activeChart, chartData, handleButtonClick],
)
const chartLines = useMemo(() => {
if (activeChart !== defaultChart) {
return (
<Line
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey="avg_delay"
stroke={getColorByIndex(activeChart)}
/>
)
}
return chartDataKey.map((key) => (
<Line
key={key}
isAnimationActive={false}
strokeWidth={1}
type="linear"
dot={false}
dataKey={key}
stroke={getColorByIndex(key)}
connectNulls={true}
/>
))
}, [activeChart, defaultChart, chartDataKey, getColorByIndex])
const processedData = useMemo(() => {
if (!isPeakEnabled) {
return activeChart === defaultChart ? formattedData : chartData[activeChart]
}
const data = (
activeChart === defaultChart ? formattedData : chartData[activeChart]
) as ResultItem[]
const windowSize = 11 // 增加窗口大小以获取更好的统计效果
const alpha = 0.3 // EWMA平滑因子
// 辅助函数:计算中位数
const getMedian = (arr: number[]) => {
const sorted = [...arr].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
// 辅助函数:异常值处理
const processValues = (values: number[]) => {
if (values.length === 0) return null
const median = getMedian(values)
const deviations = values.map((v) => Math.abs(v - median))
const medianDeviation = getMedian(deviations) * 1.4826 // MAD估计器
// 使用中位数绝对偏差(MAD)进行异常值检测
const validValues = values.filter(
(v) =>
Math.abs(v - median) <= 3 * medianDeviation && // 更严格的异常值判定
v <= median * 3, // 限制最大值不超过中位数的3倍
)
if (validValues.length === 0) return median // 如果没有有效值,返回中位数
// 计算EWMA
let ewma = validValues[0]
for (let i = 1; i < validValues.length; i++) {
ewma = alpha * validValues[i] + (1 - alpha) * ewma
}
return ewma
}
// 初始化EWMA历史值
const ewmaHistory: { [key: string]: number } = {}
return data.map((point, index) => {
if (index < windowSize - 1) return point
const window = data.slice(index - windowSize + 1, index + 1)
const smoothed = { ...point } as ResultItem
if (activeChart === defaultChart) {
for (const key of chartDataKey) {
const values = window
.map((w) => w[key])
.filter((v) => v !== undefined && v !== null) as number[]
if (values.length > 0) {
const processed = processValues(values)
if (processed !== null) {
// 应用EWMA平滑
if (ewmaHistory[key] === undefined) {
ewmaHistory[key] = processed
} else {
ewmaHistory[key] = alpha * processed + (1 - alpha) * ewmaHistory[key]
}
smoothed[key] = ewmaHistory[key]
}
}
}
} else {
const values = window
.map((w) => w.avg_delay)
.filter((v) => v !== undefined && v !== null) as number[]
if (values.length > 0) {
const processed = processValues(values)
if (processed !== null) {
// 应用EWMA平滑
if (ewmaHistory.current === undefined) {
ewmaHistory.current = processed
} else {
ewmaHistory.current = alpha * processed + (1 - alpha) * ewmaHistory.current
}
smoothed.avg_delay = ewmaHistory.current
}
}
}
return smoothed
})
}, [isPeakEnabled, activeChart, formattedData, chartData, chartDataKey, defaultChart])
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 p-0 sm:flex-row">
<div className="flex flex-none flex-col justify-center gap-1 border-b px-6 py-4">
<CardTitle className="flex flex-none items-center gap-0.5 text-md">
{serverName}
</CardTitle>
<CardDescription className="text-xs">
{chartDataKey.length} {t("ServerMonitorCount")}
</CardDescription>
<div className="mt-0.5 flex items-center space-x-2">
<Switch id="Peak" checked={isPeakEnabled} onCheckedChange={setIsPeakEnabled} />
<Label className="text-xs" htmlFor="Peak">
Peak cut
</Label>
</div>
</div>
<div className="flex w-full flex-wrap">{chartButtons}</div>
</CardHeader>
<CardContent className="py-4 pr-2 pl-0 sm:pt-6 sm:pr-6 sm:pb-6 sm:pl-2">
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
<LineChart accessibilityLayer data={processedData} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="created_at"
tickLine={true}
tickSize={3}
axisLine={false}
tickMargin={8}
minTickGap={80}
ticks={processedData
.filter((item, index, array) => {
if (array.length < 6) {
return index === 0 || index === array.length - 1
}
// 计算数据的总时间跨度(毫秒)
const timeSpan = array[array.length - 1].created_at - array[0].created_at
const hours = timeSpan / (1000 * 60 * 60)
// 根据时间跨度调整显示间隔
if (hours <= 12) {
// 12小时内每60分钟显示一个刻度
return (
index === 0 ||
index === array.length - 1 ||
new Date(item.created_at).getMinutes() % 60 === 0
)
}
// 超过12小时每2小时显示一个刻度
const date = new Date(item.created_at)
return date.getMinutes() === 0 && date.getHours() % 2 === 0
})
.map((item) => item.created_at)}
tickFormatter={(value) => {
const date = new Date(value)
const minutes = date.getMinutes()
return minutes === 0 ? `${date.getHours()}:00` : `${date.getHours()}:${minutes}`
}}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={15}
minTickGap={20}
tickFormatter={(value) => `${value}ms`}
/>
<ChartTooltip
isAnimationActive={false}
content={
<ChartTooltipContent
indicator={"line"}
labelKey="created_at"
labelFormatter={(_, payload) => {
return formatTime(payload[0].payload.created_at)
}}
/>
}
/>
{activeChart === defaultChart && <ChartLegend content={<ChartLegendContent />} />}
{chartLines}
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
})
const transformData = (data: NezhaAPIMonitor[]) => {
const monitorData: ServerMonitorChart = {}
for (const item of data) {
const monitorName = item.monitor_name
if (!monitorData[monitorName]) {
monitorData[monitorName] = []
}
for (let i = 0; i < item.created_at.length; i++) {
monitorData[monitorName].push({
created_at: item.created_at[i],
avg_delay: item.avg_delay[i],
})
}
}
return monitorData
}
const formatData = (rawData: NezhaAPIMonitor[]) => {
const result: { [time: number]: ResultItem } = {}
const allTimes = new Set<number>()
for (const item of rawData) {
for (const time of item.created_at) {
allTimes.add(time)
}
}
const allTimeArray = Array.from(allTimes).sort((a, b) => a - b)
for (const item of rawData) {
const { monitor_name, created_at, avg_delay } = item
for (const time of allTimeArray) {
if (!result[time]) {
result[time] = { created_at: time }
}
const timeIndex = created_at.indexOf(time)
// @ts-expect-error - avg_delay is an array
result[time][monitor_name] = timeIndex !== -1 ? avg_delay[timeIndex] : null
}
}
return Object.values(result).sort((a, b) => a.created_at - b.created_at)
}

View File

@ -1,892 +0,0 @@
"use client"
import {
MAX_HISTORY_LENGTH,
type ServerDataWithTimestamp,
useServerData,
} from "@/app/context/server-data-context"
import type { NezhaAPISafe } from "@/app/types/nezha-api"
import { ServerDetailChartLoading } from "@/components/loading/ServerDetailLoading"
import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar"
import { Card, CardContent } from "@/components/ui/card"
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"
import { formatBytes, formatNezhaInfo, formatRelativeTime } from "@/lib/utils"
import { useTranslations } from "next-intl"
import { useEffect, useRef, useState } from "react"
import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
type cpuChartData = {
timeStamp: string
cpu: number
}
type processChartData = {
timeStamp: string
process: number
}
type diskChartData = {
timeStamp: string
disk: number
}
type memChartData = {
timeStamp: string
mem: number
swap: number
}
type networkChartData = {
timeStamp: string
upload: number
download: number
}
type connectChartData = {
timeStamp: string
tcp: number
udp: number
}
export default function ServerDetailChartClient({
server_id,
}: {
server_id: number
show: boolean
}) {
const t = useTranslations("ServerDetailChartClient")
const { data: serverList, error, history } = useServerData()
const data = serverList?.result?.find((item) => item.id === server_id)
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("chart_fetch_error_message")}</p>
</div>
</>
)
}
if (!data) return <ServerDetailChartLoading />
return (
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
<CpuChart data={data} history={history} />
<ProcessChart data={data} history={history} />
<DiskChart data={data} history={history} />
<MemChart data={data} history={history} />
<NetworkChart data={data} history={history} />
<ConnectChart data={data} history={history} />
</section>
)
}
function CpuChart({
history,
data,
}: {
history: ServerDataWithTimestamp[]
data: NezhaAPISafe
}) {
const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { cpu } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
cpu: cpu,
}
})
.filter((item): item is cpuChartData => item !== null)
.reverse() // 保持时间顺序
setCpuChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { cpu } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as cpuChartData[]
if (cpuChartData.length === 0) {
newData = [
{ timeStamp: timestamp, cpu: cpu },
{ timeStamp: timestamp, cpu: cpu },
]
} else {
newData = [...cpuChartData, { timeStamp: timestamp, cpu: cpu }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setCpuChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
cpu: {
label: "CPU",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-md">CPU</p>
<section className="flex items-center gap-2">
<p className="w-10 text-end font-medium text-xs">{cpu.toFixed(0)}%</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={cpu}
primaryColor="hsl(var(--chart-1))"
/>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={cpuChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Area
isAnimationActive={false}
dataKey="cpu"
type="step"
fill="hsl(var(--chart-1))"
fillOpacity={0.3}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function ProcessChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [processChartData, setProcessChartData] = useState([] as processChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { process } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
process: process,
}
})
.filter((item): item is processChartData => item !== null)
.reverse()
setProcessChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { process } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as processChartData[]
if (processChartData.length === 0) {
newData = [
{ timeStamp: timestamp, process: process },
{ timeStamp: timestamp, process: process },
]
} else {
newData = [...processChartData, { timeStamp: timestamp, process: process }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setProcessChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
process: {
label: "Process",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-md">{t("Process")}</p>
<section className="flex items-center gap-2">
<p className="w-10 text-end font-medium text-xs">{process}</p>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={processChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis tickLine={false} axisLine={false} mirror={true} tickMargin={-15} />
<Area
isAnimationActive={false}
dataKey="process"
type="step"
fill="hsl(var(--chart-2))"
fillOpacity={0.3}
stroke="hsl(var(--chart-2))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function MemChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [memChartData, setMemChartData] = useState([] as memChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { mem, swap } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
mem: mem,
swap: swap,
}
})
.filter((item): item is memChartData => item !== null)
.reverse()
setMemChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { mem, swap } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as memChartData[]
if (memChartData.length === 0) {
newData = [
{ timeStamp: timestamp, mem: mem, swap: swap },
{ timeStamp: timestamp, mem: mem, swap: swap },
]
} else {
newData = [...memChartData, { timeStamp: timestamp, mem: mem, swap: swap }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setMemChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
mem: {
label: "Mem",
},
swap: {
label: "Swap",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<section className="flex items-center gap-4">
<div className="flex flex-col">
<p className=" text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center gap-2">
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={mem}
primaryColor="hsl(var(--chart-8))"
/>
<p className="font-medium text-xs">{mem.toFixed(0)}%</p>
</div>
</div>
<div className="flex flex-col">
<p className=" text-muted-foreground text-xs">{t("Swap")}</p>
<div className="flex items-center gap-2">
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={swap}
primaryColor="hsl(var(--chart-10))"
/>
<p className="font-medium text-xs">{swap.toFixed(0)}%</p>
</div>
</div>
</section>
<section className="flex flex-col items-end gap-0.5">
<div className="flex items-center gap-2 font-medium text-[11px]">
{formatBytes(data.status.MemUsed)} / {formatBytes(data.host.MemTotal)}
</div>
<div className="flex items-center gap-2 font-medium text-[11px]">
swap: {formatBytes(data.status.SwapUsed)}
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={memChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Area
isAnimationActive={false}
dataKey="mem"
type="step"
fill="hsl(var(--chart-8))"
fillOpacity={0.3}
stroke="hsl(var(--chart-8))"
/>
<Area
isAnimationActive={false}
dataKey="swap"
type="step"
fill="hsl(var(--chart-10))"
fillOpacity={0.3}
stroke="hsl(var(--chart-10))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function DiskChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [diskChartData, setDiskChartData] = useState([] as diskChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { disk } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
disk: disk,
}
})
.filter((item): item is diskChartData => item !== null)
.reverse()
setDiskChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { disk } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as diskChartData[]
if (diskChartData.length === 0) {
newData = [
{ timeStamp: timestamp, disk: disk },
{ timeStamp: timestamp, disk: disk },
]
} else {
newData = [...diskChartData, { timeStamp: timestamp, disk: disk }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setDiskChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
disk: {
label: "Disk",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="font-medium text-md">{t("Disk")}</p>
<section className="flex flex-col items-end gap-0.5">
<section className="flex items-center gap-2">
<p className="w-10 text-end font-medium text-xs">{disk.toFixed(0)}%</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={disk}
primaryColor="hsl(var(--chart-5))"
/>
</section>
<div className="flex items-center gap-2 font-medium text-[11px]">
{formatBytes(data.status.DiskUsed)} / {formatBytes(data.host.DiskTotal)}
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<AreaChart
accessibilityLayer
data={diskChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<Area
isAnimationActive={false}
dataKey="disk"
type="step"
fill="hsl(var(--chart-5))"
fillOpacity={0.3}
stroke="hsl(var(--chart-5))"
/>
</AreaChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function NetworkChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [networkChartData, setNetworkChartData] = useState([] as networkChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { up, down } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
upload: up,
download: down,
}
})
.filter((item): item is networkChartData => item !== null)
.reverse()
setNetworkChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { up, down } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as networkChartData[]
if (networkChartData.length === 0) {
newData = [
{ timeStamp: timestamp, upload: up, download: down },
{ timeStamp: timestamp, upload: up, download: down },
]
} else {
newData = [...networkChartData, { timeStamp: timestamp, upload: up, download: down }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setNetworkChartData(newData)
}
}, [data, historyLoaded])
let maxDownload = Math.max(...networkChartData.map((item) => item.download))
maxDownload = Math.ceil(maxDownload)
if (maxDownload < 1) {
maxDownload = 1
}
const chartConfig = {
upload: {
label: "Upload",
},
download: {
label: "Download",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center">
<section className="flex items-center gap-4">
<div className="flex w-20 flex-col">
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
<p className="font-medium text-xs">{up.toFixed(2)} M/s</p>
</div>
</div>
<div className="flex w-20 flex-col">
<p className=" text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
<p className="font-medium text-xs">{down.toFixed(2)} M/s</p>
</div>
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<LineChart
accessibilityLayer
data={networkChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
type="number"
minTickGap={50}
interval="preserveStartEnd"
domain={[1, maxDownload]}
tickFormatter={(value) => `${value.toFixed(0)}M/s`}
/>
<Line
isAnimationActive={false}
dataKey="upload"
type="linear"
stroke="hsl(var(--chart-1))"
strokeWidth={1}
dot={false}
/>
<Line
isAnimationActive={false}
dataKey="download"
type="linear"
stroke="hsl(var(--chart-4))"
strokeWidth={1}
dot={false}
/>
</LineChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}
function ConnectChart({
data,
history,
}: {
data: NezhaAPISafe
history: ServerDataWithTimestamp[]
}) {
const [connectChartData, setConnectChartData] = useState([] as connectChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => {
if (!hasInitialized.current && history.length > 0) {
const historyData = history
.map((msg) => {
const server = msg.data?.result?.find((item) => item.id === data.id)
if (!server) return null
const { tcp, udp } = formatNezhaInfo(server)
return {
timeStamp: msg.timestamp.toString(),
tcp: tcp,
udp: udp,
}
})
.filter((item): item is connectChartData => item !== null)
.reverse()
setConnectChartData(historyData)
hasInitialized.current = true
setHistoryLoaded(true)
} else if (history.length === 0) {
setHistoryLoaded(true)
}
}, [])
const { tcp, udp } = formatNezhaInfo(data)
useEffect(() => {
if (data && historyLoaded) {
const timestamp = Date.now().toString()
let newData = [] as connectChartData[]
if (connectChartData.length === 0) {
newData = [
{ timeStamp: timestamp, tcp: tcp, udp: udp },
{ timeStamp: timestamp, tcp: tcp, udp: udp },
]
} else {
newData = [...connectChartData, { timeStamp: timestamp, tcp: tcp, udp: udp }]
}
if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift()
}
setConnectChartData(newData)
}
}, [data, historyLoaded])
const chartConfig = {
tcp: {
label: "TCP",
},
udp: {
label: "UDP",
},
} satisfies ChartConfig
return (
<Card>
<CardContent className="px-6 py-3">
<section className="flex flex-col gap-1">
<div className="flex items-center">
<section className="flex items-center gap-4">
<div className="flex w-12 flex-col">
<p className="text-muted-foreground text-xs">TCP</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
<p className="font-medium text-xs">{tcp}</p>
</div>
</div>
<div className="flex w-12 flex-col">
<p className=" text-muted-foreground text-xs">UDP</p>
<div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
<p className="font-medium text-xs">{udp}</p>
</div>
</div>
</section>
</div>
<ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
<LineChart
accessibilityLayer
data={connectChartData}
margin={{
top: 12,
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="timeStamp"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={200}
interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)}
/>
<YAxis
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
type="number"
interval="preserveStartEnd"
/>
<Line
isAnimationActive={false}
dataKey="tcp"
type="linear"
stroke="hsl(var(--chart-1))"
strokeWidth={1}
dot={false}
/>
<Line
isAnimationActive={false}
dataKey="udp"
type="linear"
stroke="hsl(var(--chart-4))"
strokeWidth={1}
dot={false}
/>
</LineChart>
</ChartContainer>
</section>
</CardContent>
</Card>
)
}

View File

@ -1,276 +0,0 @@
"use client"
import { useServerData } from "@/app/context/server-data-context"
import { BackIcon } from "@/components/Icon"
import ServerFlag from "@/components/ServerFlag"
import { ServerDetailLoading } from "@/components/loading/ServerDetailLoading"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
import countries from "i18n-iso-countries"
import enLocale from "i18n-iso-countries/langs/en.json"
import { useTranslations } from "next-intl"
import { notFound, useRouter } from "next/navigation"
import { useEffect, useState } from "react"
countries.registerLocale(enLocale)
export default function ServerDetailClient({
server_id,
}: {
server_id: number
}) {
const t = useTranslations("ServerDetailClient")
const router = useRouter()
const [hasHistory, setHasHistory] = useState(false)
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" })
}, [])
useEffect(() => {
const previousPath = sessionStorage.getItem("fromMainPage")
if (previousPath) {
setHasHistory(true)
}
}, [])
const linkClick = () => {
if (hasHistory) {
router.back()
} else {
router.push("/")
}
}
const { data: serverList, error, isLoading } = useServerData()
const serverData = serverList?.result?.find((item) => item.id === server_id)
if (!serverData && !isLoading) {
notFound()
}
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("detail_fetch_error_message")}</p>
</div>
</>
)
}
if (!serverData) return <ServerDetailLoading />
const {
name,
online,
uptime,
version,
arch,
mem_total,
disk_total,
country_code,
platform,
platform_version,
cpu_info,
gpu_info,
load_1,
load_5,
load_15,
net_out_transfer,
net_in_transfer,
last_active_time_string,
boot_time_string,
} = formatNezhaInfo(serverData)
return (
<div>
<div
onClick={linkClick}
className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight transition-opacity duration-300 hover:opacity-50"
>
<BackIcon />
{name}
</div>
<section className="mt-3 flex flex-wrap gap-2">
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("status")}</p>
<Badge
className={cn(
"-mt-[0.3px] w-fit rounded-[6px] px-1 py-0 text-[9px] dark:text-white",
{
" bg-green-800": online,
" bg-red-600": !online,
},
)}
>
{online ? t("Online") : t("Offline")}
</Badge>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Uptime")}</p>
<div className="text-xs">
{" "}
{uptime / 86400 >= 1
? `${Math.floor(uptime / 86400)} ${t("Days")} ${Math.floor((uptime % 86400) / 3600)} ${t("Hours")}`
: `${Math.floor(uptime / 3600)} ${t("Hours")}`}
</div>
</section>
</CardContent>
</Card>
{version && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Version")}</p>
<div className="text-xs">{version} </div>
</section>
</CardContent>
</Card>
)}
{arch && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Arch")}</p>
<div className="text-xs">{arch} </div>
</section>
</CardContent>
</Card>
)}
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="text-xs">{formatBytes(mem_total)}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Disk")}</p>
<div className="text-xs">{formatBytes(disk_total)}</div>
</section>
</CardContent>
</Card>
{country_code && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Region")}</p>
<section className="flex items-start gap-1">
<div className="text-start text-xs">{countries.getName(country_code, "en")}</div>
<ServerFlag className="-mt-[1px] text-[11px]" country_code={country_code} />
</section>
</section>
</CardContent>
</Card>
)}
</section>
<section className="mt-1 flex flex-wrap gap-2">
{platform && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="text-xs">
{" "}
{platform} - {platform_version}{" "}
</div>
</section>
</CardContent>
</Card>
)}
{cpu_info && cpu_info.length > 0 && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="text-xs"> {cpu_info.join(", ")}</div>
</section>
</CardContent>
</Card>
)}
{gpu_info && gpu_info.length > 0 && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{"GPU"}</p>
<div className="text-xs"> {gpu_info.join(", ")}</div>
</section>
</CardContent>
</Card>
)}
</section>
<section className="mt-1 flex flex-wrap gap-2">
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Load")}</p>
<div className="text-xs">
{load_1 || "0.00"} / {load_5 || "0.00"} / {load_15 || "0.00"}
</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
{net_out_transfer ? (
<div className="text-xs"> {formatBytes(net_out_transfer)} </div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("Download")}</p>
{net_in_transfer ? (
<div className="text-xs"> {formatBytes(net_in_transfer)} </div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
</section>
<section className="mt-1 flex flex-wrap gap-2">
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("BootTime")}</p>
<div className="text-xs">{boot_time_string ? boot_time_string : "N/A"}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("LastActive")}</p>
<div className="text-xs">
{last_active_time_string ? last_active_time_string : "N/A"}
</div>
</section>
</CardContent>
</Card>
</section>
</div>
)
}

View File

@ -1,119 +0,0 @@
"use client"
import type { IPInfo } from "@/app/api/server-ip/route"
import { Loader } from "@/components/loading/Loader"
import { Card, CardContent } from "@/components/ui/card"
import { nezhaFetcher } from "@/lib/utils"
import { useTranslations } from "next-intl"
import useSWRImmutable from "swr/immutable"
export default function ServerIPInfo({ server_id }: { server_id: number }) {
const t = useTranslations("IPInfo")
const { data } = useSWRImmutable<IPInfo>(`/api/server-ip?server_id=${server_id}`, nezhaFetcher)
if (!data) {
return (
<div className="mb-11">
<Loader visible />
</div>
)
}
return (
<>
<section className="mb-4 flex flex-wrap gap-2">
{data.asn?.autonomous_system_organization && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{"ASN"}</p>
<div className="text-xs">{data.asn.autonomous_system_organization}</div>
</section>
</CardContent>
</Card>
)}
{data.asn?.autonomous_system_number && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("asn_number")}</p>
<div className="text-xs">AS{data.asn.autonomous_system_number}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.registered_country?.names.en && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("registered_country")}</p>
<div className="text-xs">{data.city.registered_country?.names.en}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.country?.iso_code && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{"ISO"}</p>
<div className="text-xs">{data.city.country?.iso_code}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.city?.names.en && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("city")}</p>
<div className="text-xs">{data.city.city?.names.en}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.location?.longitude && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("longitude")}</p>
<div className="text-xs">{data.city.location?.longitude}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.location?.latitude && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("latitude")}</p>
<div className="text-xs">{data.city.location?.latitude}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.location?.time_zone && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("time_zone")}</p>
<div className="text-xs">{data.city.location?.time_zone}</div>
</section>
</CardContent>
</Card>
)}
{data.city?.postal && (
<Card className="rounded-[10px] border-none bg-transparent shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-muted-foreground text-xs">{t("postal_code")}</p>
<div className="text-xs">{data.city.postal?.code}</div>
</section>
</CardContent>
</Card>
)}
</section>
</>
)
}

View File

@ -1,62 +0,0 @@
"use client"
import GlobalInfo from "@/app/(main)/ClientComponents/main/GlobalInfo"
import { InteractiveMap } from "@/app/(main)/ClientComponents/main/InteractiveMap"
import { useServerData } from "@/app/context/server-data-context"
import { TooltipProvider } from "@/app/context/tooltip-context"
import GlobalLoading from "@/components/loading/GlobalLoading"
import { geoJsonString } from "@/lib/geo/geo-json-string"
export default function ServerGlobal() {
const { data: nezhaServerList, error } = useServerData()
if (error)
return (
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
</div>
)
if (!nezhaServerList) {
return <GlobalLoading />
}
const countryList: string[] = []
const serverCounts: { [key: string]: number } = {}
for (const server of nezhaServerList.result) {
if (server.host.CountryCode) {
const countryCode = server.host.CountryCode.toUpperCase()
if (!countryList.includes(countryCode)) {
countryList.push(countryCode)
}
serverCounts[countryCode] = (serverCounts[countryCode] || 0) + 1
}
}
const width = 900
const height = 500
const geoJson = JSON.parse(geoJsonString)
const filteredFeatures = geoJson.features.filter(
(feature: any) => feature.properties.iso_a3_eh !== "",
)
return (
<section className="mt-[3.2px] flex flex-col gap-4">
<GlobalInfo countries={countryList} />
<div className="w-full overflow-x-auto">
<TooltipProvider>
<InteractiveMap
countries={countryList}
serverCounts={serverCounts}
width={width}
height={height}
filteredFeatures={filteredFeatures}
nezhaServerList={nezhaServerList}
/>
</TooltipProvider>
</div>
</section>
)
}

View File

@ -1,18 +0,0 @@
"use client"
import { useTranslations } from "next-intl"
type GlobalInfoProps = {
countries: string[]
}
export default function GlobalInfo({ countries }: GlobalInfoProps) {
const t = useTranslations("Global")
return (
<section className="flex items-center justify-between">
<p className="font-medium text-sm opacity-40">
{t("Distributions")} {countries.length} {t("Regions")}
</p>
</section>
)
}

View File

@ -1,153 +0,0 @@
"use client"
import MapTooltip from "@/app/(main)/ClientComponents/main/MapTooltip"
import { useTooltip } from "@/app/context/tooltip-context"
import { countryCoordinates } from "@/lib/geo/geo-limit"
import { geoEquirectangular, geoPath } from "d3-geo"
interface InteractiveMapProps {
countries: string[]
serverCounts: { [key: string]: number }
width: number
height: number
filteredFeatures: any[]
nezhaServerList: any
}
export function InteractiveMap({
countries,
serverCounts,
width,
height,
filteredFeatures,
nezhaServerList,
}: InteractiveMapProps) {
const { setTooltipData } = useTooltip()
const projection = geoEquirectangular()
.scale(140)
.translate([width / 2, height / 2])
.rotate([-12, 0, 0])
const path = geoPath().projection(projection)
return (
<div className="relative aspect-[2/1] w-full" onMouseLeave={() => setTooltipData(null)}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
className="h-auto w-full"
>
<title>Interactive Map</title>
<defs>
<pattern id="dots" width="2" height="2" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.5" fill="currentColor" />
</pattern>
</defs>
<g>
{/* Background rect to handle mouse events in empty areas */}
<rect
x="0"
y="0"
width={width}
height={height}
fill="transparent"
onMouseEnter={() => setTooltipData(null)}
/>
{filteredFeatures.map((feature, index) => {
const isHighlighted = countries.includes(feature.properties.iso_a2_eh)
const serverCount = serverCounts[feature.properties.iso_a2_eh] || 0
return (
<path
key={feature.properties.iso_a2_eh + String(index)}
d={path(feature) || ""}
className={
isHighlighted
? "cursor-pointer fill-green-700 transition-all hover:fill-green-600 dark:fill-green-900 dark:hover:fill-green-700"
: "fill-neutral-200/50 stroke-[0.5] stroke-neutral-300/40 dark:fill-neutral-800 dark:stroke-neutral-700"
}
onMouseEnter={() => {
if (!isHighlighted) {
setTooltipData(null)
return
}
if (path.centroid(feature)) {
const countryCode = feature.properties.iso_a2_eh
const countryServers = nezhaServerList.result
.filter(
(server: any) => server.host.CountryCode?.toUpperCase() === countryCode,
)
.map((server: any) => ({
id: server.id,
name: server.name,
status: server.online_status,
}))
setTooltipData({
centroid: path.centroid(feature),
country: feature.properties.name,
count: serverCount,
servers: countryServers,
})
}
}}
/>
)
})}
{/* 渲染不在 filteredFeatures 中的国家标记点 */}
{countries.map((countryCode) => {
// 检查该国家是否已经在 filteredFeatures 中
const isInFilteredFeatures = filteredFeatures.some(
(feature) => feature.properties.iso_a2_eh === countryCode,
)
// 如果已经在 filteredFeatures 中,跳过
if (isInFilteredFeatures) return null
// 获取国家的经纬度
const coords = countryCoordinates[countryCode]
if (!coords) return null
// 使用投影函数将经纬度转换为 SVG 坐标
const [x, y] = projection([coords.lng, coords.lat]) || [0, 0]
const serverCount = serverCounts[countryCode] || 0
return (
<g
key={countryCode}
onMouseEnter={() => {
const countryServers = nezhaServerList.result
.filter((server: any) => server.host.CountryCode?.toUpperCase() === countryCode)
.map((server: any) => ({
id: server.id,
name: server.name,
status: server.online_status,
}))
setTooltipData({
centroid: [x, y],
country: coords.name,
count: serverCount,
servers: countryServers,
})
}}
className="cursor-pointer"
>
<circle
cx={x}
cy={y}
r={4}
className="fill-sky-700 stroke-white transition-all hover:fill-sky-600 dark:fill-sky-900 dark:hover:fill-sky-700"
/>
</g>
)
})}
</g>
</svg>
<MapTooltip />
</div>
)
}

View File

@ -1,70 +0,0 @@
"use client"
import { useTooltip } from "@/app/context/tooltip-context"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { memo } from "react"
const MapTooltip = memo(function MapTooltip() {
const { tooltipData } = useTooltip()
const t = useTranslations("Global")
if (!tooltipData) return null
const sortedServers = tooltipData.servers.sort((a, b) => {
return a.status === b.status ? 0 : a.status ? 1 : -1
})
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true")
}
return (
<div
className="tooltip-animate absolute z-50 hidden rounded bg-white px-2 py-1 text-sm shadow-lg lg:block dark:border dark:border-neutral-700 dark:bg-neutral-800"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(10%, -50%)",
}}
onMouseEnter={(e) => {
e.stopPropagation()
}}
>
<div>
<p className="font-medium">
{tooltipData.country === "China" ? "Mainland China" : tooltipData.country}
</p>
<p className="mb-1 font-light text-neutral-600 text-xs dark:text-neutral-400">
{tooltipData.count} {t("Servers")}
</p>
</div>
<div
className="border-t pt-1 dark:border-neutral-700"
style={{
maxHeight: "200px",
overflowY: "auto",
}}
>
{sortedServers.map((server) => (
<Link
onClick={saveSession}
href={`/server/${server.id}`}
key={server.name}
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"
>
<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>
</Link>
))}
</div>
</div>
)
})
export default MapTooltip

View File

@ -1,222 +0,0 @@
"use client"
import { useFilter } from "@/app/context/network-filter-context"
import { useServerData } from "@/app/context/server-data-context"
import { useStatus } from "@/app/context/status-context"
import ServerCard from "@/components/ServerCard"
import ServerCardInline from "@/components/ServerCardInline"
import Switch from "@/components/Switch"
import GlobalLoading from "@/components/loading/GlobalLoading"
import { Loader } from "@/components/loading/Loader"
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import { MapIcon, ViewColumnsIcon } from "@heroicons/react/20/solid"
import { useTranslations } from "next-intl"
import dynamic from "next/dynamic"
import { useEffect, useRef, useState } from "react"
const ServerGlobal = dynamic(() => import("./Global"), {
ssr: false,
loading: () => <GlobalLoading />,
})
const sortServersByDisplayIndex = (servers: any[]) => {
return servers.sort((a, b) => {
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
return displayIndexDiff !== 0 ? displayIndexDiff : a.id - b.id
})
}
const filterServersByStatus = (servers: any[], status: string) => {
return status === "all"
? servers
: servers.filter((server) => [status].includes(server.online_status ? "online" : "offline"))
}
const filterServersByTag = (servers: any[], tag: string, defaultTag: string) => {
return tag === defaultTag ? servers : servers.filter((server) => server.tag === tag)
}
const sortServersByNetwork = (servers: any[]) => {
return [...servers].sort((a, b) => {
if (!a.online_status && b.online_status) return 1
if (a.online_status && !b.online_status) return -1
if (!a.online_status && !b.online_status) return 0
return b.status.NetInSpeed + b.status.NetOutSpeed - (a.status.NetInSpeed + a.status.NetOutSpeed)
})
}
const getTagCounts = (servers: any[]) => {
return servers.reduce((acc: Record<string, number>, server) => {
if (server.tag) {
acc[server.tag] = (acc[server.tag] || 0) + 1
}
return acc
}, {})
}
const LoadingState = ({ t }: { t: any }) => (
<div className="flex min-h-96 flex-col items-center justify-center ">
<div className="flex items-center gap-2 font-semibold text-sm">
<Loader visible={true} />
{t("connecting")}...
</div>
</div>
)
const ErrorState = ({ error, t }: { error: Error; t: any }) => (
<div className="flex flex-col items-center justify-center">
<p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="font-medium text-sm opacity-40">{t("error_message")}</p>
</div>
)
const ServerList = ({
servers,
inline,
containerRef,
}: { servers: any[]; inline: string; containerRef: any }) => {
if (inline === "1") {
return (
<section
ref={containerRef}
className="scrollbar-hidden flex flex-col gap-2 overflow-x-scroll"
>
{servers.map((serverInfo) => (
<ServerCardInline key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
)
}
return (
<section ref={containerRef} className="grid grid-cols-1 gap-2 md:grid-cols-2">
{servers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
)
}
export default function ServerListClient() {
const { status } = useStatus()
const { filter } = useFilter()
const t = useTranslations("ServerListClient")
const containerRef = useRef<HTMLDivElement>(null)
const defaultTag = "defaultTag"
const [tag, setTag] = useState<string>(defaultTag)
const [showMap, setShowMap] = useState<boolean>(false)
const [inline, setInline] = useState<string>("0")
useEffect(() => {
const inlineState = localStorage.getItem("inline")
if (inlineState !== null) {
setInline(inlineState)
}
const showMapState = localStorage.getItem("showMap")
if (showMapState !== null) {
setShowMap(showMapState === "true")
}
const savedTag = sessionStorage.getItem("selectedTag") || defaultTag
setTag(savedTag)
restoreScrollPosition()
}, [])
const handleTagChange = (newTag: string) => {
setTag(newTag)
sessionStorage.setItem("selectedTag", newTag)
sessionStorage.setItem("scrollPosition", String(containerRef.current?.scrollTop || 0))
}
const restoreScrollPosition = () => {
const savedPosition = sessionStorage.getItem("scrollPosition")
if (savedPosition && containerRef.current) {
containerRef.current.scrollTop = Number(savedPosition)
}
}
useEffect(() => {
const handleRouteChange = () => {
restoreScrollPosition()
}
window.addEventListener("popstate", handleRouteChange)
return () => {
window.removeEventListener("popstate", handleRouteChange)
}
}, [])
const { data, error } = useServerData()
if (error) return <ErrorState error={error} t={t} />
if (!data?.result) return <LoadingState t={t} />
const { result } = data
const sortedServers = sortServersByDisplayIndex(result)
const filteredServersByStatus = filterServersByStatus(sortedServers, status)
const allTag = filteredServersByStatus.map((server) => server.tag).filter(Boolean)
const uniqueTags = [...new Set(allTag)]
uniqueTags.unshift(defaultTag)
let filteredServers = filterServersByTag(filteredServersByStatus, tag, defaultTag)
if (filter) {
filteredServers = sortServersByNetwork(filteredServers)
}
const tagCountMap = getTagCounts(filteredServersByStatus)
return (
<>
<section className="flex w-full items-center gap-2 overflow-hidden">
<button
type="button"
onClick={() => {
const newShowMap = !showMap
setShowMap(newShowMap)
localStorage.setItem("showMap", String(newShowMap))
}}
className={cn(
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-[10px] text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100 ",
{
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
showMap,
},
)}
>
<MapIcon className="size-[13px]" />
</button>
<button
type="button"
onClick={() => {
const newInline = inline === "0" ? "1" : "0"
setInline(newInline)
localStorage.setItem("inline", newInline)
}}
className={cn(
"inset-shadow-2xs inset-shadow-white/20 flex cursor-pointer flex-col items-center gap-0 rounded-[50px] bg-blue-100 p-[10px] text-blue-600 transition-all dark:bg-blue-900 dark:text-blue-100 ",
{
"inset-shadow-black/20 bg-blue-600 text-white dark:bg-blue-100 dark:text-blue-600":
inline === "1",
},
)}
>
<ViewColumnsIcon className="size-[13px]" />
</button>
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && (
<Switch
allTag={uniqueTags}
nowTag={tag}
tagCountMap={tagCountMap}
onTagChange={handleTagChange}
/>
)}
</section>
{showMap && <ServerGlobal />}
<ServerList servers={filteredServers} inline={inline} containerRef={containerRef} />
</>
)
}

File diff suppressed because one or more lines are too long

View File

@ -1,58 +1,22 @@
"use client"
import pack from "@/package.json"
import { useTranslations } from "next-intl"
import { useEffect, useState } from "react"
const GITHUB_URL = "https://github.com/hamster1963/nezha-dash"
const PERSONAL_URL = "https://buycoffee.top"
type LinkProps = {
href: string
children: React.ReactNode
}
const FooterLink = ({ href, children }: LinkProps) => (
<a
href={href}
target="_blank"
className="cursor-pointer font-normal underline decoration-2 decoration-yellow-500 underline-offset-2 transition-colors hover:decoration-yellow-600 dark:decoration-yellow-500/60 dark:hover:decoration-yellow-500/80"
rel="noreferrer"
>
{children}
</a>
)
const baseTextStyles =
"text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50"
export default function Footer() {
const t = useTranslations("Footer")
const version = pack.version
const currentYear = new Date().getFullYear()
const [isMac, setIsMac] = useState(true)
useEffect(() => {
setIsMac(/macintosh|mac os x/i.test(navigator.userAgent))
}, [])
return (
<footer className="mx-auto flex w-full max-w-5xl items-center justify-between">
<footer className="mx-auto w-full max-w-5xl">
<section className="flex flex-col">
<p className={`mt-3 flex gap-1 ${baseTextStyles}`}>
{t("p_146-598_Findthecodeon")}{" "}
<FooterLink href={GITHUB_URL}>{t("a_303-585_GitHub")}</FooterLink>
<FooterLink href={`${GITHUB_URL}/releases/tag/v${version}`}>v{version}</FooterLink>
<p className="mt-3 flex gap-1 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
Find the code on{" "}
<a
href="https://github.com/hamster1963/nezha-dash"
target="_blank"
className="cursor-pointer font-normal underline decoration-yellow-500 decoration-2 underline-offset-2 dark:decoration-yellow-500/50"
>
GitHub
</a>
</p>
<section className={`mt-1 flex items-center gap-2 ${baseTextStyles}`}>
{t("section_607-869_2020")}
{currentYear} <FooterLink href={PERSONAL_URL}>{t("a_800-850_Hamster1963")}</FooterLink>
<section className="mt-1 flex items-center gap-2 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
© 2020-{new Date().getFullYear()}{" "}
<a href={"https://buycoffee.top"}>@Hamster1963</a>
</section>
</section>
<p className={`mt-1 ${baseTextStyles}`}>
<kbd className="pointer-events-none mx-1 inline-flex h-4 select-none items-center gap-1 rounded border bg-muted px-1.5 font-medium font-mono text-[10px] text-muted-foreground opacity-100">
{isMac ? <span className="text-xs"></span> : "Ctrl "}K
</kbd>
</p>
</footer>
)
);
}

View File

@ -1,162 +1,86 @@
"use client"
"use client";
import AnimateCountClient from "@/components/AnimatedCount"
import { LanguageSwitcher } from "@/components/LanguageSwitcher"
import { ModeToggle } from "@/components/ThemeSwitcher"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import getEnv from "@/lib/env-entry"
import { DateTime } from "luxon"
import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation"
import { memo, useCallback, useEffect, useState } from "react"
interface TimeState {
hh: number
mm: number
ss: number
}
interface CustomLink {
link: string
name: string
}
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
}
const Links = memo(function Links() {
const linksEnv = getEnv("NEXT_PUBLIC_Links")
const links: CustomLink[] | null = linksEnv ? JSON.parse(linksEnv) : null
if (!links) return null
return (
<div className="flex items-center gap-2">
{links.map((link) => (
<a
key={link.link}
href={link.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 font-medium text-sm opacity-50 transition-opacity hover:opacity-100"
>
{link.name}
</a>
))}
</div>
)
})
const Overview = memo(function Overview() {
const t = useTranslations("Overview")
const time = useCurrentTime()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return (
<section className={"mt-10 flex flex-col md:mt-16"}>
<p className="font-semibold text-base">{t("p_2277-2331_Overview")}</p>
<div className="flex items-center gap-1">
<p className="font-medium text-sm opacity-50">{t("p_2390-2457_wherethetimeis")}</p>
{mounted ? (
<div className="flex items-center font-medium text-sm">
<AnimateCountClient count={time.hh} minDigits={2} />
<span className="mb-[1px] font-medium text-sm opacity-50">:</span>
<AnimateCountClient count={time.mm} minDigits={2} />
<span className="mb-[1px] font-medium text-sm opacity-50">:</span>
<span className="font-medium text-sm">
<AnimateCountClient count={time.ss} minDigits={2} />
</span>
</div>
) : (
<Skeleton className="h-[21px] w-16 animate-none rounded-[5px] bg-muted-foreground/10" />
)}
</div>
</section>
)
})
import React, { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { Separator } from "@/components/ui/separator";
import { DateTime } from "luxon";
import { ModeToggle } from "@/components/ThemeSwitcher";
function Header() {
const t = useTranslations("Header")
const customLogo = getEnv("NEXT_PUBLIC_CustomLogo")
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
const router = useRouter()
const handleLogoClick = useCallback(() => {
sessionStorage.removeItem("selectedTag")
router.push("/")
}, [router])
return (
<div className="mx-auto w-full max-w-5xl">
<section className="flex items-center justify-between">
<section
onClick={handleLogoClick}
className="flex cursor-pointer items-center font-medium text-base transition-opacity duration-300 hover:opacity-50"
>
<section className="text-md flex items-center font-medium">
<div className="mr-1 flex flex-row items-center justify-start">
<img
<Image
width={40}
height={40}
unoptimized
alt="apple-touch-icon"
src={customLogo ? customLogo : "/apple-touch-icon.png"}
className="relative m-0! h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:hidden"
/>
<img
width={40}
height={40}
alt="apple-touch-icon"
src={customLogo ? customLogo : "/apple-touch-icon-dark.png"}
className="relative m-0! hidden h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:block"
src={"/apple-touch-icon.png"}
className="relative !m-0 h-6 w-6 border-2 border-white object-cover object-top !p-0 transition duration-500 group-hover:z-30 group-hover:scale-105"
/>
</div>
{customTitle ? customTitle : "NezhaDash"}
<Separator orientation="vertical" className="mx-2 hidden h-4 w-[1px] md:block" />
<p className="hidden font-medium text-sm opacity-40 md:block">
{customDescription ? customDescription : t("p_1079-1199_Simpleandbeautifuldashbo")}
NezhaDash
<Separator
orientation="vertical"
className="mx-2 hidden h-4 w-[1px] md:block"
/>
<p className="hidden text-sm font-medium opacity-40 md:block">
Simple and beautiful dashboard
</p>
</section>
<section className="flex items-center gap-2">
<div className="hidden sm:block">
<Links />
</div>
<LanguageSwitcher />
<ModeToggle />
</section>
<ModeToggle />
</section>
<div className="mt-1 flex w-full justify-end sm:hidden">
<Links />
</div>
<Overview />
</div>
)
);
}
export default Header
// https://github.com/streamich/react-use/blob/master/src/useInterval.ts
const useInterval = (callback: Function, delay?: number | null) => {
const savedCallback = useRef<Function>(() => {});
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
if (delay !== null) {
const interval = setInterval(() => savedCallback.current(), delay || 0);
return () => clearInterval(interval);
}
return undefined;
}, [delay]);
};
function Overview() {
const [mouted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const timeOption = DateTime.TIME_SIMPLE;
timeOption.hour12 = true;
const [timeString, setTimeString] = useState(
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
);
useInterval(() => {
setTimeString(DateTime.now().setLocale("en-US").toLocaleString(timeOption));
}, 1000);
return (
<section className={"mt-10 flex flex-col md:mt-16"}>
<p className="text-md font-semibold">👋 Overview</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium opacity-50">where the time is</p>
{mouted && (
<p className="opacity-1 text-sm font-medium">{timeString}</p>
)}
</div>
</section>
);
}
export default Header;

View File

@ -1,38 +1,20 @@
import Footer from "@/app/(main)/footer"
import Header from "@/app/(main)/header"
import { ServerDataProvider } from "@/app/context/server-data-context"
import { auth } from "@/auth"
import { DashCommand } from "@/components/DashCommand"
import { SignIn } from "@/components/SignIn"
import getEnv from "@/lib/env-entry"
import type React from "react"
import React from "react";
import Header from "@/app/(main)/header";
import Footer from "./footer";
type DashboardProps = {
children: React.ReactNode
}
children: React.ReactNode;
};
export default function MainLayout({ children }: DashboardProps) {
return (
<div className="flex min-h-screen w-full flex-col">
<main className="flex min-h-[calc(100vh-calc(var(--spacing)*16))] flex-1 flex-col gap-4 bg-background p-4 md:p-10 md:pt-8">
<main className="flex min-h-[calc(100vh_-_theme(spacing.16))] flex-1 flex-col gap-4 bg-muted/40 p-4 md:p-10 md:pt-8">
<Header />
<AuthProtected>
<ServerDataProvider>
{children}
<DashCommand />
</ServerDataProvider>
</AuthProtected>
{children}
<Footer />
</main>
</div>
)
}
async function AuthProtected({ children }: DashboardProps) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
return <SignIn />
}
}
return children
);
}

View File

@ -1,11 +1,22 @@
import ServerListClient from "@/app/(main)/ClientComponents/main/ServerListClient"
import ServerOverviewClient from "@/app/(main)/ClientComponents/main/ServerOverviewClient"
import ServerList from "@/components/ServerList";
import ServerOverview from "@/components/ServerOverview";
import { GetNezhaData } from "@/lib/serverFetch";
export default async function Home() {
import { SWRConfig } from "swr";
export default function Home() {
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
<ServerOverviewClient />
<ServerListClient />
</div>
)
<SWRConfig
value={{
fallback: {
"/api/server": GetNezhaData(),
},
}}
>
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
<ServerOverview />
<ServerList />
</div>
</SWRConfig>
);
}

View File

@ -1,53 +0,0 @@
"use client"
import { NetworkChartClient } from "@/app/(main)/ClientComponents/detail/NetworkChart"
import ServerDetailChartClient from "@/app/(main)/ClientComponents/detail/ServerDetailChartClient"
import ServerDetailClient from "@/app/(main)/ClientComponents/detail/ServerDetailClient"
import ServerIPInfo from "@/app/(main)/ClientComponents/detail/ServerIPInfo"
import TabSwitch from "@/components/TabSwitch"
import { Separator } from "@/components/ui/separator"
import getEnv from "@/lib/env-entry"
import { use, useState } from "react"
type PageProps = {
params: Promise<{ id: string }>
}
type TabType = "Detail" | "Network"
export default function Page({ params }: PageProps) {
const { id } = use(params)
const serverId = Number(id)
const tabs: TabType[] = ["Detail", "Network"]
const [currentTab, setCurrentTab] = useState<TabType>(tabs[0])
const tabContent = {
Detail: <ServerDetailChartClient server_id={serverId} show={currentTab === "Detail"} />,
Network: (
<>
{getEnv("NEXT_PUBLIC_ShowIpInfo") && <ServerIPInfo server_id={serverId} />}
<NetworkChartClient server_id={serverId} show={currentTab === "Network"} />
</>
),
}
return (
<main className="mx-auto grid w-full max-w-5xl gap-2">
<ServerDetailClient server_id={serverId} />
<nav className="my-2 flex w-full items-center">
<Separator className="flex-1" />
<div className="flex w-full max-w-[200px] justify-center">
<TabSwitch
tabs={tabs}
currentTab={currentTab}
setCurrentTab={(tab: string) => setCurrentTab(tab as TabType)}
/>
</div>
<Separator className="flex-1" />
</nav>
{tabContent[currentTab]}
</main>
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,3 +0,0 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@ -1,44 +0,0 @@
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetServerDetail } from "@/lib/serverFetch"
import { redirect } from "next/navigation"
import { type NextRequest, NextResponse } from "next/server"
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number
message: string
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
const { searchParams } = new URL(req.url)
const server_id = searchParams.get("server_id")
if (!server_id) {
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
}
try {
const serverIdNum = Number.parseInt(server_id, 10)
if (Number.isNaN(serverIdNum)) {
return NextResponse.json({ error: "server_id must be a valid number" }, { status: 400 })
}
const detailData = await GetServerDetail({ server_id: serverIdNum })
return NextResponse.json(detailData, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
}
}

View File

@ -1,46 +0,0 @@
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetServerMonitor } from "@/lib/serverFetch"
import { redirect } from "next/navigation"
import { type NextRequest, NextResponse } from "next/server"
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number
message: string
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
const { searchParams } = new URL(req.url)
const server_id = searchParams.get("server_id")
if (!server_id) {
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
}
try {
const serverIdNum = Number.parseInt(server_id, 10)
if (Number.isNaN(serverIdNum)) {
return NextResponse.json({ error: "server_id must be a number" }, { status: 400 })
}
const monitorData = await GetServerMonitor({
server_id: serverIdNum,
})
return NextResponse.json(monitorData, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
}
}

View File

@ -1,66 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetServerIP } from "@/lib/serverFetch"
import { type AsnResponse, type CityResponse, Reader } from "maxmind"
import { redirect } from "next/navigation"
import { type NextRequest, NextResponse } from "next/server"
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number
message: string
}
export type IPInfo = {
city: CityResponse
asn: AsnResponse
}
export async function GET(req: NextRequest) {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
if (!getEnv("NEXT_PUBLIC_ShowIpInfo")) {
return NextResponse.json({ error: "ip info is disabled" }, { status: 400 })
}
const { searchParams } = new URL(req.url)
const server_id = searchParams.get("server_id")
if (!server_id) {
return NextResponse.json({ error: "server_id is required" }, { status: 400 })
}
try {
const ip = await GetServerIP({ server_id: Number(server_id) })
const cityDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-City.mmdb")
const asnDbPath = path.join(process.cwd(), "lib", "maxmind-db", "GeoLite2-ASN.mmdb")
const cityDbBuffer = fs.readFileSync(cityDbPath)
const asnDbBuffer = fs.readFileSync(asnDbPath)
const cityLookup = new Reader<CityResponse>(cityDbBuffer)
const asnLookup = new Reader<AsnResponse>(asnDbBuffer)
const data: IPInfo = {
city: cityLookup.get(ip) as CityResponse,
asn: asnLookup.get(ip) as AsnResponse,
}
return NextResponse.json(data, { status: 200 })
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
}
}

View File

@ -1,32 +1,17 @@
import { auth } from "@/auth"
import getEnv from "@/lib/env-entry"
import { GetNezhaData } from "@/lib/serverFetch"
import { redirect } from "next/navigation"
import { NextResponse } from "next/server"
import { GetNezhaData } from "@/lib/serverFetch";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic"
interface ResError extends Error {
statusCode: number
message: string
}
export async function GET() {
if (getEnv("SitePassword")) {
const session = await auth()
if (!session) {
redirect("/")
}
}
export const dynamic = "force-dynamic";
export async function GET(_: Request) {
try {
const data = await GetNezhaData()
return NextResponse.json(data, { status: 200 })
const response = await GetNezhaData();
return NextResponse.json(response, { status: 200 });
} catch (error) {
const err = error as ResError
console.error("Error in GET handler:", err)
const statusCode = err.statusCode || 500
const message = err.message || "Internal Server Error"
return NextResponse.json({ error: message }, { status: statusCode })
console.error(error);
return NextResponse.json(
{ error: "fetch nezha data failed" },
{ status: 400 },
);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1,24 +0,0 @@
"use client"
import { type ReactNode, createContext, useContext, useState } from "react"
interface FilterContextType {
filter: boolean
setFilter: (filter: boolean) => void
}
const FilterContext = createContext<FilterContextType | undefined>(undefined)
export function FilterProvider({ children }: { children: ReactNode }) {
const [filter, setFilter] = useState<boolean>(false)
return <FilterContext.Provider value={{ filter, setFilter }}>{children}</FilterContext.Provider>
}
export function useFilter() {
const context = useContext(FilterContext)
if (context === undefined) {
throw new Error("useFilter must be used within a FilterProvider")
}
return context
}

View File

@ -1,62 +0,0 @@
"use client"
import type { ServerApi } from "@/app/types/nezha-api"
import getEnv from "@/lib/env-entry"
import { nezhaFetcher } from "@/lib/utils"
import { type ReactNode, createContext, useContext, useEffect, useState } from "react"
import useSWR from "swr"
export interface ServerDataWithTimestamp {
timestamp: number
data: ServerApi
}
interface ServerDataContextType {
data: ServerApi | undefined
error: Error | undefined
isLoading: boolean
history: ServerDataWithTimestamp[]
}
const ServerDataContext = createContext<ServerDataContextType | undefined>(undefined)
export const MAX_HISTORY_LENGTH = 30
export function ServerDataProvider({ children }: { children: ReactNode }) {
const [history, setHistory] = useState<ServerDataWithTimestamp[]>([])
const { data, error, isLoading } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
dedupingInterval: 1000,
})
useEffect(() => {
if (data) {
setHistory((prev) => {
const newHistory = [
{
timestamp: Date.now(),
data: data,
},
...prev,
].slice(0, MAX_HISTORY_LENGTH)
return newHistory
})
}
}, [data])
return (
<ServerDataContext.Provider value={{ data, error, isLoading, history }}>
{children}
</ServerDataContext.Provider>
)
}
export function useServerData() {
const context = useContext(ServerDataContext)
if (context === undefined) {
throw new Error("useServerData must be used within a ServerDataProvider")
}
return context
}

View File

@ -1,26 +0,0 @@
"use client"
import { type ReactNode, createContext, useContext, useState } from "react"
type Status = "all" | "online" | "offline"
interface StatusContextType {
status: Status
setStatus: (status: Status) => void
}
const StatusContext = createContext<StatusContextType | undefined>(undefined)
export function StatusProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<Status>("all")
return <StatusContext.Provider value={{ status, setStatus }}>{children}</StatusContext.Provider>
}
export function useStatus() {
const context = useContext(StatusContext)
if (context === undefined) {
throw new Error("useStatus must be used within a StatusProvider")
}
return context
}

View File

@ -1,39 +0,0 @@
"use client"
import { type ReactNode, createContext, useContext, useState } from "react"
export interface TooltipData {
centroid: [number, number]
country: string
count: number
servers: Array<{
id: string
name: string
status: boolean
}>
}
interface TooltipContextType {
tooltipData: TooltipData | null
setTooltipData: (data: TooltipData | null) => void
}
const TooltipContext = createContext<TooltipContextType | undefined>(undefined)
export function TooltipProvider({ children }: { children: ReactNode }) {
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null)
return (
<TooltipContext.Provider value={{ tooltipData, setTooltipData }}>
{children}
</TooltipContext.Provider>
)
}
export function useTooltip() {
const context = useContext(TooltipContext)
if (context === undefined) {
throw new Error("useTooltip must be used within a TooltipProvider")
}
return context
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 B

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 769 B

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,87 +1,61 @@
import { FilterProvider } from "@/app/context/network-filter-context"
import { StatusProvider } from "@/app/context/status-context"
import { ThemeColorManager } from "@/components/ThemeColorManager"
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import "@/styles/globals.css"
import type { Metadata } from "next"
import type { Viewport } from "next"
import { NextIntlClientProvider } from "next-intl"
import { getLocale, getMessages } from "next-intl/server"
import { PublicEnvScript } from "next-runtime-env"
import { ThemeProvider } from "next-themes"
import { Inter as FontSans } from "next/font/google"
import type React from "react"
import "@/styles/globals.css";
import type { Metadata } from "next";
import { Inter as FontSans } from "next/font/google";
import { ThemeProvider } from "next-themes";
import React from "react";
import { Viewport } from "next";
import { cn } from "@/lib/utils";
import { PublicEnvScript } from "next-runtime-env";
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
})
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
const disableIndex = getEnv("NEXT_PUBLIC_DisableIndex")
});
export const metadata: Metadata = {
manifest: "/manifest.json",
title: customTitle || "NezhaDash",
description: customDescription || "A dashboard for nezha",
title: "NezhaDash",
description: "A dashboard for nezha",
appleWebApp: {
capable: true,
title: customTitle || "NezhaDash",
statusBarStyle: "default",
title: "NezhaDash",
statusBarStyle: "black-translucent",
},
robots: {
index: !disableIndex,
follow: !disableIndex,
},
}
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
interface RootLayoutProps {
children: React.ReactNode;
}
export default async function LocaleLayout({
children,
}: {
children: React.ReactNode
}) {
const locale = await getLocale()
const messages = await getMessages()
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang={locale} suppressHydrationWarning>
<html lang="en" suppressHydrationWarning>
<head>
<PublicEnvScript />
<link
rel="stylesheet"
href="https://fastly.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"
/>
<link
rel="stylesheet"
href="https://fastly.jsdelivr.net/npm/font-logos@1/assets/font-logos.css"
/>
</head>
<body className={cn("min-h-screen bg-background font-sans antialiased", fontSans.variable)}>
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
)}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NextIntlClientProvider messages={messages}>
<FilterProvider>
<StatusProvider>
<ThemeColorManager />
{children}
</StatusProvider>
</FilterProvider>
</NextIntlClientProvider>
{children}
</ThemeProvider>
</body>
</html>
)
);
}

View File

@ -1,22 +1,27 @@
import Footer from "@/app/(main)/footer"
import Header from "@/app/(main)/header"
import { useTranslations } from "next-intl"
import Link from "next/link"
import Image from "next/image";
import Link from "next/link";
export default function NotFoundPage() {
const t = useTranslations("NotFoundPage")
return (
<div className="flex min-h-screen w-full flex-col">
<main className="flex min-h-[calc(100vh-calc(var(--spacing)*16))] flex-1 flex-col gap-4 bg-background p-4 md:p-10 md:pt-8">
<Header />
<section className="flex flex-1 flex-col items-center justify-center gap-2">
<p className="font-semibold text-sm">{t("h1_490-590_404NotFound")}</p>
<Link href="/" className="flex items-center gap-1">
<p className="font-medium text-sm opacity-40">{t("h1_490-590_404NotFoundBack")}</p>
<main className="relative h-screen w-full">
<div className="absolute inset-0 m-4 flex items-center justify-center">
<Image
priority
className="rounded-3xl object-cover"
src="/tardis.jpg"
fill={true}
alt="TARDIS"
/>
<div className="text-container absolute right-4 p-4 md:right-20">
<h1 className="text-2xl font-bold opacity-80 md:text-5xl">
404 Not Found
</h1>
<p className="text-lg opacity-60 md:text-base">TARDIS ERROR!</p>
<Link href={"/"} className="text-2xl opacity-80 md:text-3xl">
Doctor?
</Link>
</section>
<Footer />
</main>
</div>
)
</div>
</div>
</main>
);
}

View File

@ -1,77 +1,57 @@
export type ServerApi = {
live_servers: number
offline_servers: number
total_out_bandwidth: number
total_in_bandwidth: number
total_out_speed: number
total_in_speed: number
result: NezhaAPISafe[]
}
live_servers: number;
offline_servers: number;
total_bandwidth: number;
result: NezhaAPISafe[];
};
export type NezhaAPISafe = Omit<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">
export type NezhaAPISafe = Omit<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">;
export interface NezhaAPI {
id: number
name: string
tag: string
last_active: number
online_status: boolean
ipv4: string
ipv6: string
valid_ip: string
display_index: number
hide_for_guest: boolean
host: NezhaAPIHost
status: NezhaAPIStatus
id: number;
name: string;
tag: string;
last_active: number;
online_status: boolean;
ipv4: string;
ipv6: string;
valid_ip: string;
display_index: number;
host: NezhaAPIHost;
status: NezhaAPIStatus;
}
export interface NezhaAPIHost {
Platform: string
PlatformVersion: string
CPU: string[]
MemTotal: number
DiskTotal: number
SwapTotal: number
Arch: string
Virtualization: string
BootTime: number
CountryCode: string
Version: string
GPU: string[]
Platform: string;
PlatformVersion: string;
CPU: string[];
MemTotal: number;
DiskTotal: number;
SwapTotal: number;
Arch: string;
Virtualization: string;
BootTime: number;
CountryCode: string;
Version: string;
GPU: null;
}
export interface NezhaAPIStatus {
CPU: number
MemUsed: number
SwapUsed: number
DiskUsed: number
NetInTransfer: number
NetOutTransfer: number
NetInSpeed: number
NetOutSpeed: number
Uptime: number
Load1: number
Load5: number
Load15: number
TcpConnCount: number
UdpConnCount: number
ProcessCount: number
Temperatures: number
GPU: number
}
export type ServerMonitorChart = {
[key: string]: {
created_at: number
avg_delay: number
}[]
}
export interface NezhaAPIMonitor {
monitor_id: number
monitor_name: string
server_id: number
server_name: string
created_at: number[]
avg_delay: number[]
CPU: number;
MemUsed: number;
SwapUsed: number;
DiskUsed: number;
NetInTransfer: number;
NetOutTransfer: number;
NetInSpeed: number;
NetOutSpeed: number;
Uptime: number;
Load1: number;
Load5: number;
Load15: number;
TcpConnCount: number;
UdpConnCount: number;
ProcessCount: number;
Temperatures: number;
GPU: number;
}

View File

@ -1 +1,2 @@
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
export type MakeOptional<T, K extends keyof T> = Omit<T, K> &
Partial<Pick<T, K>>;

34
auth.ts
View File

@ -1,34 +0,0 @@
import getEnv from "@/lib/env-entry"
import CryptoJS from "crypto-js"
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
export const { handlers, signIn, signOut, auth } = NextAuth({
secret:
process.env.AUTH_SECRET ??
CryptoJS.MD5(`this_is_nezha_dash_web_secret_${getEnv("SitePassword")}`).toString(),
trustHost: (process.env.AUTH_TRUST_HOST as boolean | undefined) ?? true,
providers: [
CredentialsProvider({
type: "credentials",
credentials: { password: { label: "Password", type: "password" } },
// authorization function
async authorize(credentials) {
const { password } = credentials
if (password === getEnv("SitePassword")) {
return { id: "nezha-dash-auth" }
}
return { error: "Invalid password" }
},
}),
],
callbacks: {
async signIn({ user }) {
// @ts-expect-error user is not null
if (user.error) {
return false
}
return true
},
},
})

View File

@ -1,86 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false },
"files": { "ignoreUnknown": false, "ignore": [".next", "public", "styles/globals.css"] },
"formatter": {
"enabled": true,
"useEditorconfig": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"attributePosition": "auto",
"bracketSpacing": true
},
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"nursery": {
"useSortedClasses": "error"
},
"a11y": {
"useKeyWithClickEvents": "off",
"noLabelWithoutControl": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"complexity": { "noUselessTypeConstraint": "error" },
"correctness": {
"noUnusedVariables": "error",
"useArrayLiterals": "off",
"useExhaustiveDependencies": "off"
},
"style": { "noNamespace": "error", "useAsConstAssertion": "error" },
"suspicious": {
"noExplicitAny": "off",
"noExtraNonNullAssertion": "error",
"noMisleadingInstantiator": "error",
"noUnsafeDeclarationMerging": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "asNeeded",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"overrides": [
{
"include": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
"rules": {
"correctness": {
"noUnusedImports": "error"
},
"style": {
"noArguments": "error",
"noVar": "error",
"useConst": "error"
},
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"useGetterReturn": "off"
}
}
}
}
]
}

BIN
bun.lockb

Binary file not shown.

View File

@ -1,15 +0,0 @@
{
"types": {
"feat": { "title": "🚀 Features" },
"fix": { "title": "🔧 Bug Fixes" },
"docs": { "title": "📚 Documentation" },
"style": { "title": "💄 Styles" },
"refactor": { "title": "🔨 Refactor" },
"perf": { "title": "🏎 Performance" },
"test": { "title": "🚨 Tests" },
"build": { "title": "🛠 Build" },
"ci": { "title": "👷 CI" },
"chore": { "title": "🛗 Chore" },
"revert": { "title": "⏪ Revert" }
}
}

View File

@ -1,96 +0,0 @@
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 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>
)
}

34
components/BlurLayer.tsx Normal file
View File

@ -0,0 +1,34 @@
import React from "react";
const BlurLayers = () => {
const computeLayerStyle = (index: number) => {
const blurAmount = index * 3.7037;
const maskStart = index * 10;
let maskEnd = maskStart + 20;
if (maskEnd > 100) {
maskEnd = 100;
}
return {
backdropFilter: `blur(${blurAmount}px)`,
WebkitBackdropFilter: `blur(${blurAmount}px)`,
zIndex: index + 1,
maskImage: `linear-gradient(rgba(0, 0, 0, 0) ${maskStart}%, rgb(0, 0, 0) ${maskEnd}%)`,
};
};
// 根据层数动态生成层
const layers = Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={"absolute inset-0 h-full w-full"}
style={computeLayerStyle(index)}
/>
));
return (
<div className={"fixed bottom-0 left-0 right-0 z-50 h-[140px]"}>
<div className={"relative h-full"}>{layers}</div>
</div>
);
};
export default BlurLayers;

View File

@ -1,135 +0,0 @@
"use client"
import { Home, Languages, Moon, Sun, SunMoon } from "lucide-react"
import { useServerData } from "@/app/context/server-data-context"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import { localeItems } from "@/i18n-metadata"
import { setUserLocale } from "@/i18n/locale"
import { useTranslations } from "next-intl"
import { useTheme } from "next-themes"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
export function DashCommand() {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
const { data } = useServerData()
const router = useRouter()
const { setTheme } = useTheme()
const t = useTranslations("DashCommand")
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((open) => !open)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [])
if (!data?.result) return null
const sortedServers = data.result.sort((a, b) => {
const displayIndexDiff = (b.display_index || 0) - (a.display_index || 0)
if (displayIndexDiff !== 0) return displayIndexDiff
return a.id - b.id
})
const languageShortcuts = localeItems.map((item) => ({
keywords: ["language", "locale", item.code.toLowerCase()],
icon: <Languages />,
label: item.name,
action: () => setUserLocale(item.code),
value: `language ${item.name.toLowerCase()} ${item.code}`,
}))
const shortcuts = [
{
keywords: ["home", "homepage"],
icon: <Home />,
label: t("Home"),
action: () => router.push("/"),
},
{
keywords: ["light", "theme", "lightmode"],
icon: <Sun />,
label: t("ToggleLightMode"),
action: () => setTheme("light"),
},
{
keywords: ["dark", "theme", "darkmode"],
icon: <Moon />,
label: t("ToggleDarkMode"),
action: () => setTheme("dark"),
},
{
keywords: ["system", "theme", "systemmode"],
icon: <SunMoon />,
label: t("ToggleSystemMode"),
action: () => setTheme("system"),
},
...languageShortcuts,
].map((item) => ({
...item,
value: `${item.keywords.join(" ")} ${item.label}`,
}))
return (
<>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t("TypeCommand")} value={search} onValueChange={setSearch} />
<CommandList className="border-t">
<CommandEmpty>{t("NoResults")}</CommandEmpty>
<CommandGroup heading={t("Servers")}>
{sortedServers.map((server) => (
<CommandItem
key={server.id}
value={server.name}
onSelect={() => {
router.push(`/server/${server.id}`)
setOpen(false)
}}
>
{server.online_status ? (
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
) : (
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
)}
<span>{server.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={t("Shortcuts")}>
{shortcuts.map((item) => (
<CommandItem
key={item.label}
value={item.value}
onSelect={() => {
item.action()
setOpen(false)
}}
>
{item.icon}
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</>
)
}

File diff suppressed because one or more lines are too long

View File

@ -1,58 +0,0 @@
"use client"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { localeItems } from "@/i18n-metadata"
import { setUserLocale } from "@/i18n/locale"
import { cn } from "@/lib/utils"
import { CheckCircleIcon, LanguageIcon } from "@heroicons/react/20/solid"
import { useLocale } from "next-intl"
export function LanguageSwitcher() {
const locale = useLocale()
const handleSelect = (e: Event, newLocale: string) => {
e.preventDefault() // 阻止默认的关闭行为
setUserLocale(newLocale)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
>
<LanguageIcon className="size-4" />
<span className="sr-only">Change language</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col gap-0.5" align="end">
{localeItems.map((item, index) => (
<DropdownMenuItem
key={item.code}
onSelect={(e) => handleSelect(e, item.code)}
className={cn(
{
"gap-3 bg-muted font-semibold": locale === item.code,
},
{
"rounded-t-[5px]": index === localeItems.length - 1,
"rounded-[5px]": index !== 0 && index !== localeItems.length - 1,
"rounded-b-[5px]": index === 0,
},
)}
>
{item.name} {locale === item.code && <CheckCircleIcon className="size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,174 +1,130 @@
import type { NezhaAPISafe } from "@/app/types/nezha-api"
import ServerFlag from "@/components/ServerFlag"
import ServerUsageBar from "@/components/ServerUsageBar"
import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"
import getEnv from "@/lib/env-entry"
import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { NezhaAPISafe } from "@/app/types/nezha-api";
import ServerUsageBar from "@/components/ServerUsageBar";
import { Card } from "@/components/ui/card";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn, formatNezhaInfo } from "@/lib/utils";
import ServerCardPopover from "./ServerCardPopover";
import getUnicodeFlagIcon from "country-flag-icons/unicode";
import { env } from "next-runtime-env";
export default function ServerCard({
serverInfo,
}: {
serverInfo: NezhaAPISafe
serverInfo: NezhaAPISafe;
}) {
const t = useTranslations("ServerCard")
const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
formatNezhaInfo(serverInfo)
const { name, country_code, online, cpu, up, down, mem, stg, ...props } =
formatNezhaInfo(serverInfo);
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true"
const fixedTopServerName = getEnv("NEXT_PUBLIC_FixedTopServerName") === "true"
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true")
}
const showFlag = env("NEXT_PUBLIC_ShowFlag") === "true";
return online ? (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex cursor-pointer flex-col items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
{
"flex-col": fixedTopServerName,
"lg:flex-row": !fixedTopServerName,
},
)}
>
<section
className={cn("grid items-center gap-2", {
"lg:w-40": !fixedTopServerName,
})}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative">
<Card
className={
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row"
}
>
<Popover>
<PopoverTrigger asChild>
<section className={"flex items-center justify-start gap-2 lg:w-28"}>
{showFlag ? (
country_code ? (
<span className="text-[12px] text-muted-foreground">
{getUnicodeFlagIcon(country_code)}
</span>
) : (
<span className="text-[12px] text-muted-foreground">🏁</span>
)
) : null}
<p
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs " : "text-sm",
showFlag ? "text-xs" : "text-sm",
)}
>
{name}
</p>
</div>
</section>
<div className="flex flex-col gap-2">
<section
className={cn("grid grid-cols-5 items-center gap-3", {
"lg:grid-cols-6 lg:gap-4": fixedTopServerName,
})}
>
{fixedTopServerName && (
<div className={"col-span-1 hidden items-center gap-2 lg:flex lg:flex-row"}>
<div className="font-semibold text-xs">
{host.Platform.includes("Windows") ? (
<MageMicrosoftWindows className="size-[10px]" />
) : (
<p className={`fl-${GetFontLogoClass(host.Platform)}`} />
)}
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="flex items-center font-semibold text-[10.5px]">
{host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
</div>
</div>
</div>
)}
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="flex items-center font-semibold text-xs">{cpu.toFixed(2)}%</div>
<ServerUsageBar value={cpu} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center font-semibold text-xs">{mem.toFixed(2)}%</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("STG")}</p>
<div className="flex items-center font-semibold text-xs">{stg.toFixed(2)}%</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center font-semibold text-xs">
{up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center font-semibold text-xs">
{down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
</div>
</div>
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500"></span>
</section>
{showNetTransfer && (
<section className={"flex items-center justify-between gap-1"}>
<Badge
variant="secondary"
className="flex-1 items-center justify-center text-nowrap rounded-[8px] border-muted-50 text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
>
{t("Upload")}:{formatBytes(serverInfo.status.NetOutTransfer)}
</Badge>
<Badge
variant="outline"
className="flex-1 items-center justify-center text-nowrap rounded-[8px] text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none"
>
{t("Download")}:{formatBytes(serverInfo.status.NetInTransfer)}
</Badge>
</section>
)}
</div>
</Card>
</Link>
) : (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex cursor-pointer flex-col items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
showNetTransfer ? "min-h-[123px] lg:min-h-[91px]" : "min-h-[93px] lg:min-h-[61px]",
{
"flex-col": fixedTopServerName,
"lg:flex-row": !fixedTopServerName,
},
)}
>
<section
className={cn("grid items-center gap-2", {
"lg:w-40": !fixedTopServerName,
})}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</PopoverTrigger>
<PopoverContent side="top">
<ServerCardPopover status={props.status} host={props.host} />
</PopoverContent>
</Popover>
<section className={"grid grid-cols-5 items-center gap-3"}>
<div className={"flex flex-col"}>
<p className="text-xs text-muted-foreground">CPU</p>
<div className="flex items-center text-xs font-semibold">
{cpu.toFixed(2)}%
</div>
<div className="relative">
<ServerUsageBar value={cpu} />
</div>
<div className={"flex flex-col"}>
<p className="text-xs text-muted-foreground">Mem</p>
<div className="flex items-center text-xs font-semibold">
{mem.toFixed(2)}%
</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex flex-col"}>
<p className="text-xs text-muted-foreground">STG</p>
<div className="flex items-center text-xs font-semibold">
{stg.toFixed(2)}%
</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex flex-col"}>
<p className="text-xs text-muted-foreground">Upload</p>
<div className="flex items-center text-xs font-semibold">
{up.toFixed(2)}
Mb/s
</div>
</div>
<div className={"flex flex-col"}>
<p className="text-xs text-muted-foreground">Download</p>
<div className="flex items-center text-xs font-semibold">
{down.toFixed(2)}
Mb/s
</div>
</div>
</section>
</Card>
) : (
<Card
className={
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 lg:flex-row"
}
>
<Popover>
<PopoverTrigger asChild>
<section className={"flex items-center justify-start gap-2 lg:w-28"}>
{showFlag ? (
country_code ? (
<span className="text-[12px] text-muted-foreground">
{getUnicodeFlagIcon(country_code)}
</span>
) : (
<span className="text-[12px] text-muted-foreground">🏁</span>
)
) : null}
<p
className={cn("break-all font-bold tracking-tight", showFlag ? "text-xs" : "text-sm")}
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs" : "text-sm",
)}
>
{name}
</p>
</div>
</section>
</Card>
</Link>
)
<span className="h-2 w-2 shrink-0 rounded-full bg-red-500"></span>
</section>
</PopoverTrigger>
<PopoverContent className="w-fit p-2" side="top">
<p className="text-sm text-muted-foreground">Offline</p>
</PopoverContent>
</Popover>
</Card>
);
}

View File

@ -1,157 +0,0 @@
import type { NezhaAPISafe } from "@/app/types/nezha-api"
import ServerFlag from "@/components/ServerFlag"
import ServerUsageBar from "@/components/ServerUsageBar"
import { Card } from "@/components/ui/card"
import getEnv from "@/lib/env-entry"
import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
import { useTranslations } from "next-intl"
import Link from "next/link"
import { Separator } from "./ui/separator"
export default function ServerCardInline({
serverInfo,
}: {
serverInfo: NezhaAPISafe
}) {
const t = useTranslations("ServerCard")
const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
formatNezhaInfo(serverInfo)
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true")
}
return online ? (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex w-full min-w-[900px] cursor-pointer items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 lg:flex-row dark:hover:border-stone-700",
)}
>
<section
className={cn("grid items-center gap-2 lg:w-36")}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative w-28">
<p
className={cn(
"break-all font-bold tracking-tight",
showFlag ? "text-xs " : "text-sm",
)}
>
{name}
</p>
</div>
</section>
<Separator orientation="vertical" className="mx-0 ml-2 h-8" />
<div className="flex flex-col gap-2">
<section className={cn("grid flex-1 grid-cols-9 items-center gap-3")}>
<div className={"flex flex-row items-center gap-2 whitespace-nowrap"}>
<div className="font-semibold text-xs">
{host.Platform.includes("Windows") ? (
<MageMicrosoftWindows className="size-[10px]" />
) : (
<p className={`fl-${GetFontLogoClass(host.Platform)}`} />
)}
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="flex items-center font-semibold text-[10.5px]">
{host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
</div>
</div>
</div>
<div className={"flex w-20 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Uptime")}</p>
<div className="flex items-center font-semibold text-xs">
{(serverInfo?.status.Uptime / 86400).toFixed(0)} {"Days"}
</div>
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="flex items-center font-semibold text-xs">{cpu.toFixed(2)}%</div>
<ServerUsageBar value={cpu} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center font-semibold text-xs">{mem.toFixed(2)}%</div>
<ServerUsageBar value={mem} />
</div>
<div className={"flex w-14 flex-col"}>
<p className="text-muted-foreground text-xs">{t("STG")}</p>
<div className="flex items-center font-semibold text-xs">{stg.toFixed(2)}%</div>
<ServerUsageBar value={stg} />
</div>
<div className={"flex w-16 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center font-semibold text-xs">
{up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-16 flex-col"}>
<p className="text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center font-semibold text-xs">
{down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
</div>
</div>
<div className={"flex w-20 flex-col"}>
<p className="text-muted-foreground text-xs">{t("TotalUpload")}</p>
<div className="flex items-center font-semibold text-xs">
{formatBytes(serverInfo.status.NetOutTransfer)}
</div>
</div>
<div className={"flex w-20 flex-col"}>
<p className="text-muted-foreground text-xs">{t("TotalDownload")}</p>
<div className="flex items-center font-semibold text-xs">
{formatBytes(serverInfo.status.NetInTransfer)}
</div>
</div>
</section>
</div>
</Card>
</Link>
) : (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card
className={cn(
"flex min-h-[61px] min-w-[900px] flex-row items-center justify-start gap-3 p-3 hover:border-stone-300 hover:shadow-md md:px-5 dark:hover:border-stone-700",
)}
>
<section
className={cn("grid items-center gap-2 lg:w-40")}
style={{ gridTemplateColumns: "auto auto 1fr" }}
>
<span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
<div
className={cn(
"flex items-center justify-center",
showFlag ? "min-w-[17px]" : "min-w-0",
)}
>
{showFlag ? <ServerFlag country_code={country_code} /> : null}
</div>
<div className="relative w-28">
<p
className={cn("break-all font-bold tracking-tight", showFlag ? "text-xs" : "text-sm")}
>
{name}
</p>
</div>
</section>
</Card>
</Link>
)
}

View File

@ -0,0 +1,71 @@
import { NezhaAPISafe } from "@/app/types/nezha-api";
import { cn, formatBytes } from "@/lib/utils";
export function ServerCardPopoverCard({
className,
title,
content,
children,
}: {
className?: string;
title: string;
content?: string;
children?: React.ReactNode;
}) {
return (
<div className={cn("mb-[6px] flex w-full flex-col", className)}>
<div className="text-sm font-semibold">{title}</div>
{children ? (
children
) : (
<div className="break-all text-xs font-medium">{content}</div>
)}
</div>
);
}
export default function ServerCardPopover({
host,
status,
}: {
host: NezhaAPISafe["host"];
status: NezhaAPISafe["status"];
}) {
return (
<section className="max-w-[300px]">
<ServerCardPopoverCard
title="System"
content={`${host.Platform}-${host.PlatformVersion} [${host.Virtualization}: ${host.Arch}]`}
/>
<ServerCardPopoverCard
title="CPU"
content={`${host.CPU.map((item) => item).join(", ")}`}
/>
<ServerCardPopoverCard
title="Mem"
content={`${formatBytes(status.MemUsed)} / ${formatBytes(host.MemTotal)}`}
/>
<ServerCardPopoverCard
title="STG"
content={`${formatBytes(status.DiskUsed)} / ${formatBytes(host.DiskTotal)}`}
/>
<ServerCardPopoverCard
title="Swap"
content={`${formatBytes(status.SwapUsed)} / ${formatBytes(host.SwapTotal)}`}
/>
<ServerCardPopoverCard
title="Network"
content={`${formatBytes(status.NetOutTransfer)} / ${formatBytes(status.NetInTransfer)}`}
/>
<ServerCardPopoverCard
title="Load"
content={`${status.Load1.toFixed(2)} / ${status.Load5.toFixed(2)} / ${status.Load15.toFixed(2)}`}
/>
<ServerCardPopoverCard
className="mb-0"
title="Online"
content={`${(status.Uptime / 86400).toFixed(0)} Days`}
/>
</section>
);
}

View File

@ -1,52 +0,0 @@
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import getUnicodeFlagIcon from "country-flag-icons/unicode"
import { useEffect, useState } from "react"
export default function ServerFlag({
country_code,
className,
}: {
country_code: string
className?: string
}) {
const [supportsEmojiFlags, setSupportsEmojiFlags] = useState(true)
const useSvgFlag = getEnv("NEXT_PUBLIC_ForceUseSvgFlag") === "true"
useEffect(() => {
if (useSvgFlag) {
// 如果环境变量要求直接使用 SVG则无需检查 Emoji 支持
setSupportsEmojiFlags(false)
return
}
const checkEmojiSupport = () => {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
const emojiFlag = "🇺🇸" // 使用美国国旗作为测试
if (!ctx) return
ctx.fillStyle = "#000"
ctx.textBaseline = "top"
ctx.font = "32px Arial"
ctx.fillText(emojiFlag, 0, 0)
const support = ctx.getImageData(16, 16, 1, 1).data[3] !== 0
setSupportsEmojiFlags(support)
}
checkEmojiSupport()
}, [useSvgFlag]) // 将 `useSvgFlag` 作为依赖,当其变化时重新触发
if (!country_code) return null
return (
<span className={cn("text-[12px] text-muted-foreground", className)}>
{useSvgFlag || !supportsEmojiFlags ? (
<span className={`fi fi-${country_code}`} />
) : (
getUnicodeFlagIcon(country_code)
)}
</span>
)
}

View File

@ -0,0 +1,7 @@
import React from "react";
import ServerListClient from "@/app/(main)/ClientComponents/ServerListClient";
export default async function ServerList() {
return <ServerListClient />;
}

View File

@ -0,0 +1,5 @@
import ServerOverviewClient from "@/app/(main)/ClientComponents/ServerOverviewClient";
export default async function ServerOverview() {
return <ServerOverviewClient />;
}

View File

@ -1,8 +1,10 @@
import { Progress } from "@/components/ui/progress"
import React from "react";
import { Progress } from "@/components/ui/progress";
type ServerUsageBarProps = {
value: number
}
value: number;
};
export default function ServerUsageBar({ value }: ServerUsageBarProps) {
return (
@ -10,8 +12,14 @@ export default function ServerUsageBar({ value }: ServerUsageBarProps) {
aria-label={"Server Usage Bar"}
aria-labelledby={"Server Usage Bar"}
value={value}
indicatorClassName={value > 90 ? "bg-red-500" : value > 70 ? "bg-orange-400" : "bg-green-500"}
indicatorClassName={
value > 90
? "bg-red-500"
: value > 70
? "bg-orange-400"
: "bg-green-500"
}
className={"h-[3px] rounded-sm"}
/>
)
);
}

View File

@ -1,80 +0,0 @@
"use client"
import { getCsrfToken, signIn } from "next-auth/react"
import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { Loader } from "./loading/Loader"
export function SignIn() {
const t = useTranslations("SignIn")
const [csrfToken, setCsrfToken] = useState("")
const [loading, setLoading] = useState(false)
const [errorState, setErrorState] = useState(false)
const [successState, setSuccessState] = useState(false)
const router = useRouter()
useEffect(() => {
async function loadProviders() {
const csrf = await getCsrfToken()
setCsrfToken(csrf)
}
loadProviders()
}, [])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
const formData = new FormData(e.currentTarget)
const password = formData.get("password") as string
const res = await signIn("credentials", {
password: password,
redirect: false,
})
if (res?.error) {
console.log("login error")
setErrorState(true)
setSuccessState(false)
} else {
console.log("login success")
setErrorState(false)
setSuccessState(true)
router.push("/")
router.refresh()
}
setLoading(false)
}
return (
<form
className="flex flex-1 flex-col items-center justify-center gap-4 p-4 "
onSubmit={handleSubmit}
>
<input type="hidden" name="csrfToken" value={csrfToken} />
<section className="flex flex-col items-start gap-2">
<label className="flex flex-col items-start gap-1 ">
{errorState && <p className="font-semibold text-red-500 text-sm">{t("ErrorMessage")}</p>}
{successState && (
<p className="font-semibold text-green-500 text-sm">{t("SuccessMessage")}</p>
)}
<p className="font-semibold text-base">{t("SignInMessage")}</p>
<input
className="rounded-[5px] border-[1px] border-stone-300 px-1 dark:border-stone-800"
name="password"
type="password"
/>
</label>
<button
type="submit"
className="flex w-fit items-center gap-1 rounded-[8px] border border-stone-300 bg-card px-1.5 py-0.5 font-semibold text-card-foreground text-sm shadow-lg shadow-neutral-200/40 transition-all hover:brightness-95 dark:border-stone-800 dark:shadow-none"
disabled={loading}
>
{t("Submit")}
{loading && <Loader visible={true} />}
</button>
</section>
</form>
)
}

View File

@ -1,131 +0,0 @@
"use client"
import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"
import { useLocale, useTranslations } from "next-intl"
import { createRef, useEffect, useRef, useState } from "react"
export default function Switch({
allTag,
nowTag,
tagCountMap,
onTagChange,
}: {
allTag: string[]
nowTag: string
tagCountMap: Record<string, number>
onTagChange: (tag: string) => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const tagRefs = useRef(allTag.map(() => createRef<HTMLDivElement>()))
const t = useTranslations("ServerListClient")
const locale = useLocale()
const [indicator, setIndicator] = useState<{ x: number; w: number } | null>(null)
const [isFirstRender, setIsFirstRender] = useState(true)
useEffect(() => {
const savedTag = sessionStorage.getItem("selectedTag")
if (savedTag && allTag.includes(savedTag)) {
onTagChange(savedTag)
}
}, [allTag, onTagChange])
useEffect(() => {
const container = scrollRef.current
if (!container) return
const isOverflowing = container.scrollWidth > container.clientWidth
if (!isOverflowing) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
container.scrollLeft += e.deltaY
}
container.addEventListener("wheel", onWheel, { passive: false })
return () => {
container.removeEventListener("wheel", onWheel)
}
}, [])
useEffect(() => {
const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
if (currentTagElement) {
setIndicator({
x: currentTagElement.offsetLeft,
w: currentTagElement.offsetWidth,
})
}
if (isFirstRender) {
setTimeout(() => {
setIsFirstRender(false)
}, 50)
}
}, [nowTag, locale, allTag, isFirstRender])
useEffect(() => {
const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
const container = scrollRef.current
if (currentTagElement && container) {
const containerRect = container.getBoundingClientRect()
const tagRect = currentTagElement.getBoundingClientRect()
const scrollLeft = currentTagElement.offsetLeft - (containerRect.width - tagRect.width) / 2
container.scrollTo({
left: Math.max(0, scrollLeft),
behavior: "smooth",
})
}
}, [nowTag, locale])
return (
<div
ref={scrollRef}
className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]"
>
<div className="relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{indicator && (
<div
className="absolute top-[3px] left-0 z-10 h-[35px] bg-white shadow-black/5 shadow-lg dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 24,
width: `${indicator.w}px`,
transform: `translateX(${indicator.x}px)`,
transition: isFirstRender ? "none" : "all 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
)}
{allTag.map((tag, index) => (
<div
key={tag}
ref={tagRefs.current[index]}
onClick={() => {
onTagChange(tag)
sessionStorage.setItem("selectedTag", tag)
}}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
"text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
{
"text-stone-950 dark:text-stone-50": nowTag === tag,
},
)}
>
<div className="relative z-20 flex items-center gap-1">
<div className="flex items-center gap-2 whitespace-nowrap">
{tag === "defaultTag" ? t("defaultTag") : tag}{" "}
{getEnv("NEXT_PUBLIC_ShowTagCount") === "true" && tag !== "defaultTag" && (
<div className="w-fit rounded-full bg-muted px-1.5">{tagCountMap[tag]}</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -1,75 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
import { useLocale, useTranslations } from "next-intl"
import { useEffect, useRef, useState } from "react"
export default function TabSwitch({
tabs,
currentTab,
setCurrentTab,
}: {
tabs: string[]
currentTab: string
setCurrentTab: (tab: string) => void
}) {
const t = useTranslations("TabSwitch")
const [indicator, setIndicator] = useState<{ x: number; w: number }>({
x: 0,
w: 0,
})
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const locale = useLocale()
useEffect(() => {
const currentTabElement = tabRefs.current[tabs.indexOf(currentTab)]
if (currentTabElement) {
const parentPadding = 1
setIndicator({
x:
tabs.indexOf(currentTab) !== 0
? currentTabElement.offsetLeft - parentPadding
: currentTabElement.offsetLeft,
w: currentTabElement.offsetWidth,
})
}
}, [currentTab, tabs, locale])
return (
<div className="z-50 flex flex-col items-start rounded-[50px]">
<div className="relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{indicator.w > 0 && (
<div
className="absolute top-[3px] left-0 z-10 h-[35px] bg-white shadow-black/5 shadow-lg dark:bg-stone-700 dark:shadow-white/5"
style={{
borderRadius: 24,
width: `${indicator.w}px`,
transform: `translateX(${indicator.x}px)`,
transition: "all 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
)}
{tabs.map((tab: string, index) => (
<div
key={tab}
ref={(el) => {
tabRefs.current[index] = el
}}
onClick={() => setCurrentTab(tab)}
className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
"text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
{
"text-stone-950 dark:text-stone-50": currentTab === tab,
},
)}
>
<div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{t(tab)}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -1,39 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { useEffect } from "react"
export function ThemeColorManager() {
const { theme, systemTheme } = useTheme()
useEffect(() => {
const updateThemeColor = () => {
const currentTheme = theme === "system" ? systemTheme : theme
const meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
const newMeta = document.createElement("meta")
newMeta.name = "theme-color"
document.head.appendChild(newMeta)
}
const themeColor =
currentTheme === "dark"
? "hsl(30 15% 8%)" // 深色模式背景色
: "hsl(0 0% 98%)" // 浅色模式背景色
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
}
// Update on mount and theme change
updateThemeColor()
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
mediaQuery.addEventListener("change", updateThemeColor)
return () => mediaQuery.removeEventListener("change", updateThemeColor)
}, [theme, systemTheme])
return null
}

View File

@ -1,80 +1,40 @@
"use client"
"use client";
import { Button } from "@/components/ui/button"
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { CheckIcon, MinusIcon, Moon, Sun } from "lucide-react"
import { useTranslations } from "next-intl"
import { useTheme } from "next-themes"
import { useId } from "react"
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"
const items = [
{ value: "light", label: "Light", image: "/ui-light.png" },
{ value: "dark", label: "Dark", image: "/ui-dark.png" },
{ value: "system", label: "System", image: "/ui-system.png" },
]
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const { setTheme, theme } = useTheme()
const t = useTranslations("ThemeSwitcher")
const handleSelect = (newTheme: string) => {
setTheme(newTheme)
}
const id = useId()
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
>
<Sun className="dark:-rotate-90 h-4 w-4 rotate-0 scale-100 transition-all dark:scale-0" />
<Button variant="outline" size="sm" className="rounded-full px-[9px]">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="px-2 pt-2 pb-1.5" align="end">
<fieldset className="space-y-4">
<RadioGroup className="flex gap-2" defaultValue={theme} onValueChange={handleSelect}>
{items.map((item) => (
<label key={`${id}-${item.value}`}>
<RadioGroupItem
id={`${id}-${item.value}`}
value={item.value}
className="peer sr-only after:absolute after:inset-0"
/>
<img
src={item.image}
alt={item.label}
width={88}
height={70}
className="relative cursor-pointer overflow-hidden rounded-[8px] border border-neutral-300 shadow-xs outline-none transition-[color,box-shadow] peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-data-disabled:cursor-not-allowed peer-data-[state=checked]:bg-accent peer-data-disabled:opacity-50 dark:border-neutral-700"
/>
<span className="group mt-2 flex items-center gap-1 peer-data-[state=unchecked]:text-muted-foreground/70">
<CheckIcon
size={16}
className="group-peer-data-[state=unchecked]:hidden"
aria-hidden="true"
/>
<MinusIcon
size={16}
className="group-peer-data-[state=checked]:hidden"
aria-hidden="true"
/>
<span className="font-medium text-xs">{t(item.label)}</span>
</span>
</label>
))}
</RadioGroup>
</fieldset>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
);
}

View File

@ -1,16 +0,0 @@
"use client"
import { Loader } from "@/components/loading/Loader"
import { useTranslations } from "next-intl"
export default function GlobalLoading() {
const t = useTranslations("Global")
return (
<section className="mt-[3.2px] flex flex-col gap-4">
<div className="flex min-h-40 flex-col items-center justify-center font-medium text-sm">
{t("Loading")}
<Loader visible={true} />
</div>
</section>
)
}

View File

@ -1,13 +1,13 @@
const bars = Array(8).fill(0)
const bars = Array(8).fill(0);
export const Loader = ({ visible }: { visible: boolean }) => {
return (
<div className="hamster-loading-wrapper" data-visible={visible}>
<div className="hamster-spinner">
{bars.map((_, i) => (
<div className="hamster-loading-bar" key={`hamster-bar-${i + 1}`} />
<div className="hamster-loading-bar" key={`hamster-bar-${i}`} />
))}
</div>
</div>
)
}
);
};

View File

@ -1,23 +0,0 @@
import { Loader } from "@/components/loading/Loader"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export default function NetworkChartLoading() {
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5">
<CardTitle className="flex items-center gap-0.5 text-xl">
<div className="aspect-auto h-[20px] w-24 bg-muted" />
</CardTitle>
<div className="mt-[2px] aspect-auto h-[14px] w-32 bg-muted" />
</div>
<div className="hidden pt-4 pr-4 sm:block">
<Loader visible={true} />
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<div className="aspect-auto h-[250px] w-full" />
</CardContent>
</Card>
)
}

View File

@ -1,37 +0,0 @@
import { BackIcon } from "@/components/Icon"
import { Skeleton } from "@/components/ui/skeleton"
import { useRouter } from "next/navigation"
export function ServerDetailChartLoading() {
return (
<div>
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
<Skeleton className="h-[182px] w-full animate-none rounded-[5px] bg-muted-foreground/10" />
</section>
</div>
)
}
export function ServerDetailLoading() {
const router = useRouter()
return (
<>
<div
onClick={() => {
router.push("/")
}}
className="flex flex-none cursor-pointer items-center gap-0.5 break-all font-semibold text-xl leading-none tracking-tight"
>
<BackIcon />
<Skeleton className="h-[20px] w-24 animate-none rounded-[5px] bg-muted-foreground/10" />
</div>
<Skeleton className="mt-3 flex h-[81px] w-1/2 animate-none flex-wrap gap-2 rounded-[5px] bg-muted-foreground/10" />
</>
)
}

View File

@ -1,101 +0,0 @@
import { cn } from "@/lib/utils"
interface Props {
max: number
value: number
min: number
className?: string
primaryColor?: string
}
export default function AnimatedCircularProgressBar({
max = 100,
min = 0,
value = 0,
primaryColor,
className,
}: Props) {
const circumference = 2 * Math.PI * 45
const percentPx = circumference / 100
const currentPercent = ((value - min) / (max - min)) * 100
return (
<div
className={cn("relative size-40 font-semibold text-2xl", className)}
style={
{
"--circle-size": "100px",
"--circumference": circumference,
"--percent-to-px": `${percentPx}px`,
"--gap-percent": "5",
"--offset-factor": "0",
"--transition-length": "1s",
"--transition-step": "200ms",
"--delay": "0s",
"--percent-to-deg": "3.6deg",
transform: "translateZ(0)",
} as React.CSSProperties
}
>
<svg fill="none" className="size-full" strokeWidth="2" viewBox="0 0 100 100">
<title>Circular Progress Bar</title>
{currentPercent <= 90 && currentPercent >= 0 && (
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="stroke-muted opacity-100"
style={
{
"--stroke-percent": 90 - currentPercent,
"--offset-factor-secondary": "calc(1 - var(--offset-factor))",
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transform:
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)",
transition: "all var(--transition-length) ease var(--delay)",
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
)}
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("stroke-current opacity-100", {
"stroke-[var(--stroke-primary-color)]": primaryColor,
})}
style={
{
"--stroke-primary-color": primaryColor,
"--stroke-percent": currentPercent,
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transition:
"var(--transition-length) ease var(--delay),stroke var(--transition-length) ease var(--delay)",
transitionProperty: "stroke-dasharray,transform",
transform:
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
</svg>
<span
data-current-value={currentPercent}
className="fade-in absolute inset-0 m-auto size-fit animate-in delay-[var(--delay)] duration-[var(--transition-length)] ease-linear"
>
{currentPercent}
</span>
</div>
)
}

View File

@ -1,20 +1,21 @@
import Image from "next/image"
import Link from "next/link"
import Image from "next/image";
import Link from "next/link";
import React from "react";
export const AnimatedTooltip = ({
items,
}: {
items: {
id: number
name: string
designation: string
image: string
}[]
id: number;
name: string;
designation: string;
image: string;
}[];
}) => {
return (
<>
{items.map((item) => (
<div className="group -mr-4 relative" key={item.name}>
<div className="group relative -mr-4" key={item.name}>
<Link href="https://buycoffee.top" target="_blank">
<Image
width={40}
@ -22,11 +23,11 @@ export const AnimatedTooltip = ({
unoptimized
src={item.image}
alt={item.name}
className="relative m-0! h-6 w-6 rounded-full border-2 border-white object-cover object-top p-0! transition duration-500 group-hover:z-30 group-hover:scale-105"
className="relative !m-0 h-6 w-6 rounded-full border-2 border-white object-cover object-top !p-0 transition duration-500 group-hover:z-30 group-hover:scale-105"
/>
</Link>
</div>
))}
</>
)
}
);
};

View File

@ -1,13 +1,15 @@
import { cn } from "@/lib/utils"
import { type VariantProps, cva } from "class-variance-authority"
import type * as React from "react"
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center text-nowarp rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors pointer-events-none focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
@ -19,14 +21,16 @@ const badgeVariants = cva(
variant: "default",
},
},
)
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@ -1,17 +1,21 @@
import { cn } from "@/lib/utils"
import { Slot } from "@radix-ui/react-slot"
import { type VariantProps, cva } from "class-variance-authority"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
@ -27,22 +31,26 @@ const buttonVariants = cva(
size: "default",
},
},
)
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
)
Button.displayName = "Button"
);
Button.displayName = "Button";
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@ -1,58 +1,86 @@
import { cn } from "@/lib/utils"
import * as React from "react"
import * as React from "react";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none",
className,
)}
{...props}
/>
),
)
Card.displayName = "Card"
import { cn } from "@/lib/utils";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
)
CardHeader.displayName = "CardHeader"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold text-2xl leading-none tracking-tight", className)}
{...props}
/>
),
)
CardTitle.displayName = "CardTitle"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
))
CardDescription.displayName = "CardDescription"
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
)
CardContent.displayName = "CardContent"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
)
CardFooter.displayName = "CardFooter"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@ -1,347 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k: string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
if (!active || !payload?.length) {
return null
}
payload.sort((a, b) => {
return Number(b.value) - Number(a.value)
})
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 overflow-hidden rounded-sm border border-border/50 bg-stone-100 text-xs dark:bg-stone-900",
className,
)}
>
{!nestLabel && (
<div className="-mb-1 mx-auto px-2.5 pt-1">{!nestLabel ? tooltipLabel : null}</div>
)}
<div
className={cn("grid gap-1.5 bg-white px-2.5 py-1.5 dark:bg-black", {
"border-t-[1px]": !nestLabel,
})}
>
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span
className={cn(
"ml-2 font-medium text-foreground tabular-nums",
payload.length === 1 && "-ml-9",
)}
>
{typeof item.value === "number"
? item.value.toFixed(2).toLocaleString()
: item.value}{" "}
ms
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
},
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex flex-wrap items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{key}
</div>
)
})}
</div>
)
})
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -1,144 +0,0 @@
"use client"
import { type DialogProps, DialogTitle } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import * as React from "react"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogTitle />
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center bg-stone-100 px-3 dark:bg-stone-900" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("mb-1 max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:text-xs",
className,
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-[8px] px-2 py-1.5 text-xs outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-stone-100 data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 dark:data-[selected='true']:bg-stone-900 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-muted-foreground text-xs tracking-widest", className)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -1,104 +0,0 @@
"use client"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("font-semibold text-lg leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -1,32 +1,33 @@
"use client"
"use client";
import { cn } from "@/lib/utils"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
const DropdownMenu = DropdownMenuPrimitive.Root
import { cn } from "@/lib/utils";
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent",
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
@ -35,8 +36,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -45,13 +47,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -62,32 +65,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-2xl data-[state=closed]:animate-out data-[state=open]:animate-in dark:shadow-none",
"z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-[10px] px-2 py-1.5 font-normal text-xs outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs font-medium outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -96,7 +99,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
@ -109,8 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -119,7 +123,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
@ -131,22 +135,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 font-semibold text-sm", inset && "pl-8", className)}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -157,13 +165,21 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
@ -181,4 +197,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
};

View File

@ -1,7 +1,9 @@
import { cn } from "@/lib/utils"
import * as React from "react"
import * as React from "react";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
@ -9,15 +11,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
)
);
},
)
Input.displayName = "Input"
);
Input.displayName = "Input";
export { Input }
export { Input };

View File

@ -1,20 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
import * as LabelPrimitive from "@radix-ui/react-label"
import { type VariantProps, cva } from "class-variance-authority"
import * as React from "react"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -1,8 +1,9 @@
import { cn } from "@/lib/utils"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
@ -10,14 +11,17 @@ const NavigationMenu = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
@ -25,17 +29,20 @@ const NavigationMenuList = React.forwardRef<
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className,
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50",
)
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
@ -52,8 +59,8 @@ const NavigationMenuTrigger = React.forwardRef<
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
@ -62,32 +69,33 @@ const NavigationMenuContent = React.forwardRef<
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out md:absolute md:w-auto",
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
className,
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute top-full left-0 flex justify-center")}>
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full origin-top-center overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in md:w-[var(--radix-navigation-menu-viewport-width)]",
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
@ -96,15 +104,16 @@ const NavigationMenuIndicator = React.forwardRef<
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=visible]:animate-in",
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
NavigationMenu,
@ -116,4 +125,4 @@ export {
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuViewport,
}
};

View File

@ -1,12 +1,13 @@
"use client"
"use client";
import { cn } from "@/lib/utils"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react"
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
const Popover = PopoverPrimitive.Root
import { cn } from "@/lib/utils";
const PopoverTrigger = PopoverPrimitive.Trigger
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
@ -18,13 +19,13 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent }
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -1,18 +1,22 @@
"use client"
"use client";
import { cn } from "@/lib/utils"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress";
import * as React from "react";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string // 添加一个新的可选属性来自定义Indicator的类名
indicatorClassName?: string; // 添加一个新的可选属性来自定义Indicator的类名
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
@ -23,7 +27,7 @@ const Progress = React.forwardRef<
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress }
export { Progress };

View File

@ -1,50 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import type * as React from "react"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center text-current">
<svg
role="img"
aria-label="Radio indicator"
width="6"
height="6"
viewBox="0 0 6 6"
fill="currentcolor"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="3" cy="3" r="3" />
</svg>
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@ -1,25 +1,31 @@
"use client"
"use client";
import { cn } from "@/lib/utils"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };

View File

@ -1,18 +1,19 @@
"use client"
"use client";
import { cn } from "@/lib/utils"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { type VariantProps, cva } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
const Sheet = SheetPrimitive.Root
import { cn } from "@/lib/utils";
const SheetTrigger = SheetPrimitive.Trigger
const Sheet = SheetPrimitive.Root;
const SheetClose = SheetPrimitive.Close
const SheetTrigger = SheetPrimitive.Trigger;
const SheetPortal = SheetPrimitive.Portal
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
@ -20,14 +21,14 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@ -46,7 +47,7 @@ const sheetVariants = cva(
side: "right",
},
},
)
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
@ -58,29 +59,48 @@ const SheetContent = React.forwardRef<
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
@ -88,11 +108,11 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("font-semibold text-foreground text-lg", className)}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
@ -100,11 +120,11 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
@ -117,4 +137,4 @@ export {
SheetPortal,
SheetTitle,
SheetTrigger,
}
};

View File

@ -1,7 +1,15 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton }
export { Skeleton };

View File

@ -1,28 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from "react"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-3 w-6 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-2 w-2 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -1,14 +1,15 @@
"use client"
"use client";
import { cn } from "@/lib/utils"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
const TooltipProvider = TooltipPrimitive.Provider
import { cn } from "@/lib/utils";
const Tooltip = TooltipPrimitive.Root
const TooltipProvider = TooltipPrimitive.Provider;
const TooltipTrigger = TooltipPrimitive.Trigger
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
@ -18,12 +19,12 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 animate-in overflow-hidden rounded-[10px] border bg-popover px-3 py-1.5 text-popover-foreground text-sm shadow-md data-[state=closed]:animate-out",
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@ -1,17 +1,5 @@
NezhaBaseUrl=http://124.XX.XX.XX:8008
NezhaAuth=your-nezha-api-token
DefaultLocale=zh
ForceShowAllServers=false
NezhaBaseUrl=http://0.0.0.0:8008
NezhaAuth=5hAY3QX6Nl9B3UOQgB26KdsdS1dsdUdM
NEXT_PUBLIC_NezhaFetchInterval=5000
NEXT_PUBLIC_ShowFlag=true
NEXT_PUBLIC_DisableCartoon=false
NEXT_PUBLIC_ShowTag=true
NEXT_PUBLIC_ShowNetTransfer=false
NEXT_PUBLIC_ForceUseSvgFlag=false
NEXT_PUBLIC_FixedTopServerName=false
NEXT_PUBLIC_CustomLogo=https://nezha-cf.buycoffee.top/apple-touch-icon.png
NEXT_PUBLIC_CustomTitle=NezhaDash
NEXT_PUBLIC_CustomDescription=NezhaDash is a dashboard for Nezha.
NEXT_PUBLIC_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕"}]'
NEXT_PUBLIC_DisableIndex=false
NEXT_PUBLIC_ShowTagCount=false
NEXT_PUBLIC_DisableCartoon=true

View File

@ -1,4 +1,3 @@
version: "3"
services:
nezha-dash:
container_name: nezha-dash

Some files were not shown because too many files have changed in this diff Show More