Compare commits

..

217 Commits
v1.7.2 ... main

Author SHA1 Message Date
hamster1963
e03e6232fb chore: update dependencies in package.json to latest versions 2025-04-23 17:04:27 +08:00
hamster1963
484266666d chore: bump version to 2.9.3 in package.json 2025-04-10 10:39:03 +08:00
hamster1963
fb1a74ec09 style: adjust padding and spacing in ThemeSwitcher component 2025-04-10 10:37:02 +08:00
hamster1963
812397235c chore: update dependencies in package.json to latest versions 2025-04-10 10:35:10 +08:00
hamster1963
3411783401 chore: bump version to 2.9.2 in package.json 2025-04-02 13:30:01 +08:00
hamster1963
1ab1559c20 feat: implement theme switcher with radio buttons and add localization for Traditional Chinese 2025-04-02 13:18:42 +08:00
hamster1963
0837393903 chore: update dependencies in package.json to latest versions 2025-04-02 11:36:46 +08:00
hamster1963
55cec2e055 chore: bump version to 2.9.1 in package.json 2025-03-26 09:04:29 +08:00
hamster1963
14d7f1e416 refactor: replace bcrypt with CryptoJS for password hashing and update dependencies 2025-03-26 09:04:06 +08:00
hamster1963
c267b489e4 chore: bump version to 2.9.0 in package.json 2025-03-25 17:38:44 +08:00
仓鼠
8a1ce73564
refactor: improve Switch component state management and initial rende… (#265)
* refactor: improve Switch component state management and initial render handling

* chore: auto-fix linting and formatting issues
2025-03-25 17:31:05 +08:00
hamster1963
6b273622df chore: update permissions to allow write access for Deploy and auto-fix workflows 2025-03-25 16:57:41 +08:00
hamster1963
80c4500822 chore: update dependencies in package.json 2025-03-25 16:23:45 +08:00
仓鼠
aa14f6045f
chore: update permissions for auto-fix lint and format workflow (#264) 2025-03-25 16:20:33 +08:00
仓鼠
4719c2210e
Potential fix for code scanning alert no. 4: Cache Poisoning via execution of untrusted code (#262)
* Potential fix for code scanning alert no. 4: Cache Poisoning via execution of untrusted code

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* chore: auto-fix linting and formatting issues

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-03-25 16:15:09 +08:00
仓鼠
079ff0be32
chore: add permissions for contents read in Deploy.yml (#263)
* chore: add permissions for contents read in Deploy.yml

* chore: auto-fix linting and formatting issues
2025-03-25 16:14:04 +08:00
仓鼠
38ebfcee44
Merge pull request #261 from hamster1963/alert-autofix-7
Potential fix for code scanning alert no. 7: Use of password hash with insufficient computational effort
2025-03-25 16:07:01 +08:00
仓鼠
365ba91bff chore: add @types/bcrypt 2025-03-25 07:45:25 +00:00
hamster1963
865a5ba8ee chore: auto-fix linting and formatting issues 2025-03-25 07:31:50 +00:00
仓鼠
37adab9208
Potential fix for code scanning alert no. 7: Use of password hash with insufficient computational effort
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-03-25 15:31:27 +08:00
hamster1963
e5a4c2f410 chore: bump version to 2.8.3 2025-03-23 21:37:04 +08:00
hamster1963
ce21c9b774 chore: bump version to 2.8.2 2025-03-23 21:36:38 +08:00
hamster1963
fb1b412015 chore: update caniuse-lite, next, and devDependencies to latest versions 2025-03-23 21:36:01 +08:00
hamster1963
24a079624c chore: update caniuse-lite, cmdk, tailwindcss, and postcss to latest versions 2025-03-16 11:12:31 +08:00
hamster1963
b630505ca3 feat: enhance uptime display to show days and hours 2025-03-16 11:02:09 +08:00
hamster1963
d2ab62efdc docs: update demo site link in README 2025-03-14 10:43:25 +08:00
仓鼠
b5ac8251d9
Merge pull request #256 from hamster1963/boot-time
feat: add boot time display to server details and update localization
2025-03-12 09:34:58 +08:00
hamster1963
0b7a5be3c0 chore: update dependencies to latest versions 2025-03-12 09:31:24 +08:00
hamster1963
0f435ee7c1 feat: add boot time display to server details and update localization 2025-03-12 09:30:01 +08:00
hamster1963
ac15be6e71 chore: bump version to 2.8.1 2025-02-28 09:32:36 +08:00
hamster1963
b1bcd7227d feat: update dev script to use turbopack for improved performance 2025-02-28 09:32:20 +08:00
hamster1963
29b545706e fix: ensure unique keys for map features by including index in key prop 2025-02-28 09:32:15 +08:00
hamster1963
8bb5ea0cf7 feat: add loading skeleton for time display in overview section 2025-02-28 09:21:43 +08:00
hamster1963
28d1399b2f chore: update dependencies 2025-02-27 11:37:58 +08:00
hamster1963
b456cad3d5 feat: enhance accessibility and improve component styles across the application 2025-02-22 15:56:04 +08:00
hamster1963
2302a50484 chore: bump version to 2.8.0 2025-02-21 11:35:42 +08:00
hamster1963
6c3c18fa1e chore: update bun lockfile 2025-02-21 11:26:43 +08:00
hamster1963
fca3a9ab52 feat: add server ID and improve interactive map and tooltip functionality 2025-02-21 11:26:32 +08:00
hamster1963
5b8be03765 feat: update button styles in ServerListClient for improved accessibility and visual consistency 2025-02-21 11:05:45 +08:00
hamster1963
1dae136edb chore: update country-flag-icons, next-intl, tailwindcss, postcss dependencies 2025-02-20 09:47:33 +08:00
hamster1963
f49ca034cb feat: enhance network chart x-axis tick rendering with dynamic time interval logic 2025-02-19 17:45:52 +08:00
hamster1963
e8530956fa feat: replace requestAnimationFrame with setInterval for time updates 2025-02-19 15:06:18 +08:00
hamster1963
4171829a15 feat: optimize time display rendering with requestAnimationFrame 2025-02-19 14:26:22 +08:00
hamster1963
8fbe50fd8d chore: update project dependencies 2025-02-18 18:04:38 +08:00
hamster1963
e2a9357f18 feat: add AnimatedCount to server overview metrics 2025-02-18 18:04:19 +08:00
hamster1963
69ff5365d5 feat: replace NumberFlow with custom AnimatedCount component for time display 2025-02-18 17:57:18 +08:00
hamster1963
084f71e4a6 chore: bump version to 2.7.2 2025-02-16 16:03:49 +08:00
hamster1963
cc810dd41c chore: update @types/node to version 22.13.4 2025-02-16 15:57:14 +08:00
hamster1963
c14f371bc2 feat: improve network chart x-axis tick formatting and display 2025-02-16 15:54:40 +08:00
hamster1963
23a3261251 chore: bump version to 2.7.1 2025-02-13 14:59:00 +08:00
hamster1963
a6aa91d35a fix: add locale dependency to component rendering effects 2025-02-13 14:56:56 +08:00
hamster1963
e55a60c12e fix: resolve server-side rendering issue with Mac detection in Footer 2025-02-13 11:50:25 +08:00
hamster1963
2164321e72 chore: update project dependencies to latest versions 2025-02-13 11:35:09 +08:00
hamster1963
b5658d81ab feat: add platform-specific keyboard shortcut display in Footer 2025-02-13 11:34:52 +08:00
hamster1963
79ba408d9f chore: bump version to 2.7.0 2025-02-07 14:37:23 +08:00
hamster1963
98eb515144 chore: update dependencies and optimize import sorting 2025-02-07 14:34:22 +08:00
hamster1963
e8302c7667 refactor: remove environment variable warning logs 2025-02-07 14:33:51 +08:00
hamster1963
4bbda14bf0 feat: enhance auth secret generation with CryptoJS MD5 hashing 2025-02-07 14:32:57 +08:00
hamster1963
e90914b320 refactor: improve SignIn form layout and centering 2025-02-07 01:33:29 +08:00
hamster1963
fc86e904aa refactor: improve code formatting in Footer and ServerListClient components 2025-02-07 01:33:20 +08:00
hamster1963
7277e44f85 chore: bump version to 2.6.3 2025-02-06 00:53:25 +08:00
hamster1963
649a3546f1 chore: update SWR library to version 2.3.1 2025-02-06 00:53:11 +08:00
hamster1963
5e2cad71db chore: enable Next.js experimental performance optimizations 2025-02-06 00:44:29 +08:00
hamster1963
07afc44eb7 refactor: modularize ServerListClient with utility functions and components 2025-02-06 00:26:20 +08:00
hamster1963
1713189333 refactor: improve Footer component with modular link styling and constants 2025-02-06 00:20:05 +08:00
hamster1963
3ce131419f refactor: optimize Header component with memoization and time tracking 2025-02-06 00:15:37 +08:00
hamster1963
010cfce1c4 refactor: improve server details page with type safety and dynamic tab rendering 2025-02-06 00:10:43 +08:00
hamster1963
bf959fbb9a bump version to 2.6.2 2025-02-05 01:24:01 +08:00
hamster1963
d9b83b9c34 chore: update project dependencies to latest versions 2025-02-05 01:23:51 +08:00
hamster1963
9fc050c2a1 refactor: simplify seconds display in Overview time component 2025-02-05 01:23:22 +08:00
hamster1963
c520d415e8 chore: enable React Compiler in Next.js configuration 2025-02-02 21:33:48 +08:00
hamster1963
9731eeb530 bump version to 2.6.1 2025-02-01 19:54:43 +08:00
hamster1963
77ac6f2f44 refactor: reposition DashCommand component in MainLayout 2025-02-01 19:52:48 +08:00
hamster1963
d715d050d2 chore: update dev dependencies to latest versions 2025-02-01 19:47:17 +08:00
hamster1963
e7f947a692 bump version to 2.6.0 2025-01-30 01:05:17 +08:00
hamster1963
6f36d49e00 chore: update cmdk and lucide-react to latest versions 2025-01-30 01:05:02 +08:00
hamster1963
56ddf847a0 style: remove wave emoji from Overview section in localization files 2025-01-30 01:02:42 +08:00
hamster1963
4da2eca1c1 refactor: modify getEnv to return undefined for non-existent environment variables 2025-01-30 00:48:48 +08:00
hamster1963
d775cde3ca refactor: improve environment variable retrieval with optional return type 2025-01-30 00:44:57 +08:00
hamster1963
c59b381a35 feat: add site password configuration support 2025-01-30 00:35:49 +08:00
hamster1963
5cd8d1d92e chore: update dependencies to latest versions 2025-01-30 00:29:02 +08:00
hamster1963
b74e7827d3 feat: enhance time display with NumberFlow component 2025-01-30 00:28:43 +08:00
hamster1963
43fb66552e style: enhance server card hover effects with border and shadow 2025-01-29 23:58:25 +08:00
hamster1963
25bd7c41b4 refactor: improve environment variable handling with type-safe configuration 2025-01-29 23:32:51 +08:00
hamster1963
f4a400522a bump version to 2.5.1 2025-01-28 16:09:40 +08:00
hamster1963
bbee98b19e fix: replace paragraph with div in chart tooltip 2025-01-28 16:08:42 +08:00
hamster1963
361a0ecffd chore: deps 2025-01-28 16:07:34 +08:00
hamster1963
ac23b6a77e style: refine command dialog border and input styling 2025-01-28 16:06:17 +08:00
hamster1963
c4196eec83 bump version to 2.5.0 2025-01-25 12:04:50 +08:00
hamster1963
f7b081fad4 refactor: update server fetch and config with minor optimizations 2025-01-25 12:00:16 +08:00
hamster1963
527bf45f43 feat: update LanguageSwitcher with LanguageIcon 2025-01-25 11:33:01 +08:00
hamster1963
9f6fa51c5a fix: remove unused script 2025-01-25 11:07:54 +08:00
hamster1963
4a3c88c681 perf: remove eslint deps 2025-01-25 11:06:58 +08:00
hamster1963
1b00cd7bf0 chore: deps 2025-01-25 11:03:17 +08:00
hamster1963
d7f9e378fe style: better network tooltip 2025-01-25 10:57:16 +08:00
hamster1963
ea7b8969ee v2.4.0 2025-01-24 00:13:33 +08:00
hamster1963
3ca6a8e310 feat: footer add kdb 2025-01-24 00:13:10 +08:00
hamster1963
f69ec0a010 feat: dash command 2025-01-23 23:49:24 +08:00
hamster1963
a4c0ab7e07 chore: bump tailwindcss to 4.0.0 2025-01-23 10:04:04 +08:00
hamster1963
fe18404f4e v2.3.0 2025-01-21 10:40:10 +08:00
hamster1963
f9f57e4d19 perf: fully optimize 2025-01-21 10:39:52 +08:00
hamster1963
646354e515 refactor: biome config 2025-01-21 10:31:54 +08:00
hamster1963
a8f4c8564f perf: remove unused imports 2025-01-21 10:08:02 +08:00
hamster1963
b4c3bccace chore: deps 2025-01-21 10:04:45 +08:00
hamster1963
b76ab55cb2 refactor: core lib 2025-01-21 10:02:23 +08:00
hamster1963
bc0886e8c0 v2.2.0 2025-01-12 22:58:42 +08:00
hamster1963
957c679a90 chore: deps 2025-01-12 22:57:31 +08:00
hamster1963
0dd8bf7bb7 feat: show last active 2025-01-12 22:56:37 +08:00
hamster1963
fbc4eda27d style: button add hover animation 2025-01-11 18:53:54 +08:00
hamster1963
b265b68fa3 chore: deps 2025-01-11 18:47:56 +08:00
hamster1963
0e1f59bb30 v2.1.3 2025-01-09 00:57:48 +08:00
hamster1963
95525f0adb chore: bump nextjs version 2025-01-09 00:57:10 +08:00
hamster1963
aa9ea7b763 fix: enLocale import 2025-01-09 00:55:09 +08:00
hamster1963
f38d3a192b chore: deps 2025-01-06 21:14:49 +08:00
hamster1963
6ff8b275e7 fix: tag switch gap 2025-01-06 21:13:08 +08:00
hamster1963
36c7316a7f v2.1.2 2025-01-05 22:06:12 +08:00
hamster1963
a5ed28c0f1 feat: use full region name 2025-01-05 22:05:57 +08:00
hamster1963
51689d482e v2.1.1 2025-01-04 00:41:07 +08:00
hamster1963
d8b55564cb chore: deps 2025-01-04 00:40:54 +08:00
hamster1963
35457f8a17 style: tag hover animation 2025-01-04 00:40:34 +08:00
hamster1963
8228f15125 style: header hover animation 2025-01-04 00:38:40 +08:00
hamster1963
cc97147270 style: dropdown text display 2025-01-04 00:28:50 +08:00
hamster1963
6bc7e0de0e fix: use MAX_HISTORY_LENGTH config 2025-01-04 00:21:42 +08:00
hamster1963
6ba7747dd6 fix: lint 2025-01-04 00:12:52 +08:00
hamster1963
15086d054a chore: deps 2025-01-04 00:00:39 +08:00
hamster1963
6979139c98 fix: remove used code 2025-01-04 00:00:11 +08:00
hamster1963
68b7034db6 v2.1.0 happy new year! 2024-12-31 23:45:52 +08:00
hamster1963
2bc608e332 feat: refactor now time 2024-12-31 23:45:26 +08:00
hamster1963
c5b8695a82 fix: auto scroll 2024-12-31 23:26:25 +08:00
hamster1963
0de53d8888 chore: deps 2024-12-31 17:59:50 +08:00
hamster1963
297dd53dd2 fix: remove wrong comment 2024-12-31 17:52:31 +08:00
hamster1963
70b328b8d7 perf: refactor animate 2024-12-31 17:51:32 +08:00
hamster1963
a07a70e965 fix(overview): bandwidth display 2024-12-27 16:41:15 +08:00
hamster1963
39c1eca1b0 docs: new image 2024-12-26 16:28:44 +08:00
hamster1963
83122ad867 fix: chart 2024-12-26 15:17:03 +08:00
hamster1963
93a90f4a33 v2.0.0 2024-12-26 14:42:57 +08:00
hamster1963
8a93bda5b6 feat: use biome 2024-12-26 14:36:51 +08:00
hamster1963
8575aee27e style: DropdownMenuItem rounded 2024-12-26 14:09:21 +08:00
hamster1963
4c7ffb509c feat: loading text 2024-12-26 14:01:53 +08:00
hamster1963
2a44cc7afb chore: deps 2024-12-26 13:48:37 +08:00
hamster1963
72472f7cc1 feat: detail chart history point 2024-12-26 13:48:19 +08:00
hamster1963
a9a7a367c0 fix: set dedupingInterval to 500 2024-12-26 13:22:01 +08:00
hamster1963
6b52a8dedb perf: refactor server data fetch 2024-12-26 13:20:49 +08:00
hamster1963
0c3479bb3b chore: deps 2024-12-24 11:48:57 +08:00
hamster1963
114a6f95eb feat: uptime days unit 2024-12-24 11:48:29 +08:00
hamster1963
682e4ccfe0 feat: offline card link 2024-12-24 11:42:59 +08:00
hamster1963
14b12bf8e0 v1.9.0 2024-12-23 11:31:28 +08:00
hamster1963
80a58d8cfc fix: emoji flag priority 2024-12-23 11:24:24 +08:00
hamster1963
9ff5913ffb fix: remove flag transform 2024-12-23 11:18:14 +08:00
hamster1963
4c5a012bdc chore: deps 2024-12-23 11:17:34 +08:00
hamster1963
c212c6688c v1.8.6 2024-12-20 20:36:54 +08:00
hamster1963
b7ed4b8a4b fix: custom links overflow on mobile 2024-12-20 20:36:43 +08:00
hamster1963
eebda376ca chore: deps 2024-12-20 20:32:57 +08:00
hamster1963
474ed06445 fix: relax the criteria for determining online status 2024-12-20 20:30:15 +08:00
hamster1963
83942483cb v1.8.5 2024-12-20 10:22:26 +08:00
hamster1963
f72ea3079b chore: deps 2024-12-20 10:22:13 +08:00
hamster1963
1ec186faf7 fix: network overview contains offline server 2024-12-20 10:21:48 +08:00
hamster1963
2f058d8777 v1.8.4 2024-12-18 17:38:50 +08:00
hamster1963
24d038b914 feat: refactor peak cut 2024-12-18 17:38:32 +08:00
hamster1963
5f7892c837 v1.8.3 2024-12-18 15:48:51 +08:00
hamster1963
0ac6235422 fix: overview bandwidth overflow on mobile 2024-12-18 15:48:24 +08:00
hamster1963
a19da63fce chore: deps 2024-12-18 15:38:22 +08:00
hamster1963
b21f4288a7 v1.8.2 2024-12-16 10:06:06 +08:00
hamster1963
6e6a3315b8 fix: not-found page height 2024-12-16 10:04:50 +08:00
hamster1963
4de49cb3ab fix: refactor main page 2024-12-16 09:39:48 +08:00
hamster1963
95530243a4 chore: deps 2024-12-16 09:29:00 +08:00
hamster1963
6f16d4b4eb fix: overview image z-index 2024-12-16 09:28:53 +08:00
hamster1963
583a52cced fix: overview network overflow on mobile 2024-12-15 01:30:30 +08:00
仓鼠
406b7ef37f
Merge pull request #186 from hamster1963/pr-dev
test: auto lint
2024-12-13 15:40:48 +08:00
hamster1963
f6e0654bec fix: prettier config 2024-12-13 15:39:34 +08:00
hamster1963
01ac9403e9 fix: eslint error 2024-12-13 15:37:04 +08:00
hamster1963
74418c6ae5 fix: use js config 2024-12-13 15:33:28 +08:00
hamster1963
ecd4688ec9 test: ues next lint 2024-12-13 15:29:50 +08:00
hamster1963
2b918ef915 Merge branch 'main' into pr-dev 2024-12-13 15:28:43 +08:00
hamster1963
533d73c9c8 fix: remove .eslintrc.json 2024-12-13 15:28:15 +08:00
hamster1963
6529ed1b8d fix: build 2024-12-13 15:25:24 +08:00
hamster1963
4cc4cf98d6 fix: build 2024-12-13 15:23:37 +08:00
hamster1963
d26490d644 chore: auto-fix linting and formatting issues 2024-12-13 07:22:22 +00:00
hamster1963
124966c27c test: auto lint 2024-12-13 15:21:38 +08:00
hamster1963
408cd9ce4b fix: lint 2024-12-13 15:19:39 +08:00
hamster1963
55cc033cf5 feat: pr lint 2024-12-13 15:09:12 +08:00
hamster1963
17946d5f0b v1.8.1 2024-12-13 10:39:31 +08:00
hamster1963
3ddf688229 fix: build ignore eslint error 2024-12-13 10:38:54 +08:00
hamster1963
c083a1bfa8 fix: serverActions allowedOrigins 2024-12-13 10:26:31 +08:00
hamster1963
315b3ee16f fix: peer deps 2024-12-13 10:16:42 +08:00
hamster1963
231a66931b fix: ip info error 2024-12-13 10:11:00 +08:00
hamster1963
d075ab5e0e v1.8.0-fix 2024-12-12 11:22:42 +08:00
hamster1963
1e881f5d9f fix(detail): detail page api dedupingInterval 2024-12-12 11:22:16 +08:00
hamster1963
2b426c8d18 v1.8.0 2024-12-12 11:10:13 +08:00
仓鼠
aafc19872b
Merge pull request #178 from hamster1963/ip-info
Ip info
2024-12-12 11:08:50 +08:00
hamster1963
e0e9faaa24 feat: ip info i18n 2024-12-12 11:00:38 +08:00
hamster1963
33d22fa8b3 feat: ip info 2024-12-12 10:46:37 +08:00
hamster1963
d72cd29446 fix: lint 2024-12-12 09:41:46 +08:00
hamster1963
4036c157c9 feat: use api 2024-12-12 09:39:51 +08:00
hamster1963
d64683a617 fix: path 2024-12-12 00:59:44 +08:00
hamster1963
dfef790884 fix: use fs 2024-12-12 00:52:19 +08:00
hamster1963
8f38cf94d8 fix: get file 2024-12-12 00:45:04 +08:00
hamster1963
b573b62452 feat: get ip info 2024-12-12 00:31:06 +08:00
hamster1963
f28f559a0b fix(ServerList): refreshInterval 2024-12-11 21:55:55 +08:00
hamster1963
7a31071c3d 1.7.6 2024-12-11 16:12:13 +08:00
hamster1963
7200c3ecd1 fix: dynamic import disable ssr 2024-12-11 16:10:32 +08:00
hamster1963
452b045e14 fix(overview): image opacity 2024-12-11 15:58:09 +08:00
hamster1963
50a98911ce fix: nextjs version 2024-12-11 15:57:51 +08:00
hamster1963
4bf4b62ed0 chore: deps 2024-12-11 15:43:25 +08:00
hamster1963
b86bfa20f4 v1.7.5 2024-12-10 00:42:36 +08:00
hamster1963
0806fc09d8 perf: remove dev log 2024-12-10 00:41:22 +08:00
hamster1963
d582027fcf fix(global): server sort 2024-12-10 00:38:35 +08:00
hamster1963
ea385ee699 fix: next canary 2024-12-10 00:24:16 +08:00
hamster1963
b4e4c7908e perf: use inlineCss 2024-12-10 00:19:01 +08:00
hamster1963
8c70cce555 perf: use turbo 2024-12-09 00:49:16 +08:00
hamster1963
841650ba7a v1.7.4 2024-12-07 21:16:18 +08:00
hamster1963
2fc4686d51 perf: use fastly cdn 2024-12-07 21:15:53 +08:00
hamster1963
eddacdd781 fix: tooltip position 2024-12-07 21:15:05 +08:00
hamster1963
39145adeb4 fix: lint 2024-12-07 19:28:31 +08:00
hamster1963
08b6c54a68 v1.7.3 2024-12-07 19:25:58 +08:00
hamster1963
ba6186aaba feat(global): new tooltip 2024-12-07 19:25:36 +08:00
hamster1963
4135a4fd77 chore: deps 2024-12-07 18:42:25 +08:00
129 changed files with 4663 additions and 3575 deletions

View File

@ -15,3 +15,4 @@ 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_Links='[{"link":"https://github.com/hamster1963/nezha-dash","name":"GitHub"},{"link":"https://buycoffee.top/coffee","name":"Buycoffee☕"}]'
NEXT_PUBLIC_DisableIndex=false NEXT_PUBLIC_DisableIndex=false
NEXT_PUBLIC_ShowTagCount=false NEXT_PUBLIC_ShowTagCount=false
NEXT_PUBLIC_ShowIpInfo=false

View File

@ -1,8 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@next/next/no-img-element": "off",
"react-hooks/exhaustive-deps": "off"
}
}

BIN
.github/1-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

BIN
.github/1.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

BIN
.github/2-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

BIN
.github/2.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

BIN
.github/3-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

BIN
.github/3.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

BIN
.github/4-dark.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

BIN
.github/4.webp vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

BIN
.github/v2-1.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

BIN
.github/v2-2.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
.github/v2-3.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
.github/v2-4.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
.github/v2-dark.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@ -1,5 +1,7 @@
name: Build and push Docker image name: Build and push Docker image
permissions:
contents: write
on: on:
push: push:
tags: tags:

View File

@ -0,0 +1,56 @@
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

@ -20,7 +20,7 @@
- Cloudflare - Cloudflare
- Docker - Docker
[演示站点](https://nezha-cf.buycoffee.top) [演示站点](https://nezha-vercel.vercel.app)
[说明文档](https://nezhadash-docs.vercel.app) [说明文档](https://nezhadash-docs.vercel.app)
### 如何更新 ### 如何更新
@ -31,11 +31,8 @@
[环境变量介绍](https://nezhadash-docs.vercel.app/environment) [环境变量介绍](https://nezhadash-docs.vercel.app/environment)
![screen](/.github/1.webp) ![screen](/.github/v2-1.webp)
![screen](/.github/2.webp) ![screen](/.github/v2-2.webp)
![screen](/.github/3.webp) ![screen](/.github/v2-3.webp)
![screen](/.github/4.webp) ![screen](/.github/v2-4.webp)
![screen](/.github/1-dark.webp) ![screen](/.github/v2-dark.webp)
![screen](/.github/2-dark.webp)
![screen](/.github/3-dark.webp)
![screen](/.github/4-dark.webp)

View File

@ -1,64 +0,0 @@
"use client";
import { ServerApi } from "@/app/types/nezha-api";
import { nezhaFetcher } from "@/lib/utils";
import useSWR from "swr";
import { geoJsonString } from "../../../lib/geo-json-string";
import GlobalInfo from "./GlobalInfo";
import GlobalLoading from "./GlobalLoading";
import { InteractiveMap } from "./InteractiveMap";
export default function ServerGlobal() {
const { data: nezhaServerList, error } = useSWR<ServerApi>(
"/api/server",
nezhaFetcher,
);
if (error)
return (
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{error.message}</p>
</div>
);
if (!nezhaServerList) {
return <GlobalLoading />;
}
const countryList: string[] = [];
const serverCounts: { [key: string]: number } = {};
nezhaServerList.result.forEach((server) => {
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="flex flex-col gap-4 mt-[3.2px]">
<GlobalInfo countries={countryList} />
<div className="w-full overflow-x-auto">
<InteractiveMap
countries={countryList}
serverCounts={serverCounts}
width={width}
height={height}
filteredFeatures={filteredFeatures}
/>
</div>
</section>
);
}

View File

@ -1,156 +0,0 @@
"use client";
import { countryCoordinates } from "@/lib/geo-limit";
import { geoEquirectangular, geoPath } from "d3-geo";
import { AnimatePresence, m } from "framer-motion";
import { useTranslations } from "next-intl";
import { useState } from "react";
interface InteractiveMapProps {
countries: string[];
serverCounts: { [key: string]: number };
width: number;
height: number;
filteredFeatures: any[];
}
export function InteractiveMap({
countries,
serverCounts,
width,
height,
filteredFeatures,
}: InteractiveMapProps) {
const t = useTranslations("Global");
const [tooltipData, setTooltipData] = useState<{
centroid: [number, number];
country: string;
count: number;
} | null>(null);
const projection = geoEquirectangular()
.scale(140)
.translate([width / 2, height / 2])
.rotate([-12, 0, 0]);
const path = geoPath().projection(projection);
return (
<div className="relative w-full aspect-[2/1]">
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
className="w-full h-auto"
>
<defs>
<pattern id="dots" width="2" height="2" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.5" fill="currentColor" />
</pattern>
</defs>
<g>
{filteredFeatures.map((feature, index) => {
const isHighlighted = countries.includes(
feature.properties.iso_a2_eh,
);
if (isHighlighted) {
console.log(feature.properties.iso_a2_eh);
}
const serverCount = serverCounts[feature.properties.iso_a2_eh] || 0;
return (
<path
key={index}
d={path(feature) || ""}
className={
isHighlighted
? "fill-green-700 hover:fill-green-600 dark:fill-green-900 dark:hover:fill-green-700 transition-all cursor-pointer"
: "fill-neutral-200/50 dark:fill-neutral-800 stroke-neutral-300/40 dark:stroke-neutral-700 stroke-[0.5]"
}
onMouseEnter={() => {
if (isHighlighted && path.centroid(feature)) {
setTooltipData({
centroid: path.centroid(feature),
country: feature.properties.name,
count: serverCount,
});
}
}}
onMouseLeave={() => setTooltipData(null)}
/>
);
})}
{/* 渲染不在 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={() => {
setTooltipData({
centroid: [x, y],
country: coords.name,
count: serverCount,
});
}}
onMouseLeave={() => setTooltipData(null)}
className="cursor-pointer"
>
<circle
cx={x}
cy={y}
r={4}
className="fill-sky-700 stroke-white hover:fill-sky-600 dark:fill-sky-900 dark:hover:fill-sky-700 transition-all"
/>
</g>
);
})}
</g>
</svg>
<AnimatePresence mode="wait">
{tooltipData && (
<m.div
initial={{ opacity: 0, filter: "blur(10px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
className="absolute hidden lg:block pointer-events-none bg-white dark:bg-neutral-800 px-2 py-1 rounded shadow-lg text-sm dark:border dark:border-neutral-700"
key={tooltipData.country}
style={{
left: tooltipData.centroid[0],
top: tooltipData.centroid[1],
transform: "translate(-50%, -50%)",
}}
>
<p className="font-medium">
{tooltipData.country === "China"
? "Mainland China"
: tooltipData.country}
</p>
<p className="text-neutral-600 dark:text-neutral-400">
{tooltipData.count} {t("Servers")}
</p>
</m.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -1,352 +0,0 @@
"use client";
import NetworkChartLoading from "@/app/(main)/ClientComponents/NetworkChartLoading";
import { NezhaAPIMonitor, ServerMonitorChart } from "@/app/types/nezha-api";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
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 { formatRelativeTime } 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="text-sm font-medium opacity-40">{error.message}</p>
<p className="text-sm font-medium 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
key={key}
data-active={activeChart === key}
className={`relative z-30 flex cursor-pointer grow basis-0 flex-col justify-center gap-1 border-b border-neutral-200 dark:border-neutral-800 px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-6`}
onClick={() => handleButtonClick(key)}
>
<span className="whitespace-nowrap text-xs text-muted-foreground">
{key}
</span>
<span className="text-md font-bold 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 = 7; // 增加到7个点的移动平均
const weights = [0.1, 0.1, 0.15, 0.3, 0.15, 0.1, 0.1]; // 加权平均的权重
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) {
// 处理所有线路的数据
chartDataKey.forEach((key) => {
const values = window
.map((w) => w[key])
.filter((v) => v !== undefined && v !== null) as number[];
if (values.length === windowSize) {
smoothed[key] = values.reduce(
(acc, val, idx) => acc + val * weights[idx],
0,
);
}
});
} else {
// 处理单条线路的数据
const values = window
.map((w) => w.avg_delay)
.filter((v) => v !== undefined && v !== null) as number[];
if (values.length === windowSize) {
smoothed.avg_delay = values.reduce(
(acc, val, idx) => acc + val * weights[idx],
0,
);
}
}
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="flex items-center mt-0.5 space-x-2">
<Switch
id="Peak"
checked={isPeakEnabled}
onCheckedChange={setIsPeakEnabled}
/>
<Label className="text-xs" htmlFor="Peak">
Peak cut
</Label>
</div>
</div>
<div className="flex flex-wrap w-full">{chartButtons}</div>
</CardHeader>
<CardContent className="pr-2 pl-0 py-4 sm:pt-6 sm:pb-6 sm:pr-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={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
interval={"preserveStartEnd"}
tickFormatter={(value) => formatRelativeTime(value)}
/>
<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 = {};
data.forEach((item) => {
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>();
rawData.forEach((item) => {
item.created_at.forEach((time) => allTimes.add(time));
});
const allTimeArray = Array.from(allTimes).sort((a, b) => a - b);
rawData.forEach((item) => {
const { monitor_name, created_at, avg_delay } = item;
allTimeArray.forEach((time) => {
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,260 +0,0 @@
"use client";
import { ServerDetailLoading } from "@/app/(main)/ClientComponents/ServerDetailLoading";
import { NezhaAPISafe, ServerApi } from "@/app/types/nezha-api";
import { BackIcon } from "@/components/Icon";
import ServerFlag from "@/components/ServerFlag";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import getEnv from "@/lib/env-entry";
import { cn, formatBytes, nezhaFetcher } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { notFound, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
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: allFallbackData, isLoading } = useSWRImmutable<ServerApi>(
"/api/server",
nezhaFetcher,
);
const fallbackData = allFallbackData?.result?.find(
(item) => item.id === server_id,
);
if (!fallbackData && !isLoading) {
notFound();
}
const { data, error } = useSWR<NezhaAPISafe>(
`/api/detail?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
fallbackData,
revalidateOnMount: false,
revalidateIfStale: false,
},
);
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{error.message}</p>
<p className="text-sm font-medium opacity-40">
{t("detail_fetch_error_message")}
</p>
</div>
</>
);
}
if (!data) return <ServerDetailLoading />;
return (
<div>
<div
onClick={linkClick}
className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl"
>
<BackIcon />
{data?.name}
</div>
<section className="flex flex-wrap gap-2 mt-3">
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("status")}</p>
<Badge
className={cn(
"text-[9px] rounded-[6px] w-fit px-1 py-0 -mt-[0.3px] dark:text-white",
{
" bg-green-800": data?.online_status,
" bg-red-600": !data?.online_status,
},
)}
>
{data?.online_status ? t("Online") : t("Offline")}
</Badge>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Uptime")}</p>
<div className="text-xs">
{" "}
{(data?.status.Uptime / 86400).toFixed(0)} {t("Days")}{" "}
</div>
</section>
</CardContent>
</Card>
{data?.host.Version && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Version")}</p>
<div className="text-xs">{data?.host.Version} </div>
</section>
</CardContent>
</Card>
)}
{data?.host.Arch && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Arch")}</p>
<div className="text-xs">{data?.host.Arch} </div>
</section>
</CardContent>
</Card>
)}
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Mem")}</p>
<div className="text-xs">{formatBytes(data?.host.MemTotal)}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Disk")}</p>
<div className="text-xs">{formatBytes(data?.host.DiskTotal)}</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Region")}</p>
<section className="flex items-start gap-1">
<div className="text-xs text-start">
{data?.host.CountryCode.toUpperCase()}
</div>
<ServerFlag
className="text-[11px] -mt-[1px]"
country_code={data?.host.CountryCode}
/>
</section>
</section>
</CardContent>
</Card>
</section>
<section className="flex flex-wrap gap-2 mt-1">
{data?.host.Platform && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("System")}</p>
<div className="text-xs">
{" "}
{data?.host.Platform} - {data?.host.PlatformVersion}{" "}
</div>
</section>
</CardContent>
</Card>
)}
{data?.host.CPU && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("CPU")}</p>
<div className="text-xs"> {data?.host.CPU.join(", ")}</div>
</section>
</CardContent>
</Card>
)}
{data?.host.GPU && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{"GPU"}</p>
<div className="text-xs"> {data?.host.GPU.join(", ")}</div>
</section>
</CardContent>
</Card>
)}
</section>
<section className="flex flex-wrap gap-2 mt-1">
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Load")}</p>
<div className="text-xs">
{data.status.Load1.toFixed(2) || "0.00"} /{" "}
{data.status.Load5.toFixed(2) || "0.00"} /{" "}
{data.status.Load15.toFixed(2) || "0.00"}
</div>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Upload")}</p>
{data.status.NetOutTransfer ? (
<div className="text-xs">
{" "}
{formatBytes(data.status.NetOutTransfer)}{" "}
</div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("Download")}</p>
{data.status.NetInTransfer ? (
<div className="text-xs">
{" "}
{formatBytes(data.status.NetInTransfer)}{" "}
</div>
) : (
<div className="text-xs">Unknown</div>
)}
</section>
</CardContent>
</Card>
</section>
</div>
);
}

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 md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-3">
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
<Skeleton className="h-[182px] w-full rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
</section>
</div>
);
}
export function ServerDetailLoading() {
const router = useRouter();
return (
<>
<div
onClick={() => {
router.push(`/`);
}}
className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl"
>
<BackIcon />
<Skeleton className="h-[20px] w-24 rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
</div>
<Skeleton className="flex flex-wrap gap-2 h-[81px] w-1/2 mt-3 rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
</>
);
}

View File

@ -1,199 +0,0 @@
"use client";
import { ServerApi } from "@/app/types/nezha-api";
import ServerCard from "@/components/ServerCard";
import ServerCardInline from "@/components/ServerCardInline";
import Switch from "@/components/Switch";
import getEnv from "@/lib/env-entry";
import { useFilter } from "@/lib/network-filter-context";
import { useStatus } from "@/lib/status-context";
import { cn, nezhaFetcher } 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";
import useSWR from "swr";
import GlobalLoading from "./GlobalLoading";
const ServerGlobal = dynamic(() => import("./Global"), {
loading: () => <GlobalLoading />,
});
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) {
console.log("inlineState", inlineState);
setInline(inlineState);
}
}, []);
useEffect(() => {
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 } = useSWR<ServerApi>("/api/server", nezhaFetcher, {
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
});
if (error)
return (
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{error.message}</p>
<p className="text-sm font-medium opacity-40">{t("error_message")}</p>
</div>
);
if (!data?.result) return null;
const { result } = data;
const sortedServers = 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 filteredServersByStatus =
status === "all"
? sortedServers
: sortedServers.filter((server) =>
[status].includes(server.online_status ? "online" : "offline"),
);
const allTag = filteredServersByStatus
.map((server) => server.tag)
.filter(Boolean);
const uniqueTags = [...new Set(allTag)];
uniqueTags.unshift(defaultTag);
const filteredServers =
tag === defaultTag
? filteredServersByStatus
: filteredServersByStatus.filter((server) => server.tag === tag);
if (filter) {
filteredServers.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 tagCountMap: Record<string, number> = {};
filteredServersByStatus.forEach((server) => {
if (server.tag) {
tagCountMap[server.tag] = (tagCountMap[server.tag] || 0) + 1;
}
});
return (
<>
<section className="flex items-center gap-2 w-full overflow-hidden">
<button
onClick={() => {
setShowMap(!showMap);
}}
className={cn(
"rounded-[50px] text-white cursor-pointer [text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] bg-blue-600 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)]",
{
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] bg-blue-500": showMap,
},
)}
>
<MapIcon className="size-[13px]" />
</button>
<button
onClick={() => {
setInline(inline === "0" ? "1" : "0");
localStorage.setItem("inline", inline === "0" ? "1" : "0");
}}
className={cn(
"rounded-[50px] text-white cursor-pointer [text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] bg-blue-600 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] ",
{
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] bg-blue-500":
inline === "1",
},
)}
>
<ViewColumnsIcon className="size-[13px]" />
</button>
{getEnv("NEXT_PUBLIC_ShowTag") === "true" && (
<Switch
allTag={uniqueTags}
nowTag={tag}
tagCountMap={tagCountMap}
onTagChange={handleTagChange}
/>
)}
</section>
{showMap && <ServerGlobal />}
{inline === "1" && (
<section
ref={containerRef}
className="flex flex-col gap-2 overflow-x-scroll scrollbar-hidden"
>
{filteredServers.map((serverInfo) => (
<ServerCardInline key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
)}
{inline === "0" && (
<section
ref={containerRef}
className="grid grid-cols-1 gap-2 md:grid-cols-2"
>
{filteredServers.map((serverInfo) => (
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
))}
</section>
)}
</>
);
}

View File

@ -0,0 +1,398 @@
"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,159 +1,158 @@
"use client"; "use client"
import { ServerDetailChartLoading } from "@/app/(main)/ClientComponents/ServerDetailLoading";
import { NezhaAPISafe, ServerApi } from "@/app/types/nezha-api";
import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar";
import { Card, CardContent } from "@/components/ui/card";
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
import getEnv from "@/lib/env-entry";
import { import {
formatBytes, MAX_HISTORY_LENGTH,
formatNezhaInfo, type ServerDataWithTimestamp,
formatRelativeTime, useServerData,
nezhaFetcher, } from "@/app/context/server-data-context"
} from "@/lib/utils"; import type { NezhaAPISafe } from "@/app/types/nezha-api"
import { useTranslations } from "next-intl"; import { ServerDetailChartLoading } from "@/components/loading/ServerDetailLoading"
import { useEffect, useState } from "react"; import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar"
import { import { Card, CardContent } from "@/components/ui/card"
Area, import { type ChartConfig, ChartContainer } from "@/components/ui/chart"
AreaChart, import { formatBytes, formatNezhaInfo, formatRelativeTime } from "@/lib/utils"
CartesianGrid, import { useTranslations } from "next-intl"
Line, import { useEffect, useRef, useState } from "react"
LineChart, import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
XAxis,
YAxis,
} from "recharts";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
type cpuChartData = { type cpuChartData = {
timeStamp: string; timeStamp: string
cpu: number; cpu: number
}; }
type processChartData = { type processChartData = {
timeStamp: string; timeStamp: string
process: number; process: number
}; }
type diskChartData = { type diskChartData = {
timeStamp: string; timeStamp: string
disk: number; disk: number
}; }
type memChartData = { type memChartData = {
timeStamp: string; timeStamp: string
mem: number; mem: number
swap: number; swap: number
}; }
type networkChartData = { type networkChartData = {
timeStamp: string; timeStamp: string
upload: number; upload: number
download: number; download: number
}; }
type connectChartData = { type connectChartData = {
timeStamp: string; timeStamp: string
tcp: number; tcp: number
udp: number; udp: number
}; }
export default function ServerDetailChartClient({ export default function ServerDetailChartClient({
server_id, server_id,
show,
}: { }: {
server_id: number; server_id: number
show: boolean; show: boolean
}) { }) {
const t = useTranslations("ServerDetailChartClient"); const t = useTranslations("ServerDetailChartClient")
const { data: allFallbackData } = useSWRImmutable<ServerApi>( const { data: serverList, error, history } = useServerData()
"/api/server",
nezhaFetcher,
);
const fallbackData = allFallbackData?.result?.find(
(item) => item.id === server_id,
);
const { data, error } = useSWR<NezhaAPISafe>( const data = serverList?.result?.find((item) => item.id === server_id)
`/api/detail?server_id=${server_id}`,
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
isVisible: () => show,
fallbackData,
revalidateOnMount: false,
revalidateIfStale: false,
},
);
if (error) { if (error) {
return ( return (
<> <>
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{error.message}</p> <p className="font-medium text-sm opacity-40">{error.message}</p>
<p className="text-sm font-medium opacity-40"> <p className="font-medium text-sm opacity-40">{t("chart_fetch_error_message")}</p>
{t("chart_fetch_error_message")}
</p>
</div> </div>
</> </>
); )
} }
if (!data) return <ServerDetailChartLoading />; if (!data) return <ServerDetailChartLoading />
return ( return (
<section className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-3"> <section className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
<CpuChart data={data} /> <CpuChart data={data} history={history} />
<ProcessChart data={data} /> <ProcessChart data={data} history={history} />
<DiskChart data={data} /> <DiskChart data={data} history={history} />
<MemChart data={data} /> <MemChart data={data} history={history} />
<NetworkChart data={data} /> <NetworkChart data={data} history={history} />
<ConnectChart data={data} /> <ConnectChart data={data} history={history} />
</section> </section>
); )
} }
function CpuChart({ data }: { data: NezhaAPISafe }) { function CpuChart({
const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[]); history,
data,
const { cpu } = formatNezhaInfo(data); }: {
history: ServerDataWithTimestamp[]
data: NezhaAPISafe
}) {
const [cpuChartData, setCpuChartData] = useState([] as cpuChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => { useEffect(() => {
if (data) { if (!hasInitialized.current && history.length > 0) {
const timestamp = Date.now().toString(); const historyData = history
let newData = [] as cpuChartData[]; .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) { if (cpuChartData.length === 0) {
newData = [ newData = [
{ timeStamp: timestamp, cpu: cpu }, { timeStamp: timestamp, cpu: cpu },
{ timeStamp: timestamp, cpu: cpu }, { timeStamp: timestamp, cpu: cpu },
]; ]
} else { } else {
newData = [...cpuChartData, { timeStamp: timestamp, cpu: cpu }]; newData = [...cpuChartData, { timeStamp: timestamp, cpu: cpu }]
} }
if (newData.length > 30) { if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift(); newData.shift()
} }
setCpuChartData(newData); setCpuChartData(newData)
} }
}, [data]); }, [data, historyLoaded])
const chartConfig = { const chartConfig = {
cpu: { cpu: {
label: "CPU", label: "CPU",
}, },
} satisfies ChartConfig; } satisfies ChartConfig
return ( return (
<Card> <Card>
<CardContent className="px-6 py-3"> <CardContent className="px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-md font-medium">CPU</p> <p className="font-medium text-md">CPU</p>
<section className="flex items-center gap-2"> <section className="flex items-center gap-2">
<p className="text-xs text-end w-10 font-medium"> <p className="w-10 text-end font-medium text-xs">{cpu.toFixed(0)}%</p>
{cpu.toFixed(0)}%
</p>
<AnimatedCircularProgressBar <AnimatedCircularProgressBar
className="size-3 text-[0px]" className="size-3 text-[0px]"
max={100} max={100}
@ -163,10 +162,7 @@ function CpuChart({ data }: { data: NezhaAPISafe }) {
/> />
</section> </section>
</div> </div>
<ChartContainer <ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
config={chartConfig}
className="aspect-auto h-[130px] w-full"
>
<AreaChart <AreaChart
accessibilityLayer accessibilityLayer
data={cpuChartData} data={cpuChartData}
@ -207,60 +203,82 @@ function CpuChart({ data }: { data: NezhaAPISafe }) {
</section> </section>
</CardContent> </CardContent>
</Card> </Card>
); )
} }
function ProcessChart({ data }: { data: NezhaAPISafe }) { function ProcessChart({
const t = useTranslations("ServerDetailChartClient"); data,
history,
const [processChartData, setProcessChartData] = useState( }: {
[] as processChartData[], data: NezhaAPISafe
); history: ServerDataWithTimestamp[]
}) {
const { process } = formatNezhaInfo(data); const t = useTranslations("ServerDetailChartClient")
const [processChartData, setProcessChartData] = useState([] as processChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => { useEffect(() => {
if (data) { if (!hasInitialized.current && history.length > 0) {
const timestamp = Date.now().toString(); const historyData = history
let newData = [] as processChartData[]; .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) { if (processChartData.length === 0) {
newData = [ newData = [
{ timeStamp: timestamp, process: process }, { timeStamp: timestamp, process: process },
{ timeStamp: timestamp, process: process }, { timeStamp: timestamp, process: process },
]; ]
} else { } else {
newData = [ newData = [...processChartData, { timeStamp: timestamp, process: process }]
...processChartData,
{ timeStamp: timestamp, process: process },
];
} }
if (newData.length > 30) { if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift(); newData.shift()
} }
setProcessChartData(newData); setProcessChartData(newData)
} }
}, [data]); }, [data, historyLoaded])
const chartConfig = { const chartConfig = {
process: { process: {
label: "Process", label: "Process",
}, },
} satisfies ChartConfig; } satisfies ChartConfig
return ( return (
<Card> <Card>
<CardContent className="px-6 py-3"> <CardContent className="px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-md font-medium">{t("Process")}</p> <p className="font-medium text-md">{t("Process")}</p>
<section className="flex items-center gap-2"> <section className="flex items-center gap-2">
<p className="text-xs text-end w-10 font-medium">{process}</p> <p className="w-10 text-end font-medium text-xs">{process}</p>
</section> </section>
</div> </div>
<ChartContainer <ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
config={chartConfig}
className="aspect-auto h-[130px] w-full"
>
<AreaChart <AreaChart
accessibilityLayer accessibilityLayer
data={processChartData} data={processChartData}
@ -280,12 +298,7 @@ function ProcessChart({ data }: { data: NezhaAPISafe }) {
interval="preserveStartEnd" interval="preserveStartEnd"
tickFormatter={(value) => formatRelativeTime(value)} tickFormatter={(value) => formatRelativeTime(value)}
/> />
<YAxis <YAxis tickLine={false} axisLine={false} mirror={true} tickMargin={-15} />
tickLine={false}
axisLine={false}
mirror={true}
tickMargin={-15}
/>
<Area <Area
isAnimationActive={false} isAnimationActive={false}
dataKey="process" dataKey="process"
@ -299,37 +312,65 @@ function ProcessChart({ data }: { data: NezhaAPISafe }) {
</section> </section>
</CardContent> </CardContent>
</Card> </Card>
); )
} }
function MemChart({ data }: { data: NezhaAPISafe }) { function MemChart({
const t = useTranslations("ServerDetailChartClient"); data,
history,
const [memChartData, setMemChartData] = useState([] as memChartData[]); }: {
data: NezhaAPISafe
const { mem, swap } = formatNezhaInfo(data); history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [memChartData, setMemChartData] = useState([] as memChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => { useEffect(() => {
if (data) { if (!hasInitialized.current && history.length > 0) {
const timestamp = Date.now().toString(); const historyData = history
let newData = [] as memChartData[]; .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) { if (memChartData.length === 0) {
newData = [ newData = [
{ timeStamp: timestamp, mem: mem, swap: swap }, { timeStamp: timestamp, mem: mem, swap: swap },
{ timeStamp: timestamp, mem: mem, swap: swap }, { timeStamp: timestamp, mem: mem, swap: swap },
]; ]
} else { } else {
newData = [ newData = [...memChartData, { timeStamp: timestamp, mem: mem, swap: swap }]
...memChartData,
{ timeStamp: timestamp, mem: mem, swap: swap },
];
} }
if (newData.length > 30) { if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift(); newData.shift()
} }
setMemChartData(newData); setMemChartData(newData)
} }
}, [data]); }, [data, historyLoaded])
const chartConfig = { const chartConfig = {
mem: { mem: {
@ -338,7 +379,7 @@ function MemChart({ data }: { data: NezhaAPISafe }) {
swap: { swap: {
label: "Swap", label: "Swap",
}, },
} satisfies ChartConfig; } satisfies ChartConfig
return ( return (
<Card> <Card>
@ -347,7 +388,7 @@ function MemChart({ data }: { data: NezhaAPISafe }) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<section className="flex items-center gap-4"> <section className="flex items-center gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<p className=" text-xs text-muted-foreground">{t("Mem")}</p> <p className=" text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AnimatedCircularProgressBar <AnimatedCircularProgressBar
className="size-3 text-[0px]" className="size-3 text-[0px]"
@ -356,11 +397,11 @@ function MemChart({ data }: { data: NezhaAPISafe }) {
value={mem} value={mem}
primaryColor="hsl(var(--chart-8))" primaryColor="hsl(var(--chart-8))"
/> />
<p className="text-xs font-medium">{mem.toFixed(0)}%</p> <p className="font-medium text-xs">{mem.toFixed(0)}%</p>
</div> </div>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className=" text-xs text-muted-foreground">{t("Swap")}</p> <p className=" text-muted-foreground text-xs">{t("Swap")}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AnimatedCircularProgressBar <AnimatedCircularProgressBar
className="size-3 text-[0px]" className="size-3 text-[0px]"
@ -369,24 +410,20 @@ function MemChart({ data }: { data: NezhaAPISafe }) {
value={swap} value={swap}
primaryColor="hsl(var(--chart-10))" primaryColor="hsl(var(--chart-10))"
/> />
<p className="text-xs font-medium">{swap.toFixed(0)}%</p> <p className="font-medium text-xs">{swap.toFixed(0)}%</p>
</div> </div>
</div> </div>
</section> </section>
<section className="flex flex-col items-end gap-0.5"> <section className="flex flex-col items-end gap-0.5">
<div className="flex text-[11px] font-medium items-center gap-2"> <div className="flex items-center gap-2 font-medium text-[11px]">
{formatBytes(data.status.MemUsed)} /{" "} {formatBytes(data.status.MemUsed)} / {formatBytes(data.host.MemTotal)}
{formatBytes(data.host.MemTotal)}
</div> </div>
<div className="flex text-[11px] font-medium items-center gap-2"> <div className="flex items-center gap-2 font-medium text-[11px]">
swap: {formatBytes(data.status.SwapUsed)} swap: {formatBytes(data.status.SwapUsed)}
</div> </div>
</section> </section>
</div> </div>
<ChartContainer <ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
config={chartConfig}
className="aspect-auto h-[130px] w-full"
>
<AreaChart <AreaChart
accessibilityLayer accessibilityLayer
data={memChartData} data={memChartData}
@ -435,52 +472,80 @@ function MemChart({ data }: { data: NezhaAPISafe }) {
</section> </section>
</CardContent> </CardContent>
</Card> </Card>
); )
} }
function DiskChart({ data }: { data: NezhaAPISafe }) { function DiskChart({
const t = useTranslations("ServerDetailChartClient"); data,
history,
const [diskChartData, setDiskChartData] = useState([] as diskChartData[]); }: {
data: NezhaAPISafe
const { disk } = formatNezhaInfo(data); history: ServerDataWithTimestamp[]
}) {
const t = useTranslations("ServerDetailChartClient")
const [diskChartData, setDiskChartData] = useState([] as diskChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => { useEffect(() => {
if (data) { if (!hasInitialized.current && history.length > 0) {
const timestamp = Date.now().toString(); const historyData = history
let newData = [] as diskChartData[]; .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) { if (diskChartData.length === 0) {
newData = [ newData = [
{ timeStamp: timestamp, disk: disk }, { timeStamp: timestamp, disk: disk },
{ timeStamp: timestamp, disk: disk }, { timeStamp: timestamp, disk: disk },
]; ]
} else { } else {
newData = [...diskChartData, { timeStamp: timestamp, disk: disk }]; newData = [...diskChartData, { timeStamp: timestamp, disk: disk }]
} }
if (newData.length > 30) { if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift(); newData.shift()
} }
setDiskChartData(newData); setDiskChartData(newData)
} }
}, [data]); }, [data, historyLoaded])
const chartConfig = { const chartConfig = {
disk: { disk: {
label: "Disk", label: "Disk",
}, },
} satisfies ChartConfig; } satisfies ChartConfig
return ( return (
<Card> <Card>
<CardContent className="px-6 py-3"> <CardContent className="px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-md font-medium">{t("Disk")}</p> <p className="font-medium text-md">{t("Disk")}</p>
<section className="flex flex-col items-end gap-0.5"> <section className="flex flex-col items-end gap-0.5">
<section className="flex items-center gap-2"> <section className="flex items-center gap-2">
<p className="text-xs text-end w-10 font-medium"> <p className="w-10 text-end font-medium text-xs">{disk.toFixed(0)}%</p>
{disk.toFixed(0)}%
</p>
<AnimatedCircularProgressBar <AnimatedCircularProgressBar
className="size-3 text-[0px]" className="size-3 text-[0px]"
max={100} max={100}
@ -489,16 +554,12 @@ function DiskChart({ data }: { data: NezhaAPISafe }) {
primaryColor="hsl(var(--chart-5))" primaryColor="hsl(var(--chart-5))"
/> />
</section> </section>
<div className="flex text-[11px] font-medium items-center gap-2"> <div className="flex items-center gap-2 font-medium text-[11px]">
{formatBytes(data.status.DiskUsed)} /{" "} {formatBytes(data.status.DiskUsed)} / {formatBytes(data.host.DiskTotal)}
{formatBytes(data.host.DiskTotal)}
</div> </div>
</section> </section>
</div> </div>
<ChartContainer <ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
config={chartConfig}
className="aspect-auto h-[130px] w-full"
>
<AreaChart <AreaChart
accessibilityLayer accessibilityLayer
data={diskChartData} data={diskChartData}
@ -539,44 +600,70 @@ function DiskChart({ data }: { data: NezhaAPISafe }) {
</section> </section>
</CardContent> </CardContent>
</Card> </Card>
); )
} }
function NetworkChart({ data }: { data: NezhaAPISafe }) { function NetworkChart({
const t = useTranslations("ServerDetailChartClient"); data,
history,
const [networkChartData, setNetworkChartData] = useState( }: {
[] as networkChartData[], data: NezhaAPISafe
); history: ServerDataWithTimestamp[]
}) {
const { up, down } = formatNezhaInfo(data); const t = useTranslations("ServerDetailChartClient")
const [networkChartData, setNetworkChartData] = useState([] as networkChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => { useEffect(() => {
if (data) { if (!hasInitialized.current && history.length > 0) {
const timestamp = Date.now().toString(); const historyData = history
let newData = [] as networkChartData[]; .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) { if (networkChartData.length === 0) {
newData = [ newData = [
{ timeStamp: timestamp, upload: up, download: down }, { timeStamp: timestamp, upload: up, download: down },
{ timeStamp: timestamp, upload: up, download: down }, { timeStamp: timestamp, upload: up, download: down },
]; ]
} else { } else {
newData = [ newData = [...networkChartData, { timeStamp: timestamp, upload: up, download: down }]
...networkChartData,
{ timeStamp: timestamp, upload: up, download: down },
];
} }
if (newData.length > 30) { if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift(); newData.shift()
} }
setNetworkChartData(newData); setNetworkChartData(newData)
} }
}, [data]); }, [data, historyLoaded])
let maxDownload = Math.max(...networkChartData.map((item) => item.download)); let maxDownload = Math.max(...networkChartData.map((item) => item.download))
maxDownload = Math.ceil(maxDownload); maxDownload = Math.ceil(maxDownload)
if (maxDownload < 1) { if (maxDownload < 1) {
maxDownload = 1; maxDownload = 1
} }
const chartConfig = { const chartConfig = {
@ -586,7 +673,7 @@ function NetworkChart({ data }: { data: NezhaAPISafe }) {
download: { download: {
label: "Download", label: "Download",
}, },
} satisfies ChartConfig; } satisfies ChartConfig
return ( return (
<Card> <Card>
@ -594,28 +681,23 @@ function NetworkChart({ data }: { data: NezhaAPISafe }) {
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<div className="flex items-center"> <div className="flex items-center">
<section className="flex items-center gap-4"> <section className="flex items-center gap-4">
<div className="flex flex-col w-20"> <div className="flex w-20 flex-col">
<p className="text-xs text-muted-foreground">{t("Upload")}</p> <p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]"></span> <span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
<p className="text-xs font-medium">{up.toFixed(2)} M/s</p> <p className="font-medium text-xs">{up.toFixed(2)} M/s</p>
</div> </div>
</div> </div>
<div className="flex flex-col w-20"> <div className="flex w-20 flex-col">
<p className=" text-xs text-muted-foreground"> <p className=" text-muted-foreground text-xs">{t("Download")}</p>
{t("Download")}
</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]"></span> <span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
<p className="text-xs font-medium">{down.toFixed(2)} M/s</p> <p className="font-medium text-xs">{down.toFixed(2)} M/s</p>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<ChartContainer <ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
config={chartConfig}
className="aspect-auto h-[130px] w-full"
>
<LineChart <LineChart
accessibilityLayer accessibilityLayer
data={networkChartData} data={networkChartData}
@ -667,37 +749,64 @@ function NetworkChart({ data }: { data: NezhaAPISafe }) {
</section> </section>
</CardContent> </CardContent>
</Card> </Card>
); )
} }
function ConnectChart({ data }: { data: NezhaAPISafe }) { function ConnectChart({
const [connectChartData, setConnectChartData] = useState( data,
[] as connectChartData[], history,
); }: {
data: NezhaAPISafe
const { tcp, udp } = formatNezhaInfo(data); history: ServerDataWithTimestamp[]
}) {
const [connectChartData, setConnectChartData] = useState([] as connectChartData[])
const hasInitialized = useRef(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
useEffect(() => { useEffect(() => {
if (data) { if (!hasInitialized.current && history.length > 0) {
const timestamp = Date.now().toString(); const historyData = history
let newData = [] as connectChartData[]; .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) { if (connectChartData.length === 0) {
newData = [ newData = [
{ timeStamp: timestamp, tcp: tcp, udp: udp }, { timeStamp: timestamp, tcp: tcp, udp: udp },
{ timeStamp: timestamp, tcp: tcp, udp: udp }, { timeStamp: timestamp, tcp: tcp, udp: udp },
]; ]
} else { } else {
newData = [ newData = [...connectChartData, { timeStamp: timestamp, tcp: tcp, udp: udp }]
...connectChartData,
{ timeStamp: timestamp, tcp: tcp, udp: udp },
];
} }
if (newData.length > 30) { if (newData.length > MAX_HISTORY_LENGTH) {
newData.shift(); newData.shift()
} }
setConnectChartData(newData); setConnectChartData(newData)
} }
}, [data]); }, [data, historyLoaded])
const chartConfig = { const chartConfig = {
tcp: { tcp: {
@ -706,7 +815,7 @@ function ConnectChart({ data }: { data: NezhaAPISafe }) {
udp: { udp: {
label: "UDP", label: "UDP",
}, },
} satisfies ChartConfig; } satisfies ChartConfig
return ( return (
<Card> <Card>
@ -714,26 +823,23 @@ function ConnectChart({ data }: { data: NezhaAPISafe }) {
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<div className="flex items-center"> <div className="flex items-center">
<section className="flex items-center gap-4"> <section className="flex items-center gap-4">
<div className="flex flex-col w-12"> <div className="flex w-12 flex-col">
<p className="text-xs text-muted-foreground">TCP</p> <p className="text-muted-foreground text-xs">TCP</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]"></span> <span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-1))]" />
<p className="text-xs font-medium">{tcp}</p> <p className="font-medium text-xs">{tcp}</p>
</div> </div>
</div> </div>
<div className="flex flex-col w-12"> <div className="flex w-12 flex-col">
<p className=" text-xs text-muted-foreground">UDP</p> <p className=" text-muted-foreground text-xs">UDP</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]"></span> <span className="relative inline-flex size-1.5 rounded-full bg-[hsl(var(--chart-4))]" />
<p className="text-xs font-medium">{udp}</p> <p className="font-medium text-xs">{udp}</p>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<ChartContainer <ChartContainer config={chartConfig} className="aspect-auto h-[130px] w-full">
config={chartConfig}
className="aspect-auto h-[130px] w-full"
>
<LineChart <LineChart
accessibilityLayer accessibilityLayer
data={connectChartData} data={connectChartData}
@ -782,5 +888,5 @@ function ConnectChart({ data }: { data: NezhaAPISafe }) {
</section> </section>
</CardContent> </CardContent>
</Card> </Card>
); )
} }

View File

@ -0,0 +1,276 @@
"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

@ -0,0 +1,119 @@
"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

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

View File

@ -0,0 +1,153 @@
"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

@ -0,0 +1,70 @@
"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

@ -0,0 +1,222 @@
"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} />
</>
)
}

View File

@ -1,43 +1,35 @@
"use client"; "use client"
import { ServerApi } from "@/app/types/nezha-api"; import { useFilter } from "@/app/context/network-filter-context"
import { Loader } from "@/components/loading/Loader"; import { useServerData } from "@/app/context/server-data-context"
import { Card, CardContent } from "@/components/ui/card"; import { useStatus } from "@/app/context/status-context"
import getEnv from "@/lib/env-entry"; import AnimateCountClient from "@/components/AnimatedCount"
import { useFilter } from "@/lib/network-filter-context"; import { Loader } from "@/components/loading/Loader"
import { useStatus } from "@/lib/status-context"; import { Card, CardContent } from "@/components/ui/card"
import { cn, formatBytes, nezhaFetcher } from "@/lib/utils"; import getEnv from "@/lib/env-entry"
import blogMan from "@/public/blog-man.webp"; import { cn, formatBytes } from "@/lib/utils"
import { import blogMan from "@/public/blog-man.webp"
ArrowDownCircleIcon, import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid"
ArrowUpCircleIcon, import { useTranslations } from "next-intl"
} from "@heroicons/react/20/solid"; import Image from "next/image"
import { useTranslations } from "next-intl";
import Image from "next/image";
import useSWR from "swr";
export default function ServerOverviewClient() { export default function ServerOverviewClient() {
const { status, setStatus } = useStatus(); const { data, error, isLoading } = useServerData()
const { filter, setFilter } = useFilter(); const { status, setStatus } = useStatus()
const t = useTranslations("ServerOverviewClient"); const { filter, setFilter } = useFilter()
const { data, error, isLoading } = useSWR<ServerApi>( const t = useTranslations("ServerOverviewClient")
"/api/server", const disableCartoon = getEnv("NEXT_PUBLIC_DisableCartoon") === "true"
nezhaFetcher,
{
refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 2000,
},
);
const disableCartoon = getEnv("NEXT_PUBLIC_DisableCartoon") === "true";
if (error) { if (error) {
const errorInfo = error as any
return ( return (
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40"> <p className="font-medium text-sm opacity-40">
Error status:{error.status} {error.info?.cause ?? error.message} Error status:{errorInfo?.status} {errorInfo.info?.cause ?? errorInfo?.message}
</p> </p>
<p className="text-sm font-medium opacity-40">{t("error_message")}</p> <p className="font-medium text-sm opacity-40">{t("error_message")}</p>
</div> </div>
); )
} }
return ( return (
@ -45,23 +37,21 @@ export default function ServerOverviewClient() {
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4"> <section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card <Card
onClick={() => { onClick={() => {
setFilter(false); setFilter(false)
setStatus("all"); setStatus("all")
}} }}
className={cn("cursor-pointer hover:border-blue-500 transition-all")} className={cn("group cursor-pointer transition-all hover:border-blue-500")}
> >
<CardContent className="flex h-full items-center px-6 py-3"> <CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base"> <p className="font-medium text-sm md:text-base">{t("p_816-881_Totalservers")}</p>
{t("p_816-881_Totalservers")} <div className="flex min-h-[28px] items-center gap-2">
</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500"></span> <span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span> </span>
{data?.result ? ( {data?.result ? (
<div className="text-lg font-semibold"> <div className="font-semibold text-lg">
{data?.result.length} <AnimateCountClient count={data?.result.length} />
</div> </div>
) : ( ) : (
<div className="flex h-7 items-center"> <div className="flex h-7 items-center">
@ -74,29 +64,27 @@ export default function ServerOverviewClient() {
</Card> </Card>
<Card <Card
onClick={() => { onClick={() => {
setFilter(false); setFilter(false)
setStatus("online"); setStatus("online")
}} }}
className={cn( className={cn(
"cursor-pointer hover:ring-green-500 ring-1 ring-transparent transition-all", "cursor-pointer ring-1 ring-transparent transition-all hover:ring-green-500",
{ {
"ring-green-500 ring-2 border-transparent": status === "online", "border-transparent ring-2 ring-green-500": status === "online",
}, },
)} )}
> >
<CardContent className="flex h-full items-center px-6 py-3"> <CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base"> <p className="font-medium text-sm md:text-base">{t("p_1610-1676_Onlineservers")}</p>
{t("p_1610-1676_Onlineservers")} <div className="flex min-h-[28px] items-center gap-2">
</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span> <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span> </span>
{data?.result ? ( {data?.result ? (
<div className="text-lg font-semibold"> <div className="font-semibold text-lg">
{data?.live_servers} <AnimateCountClient count={data?.live_servers} />
</div> </div>
) : ( ) : (
<div className="flex h-7 items-center"> <div className="flex h-7 items-center">
@ -109,29 +97,27 @@ export default function ServerOverviewClient() {
</Card> </Card>
<Card <Card
onClick={() => { onClick={() => {
setFilter(false); setFilter(false)
setStatus("offline"); setStatus("offline")
}} }}
className={cn( className={cn(
"cursor-pointer hover:ring-red-500 ring-1 ring-transparent transition-all", "cursor-pointer ring-1 ring-transparent transition-all hover:ring-red-500",
{ {
"ring-red-500 ring-2 border-transparent": status === "offline", "border-transparent ring-2 ring-red-500": status === "offline",
}, },
)} )}
> >
<CardContent className="flex h-full items-center px-6 py-3"> <CardContent className="flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1"> <section className="flex flex-col gap-1">
<p className="text-sm font-medium md:text-base"> <p className="font-medium text-sm md:text-base">{t("p_2532-2599_Offlineservers")}</p>
{t("p_2532-2599_Offlineservers")} <div className="flex min-h-[28px] items-center gap-2">
</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span> <span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
</span> </span>
{data?.result ? ( {data?.result ? (
<div className="text-lg font-semibold"> <div className="font-semibold text-lg">
{data?.offline_servers} <AnimateCountClient count={data?.offline_servers} />
</div> </div>
) : ( ) : (
<div className="flex h-7 items-center"> <div className="flex h-7 items-center">
@ -144,40 +130,38 @@ export default function ServerOverviewClient() {
</Card> </Card>
<Card <Card
onClick={() => { onClick={() => {
setStatus("all"); setStatus("all")
setFilter(true); setFilter(true)
}} }}
className={cn( className={cn(
"cursor-pointer hover:ring-purple-500 ring-1 ring-transparent transition-all", "group cursor-pointer ring-1 ring-transparent transition-all hover:ring-purple-500",
{ {
"ring-purple-500 ring-2 border-transparent": filter === true, "border-transparent ring-2 ring-purple-500": filter === true,
}, },
)} )}
> >
<CardContent className="flex h-full items-center relative px-6 py-3"> <CardContent className="relative flex h-full items-center px-6 py-3">
<section className="flex flex-col gap-1 w-full"> <section className="flex w-full flex-col gap-1">
<div className="flex items-center w-full justify-between"> <div className="flex w-full items-center justify-between">
<p className="text-sm font-medium md:text-base"> <p className="font-medium text-sm md:text-base">{t("network")}</p>
{t("network")}
</p>
</div> </div>
{data?.result ? ( {data?.result ? (
<> <>
<section className="flex items-start flex-row z-[999] pr-2 sm:pr-0 gap-1"> <section className="flex flex-row flex-wrap items-start gap-1 pr-0">
<p className="sm:text-[12px] text-[10px] text-blue-800 dark:text-blue-400 text-nowrap font-medium"> <p className="text-nowrap font-medium text-[12px] text-blue-800 dark:text-blue-400">
{formatBytes(data?.total_out_bandwidth)} {formatBytes(data?.total_out_bandwidth)}
</p> </p>
<p className="sm:text-[12px] text-[10px] text-purple-800 dark:text-purple-400 text-nowrap font-medium"> <p className="text-nowrap font-medium text-[12px] text-purple-800 dark:text-purple-400">
{formatBytes(data?.total_in_bandwidth)} {formatBytes(data?.total_in_bandwidth)}
</p> </p>
</section> </section>
<section className="flex flex-col sm:flex-row -mr-1 sm:items-center items-start gap-1"> <section className="-mr-1 flex flex-row flex-wrap items-start gap-1 sm:items-center">
<p className="text-[11px] flex items-center text-nowrap font-semibold"> <p className="flex items-center text-nowrap font-semibold text-[11px]">
<ArrowUpCircleIcon className="size-3 mr-0.5 sm:mb-[1px]" /> <ArrowUpCircleIcon className="mr-0.5 size-3 sm:mb-[1px]" />
{formatBytes(data?.total_out_speed)}/s {formatBytes(data?.total_out_speed)}/s
</p> </p>
<p className="text-[11px] flex items-center text-nowrap font-semibold"> <p className="flex items-center text-nowrap font-semibold text-[11px]">
<ArrowDownCircleIcon className="size-3 mr-0.5" /> <ArrowDownCircleIcon className="mr-0.5 size-3" />
{formatBytes(data?.total_in_speed)}/s {formatBytes(data?.total_in_speed)}/s
</p> </p>
</section> </section>
@ -190,7 +174,7 @@ export default function ServerOverviewClient() {
</section> </section>
{!disableCartoon && ( {!disableCartoon && (
<Image <Image
className="pointer-events-none absolute right-3 top-[-85px] z-10 w-20 scale-90 md:scale-100" className="absolute top-[-85px] right-3 z-50 w-20 scale-90 transition-all group-hover:opacity-50 md:scale-100"
alt={"Hamster1963"} alt={"Hamster1963"}
src={blogMan} src={blogMan}
priority priority
@ -202,9 +186,9 @@ export default function ServerOverviewClient() {
</section> </section>
{data?.result === undefined && !isLoading && ( {data?.result === undefined && !isLoading && (
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{t("error_message")}</p> <p className="font-medium text-sm opacity-40">{t("error_message")}</p>
</div> </div>
)} )}
</> </>
); )
} }

View File

@ -1,35 +1,58 @@
import pack from "@/package.json"; "use client"
import { useTranslations } from "next-intl";
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() { export default function Footer() {
const t = useTranslations("Footer"); const t = useTranslations("Footer")
const version = pack.version; 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 ( return (
<footer className="mx-auto w-full max-w-5xl"> <footer className="mx-auto flex w-full max-w-5xl items-center justify-between">
<section className="flex flex-col"> <section className="flex flex-col">
<p className="mt-3 flex gap-1 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50"> <p className={`mt-3 flex gap-1 ${baseTextStyles}`}>
{t("p_146-598_Findthecodeon")}{" "} {t("p_146-598_Findthecodeon")}{" "}
<a <FooterLink href={GITHUB_URL}>{t("a_303-585_GitHub")}</FooterLink>
href="https://github.com/hamster1963/nezha-dash" <FooterLink href={`${GITHUB_URL}/releases/tag/v${version}`}>v{version}</FooterLink>
target="_blank"
className="cursor-pointer font-normal underline decoration-yellow-500 hover:decoration-yellow-600 transition-colors decoration-2 underline-offset-2 dark:decoration-yellow-500/60 dark:hover:decoration-yellow-500/80"
>
{t("a_303-585_GitHub")}
</a>
<a
href={`https://github.com/hamster1963/nezha-dash/releases/tag/v${version}`}
target="_blank"
className="cursor-pointer font-normal underline decoration-yellow-500 hover:decoration-yellow-600 transition-colors decoration-2 underline-offset-2 dark:decoration-yellow-500/60 dark:hover:decoration-yellow-500/80"
>
v{version}
</a>
</p> </p>
<section className="mt-1 flex items-center gap-2 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50"> <section className={`mt-1 flex items-center gap-2 ${baseTextStyles}`}>
{t("section_607-869_2020")} {t("section_607-869_2020")}
{new Date().getFullYear()}{" "} {currentYear} <FooterLink href={PERSONAL_URL}>{t("a_800-850_Hamster1963")}</FooterLink>
<a href={"https://buycoffee.top"}>{t("a_800-850_Hamster1963")}</a>
</section> </section>
</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> </footer>
); )
} }

View File

@ -1,32 +1,125 @@
"use client"; "use client"
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import AnimateCountClient from "@/components/AnimatedCount"
import { ModeToggle } from "@/components/ThemeSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"
import { Separator } from "@/components/ui/separator"; import { ModeToggle } from "@/components/ThemeSwitcher"
import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"
import getEnv from "@/lib/env-entry"; import { Skeleton } from "@/components/ui/skeleton"
import { DateTime } from "luxon";
import { useTranslations } from "next-intl"; import getEnv from "@/lib/env-entry"
import { useRouter } from "next/navigation"; import { DateTime } from "luxon"
import React, { useEffect, useRef, useState } from "react"; 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>
)
})
function Header() { function Header() {
const t = useTranslations("Header"); const t = useTranslations("Header")
const customLogo = getEnv("NEXT_PUBLIC_CustomLogo"); const customLogo = getEnv("NEXT_PUBLIC_CustomLogo")
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle"); const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription"); const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
const router = useRouter(); const router = useRouter()
const handleLogoClick = useCallback(() => {
sessionStorage.removeItem("selectedTag")
router.push("/")
}, [router])
return ( return (
<div className="mx-auto w-full max-w-5xl"> <div className="mx-auto w-full max-w-5xl">
<section className="flex items-center justify-between"> <section className="flex items-center justify-between">
<section <section
onClick={() => { onClick={handleLogoClick}
sessionStorage.removeItem("selectedTag"); className="flex cursor-pointer items-center font-medium text-base transition-opacity duration-300 hover:opacity-50"
router.push(`/`);
}}
className="flex cursor-pointer items-center text-base font-medium"
> >
<div className="mr-1 flex flex-row items-center justify-start"> <div className="mr-1 flex flex-row items-center justify-start">
<img <img
@ -34,111 +127,36 @@ function Header() {
height={40} height={40}
alt="apple-touch-icon" alt="apple-touch-icon"
src={customLogo ? customLogo : "/apple-touch-icon.png"} src={customLogo ? customLogo : "/apple-touch-icon.png"}
className="relative m-0! border-2 border-transparent h-6 w-6 object-cover object-top p-0! dark:hidden" className="relative m-0! h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:hidden"
/> />
<img <img
width={40} width={40}
height={40} height={40}
alt="apple-touch-icon" alt="apple-touch-icon"
src={customLogo ? customLogo : "/apple-touch-icon-dark.png"} src={customLogo ? customLogo : "/apple-touch-icon-dark.png"}
className="relative m-0! border-2 border-transparent h-6 w-6 object-cover object-top p-0! hidden dark:block" className="relative m-0! hidden h-6 w-6 border-2 border-transparent object-cover object-top p-0! dark:block"
/> />
</div> </div>
{customTitle ? customTitle : "NezhaDash"} {customTitle ? customTitle : "NezhaDash"}
<Separator <Separator orientation="vertical" className="mx-2 hidden h-4 w-[1px] md:block" />
orientation="vertical" <p className="hidden font-medium text-sm opacity-40 md:block">
className="mx-2 hidden h-4 w-[1px] md:block" {customDescription ? customDescription : t("p_1079-1199_Simpleandbeautifuldashbo")}
/>
<p className="hidden text-sm font-medium opacity-40 md:block">
{customDescription
? customDescription
: t("p_1079-1199_Simpleandbeautifuldashbo")}
</p> </p>
</section> </section>
<section className="flex items-center gap-2"> <section className="flex items-center gap-2">
<Links /> <div className="hidden sm:block">
<Links />
</div>
<LanguageSwitcher /> <LanguageSwitcher />
<ModeToggle /> <ModeToggle />
</section> </section>
</section> </section>
<div className="mt-1 flex w-full justify-end sm:hidden">
<Links />
</div>
<Overview /> <Overview />
</div> </div>
); )
} }
type links = { export default Header
link: string;
name: string;
};
function Links() {
const linksEnv = getEnv("NEXT_PUBLIC_Links");
const links: links[] | null = linksEnv ? JSON.parse(linksEnv) : null;
if (!links) return null;
return (
<div className="flex items-center gap-2">
{links.map((link, index) => {
return (
<a
key={index}
href={link.link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm font-medium opacity-50 transition-opacity hover:opacity-100"
>
{link.name}
</a>
);
})}
</div>
);
}
// https://github.com/streamich/react-use/blob/master/src/useInterval.ts
const useInterval = (callback: () => void, delay: number | null) => {
const savedCallback = useRef<() => void>(() => {});
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
if (delay !== null) {
const interval = setInterval(() => savedCallback.current(), delay || 0);
return () => clearInterval(interval);
}
return undefined;
}, [delay]);
};
function Overview() {
const t = useTranslations("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-base font-semibold">{t("p_2277-2331_Overview")}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium opacity-50">
{t("p_2390-2457_wherethetimeis")}
</p>
{mouted ? (
<p className="text-sm font-medium">{timeString}</p>
) : (
<Skeleton className="h-[20px] w-[50px] rounded-[5px] bg-muted-foreground/10 animate-none"></Skeleton>
)}
</div>
</section>
);
}
export default Header;

View File

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

View File

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

View File

@ -1,42 +1,53 @@
"use client"; "use client"
import { NetworkChartClient } from "@/app/(main)/ClientComponents/NetworkChart"; import { NetworkChartClient } from "@/app/(main)/ClientComponents/detail/NetworkChart"
import ServerDetailChartClient from "@/app/(main)/ClientComponents/ServerDetailChartClient"; import ServerDetailChartClient from "@/app/(main)/ClientComponents/detail/ServerDetailChartClient"
import ServerDetailClient from "@/app/(main)/ClientComponents/ServerDetailClient"; import ServerDetailClient from "@/app/(main)/ClientComponents/detail/ServerDetailClient"
import TabSwitch from "@/components/TabSwitch"; import ServerIPInfo from "@/app/(main)/ClientComponents/detail/ServerIPInfo"
import { Separator } from "@/components/ui/separator"; import TabSwitch from "@/components/TabSwitch"
import { use, useState } from "react"; 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"} />
</>
),
}
export default function Page(props: { params: Promise<{ id: string }> }) {
const params = use(props.params);
const tabs = ["Detail", "Network"];
const [currentTab, setCurrentTab] = useState(tabs[0]);
return ( return (
<div className="mx-auto grid w-full max-w-5xl gap-2"> <main className="mx-auto grid w-full max-w-5xl gap-2">
<ServerDetailClient server_id={Number(params.id)} /> <ServerDetailClient server_id={serverId} />
<section className="flex items-center my-2 w-full">
<nav className="my-2 flex w-full items-center">
<Separator className="flex-1" /> <Separator className="flex-1" />
<div className="flex justify-center w-full max-w-[200px]"> <div className="flex w-full max-w-[200px] justify-center">
<TabSwitch <TabSwitch
tabs={tabs} tabs={tabs}
currentTab={currentTab} currentTab={currentTab}
setCurrentTab={setCurrentTab} setCurrentTab={(tab: string) => setCurrentTab(tab as TabType)}
/> />
</div> </div>
<Separator className="flex-1" /> <Separator className="flex-1" />
</section> </nav>
<div style={{ display: currentTab === tabs[0] ? "block" : "none" }}>
<ServerDetailChartClient {tabContent[currentTab]}
server_id={Number(params.id)} </main>
show={currentTab === tabs[0]} )
/>
</div>
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
<NetworkChartClient
server_id={Number(params.id)}
show={currentTab === tabs[1]}
/>
</div>
</div>
);
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,24 @@
"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

@ -0,0 +1,62 @@
"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

@ -0,0 +1,26 @@
"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

@ -0,0 +1,39 @@
"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
}

View File

@ -1,28 +1,26 @@
// @auto-i18n-check. Please do not delete the line. import { FilterProvider } from "@/app/context/network-filter-context"
import { ThemeColorManager } from "@/components/ThemeColorManager"; import { StatusProvider } from "@/app/context/status-context"
import { MotionProvider } from "@/components/motion/motion-provider"; import { ThemeColorManager } from "@/components/ThemeColorManager"
import getEnv from "@/lib/env-entry"; import getEnv from "@/lib/env-entry"
import { FilterProvider } from "@/lib/network-filter-context"; import { cn } from "@/lib/utils"
import { StatusProvider } from "@/lib/status-context"; import "@/styles/globals.css"
import { cn } from "@/lib/utils"; import type { Metadata } from "next"
import "@/styles/globals.css"; import type { Viewport } from "next"
import type { Metadata } from "next"; import { NextIntlClientProvider } from "next-intl"
import { Viewport } from "next"; import { getLocale, getMessages } from "next-intl/server"
import { NextIntlClientProvider } from "next-intl"; import { PublicEnvScript } from "next-runtime-env"
import { getLocale, getMessages } from "next-intl/server"; import { ThemeProvider } from "next-themes"
import { PublicEnvScript } from "next-runtime-env"; import { Inter as FontSans } from "next/font/google"
import { ThemeProvider } from "next-themes"; import type React from "react"
import { Inter as FontSans } from "next/font/google";
import React from "react";
const fontSans = FontSans({ const fontSans = FontSans({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-sans", variable: "--font-sans",
}); })
const customTitle = getEnv("NEXT_PUBLIC_CustomTitle"); const customTitle = getEnv("NEXT_PUBLIC_CustomTitle")
const customDescription = getEnv("NEXT_PUBLIC_CustomDescription"); const customDescription = getEnv("NEXT_PUBLIC_CustomDescription")
const disableIndex = getEnv("NEXT_PUBLIC_DisableIndex"); const disableIndex = getEnv("NEXT_PUBLIC_DisableIndex")
export const metadata: Metadata = { export const metadata: Metadata = {
manifest: "/manifest.json", manifest: "/manifest.json",
@ -34,25 +32,25 @@ export const metadata: Metadata = {
statusBarStyle: "default", statusBarStyle: "default",
}, },
robots: { robots: {
index: disableIndex ? false : true, index: !disableIndex,
follow: disableIndex ? false : true, follow: !disableIndex,
}, },
}; }
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
userScalable: false, userScalable: false,
}; }
export default async function LocaleLayout({ export default async function LocaleLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode
}) { }) {
const locale = await getLocale(); const locale = await getLocale()
const messages = await getMessages(); const messages = await getMessages()
return ( return (
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
@ -60,37 +58,30 @@ export default async function LocaleLayout({
<PublicEnvScript /> <PublicEnvScript />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css" href="https://fastly.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"
/> />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/font-logos@1/assets/font-logos.css" href="https://fastly.jsdelivr.net/npm/font-logos@1/assets/font-logos.css"
/> />
</head> </head>
<body <body className={cn("min-h-screen bg-background font-sans antialiased", fontSans.variable)}>
className={cn( <ThemeProvider
"min-h-screen bg-background font-sans antialiased", attribute="class"
fontSans.variable, defaultTheme="system"
)} enableSystem
> disableTransitionOnChange
<MotionProvider> >
<ThemeProvider <NextIntlClientProvider messages={messages}>
attribute="class" <FilterProvider>
defaultTheme="system" <StatusProvider>
enableSystem <ThemeColorManager />
disableTransitionOnChange {children}
> </StatusProvider>
<NextIntlClientProvider messages={messages}> </FilterProvider>
<FilterProvider> </NextIntlClientProvider>
<StatusProvider> </ThemeProvider>
<ThemeColorManager />
{children}
</StatusProvider>
</FilterProvider>
</NextIntlClientProvider>
</ThemeProvider>
</MotionProvider>
</body> </body>
</html> </html>
); )
} }

View File

@ -1,25 +1,22 @@
import { useTranslations } from "next-intl"; import Footer from "@/app/(main)/footer"
import Link from "next/link"; import Header from "@/app/(main)/header"
import { useTranslations } from "next-intl"
import Footer from "./(main)/footer"; import Link from "next/link"
import Header from "./(main)/header";
export default function NotFoundPage() { export default function NotFoundPage() {
const t = useTranslations("NotFoundPage"); const t = useTranslations("NotFoundPage")
return ( return (
<div className="flex min-h-screen w-full flex-col"> <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-calc(var(--spacing)*16))] flex-1 flex-col gap-4 bg-background p-4 md:p-10 md:pt-8">
<Header /> <Header />
<section className="flex flex-col items-center min-h-44 justify-center gap-2"> <section className="flex flex-1 flex-col items-center justify-center gap-2">
<p className="text-sm font-semibold">{t("h1_490-590_404NotFound")}</p> <p className="font-semibold text-sm">{t("h1_490-590_404NotFound")}</p>
<Link href="/" className="flex items-center gap-1"> <Link href="/" className="flex items-center gap-1">
<p className="text-sm font-medium opacity-40"> <p className="font-medium text-sm opacity-40">{t("h1_490-590_404NotFoundBack")}</p>
{t("h1_490-590_404NotFoundBack")}
</p>
</Link> </Link>
</section> </section>
<Footer /> <Footer />
</main> </main>
</div> </div>
); )
} }

View File

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

View File

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

26
auth.ts
View File

@ -1,10 +1,12 @@
import NextAuth from "next-auth"; import getEnv from "@/lib/env-entry"
import CredentialsProvider from "next-auth/providers/credentials"; import CryptoJS from "crypto-js"
import NextAuth from "next-auth"
import getEnv from "./lib/env-entry"; import CredentialsProvider from "next-auth/providers/credentials"
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
secret: process.env.AUTH_SECRET ?? "this_is_nezha_dash_web_secret", 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, trustHost: (process.env.AUTH_TRUST_HOST as boolean | undefined) ?? true,
providers: [ providers: [
CredentialsProvider({ CredentialsProvider({
@ -12,21 +14,21 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
credentials: { password: { label: "Password", type: "password" } }, credentials: { password: { label: "Password", type: "password" } },
// authorization function // authorization function
async authorize(credentials) { async authorize(credentials) {
const { password } = credentials; const { password } = credentials
if (password === getEnv("SitePassword")) { if (password === getEnv("SitePassword")) {
return { id: "nezha-dash-auth" }; return { id: "nezha-dash-auth" }
} }
return { error: "Invalid password" }; return { error: "Invalid password" }
}, },
}), }),
], ],
callbacks: { callbacks: {
async signIn({ user }) { async signIn({ user }) {
// @ts-ignore // @ts-expect-error user is not null
if (user.error) { if (user.error) {
return false; return false
} }
return true; return true
}, },
}, },
}); })

86
biome.json Normal file
View File

@ -0,0 +1,86 @@
{
"$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,2 +0,0 @@
[install]
registry = "https://registry.npmmirror.com/"

View File

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

View File

@ -1,35 +0,0 @@
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-sm(${blurAmount}px)`,
WebkitBackdropFilter: `blur-sm(${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;

135
components/DashCommand.tsx Normal file
View File

@ -0,0 +1,135 @@
"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>
</>
)
}

View File

@ -1,31 +0,0 @@
"use client";
import { useFilter } from "@/lib/network-filter-context";
import { useStatus } from "@/lib/status-context";
import { ServerStackIcon } from "@heroicons/react/20/solid";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function GlobalBackButton() {
const router = useRouter();
const { setStatus } = useStatus();
const { setFilter } = useFilter();
useEffect(() => {
setStatus("all");
setFilter(false);
sessionStorage.removeItem("selectedTag");
router.prefetch(`/`);
}, []);
return (
<button
onClick={() => {
router.push(`/`);
}}
className="rounded-[50px] mt-[1px] w-fit text-white cursor-pointer [text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] bg-green-600 hover:bg-green-500 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] hover:shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] "
>
<ServerStackIcon className="size-[13px]" />
</button>
);
}

View File

@ -1,11 +1,11 @@
import Image from "next/image"; import Image from "next/image"
export function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) { export function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return ( return (
<svg viewBox="0 0 496 512" fill="white" {...props}> <svg role="img" aria-label="github-icon" viewBox="0 0 496 512" fill="white" {...props}>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" /> <path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg> </svg>
); )
} }
export function BackIcon() { export function BackIcon() {
@ -30,5 +30,5 @@ export function BackIcon() {
height="20" height="20"
/> />
</> </>
); )
} }

View File

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

View File

@ -1,41 +1,36 @@
import { NezhaAPISafe } from "@/app/types/nezha-api"; import type { NezhaAPISafe } from "@/app/types/nezha-api"
import ServerFlag from "@/components/ServerFlag"; import ServerFlag from "@/components/ServerFlag"
import ServerUsageBar from "@/components/ServerUsageBar"; import ServerUsageBar from "@/components/ServerUsageBar"
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge"
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card"
import getEnv from "@/lib/env-entry"; import getEnv from "@/lib/env-entry"
import { import { GetFontLogoClass, GetOsName, MageMicrosoftWindows } from "@/lib/logo-class"
GetFontLogoClass, import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils"
GetOsName, import { useTranslations } from "next-intl"
MageMicrosoftWindows, import Link from "next/link"
} from "@/lib/logo-class";
import { cn, formatBytes, formatNezhaInfo } from "@/lib/utils";
import { useTranslations } from "next-intl";
import Link from "next/link";
export default function ServerCard({ export default function ServerCard({
serverInfo, serverInfo,
}: { }: {
serverInfo: NezhaAPISafe; serverInfo: NezhaAPISafe
}) { }) {
const t = useTranslations("ServerCard"); const t = useTranslations("ServerCard")
const { id, name, country_code, online, cpu, up, down, mem, stg, host } = const { id, name, country_code, online, cpu, up, down, mem, stg, host } =
formatNezhaInfo(serverInfo); formatNezhaInfo(serverInfo)
const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"; const showFlag = getEnv("NEXT_PUBLIC_ShowFlag") === "true"
const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true"; const showNetTransfer = getEnv("NEXT_PUBLIC_ShowNetTransfer") === "true"
const fixedTopServerName = const fixedTopServerName = getEnv("NEXT_PUBLIC_FixedTopServerName") === "true"
getEnv("NEXT_PUBLIC_FixedTopServerName") === "true";
const saveSession = () => { const saveSession = () => {
sessionStorage.setItem("fromMainPage", "true"); sessionStorage.setItem("fromMainPage", "true")
}; }
return online ? ( return online ? (
<Link onClick={saveSession} href={`/server/${id}`} prefetch={true}> <Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
<Card <Card
className={cn( className={cn(
"flex flex-col items-center justify-start gap-3 p-3 md:px-5 cursor-pointer hover:bg-accent/50 transition-colors", "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, "flex-col": fixedTopServerName,
"lg:flex-row": !fixedTopServerName, "lg:flex-row": !fixedTopServerName,
@ -48,7 +43,7 @@ export default function ServerCard({
})} })}
style={{ gridTemplateColumns: "auto auto 1fr" }} style={{ gridTemplateColumns: "auto auto 1fr" }}
> >
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500 self-center"></span> <span className="h-2 w-2 shrink-0 self-center rounded-full bg-green-500" />
<div <div
className={cn( className={cn(
"flex items-center justify-center", "flex items-center justify-center",
@ -75,12 +70,8 @@ export default function ServerCard({
})} })}
> >
{fixedTopServerName && ( {fixedTopServerName && (
<div <div className={"col-span-1 hidden items-center gap-2 lg:flex lg:flex-row"}>
className={ <div className="font-semibold text-xs">
"hidden col-span-1 items-center lg:flex lg:flex-row gap-2"
}
>
<div className="text-xs font-semibold">
{host.Platform.includes("Windows") ? ( {host.Platform.includes("Windows") ? (
<MageMicrosoftWindows className="size-[10px]" /> <MageMicrosoftWindows className="size-[10px]" />
) : ( ) : (
@ -88,50 +79,38 @@ export default function ServerCard({
)} )}
</div> </div>
<div className={"flex w-14 flex-col"}> <div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("System")}</p> <p className="text-muted-foreground text-xs">{t("System")}</p>
<div className="flex items-center text-[10.5px] font-semibold"> <div className="flex items-center font-semibold text-[10.5px]">
{host.Platform.includes("Windows") {host.Platform.includes("Windows") ? "Windows" : GetOsName(host.Platform)}
? "Windows"
: GetOsName(host.Platform)}
</div> </div>
</div> </div>
</div> </div>
)} )}
<div className={"flex w-14 flex-col"}> <div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("CPU")}</p> <p className="text-muted-foreground text-xs">{t("CPU")}</p>
<div className="flex items-center text-xs font-semibold"> <div className="flex items-center font-semibold text-xs">{cpu.toFixed(2)}%</div>
{cpu.toFixed(2)}%
</div>
<ServerUsageBar value={cpu} /> <ServerUsageBar value={cpu} />
</div> </div>
<div className={"flex w-14 flex-col"}> <div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("Mem")}</p> <p className="text-muted-foreground text-xs">{t("Mem")}</p>
<div className="flex items-center text-xs font-semibold"> <div className="flex items-center font-semibold text-xs">{mem.toFixed(2)}%</div>
{mem.toFixed(2)}%
</div>
<ServerUsageBar value={mem} /> <ServerUsageBar value={mem} />
</div> </div>
<div className={"flex w-14 flex-col"}> <div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("STG")}</p> <p className="text-muted-foreground text-xs">{t("STG")}</p>
<div className="flex items-center text-xs font-semibold"> <div className="flex items-center font-semibold text-xs">{stg.toFixed(2)}%</div>
{stg.toFixed(2)}%
</div>
<ServerUsageBar value={stg} /> <ServerUsageBar value={stg} />
</div> </div>
<div className={"flex w-14 flex-col"}> <div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("Upload")}</p> <p className="text-muted-foreground text-xs">{t("Upload")}</p>
<div className="flex items-center text-xs font-semibold"> <div className="flex items-center font-semibold text-xs">
{up >= 1024 {up >= 1024 ? `${(up / 1024).toFixed(2)}G/s` : `${up.toFixed(2)}M/s`}
? `${(up / 1024).toFixed(2)}G/s`
: `${up.toFixed(2)}M/s`}
</div> </div>
</div> </div>
<div className={"flex w-14 flex-col"}> <div className={"flex w-14 flex-col"}>
<p className="text-xs text-muted-foreground">{t("Download")}</p> <p className="text-muted-foreground text-xs">{t("Download")}</p>
<div className="flex items-center text-xs font-semibold"> <div className="flex items-center font-semibold text-xs">
{down >= 1024 {down >= 1024 ? `${(down / 1024).toFixed(2)}G/s` : `${down.toFixed(2)}M/s`}
? `${(down / 1024).toFixed(2)}G/s`
: `${down.toFixed(2)}M/s`}
</div> </div>
</div> </div>
</section> </section>
@ -139,13 +118,13 @@ export default function ServerCard({
<section className={"flex items-center justify-between gap-1"}> <section className={"flex items-center justify-between gap-1"}>
<Badge <Badge
variant="secondary" variant="secondary"
className="items-center flex-1 justify-center rounded-[8px] text-nowrap text-[11px] border-muted-50 shadow-md shadow-neutral-200/30 dark:shadow-none" 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)} {t("Upload")}:{formatBytes(serverInfo.status.NetOutTransfer)}
</Badge> </Badge>
<Badge <Badge
variant="outline" variant="outline"
className="items-center flex-1 justify-center rounded-[8px] text-nowrap text-[11px] shadow-md shadow-neutral-200/30 dark:shadow-none" 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)} {t("Download")}:{formatBytes(serverInfo.status.NetInTransfer)}
</Badge> </Badge>
@ -155,44 +134,41 @@ export default function ServerCard({
</Card> </Card>
</Link> </Link>
) : ( ) : (
<Card <Link onClick={saveSession} href={`/server/${id}`} prefetch={true}>
className={cn( <Card
"flex flex-col items-center justify-start gap-3 p-3 md:px-5", className={cn(
showNetTransfer "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",
? "lg:min-h-[91px] min-h-[123px]" showNetTransfer ? "min-h-[123px] lg:min-h-[91px]" : "min-h-[93px] lg:min-h-[61px]",
: "lg:min-h-[61px] min-h-[93px]", {
{ "flex-col": fixedTopServerName,
"flex-col": fixedTopServerName, "lg:flex-row": !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 rounded-full bg-red-500 self-center"></span> <section
<div className={cn("grid items-center gap-2", {
className={cn( "lg:w-40": !fixedTopServerName,
"flex items-center justify-center", })}
showFlag ? "min-w-[17px]" : "min-w-0", style={{ gridTemplateColumns: "auto auto 1fr" }}
)}
> >
{showFlag ? <ServerFlag country_code={country_code} /> : null} <span className="h-2 w-2 shrink-0 self-center rounded-full bg-red-500" />
</div> <div
<div className="relative">
<p
className={cn( className={cn(
"break-all font-bold tracking-tight", "flex items-center justify-center",
showFlag ? "text-xs" : "text-sm", showFlag ? "min-w-[17px]" : "min-w-0",
)} )}
> >
{name} {showFlag ? <ServerFlag country_code={country_code} /> : null}
</p> </div>
</div> <div className="relative">
</section> <p
</Card> className={cn("break-all font-bold tracking-tight", showFlag ? "text-xs" : "text-sm")}
); >
{name}
</p>
</div>
</section>
</Card>
</Link>
)
} }

View File

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

View File

@ -1,73 +0,0 @@
import { NezhaAPISafe } from "@/app/types/nezha-api";
import { cn, formatBytes } from "@/lib/utils";
import { useTranslations } from "next-intl";
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"];
}) {
const t = useTranslations("ServerCardPopover");
return (
<section className="max-w-[300px]">
<ServerCardPopoverCard
title={t("System")}
content={`${host.Platform}-${host.PlatformVersion} [${host.Virtualization}: ${host.Arch}]`}
/>
<ServerCardPopoverCard
title={t("CPU")}
content={`${host.CPU.map((item) => item).join(", ")}`}
/>
<ServerCardPopoverCard
title={t("Mem")}
content={`${formatBytes(status.MemUsed)} / ${formatBytes(host.MemTotal)}`}
/>
<ServerCardPopoverCard
title={t("STG")}
content={`${formatBytes(status.DiskUsed)} / ${formatBytes(host.DiskTotal)}`}
/>
<ServerCardPopoverCard
title={t("Swap")}
content={`${formatBytes(status.SwapUsed)} / ${formatBytes(host.SwapTotal)}`}
/>
<ServerCardPopoverCard
title={t("Network")}
content={`${formatBytes(status.NetOutTransfer)} / ${formatBytes(status.NetInTransfer)}`}
/>
<ServerCardPopoverCard
title={t("Load")}
content={`${status.Load1.toFixed(2)} / ${status.Load5.toFixed(2)} / ${status.Load15.toFixed(2)}`}
/>
<ServerCardPopoverCard
className="mb-0"
title={t("Online")}
content={`${(status.Uptime / 86400).toFixed(0)} Days`}
/>
</section>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,9 @@
"use client"; "use client"
import getEnv from "@/lib/env-entry"; import getEnv from "@/lib/env-entry"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import { m } from "framer-motion"; import { useLocale, useTranslations } from "next-intl"
import { useTranslations } from "next-intl"; import { createRef, useEffect, useRef, useState } from "react"
import React, { createRef, useEffect, useRef } from "react";
export default function Switch({ export default function Switch({
allTag, allTag,
@ -12,94 +11,121 @@ export default function Switch({
tagCountMap, tagCountMap,
onTagChange, onTagChange,
}: { }: {
allTag: string[]; allTag: string[]
nowTag: string; nowTag: string
tagCountMap: Record<string, number>; tagCountMap: Record<string, number>
onTagChange: (tag: string) => void; onTagChange: (tag: string) => void
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null)
const tagRefs = useRef(allTag.map(() => createRef<HTMLDivElement>())); const tagRefs = useRef(allTag.map(() => createRef<HTMLDivElement>()))
const t = useTranslations("ServerListClient"); const t = useTranslations("ServerListClient")
const locale = useLocale()
const [indicator, setIndicator] = useState<{ x: number; w: number } | null>(null)
const [isFirstRender, setIsFirstRender] = useState(true)
useEffect(() => { useEffect(() => {
const savedTag = sessionStorage.getItem("selectedTag"); const savedTag = sessionStorage.getItem("selectedTag")
if (savedTag && allTag.includes(savedTag)) { if (savedTag && allTag.includes(savedTag)) {
onTagChange(savedTag); onTagChange(savedTag)
} }
}, [allTag, onTagChange]); }, [allTag, onTagChange])
useEffect(() => { useEffect(() => {
const container = scrollRef.current; const container = scrollRef.current
if (!container) return; if (!container) return
const isOverflowing = container.scrollWidth > container.clientWidth; const isOverflowing = container.scrollWidth > container.clientWidth
if (!isOverflowing) return; if (!isOverflowing) return
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
e.preventDefault(); e.preventDefault()
container.scrollLeft += e.deltaY; container.scrollLeft += e.deltaY
}; }
container.addEventListener("wheel", onWheel, { passive: false }); container.addEventListener("wheel", onWheel, { passive: false })
return () => { return () => {
container.removeEventListener("wheel", onWheel); container.removeEventListener("wheel", onWheel)
}; }
}, []); }, [])
useEffect(() => { useEffect(() => {
const currentTagRef = tagRefs.current[allTag.indexOf(nowTag)]; const currentTagElement = tagRefs.current[allTag.indexOf(nowTag)]?.current
if (currentTagRef && currentTagRef.current) { if (currentTagElement) {
currentTagRef.current.scrollIntoView({ setIndicator({
behavior: "smooth", x: currentTagElement.offsetLeft,
block: "nearest", w: currentTagElement.offsetWidth,
inline: "center", })
});
} }
}, [nowTag]);
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 ( return (
<div <div
ref={scrollRef} ref={scrollRef}
className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]" className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]"
> >
<div className="flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800"> <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) => ( {allTag.map((tag, index) => (
<div <div
key={tag} key={tag}
ref={tagRefs.current[index]} ref={tagRefs.current[index]}
onClick={() => onTagChange(tag)} onClick={() => {
onTagChange(tag)
sessionStorage.setItem("selectedTag", tag)
}}
className={cn( className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500", "relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
nowTag === tag "text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
? "text-black dark:text-white" {
: "text-stone-400 dark:text-stone-500", "text-stone-950 dark:text-stone-50": nowTag === tag,
},
)} )}
> >
{nowTag === tag && (
<m.div
layoutId="nav-item"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1"> <div className="relative z-20 flex items-center gap-1">
<div className="whitespace-nowrap flex items-center gap-2"> <div className="flex items-center gap-2 whitespace-nowrap">
{tag === "defaultTag" ? t("defaultTag") : tag}{" "} {tag === "defaultTag" ? t("defaultTag") : tag}{" "}
{getEnv("NEXT_PUBLIC_ShowTagCount") === "true" && {getEnv("NEXT_PUBLIC_ShowTagCount") === "true" && tag !== "defaultTag" && (
tag !== "defaultTag" && ( <div className="w-fit rounded-full bg-muted px-1.5">{tagCountMap[tag]}</div>
<div className="w-fit px-1.5 rounded-full bg-muted"> )}
{tagCountMap[tag]}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
); )
} }

View File

@ -1,44 +1,69 @@
"use client"; "use client"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import { m } from "framer-motion"; import { useLocale, useTranslations } from "next-intl"
import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"
import React from "react";
export default function TabSwitch({ export default function TabSwitch({
tabs, tabs,
currentTab, currentTab,
setCurrentTab, setCurrentTab,
}: { }: {
tabs: string[]; tabs: string[]
currentTab: string; currentTab: string
setCurrentTab: (tab: string) => void; setCurrentTab: (tab: string) => void
}) { }) {
const t = useTranslations("TabSwitch"); 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 ( return (
<div className="z-50 flex flex-col items-start rounded-[50px]"> <div className="z-50 flex flex-col items-start rounded-[50px]">
<div className="flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800"> <div className="relative flex items-center gap-1 rounded-[50px] bg-stone-100 p-[3px] dark:bg-stone-800">
{tabs.map((tab: string) => ( {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 <div
key={tab} key={tab}
ref={(el) => {
tabRefs.current[index] = el
}}
onClick={() => setCurrentTab(tab)} onClick={() => setCurrentTab(tab)}
className={cn( className={cn(
"relative cursor-pointer rounded-3xl px-2.5 py-[8px] text-[13px] font-[600] transition-all duration-500", "relative cursor-pointer rounded-3xl px-2.5 py-[8px] font-[600] text-[13px]",
currentTab === tab "text-stone-400 transition-all duration-500 ease-in-out hover:text-stone-950 dark:text-stone-500 hover:dark:text-stone-50",
? "text-black dark:text-white" {
: "text-stone-400 dark:text-stone-500", "text-stone-950 dark:text-stone-50": currentTab === tab,
},
)} )}
> >
{currentTab === tab && (
<m.div
layoutId="tab-switch"
className="absolute inset-0 z-10 h-full w-full content-center bg-white shadow-lg shadow-black/5 dark:bg-stone-700 dark:shadow-white/5"
style={{
originY: "0px",
borderRadius: 46,
}}
/>
)}
<div className="relative z-20 flex items-center gap-1"> <div className="relative z-20 flex items-center gap-1">
<p className="whitespace-nowrap">{t(tab)}</p> <p className="whitespace-nowrap">{t(tab)}</p>
</div> </div>
@ -46,5 +71,5 @@ export default function TabSwitch({
))} ))}
</div> </div>
</div> </div>
); )
} }

View File

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

View File

@ -1,26 +1,31 @@
"use client"; "use client"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"; import { CheckIcon, MinusIcon, Moon, Sun } from "lucide-react"
import { CheckCircleIcon } from "@heroicons/react/20/solid"; import { useTranslations } from "next-intl"
import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"
import { useTranslations } from "next-intl"; import { useId } from "react"
import { useTheme } from "next-themes"; 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" },
]
export function ModeToggle() { export function ModeToggle() {
const { setTheme, theme } = useTheme(); const { setTheme, theme } = useTheme()
const t = useTranslations("ThemeSwitcher"); const t = useTranslations("ThemeSwitcher")
const handleSelect = (e: Event, newTheme: string) => { const handleSelect = (newTheme: string) => {
e.preventDefault(); setTheme(newTheme)
setTheme(newTheme); }
}; const id = useId()
return ( return (
<DropdownMenu> <DropdownMenu>
@ -28,36 +33,48 @@ export function ModeToggle() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="rounded-full px-[9px] bg-white dark:bg-black cursor-pointer hover:bg-accent/50 dark:hover:bg-accent/50" className="cursor-pointer rounded-full bg-white px-[9px] hover:bg-accent/50 dark:bg-black dark:hover:bg-accent/50"
> >
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun className="dark:-rotate-90 h-4 w-4 rotate-0 scale-100 transition-all dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <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> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col gap-0.5" align="end"> <DropdownMenuContent className="px-2 pt-2 pb-1.5" align="end">
<DropdownMenuItem <fieldset className="space-y-4">
className={cn({ "gap-3 bg-muted": theme === "light" })} <RadioGroup className="flex gap-2" defaultValue={theme} onValueChange={handleSelect}>
onSelect={(e) => handleSelect(e, "light")} {items.map((item) => (
> <label key={`${id}-${item.value}`}>
{t("Light")}{" "} <RadioGroupItem
{theme === "light" && <CheckCircleIcon className="size-4" />} id={`${id}-${item.value}`}
</DropdownMenuItem> value={item.value}
<DropdownMenuItem className="peer sr-only after:absolute after:inset-0"
className={cn({ "gap-3 bg-muted": theme === "dark" })} />
onSelect={(e) => handleSelect(e, "dark")} <img
> src={item.image}
{t("Dark")}{" "} alt={item.label}
{theme === "dark" && <CheckCircleIcon className="size-4" />} width={88}
</DropdownMenuItem> height={70}
<DropdownMenuItem 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"
className={cn({ "gap-3 bg-muted": theme === "system" })} />
onSelect={(e) => handleSelect(e, "system")} <span className="group mt-2 flex items-center gap-1 peer-data-[state=unchecked]:text-muted-foreground/70">
> <CheckIcon
{t("System")}{" "} size={16}
{theme === "system" && <CheckCircleIcon className="size-4" />} className="group-peer-data-[state=unchecked]:hidden"
</DropdownMenuItem> 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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); )
} }

View File

@ -1,16 +1,16 @@
"use client"; "use client"
import { Loader } from "@/components/loading/Loader"; import { Loader } from "@/components/loading/Loader"
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl"
export default function GlobalLoading() { export default function GlobalLoading() {
const t = useTranslations("Global"); const t = useTranslations("Global")
return ( return (
<section className="flex flex-col gap-4 mt-[3.2px]"> <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"> <div className="flex min-h-40 flex-col items-center justify-center font-medium text-sm">
{t("Loading")} {t("Loading")}
<Loader visible={true} /> <Loader visible={true} />
</div> </div>
</section> </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 }) => { export const Loader = ({ visible }: { visible: boolean }) => {
return ( return (
<div className="hamster-loading-wrapper" data-visible={visible}> <div className="hamster-loading-wrapper" data-visible={visible}>
<div className="hamster-spinner"> <div className="hamster-spinner">
{bars.map((_, i) => ( {bars.map((_, i) => (
<div className="hamster-loading-bar" key={`hamster-bar-${i}`} /> <div className="hamster-loading-bar" key={`hamster-bar-${i + 1}`} />
))} ))}
</div> </div>
</div> </div>
); )
}; }

View File

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

View File

@ -0,0 +1,37 @@
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 +0,0 @@
export { domMax as default } from "framer-motion";

View File

@ -1,14 +0,0 @@
"use client";
import { LazyMotion } from "framer-motion";
const loadFeatures = () =>
import("./framer-lazy-feature").then((res) => res.default);
export const MotionProvider = ({ children }: { children: React.ReactNode }) => {
return (
<LazyMotion features={loadFeatures} strict key="framer">
{children}
</LazyMotion>
);
};

View File

@ -1,11 +1,11 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
interface Props { interface Props {
max: number; max: number
value: number; value: number
min: number; min: number
className?: string; className?: string
primaryColor?: string; primaryColor?: string
} }
export default function AnimatedCircularProgressBar({ export default function AnimatedCircularProgressBar({
@ -15,13 +15,13 @@ export default function AnimatedCircularProgressBar({
primaryColor, primaryColor,
className, className,
}: Props) { }: Props) {
const circumference = 2 * Math.PI * 45; const circumference = 2 * Math.PI * 45
const percentPx = circumference / 100; const percentPx = circumference / 100
const currentPercent = ((value - min) / (max - min)) * 100; const currentPercent = ((value - min) / (max - min)) * 100
return ( return (
<div <div
className={cn("relative size-40 text-2xl font-semibold", className)} className={cn("relative size-40 font-semibold text-2xl", className)}
style={ style={
{ {
"--circle-size": "100px", "--circle-size": "100px",
@ -37,12 +37,8 @@ export default function AnimatedCircularProgressBar({
} as React.CSSProperties } as React.CSSProperties
} }
> >
<svg <svg fill="none" className="size-full" strokeWidth="2" viewBox="0 0 100 100">
fill="none" <title>Circular Progress Bar</title>
className="size-full"
strokeWidth="2"
viewBox="0 0 100 100"
>
{currentPercent <= 90 && currentPercent >= 0 && ( {currentPercent <= 90 && currentPercent >= 0 && (
<circle <circle
cx="50" cx="50"
@ -52,7 +48,7 @@ export default function AnimatedCircularProgressBar({
strokeDashoffset="0" strokeDashoffset="0"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="opacity-100 stroke-muted" className="stroke-muted opacity-100"
style={ style={
{ {
"--stroke-percent": 90 - currentPercent, "--stroke-percent": 90 - currentPercent,
@ -62,8 +58,7 @@ export default function AnimatedCircularProgressBar({
transform: transform:
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)", "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)", transition: "all var(--transition-length) ease var(--delay)",
transformOrigin: transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties } as React.CSSProperties
} }
/> />
@ -76,7 +71,7 @@ export default function AnimatedCircularProgressBar({
strokeDashoffset="0" strokeDashoffset="0"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className={cn("opacity-100 stroke-current", { className={cn("stroke-current opacity-100", {
"stroke-[var(--stroke-primary-color)]": primaryColor, "stroke-[var(--stroke-primary-color)]": primaryColor,
})} })}
style={ style={
@ -90,18 +85,17 @@ export default function AnimatedCircularProgressBar({
transitionProperty: "stroke-dasharray,transform", transitionProperty: "stroke-dasharray,transform",
transform: transform:
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))", "rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
transformOrigin: transformOrigin: "calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties } as React.CSSProperties
} }
/> />
</svg> </svg>
<span <span
data-current-value={currentPercent} data-current-value={currentPercent}
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in" className="fade-in absolute inset-0 m-auto size-fit animate-in delay-[var(--delay)] duration-[var(--transition-length)] ease-linear"
> >
{currentPercent} {currentPercent}
</span> </span>
</div> </div>
); )
} }

View File

@ -1,21 +1,20 @@
import Image from "next/image"; import Image from "next/image"
import Link from "next/link"; import Link from "next/link"
import React from "react";
export const AnimatedTooltip = ({ export const AnimatedTooltip = ({
items, items,
}: { }: {
items: { items: {
id: number; id: number
name: string; name: string
designation: string; designation: string
image: string; image: string
}[]; }[]
}) => { }) => {
return ( return (
<> <>
{items.map((item) => ( {items.map((item) => (
<div className="group relative -mr-4" key={item.name}> <div className="group -mr-4 relative" key={item.name}>
<Link href="https://buycoffee.top" target="_blank"> <Link href="https://buycoffee.top" target="_blank">
<Image <Image
width={40} width={40}
@ -29,5 +28,5 @@ export const AnimatedTooltip = ({
</div> </div>
))} ))}
</> </>
); )
}; }

View File

@ -1,14 +1,13 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import { type VariantProps, cva } from "class-variance-authority"; import { type VariantProps, cva } from "class-variance-authority"
import * as React from "react"; import type * as React from "react"
const badgeVariants = cva( 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 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",
{ {
variants: { variants: {
variant: { variant: {
default: default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
@ -20,16 +19,14 @@ const badgeVariants = cva(
variant: "default", variant: "default",
}, },
}, },
); )
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {} VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return <div className={cn(badgeVariants({ variant }), className)} {...props} />
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
} }
export { Badge, badgeVariants }; export { Badge, badgeVariants }

View File

@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot"
import { type VariantProps, cva } from "class-variance-authority"; import { type VariantProps, cva } from "class-variance-authority"
import * as React from "react"; import * as React from "react"
const buttonVariants = cva( 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-hidden disabled:pointer-events-none disabled:opacity-50",
@ -9,12 +9,9 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
outline: secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"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", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
@ -30,26 +27,22 @@ const buttonVariants = cva(
size: "default", size: "default",
}, },
}, },
); )
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
className={cn(buttonVariants({ variant, size, className }))} )
ref={ref}
{...props}
/>
);
}, },
); )
Button.displayName = "Button"; Button.displayName = "Button"
export { Button, buttonVariants }; export { Button, buttonVariants }

View File

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

View File

@ -1,49 +1,47 @@
"use client"; "use client"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import * as React from "react"; import * as React from "react"
import * as RechartsPrimitive from "recharts"; import * as RechartsPrimitive from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const; const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = { export type ChartConfig = {
[k: string]: { [k: string]: {
label?: React.ReactNode; label?: React.ReactNode
icon?: React.ComponentType; icon?: React.ComponentType
} & ( } & (
| { color?: string; theme?: never } | { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> } | { color?: never; theme: Record<keyof typeof THEMES, string> }
); )
}; }
type ChartContextProps = { type ChartContextProps = {
config: ChartConfig; config: ChartConfig
}; }
const ChartContext = React.createContext<ChartContextProps | null>(null); const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() { function useChart() {
const context = React.useContext(ChartContext); const context = React.useContext(ChartContext)
if (!context) { if (!context) {
throw new Error("useChart must be used within a <ChartContainer />"); throw new Error("useChart must be used within a <ChartContainer />")
} }
return context; return context
} }
const ChartContainer = React.forwardRef< const ChartContainer = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
config: ChartConfig; config: ChartConfig
children: React.ComponentProps< children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"]
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
} }
>(({ id, className, children, config, ...props }, ref) => { >(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId(); const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return ( return (
<ChartContext.Provider value={{ config }}> <ChartContext.Provider value={{ config }}>
@ -57,22 +55,18 @@ const ChartContainer = React.forwardRef<
{...props} {...props}
> >
<ChartStyle id={chartId} config={config} /> <ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div> </div>
</ChartContext.Provider> </ChartContext.Provider>
); )
}); })
ChartContainer.displayName = "Chart"; ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) { if (!colorConfig.length) {
return null; return null
} }
return ( return (
@ -84,10 +78,8 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
${prefix} [data-chart=${id}] { ${prefix} [data-chart=${id}] {
${colorConfig ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || return color ? ` --color-${key}: ${color};` : null
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
}) })
.join("\n")} .join("\n")}
} }
@ -96,20 +88,20 @@ ${colorConfig
.join("\n"), .join("\n"),
}} }}
/> />
); )
}; }
const ChartTooltip = RechartsPrimitive.Tooltip; const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef< const ChartTooltipContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean; hideLabel?: boolean
hideIndicator?: boolean; hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"; indicator?: "line" | "dot" | "dashed"
nameKey?: string; nameKey?: string
labelKey?: string; labelKey?: string
} }
>( >(
( (
@ -130,68 +122,65 @@ const ChartTooltipContent = React.forwardRef<
}, },
ref, ref,
) => { ) => {
const { config } = useChart(); const { config } = useChart()
const tooltipLabel = React.useMemo(() => { const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) { if (hideLabel || !payload?.length) {
return null; return null
} }
const [item] = payload; const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`; const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key); const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value = const value =
!labelKey && typeof label === "string" !labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label ? config[label as keyof typeof config]?.label || label
: itemConfig?.label; : itemConfig?.label
if (labelFormatter) { if (labelFormatter) {
return ( return (
<div className={cn("font-medium", labelClassName)}> <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
{labelFormatter(value, payload)} )
</div>
);
} }
if (!value) { if (!value) {
return null; return null
} }
return <div className={cn("font-medium", labelClassName)}>{value}</div>; return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [ }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) { if (!active || !payload?.length) {
return null; return null
} }
payload.sort((a, b) => { payload.sort((a, b) => {
return Number(b.value) - Number(a.value); return Number(b.value) - Number(a.value)
}); })
const nestLabel = payload.length === 1 && indicator !== "dot"; const nestLabel = payload.length === 1 && indicator !== "dot"
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-sm border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", "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, className,
)} )}
> >
{!nestLabel ? tooltipLabel : null} {!nestLabel && (
<div className="grid gap-1.5"> <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) => { {payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`; const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key); const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color; const indicatorColor = color || item.payload.fill || item.color
return ( return (
<div <div
@ -244,125 +233,108 @@ const ChartTooltipContent = React.forwardRef<
{item.value && ( {item.value && (
<span <span
className={cn( className={cn(
"ml-2 font-mono font-medium tabular-nums text-foreground", "ml-2 font-medium text-foreground tabular-nums",
payload.length === 1 && "-ml-9", payload.length === 1 && "-ml-9",
)} )}
> >
{typeof item.value === "number" {typeof item.value === "number"
? item.value.toFixed(3).toLocaleString() ? item.value.toFixed(2).toLocaleString()
: item.value} : item.value}{" "}
ms
</span> </span>
)} )}
</div> </div>
</> </>
)} )}
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
); )
}, },
); )
ChartTooltipContent.displayName = "ChartTooltip"; ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend; const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef< const ChartLegendContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean; hideIcon?: boolean
nameKey?: string; nameKey?: string
} }
>( >(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
( const { config } = useChart()
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) { if (!payload?.length) {
return null; return null
} }
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"flex flex-wrap items-center justify-center gap-4", "flex flex-wrap items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3", verticalAlign === "top" ? "pb-3" : "pt-3",
className, className,
)} )}
> >
{payload.map((item) => { {payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`; const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key); const itemConfig = getPayloadConfigFromPayload(config, item, key)
return ( return (
<div <div
key={item.value} key={item.value}
className={cn( className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)} )}
> >
{itemConfig?.icon && !hideIcon ? ( {itemConfig?.icon && !hideIcon ? (
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
<div <div
className="h-2 w-2 shrink-0 rounded-[2px]" className="h-2 w-2 shrink-0 rounded-[2px]"
style={{ style={{
backgroundColor: item.color, backgroundColor: item.color,
}} }}
/> />
)} )}
{key} {key}
</div> </div>
); )
})} })}
</div> </div>
); )
}, })
); ChartLegendContent.displayName = "ChartLegend"
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload. // Helper to extract item config from a payload.
function getPayloadConfigFromPayload( function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) { if (typeof payload !== "object" || payload === null) {
return undefined; return undefined
} }
const payloadPayload = const payloadPayload =
"payload" in payload && "payload" in payload && typeof payload.payload === "object" && payload.payload !== null
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload ? payload.payload
: undefined; : undefined
let configLabelKey: string = key; let configLabelKey: string = key
if ( if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
key in payload && configLabelKey = payload[key as keyof typeof payload] as string
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if ( } else if (
payloadPayload && payloadPayload &&
key in payloadPayload && key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string" typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) { ) {
configLabelKey = payloadPayload[ configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string
key as keyof typeof payloadPayload
] as string;
} }
return configLabelKey in config return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
? config[configLabelKey]
: config[key as keyof typeof config];
} }
export { export {
@ -372,4 +344,4 @@ export {
ChartLegend, ChartLegend,
ChartLegendContent, ChartLegendContent,
ChartStyle, ChartStyle,
}; }

144
components/ui/command.tsx Normal file
View File

@ -0,0 +1,144 @@
"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,
}

104
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,104 @@
"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,26 +1,26 @@
"use client"; "use client"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"; import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from "react"; import * as React from "react"
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group; const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal; const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean; inset?: boolean
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@ -35,9 +35,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)); ))
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -46,14 +45,13 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"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", "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",
className, className,
)} )}
{...props} {...props}
/> />
)); ))
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -64,32 +62,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 overflow-hidden rounded-md border bg-popover p-1.5 text-popover-foreground shadow-2xl dark:shadow-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", "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",
className, className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)); ))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-[10px] px-2 py-1.5 text-xs font-medium 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-[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",
inset && "pl-8", inset && "pl-8",
className, className,
)} )}
{...props} {...props}
/> />
)); ))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -98,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 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 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",
className, className,
)} )}
checked={checked} checked={checked}
@ -111,9 +109,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)); ))
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -122,7 +119,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 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 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",
className, className,
)} )}
{...props} {...props}
@ -134,26 +131,22 @@ const DropdownMenuRadioItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)); ))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean; inset?: boolean
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn("px-2 py-1.5 font-semibold text-sm", inset && "pl-8", className)}
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props} {...props}
/> />
)); ))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -164,21 +157,13 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)); ))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
className, return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
...props }
}: React.HTMLAttributes<HTMLSpanElement>) => { DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export { export {
DropdownMenu, DropdownMenu,
@ -196,4 +181,4 @@ export {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
}; }

View File

@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import * as React from "react"; import * as React from "react"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>; export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
@ -9,15 +9,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( 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:text-sm file:font-medium 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: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",
className, className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
); )
}, },
); )
Input.displayName = "Input"; Input.displayName = "Input"
export { Input }; export { Input }

View File

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

View File

@ -1,8 +1,8 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"; import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react"
import * as React from "react"; import * as React from "react"
const NavigationMenu = React.forwardRef< const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>, React.ElementRef<typeof NavigationMenuPrimitive.Root>,
@ -10,17 +10,14 @@ const NavigationMenu = React.forwardRef<
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root <NavigationMenuPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props} {...props}
> >
{children} {children}
<NavigationMenuViewport /> <NavigationMenuViewport />
</NavigationMenuPrimitive.Root> </NavigationMenuPrimitive.Root>
)); ))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef< const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>, React.ElementRef<typeof NavigationMenuPrimitive.List>,
@ -28,20 +25,17 @@ const NavigationMenuList = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List <NavigationMenuPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn("group flex flex-1 list-none items-center justify-center space-x-1", className)}
"group flex flex-1 list-none items-center justify-center space-x-1",
className,
)}
{...props} {...props}
/> />
)); ))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item; const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva( 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-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50",
); )
const NavigationMenuTrigger = React.forwardRef< const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
@ -58,8 +52,8 @@ const NavigationMenuTrigger = React.forwardRef<
aria-hidden="true" aria-hidden="true"
/> />
</NavigationMenuPrimitive.Trigger> </NavigationMenuPrimitive.Trigger>
)); ))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef< const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>, React.ElementRef<typeof NavigationMenuPrimitive.Content>,
@ -68,33 +62,32 @@ const NavigationMenuContent = React.forwardRef<
<NavigationMenuPrimitive.Content <NavigationMenuPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"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", "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",
className, className,
)} )}
{...props} {...props}
/> />
)); ))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link; const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef< const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}> <div className={cn("absolute top-full left-0 flex justify-center")}>
<NavigationMenuPrimitive.Viewport <NavigationMenuPrimitive.Viewport
className={cn( className={cn(
"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)]", "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)]",
className, className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
</div> </div>
)); ))
NavigationMenuViewport.displayName = NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef< const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
@ -103,16 +96,15 @@ const NavigationMenuIndicator = React.forwardRef<
<NavigationMenuPrimitive.Indicator <NavigationMenuPrimitive.Indicator
ref={ref} ref={ref}
className={cn( className={cn(
"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", "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",
className, className,
)} )}
{...props} {...props}
> >
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator> </NavigationMenuPrimitive.Indicator>
)); ))
NavigationMenuIndicator.displayName = NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName
NavigationMenuPrimitive.Indicator.displayName;
export { export {
NavigationMenu, NavigationMenu,
@ -124,4 +116,4 @@ export {
NavigationMenuTrigger, NavigationMenuTrigger,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
NavigationMenuViewport, NavigationMenuViewport,
}; }

View File

@ -1,12 +1,12 @@
"use client"; "use client"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import * as PopoverPrimitive from "@radix-ui/react-popover"; import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react"; import * as React from "react"
const Popover = PopoverPrimitive.Root; const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
@ -18,13 +18,13 @@ const PopoverContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden 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", "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",
className, className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
)); ))
PopoverContent.displayName = PopoverPrimitive.Content.displayName; PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }; export { Popover, PopoverTrigger, PopoverContent }

View File

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

View File

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

View File

@ -1,18 +1,18 @@
"use client"; "use client"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import * as SheetPrimitive from "@radix-ui/react-dialog"; import * as SheetPrimitive from "@radix-ui/react-dialog"
import { type VariantProps, cva } from "class-variance-authority"; import { type VariantProps, cva } from "class-variance-authority"
import { X } from "lucide-react"; import { X } from "lucide-react"
import * as React from "react"; import * as React from "react"
const Sheet = SheetPrimitive.Root; const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger; const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close; const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal; const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef< const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>, React.ElementRef<typeof SheetPrimitive.Overlay>,
@ -20,14 +20,14 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
"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", "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, className,
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)); ))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( 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", "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 +46,7 @@ const sheetVariants = cva(
side: "right", side: "right",
}, },
}, },
); )
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
@ -58,48 +58,29 @@ const SheetContent = React.forwardRef<
>(({ side = "right", className, children, ...props }, ref) => ( >(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children} {children}
<SheetPrimitive.Close className="absolute right-4 top-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 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">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
)); ))
SheetContent.displayName = SheetPrimitive.Content.displayName; SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
className, <div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
...props )
}: React.HTMLAttributes<HTMLDivElement>) => ( SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props} {...props}
/> />
); )
SheetHeader.displayName = "SheetHeader"; SheetFooter.displayName = "SheetFooter"
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< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
@ -107,11 +88,11 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Title <SheetPrimitive.Title
ref={ref} ref={ref}
className={cn("text-lg font-semibold text-foreground", className)} className={cn("font-semibold text-foreground text-lg", className)}
{...props} {...props}
/> />
)); ))
SheetTitle.displayName = SheetPrimitive.Title.displayName; SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef< const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>, React.ElementRef<typeof SheetPrimitive.Description>,
@ -119,11 +100,11 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Description <SheetPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
)); ))
SheetDescription.displayName = SheetPrimitive.Description.displayName; SheetDescription.displayName = SheetPrimitive.Description.displayName
export { export {
Sheet, Sheet,
@ -136,4 +117,4 @@ export {
SheetPortal, SheetPortal,
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
}; }

View File

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

View File

@ -1,8 +1,8 @@
"use client"; "use client"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import * as SwitchPrimitives from "@radix-ui/react-switch"; import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from "react"; import * as React from "react"
const Switch = React.forwardRef< const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>, React.ElementRef<typeof SwitchPrimitives.Root>,
@ -22,7 +22,7 @@ const Switch = React.forwardRef<
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>
)); ))
Switch.displayName = SwitchPrimitives.Root.displayName; Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }; export { Switch }

View File

@ -1,14 +1,14 @@
"use client"; "use client"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"; import * as React from "react"
const TooltipProvider = TooltipPrimitive.Provider; const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root; const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger; const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef< const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>, React.ElementRef<typeof TooltipPrimitive.Content>,
@ -18,12 +18,12 @@ const TooltipContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 overflow-hidden rounded-[10px] 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", "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",
className, className,
)} )}
{...props} {...props}
/> />
)); ))
TooltipContent.displayName = TooltipPrimitive.Content.displayName; TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

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