Compare commits
336 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02eee5ac36 | ||
|
|
ae06f1a98d | ||
|
|
02ef7a627e | ||
|
|
33f7b3f653 | ||
|
|
ffa1f03b52 | ||
|
|
baf87f1cd8 | ||
|
|
405174220f | ||
|
|
0cb3946b39 | ||
|
|
3d24f9bd71 | ||
|
|
a4b4bc7e4e | ||
|
|
46a791c849 | ||
|
|
ace732a558 | ||
|
|
2ddafd6aec | ||
|
|
e935adf4b6 | ||
|
|
802db8724a | ||
|
|
7ee88c8026 | ||
|
|
d56ddaf105 | ||
|
|
1be15598ef | ||
|
|
bd70198b04 | ||
|
|
3da9930247 | ||
|
|
67c814f194 | ||
|
|
d75ec5fda2 | ||
|
|
b9a80d4df4 | ||
|
|
7e5c0d9de6 | ||
|
|
349a91cd0c | ||
|
|
9e3249eda4 | ||
|
|
4524f5ab95 | ||
|
|
4e6c6b77b6 | ||
|
|
be672722bf | ||
|
|
440d310076 | ||
|
|
c4a43a3a8d | ||
|
|
9d2d730987 | ||
|
|
814610cd59 | ||
|
|
70857c7fec | ||
|
|
940b02313c | ||
|
|
9c3e9ddc0f | ||
|
|
76f792cc7d | ||
|
|
bfb1a14045 | ||
|
|
044356ad29 | ||
|
|
a43135f088 | ||
|
|
2d44423e14 | ||
|
|
eac50a40f8 | ||
|
|
55a46c184b | ||
|
|
c4e62e9322 | ||
|
|
25ac692268 | ||
|
|
8f4847e13c | ||
|
|
d47689adcf | ||
|
|
bc20bb2fec | ||
|
|
14a2f4a940 | ||
|
|
f96c3d4f7e | ||
|
|
5335834809 | ||
|
|
ecca6abdf7 | ||
|
|
97097b143a | ||
|
|
ad0c4b2b6e | ||
|
|
1fd837d4cb | ||
|
|
f12c9e326b | ||
|
|
2d2a30cd8b | ||
|
|
17df046ca1 | ||
|
|
d23cabeebb | ||
|
|
cfcbc3fe26 | ||
|
|
3f5d2a8364 | ||
|
|
e52b5b82fb | ||
|
|
5c8d79f78f | ||
|
|
0d91760ef6 | ||
|
|
3cc2df46f8 | ||
|
|
28367890a1 | ||
|
|
4afa235a17 | ||
|
|
75d1ab0c83 | ||
|
|
509ad15652 | ||
|
|
356d5e2b4a | ||
|
|
9a3163883d | ||
|
|
f23c6e5159 | ||
|
|
c9efd7f0ef | ||
|
|
457e161281 | ||
|
|
d25de8d0f7 | ||
|
|
877e826d23 | ||
|
|
0a128254d3 | ||
|
|
8aa0609c95 | ||
|
|
968f764af7 | ||
|
|
e2748c00a0 | ||
|
|
83792c98e9 | ||
|
|
6f0b5eb6ba | ||
|
|
64ad667002 | ||
|
|
596b7622bf | ||
|
|
6bb5adadab | ||
|
|
f1f9fc1d63 | ||
|
|
1973132c52 | ||
|
|
ee54fb101e | ||
|
|
5600b85e6d | ||
|
|
34f73ca0e5 | ||
|
|
4c42874b1a | ||
|
|
9eb296cf51 | ||
|
|
b705b90763 | ||
|
|
bb37643e6d | ||
|
|
ade3aa2093 | ||
|
|
0b72ced8d6 | ||
|
|
5520a004e1 | ||
|
|
01e3b7ddd3 | ||
|
|
2310fcfc3e | ||
|
|
8c502bd6ac | ||
|
|
4df8e985c1 | ||
|
|
0e28b20dbe | ||
|
|
1556248883 | ||
|
|
4433ad57f4 | ||
|
|
a167ff8dd9 | ||
|
|
22defdfb62 | ||
|
|
597fa4c2b6 | ||
|
|
67ba274377 | ||
|
|
2aabc9505c | ||
|
|
3bf702e1d5 | ||
|
|
785bad134e | ||
|
|
e7401640a6 | ||
|
|
f9c517150d | ||
|
|
6a79424ef1 | ||
|
|
b9b64ba69d | ||
|
|
d5dd129f7b | ||
|
|
37df8141cf | ||
|
|
1277a3132b | ||
|
|
feb0e31eb2 | ||
|
|
709a56609e | ||
|
|
6e6bc22177 | ||
|
|
639cae5821 | ||
|
|
436a30e9d0 | ||
|
|
92bbb4468a | ||
|
|
d5acf4f34d | ||
|
|
4382b0b045 | ||
|
|
5e931e50a5 | ||
|
|
17b7577a9a | ||
|
|
c65d49635b | ||
|
|
a0d7c1071a | ||
|
|
dc06fa446e | ||
|
|
bb957c9ee6 | ||
|
|
f21bb49b49 | ||
|
|
f90ccca8ba | ||
|
|
33a98bf83e | ||
|
|
5890b78b5b | ||
|
|
0faca9eda3 | ||
|
|
5f9ccd8ef3 | ||
|
|
a442d46755 | ||
|
|
9dd7b315d9 | ||
|
|
ac7d5a7552 | ||
|
|
9cbe4f47f6 | ||
|
|
4d8c12a324 | ||
|
|
d19a2c161a | ||
|
|
14968a7b87 | ||
|
|
2e0df57687 | ||
|
|
1acf785219 | ||
|
|
6406579f03 | ||
|
|
bbe3dc4956 | ||
|
|
d0ba3a1686 | ||
|
|
c102955cd5 | ||
|
|
51e3403b1d | ||
|
|
a4a7d500a2 | ||
|
|
75dc7f478c | ||
|
|
9ea94d53bd | ||
|
|
9184a1740e | ||
|
|
01b9bbfd16 | ||
|
|
11e57a2374 | ||
|
|
192b6c0077 | ||
|
|
16ed4932a0 | ||
|
|
8aa4602390 | ||
|
|
c1743c2840 | ||
|
|
dcb8479017 | ||
|
|
cdee24b5c9 | ||
|
|
0721a06c49 | ||
|
|
09a9ebbe22 | ||
|
|
acc3b33d77 | ||
|
|
cd767f1aa0 | ||
|
|
fc9deeb715 | ||
|
|
4b770ad07c | ||
|
|
8e13fec132 | ||
|
|
a9156f505c | ||
|
|
b342c8c373 | ||
|
|
367da79ed9 | ||
|
|
ae97fe895e | ||
|
|
fd3567d48e | ||
|
|
76558d7703 | ||
|
|
13171986af | ||
|
|
8e57a4cbb9 | ||
|
|
0d177f7c33 | ||
|
|
a2dc432c97 | ||
|
|
4573373544 | ||
|
|
858b02fa0c | ||
|
|
2f8baab19a | ||
|
|
75fe4ab9eb | ||
|
|
f4a3386c7d | ||
|
|
7c77234026 | ||
|
|
ad11f8e155 | ||
|
|
a7a7d2a878 | ||
|
|
d038169846 | ||
|
|
2f40312ff1 | ||
|
|
6c784d255d | ||
|
|
8f44b8ae11 | ||
|
|
6ede43b9fb | ||
|
|
eeb45f67b2 | ||
|
|
45542289f5 | ||
|
|
0d4d0fdb0e | ||
|
|
cbdccaa60e | ||
|
|
87009bfd05 | ||
|
|
94a79dd26b | ||
|
|
5ef68eec63 | ||
|
|
e01886e505 | ||
|
|
67672624e3 | ||
|
|
d937858ce3 | ||
|
|
468a6f5645 | ||
|
|
1640f263b1 | ||
|
|
cc4515cc6d | ||
|
|
6a79ab40b1 | ||
|
|
a11373a90b | ||
|
|
26020cf572 | ||
|
|
b7221d31c1 | ||
|
|
5ffa4bda8f | ||
|
|
7dcf93f759 | ||
|
|
e63e9b9922 | ||
|
|
8d9bccfae8 | ||
|
|
e55e6378e1 | ||
|
|
bbf15a0551 | ||
|
|
6c0be291e0 | ||
|
|
351dd9b559 | ||
|
|
8e4530236c | ||
|
|
c51a0443a8 | ||
|
|
657cd32aaa | ||
|
|
7d629e9cd4 | ||
|
|
ca35d96925 | ||
|
|
dc60a00103 | ||
|
|
d1d1044bbc | ||
|
|
f68dda63af | ||
|
|
ab8e0619c4 | ||
|
|
99ad3f6f8e | ||
|
|
217166d3aa | ||
|
|
cdee1a227a | ||
|
|
e6582f26c8 | ||
|
|
80a873d56a | ||
|
|
9dc3d3d697 | ||
|
|
0d1ea92965 | ||
|
|
bba4c6bae7 | ||
|
|
61ff34464f | ||
|
|
f4c9bad648 | ||
|
|
80796cdcca | ||
|
|
970dc5bbe8 | ||
|
|
6cade53ec5 | ||
|
|
34981f821d | ||
|
|
22f1686ff7 | ||
|
|
5d34f3707a | ||
|
|
d8ec5683d1 | ||
|
|
06a70b6680 | ||
|
|
938fcd4422 | ||
|
|
d86467674e | ||
|
|
a21ce6e8d6 | ||
|
|
0a4507bbd0 | ||
|
|
2f27c330a1 | ||
|
|
cff4579ff1 | ||
|
|
f2de129ba8 | ||
|
|
2a9a242f50 | ||
|
|
4e74053017 | ||
|
|
bb1fcd8c37 | ||
|
|
fb694cc749 | ||
|
|
778b18dd35 | ||
|
|
c80de8800f | ||
|
|
54c0ba67c8 | ||
|
|
2f19553dec | ||
|
|
d7c213c110 | ||
|
|
b832de9194 | ||
|
|
a781e84537 | ||
|
|
17881b5b0e | ||
|
|
40ece4e764 | ||
|
|
a983da20e5 | ||
|
|
43555a9985 | ||
|
|
adb0ce31c0 | ||
|
|
c619931386 | ||
|
|
026dfb2ffe | ||
|
|
5396a4e628 | ||
|
|
3e801fe85a | ||
|
|
ec3372a3c0 | ||
|
|
157c07c747 | ||
|
|
6505d1876a | ||
|
|
2a3ff9db9b | ||
|
|
434e326991 | ||
|
|
16b66f83da | ||
|
|
cc4c6c5e96 | ||
|
|
3301570213 | ||
|
|
c65176e607 | ||
|
|
55aa93d117 | ||
|
|
7c61b7fc44 | ||
|
|
bc7f86119c | ||
|
|
bc1490f0fd | ||
|
|
7bf2aa8b3c | ||
|
|
ba086b602f | ||
|
|
0d793ee31c | ||
|
|
7579be007f | ||
|
|
515e7d2719 | ||
|
|
c9cfdfc07a | ||
|
|
c589afd859 | ||
|
|
f10f8af0f6 | ||
|
|
cbeba449ef | ||
|
|
fa02ca000b | ||
|
|
af01d60d9b | ||
|
|
06f06a8a52 | ||
|
|
fc54d9e176 | ||
|
|
8fab48f849 | ||
|
|
69878126f6 | ||
|
|
0185520445 | ||
|
|
00cb224e84 | ||
|
|
c5a932b9f1 | ||
|
|
becc328811 | ||
|
|
c2ae4c78f7 | ||
|
|
40f2483332 | ||
|
|
a27eb84d61 | ||
|
|
01b80eaf9e | ||
|
|
1e713ea613 | ||
|
|
1eb7c71ff9 | ||
|
|
b9bf2733f9 | ||
|
|
1e48c209f7 | ||
|
|
fff455312e | ||
|
|
a5626ebefe | ||
|
|
400b1aac8d | ||
|
|
872baa7933 | ||
|
|
04b47b62ad | ||
|
|
8894d2daae | ||
|
|
3145ef884d | ||
|
|
7b1c21e972 | ||
|
|
5421285a29 | ||
|
|
59727b6be9 | ||
|
|
142cdf8b41 | ||
|
|
24ae1bc45e | ||
|
|
a1cd74e90f | ||
|
|
6e8b1ccefd | ||
|
|
4d9c20d90d | ||
|
|
91d3f06f38 | ||
|
|
a5f5312476 | ||
|
|
36254e048e | ||
|
|
72f76b8bca | ||
|
|
11d8f0d1d5 | ||
|
|
2369885fda | ||
|
|
8a05fa0907 | ||
|
|
28c0cf7da7 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
# .dockerignore
|
||||
# node_modules
|
||||
# npm-debug.log
|
||||
# README.md
|
||||
# .env*
|
||||
# .next
|
||||
# .git
|
||||
44
.env.example
44
.env.example
@@ -2,11 +2,18 @@
|
||||
# App - Don't add "/" in the end of the url (same in production)
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_APP_NAME=WR.DO
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Authentication (NextAuth.js)
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
AUTH_SECRET=
|
||||
DATABASE_URL='postgres://[user]:[password]@[hostname]:5432/[dbname]'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Authentication (NextAuth.js 5.0.x)
|
||||
# -----------------------------------------------------------------------------
|
||||
AUTH_SECRET=abc123
|
||||
AUTH_URL=http://localhost:3000
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
@@ -16,28 +23,21 @@ LinuxDo_CLIENT_ID=
|
||||
LinuxDo_CLIENT_SECRET=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database, example: DATABASE_URL='postgres://[user]:[password]@[hostname]:5432/[dbname]'
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=require'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Email api (https://resend.com) for login and send email
|
||||
# Email api for send email (https://resend.com, https://www.brevo.com)
|
||||
# -----------------------------------------------------------------------------
|
||||
RESEND_API_KEY=
|
||||
BREVO_API_KEY=
|
||||
EMAIL_FROM=service@wr.do
|
||||
EMAIL_FROM_NAME=WRDO
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Cloudflare
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=wr.do,uv.do
|
||||
CLOUDFLARE_ZONE=[{"zone_id":"abc123", "zone_name": "wr.do"},{"zone_id":"abc456", "zone_name": "uv.do"}]
|
||||
CLOUDFLARE_API_KEY=
|
||||
CLOUDFLARE_EMAIL=
|
||||
|
||||
# Open Signup
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
# Cloudflare R2 Storage custom domain
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN=
|
||||
|
||||
# Google Analytics
|
||||
NEXT_PUBLIC_GOOGLE_ID=
|
||||
# Umami Script
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT=
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
|
||||
# ScreenShot API
|
||||
SCREENSHOTONE_BASE_URL=https://shot.wr.do
|
||||
@@ -45,7 +45,9 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
|
||||
# GitHub api token for getting gitHub stars count
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# Short domains, split by ","
|
||||
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
|
||||
|
||||
# Skip DB check and migration. if false, will check and migrate database each time start docker compose.
|
||||
SKIP_DB_CHECK=false
|
||||
SKIP_DB_MIGRATION=false
|
||||
|
||||
# Support email
|
||||
NEXT_PUBLIC_SUPPORT_EMAIL=your_support_email@gmail.com
|
||||
@@ -15,7 +15,9 @@
|
||||
"tailwindcss/enforces-shorthand": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-img-element": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"tailwindcss/migration-from-tailwind-2": "off"
|
||||
},
|
||||
"settings": {
|
||||
"tailwindcss": {
|
||||
|
||||
81
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
81
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: 报告软件缺陷或问题
|
||||
title: '[BUG] '
|
||||
labels: 'bug, needs-triage'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 问题描述 / Bug Description
|
||||
<!-- 请简洁明了地描述遇到的问题 -->
|
||||
|
||||
## 复现步骤 / Steps to Reproduce
|
||||
<!-- 请详细描述如何重现这个问题 -->
|
||||
1. 前往 '...'
|
||||
2. 点击 '...'
|
||||
3. 滚动到 '...'
|
||||
4. 看到错误 '...'
|
||||
|
||||
## 期望行为 / Expected Behavior
|
||||
<!-- 描述你期望发生什么 -->
|
||||
|
||||
## 实际行为 / Actual Behavior
|
||||
<!-- 描述实际发生了什么 -->
|
||||
|
||||
## 截图/视频 / Screenshots/Videos
|
||||
<!-- 如果适用,请添加截图或视频来帮助解释问题 -->
|
||||
|
||||
## 环境信息 / Environment
|
||||
**桌面环境 / Desktop:**
|
||||
- 操作系统 / OS: [例如 Windows 11, macOS 14.0, Ubuntu 22.04]
|
||||
- 浏览器 / Browser: [例如 Chrome 120, Firefox 119, Safari 17]
|
||||
- 版本 / Version: [例如 v1.2.3]
|
||||
|
||||
**移动设备 / Mobile:**
|
||||
- 设备 / Device: [例如 iPhone 15, Samsung Galaxy S24]
|
||||
- 操作系统 / OS: [例如 iOS 17.1, Android 14]
|
||||
- 浏览器 / Browser: [例如 Safari, Chrome Mobile]
|
||||
- 版本 / Version: [例如 v1.2.3]
|
||||
|
||||
## 控制台日志 / Console Logs
|
||||
<!-- 如果有相关的错误日志,请贴在这里 -->
|
||||
```
|
||||
请粘贴相关的错误信息或日志
|
||||
```
|
||||
|
||||
## 网络信息 / Network Info
|
||||
<!-- 如果问题可能与网络相关 -->
|
||||
- 网络类型 / Network Type: [例如 WiFi, 4G, 5G, 有线]
|
||||
- 网络状况 / Network Status: [例如 正常, 缓慢, 不稳定]
|
||||
|
||||
## 频率 / Frequency
|
||||
请选择问题发生的频率:
|
||||
- [ ] 总是发生 / Always
|
||||
- [ ] 经常发生 / Often
|
||||
- [ ] 偶尔发生 / Sometimes
|
||||
- [ ] 很少发生 / Rarely
|
||||
- [ ] 只发生过一次 / Once
|
||||
|
||||
## 严重程度 / Severity
|
||||
请选择问题的严重程度:
|
||||
- [ ] 严重 / Critical - 系统崩溃或数据丢失
|
||||
- [ ] 高 / High - 核心功能无法使用
|
||||
- [ ] 中 / Medium - 部分功能受影响
|
||||
- [ ] 低 / Low - 界面问题或小的功能缺陷
|
||||
|
||||
## 变通方法 / Workaround
|
||||
<!-- 如果你找到了临时解决方案,请描述 -->
|
||||
|
||||
## 附加信息 / Additional Context
|
||||
<!-- 添加任何其他有助于理解问题的信息 -->
|
||||
|
||||
## 相关链接 / Related Links
|
||||
<!-- 如果有相关的Issue、PR或讨论,请链接 -->
|
||||
|
||||
---
|
||||
|
||||
**提交前检查清单 / Pre-submission Checklist:**
|
||||
- [ ] 我已经搜索了现有的issues,确认这不是重复报告
|
||||
- [ ] 我已经尝试了最新版本,问题依然存在
|
||||
- [ ] 我已经提供了足够的信息来重现问题
|
||||
- [ ] 我已经检查了文档和FAQ
|
||||
45
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
45
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: New Features
|
||||
about: 提交新功能需求
|
||||
title: '[FEATURE] '
|
||||
labels: 'enhancement, feature-request'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 功能描述 / Feature Description
|
||||
<!-- 请简洁明了地描述你希望添加的新功能 -->
|
||||
|
||||
## 使用场景 / Use Case
|
||||
<!-- 描述在什么情况下需要这个功能,解决什么问题 -->
|
||||
|
||||
## 期望行为 / Expected Behavior
|
||||
<!-- 详细描述你期望这个功能如何工作 -->
|
||||
|
||||
## 替代方案 / Alternatives Considered
|
||||
<!-- 你是否考虑过其他解决方案?如果有,请描述 -->
|
||||
|
||||
## 附加信息 / Additional Context
|
||||
<!-- 提供任何其他有助于理解这个功能需求的信息 -->
|
||||
|
||||
## 优先级 / Priority
|
||||
请选择一个优先级:
|
||||
- [ ] 低 (Low) - 有了更好,没有也行
|
||||
- [ ] 中 (Medium) - 比较重要,希望能实现
|
||||
- [ ] 高 (High) - 非常重要,急需这个功能
|
||||
|
||||
## 愿意贡献 / Willing to Contribute
|
||||
- [ ] 我愿意提交PR来实现这个功能
|
||||
- [ ] 我可以帮助测试这个功能
|
||||
- [ ] 我只是提出建议
|
||||
|
||||
## 环境信息 / Environment
|
||||
- 操作系统 / OS:
|
||||
- 浏览器 / Browser:
|
||||
- 版本 / Version:
|
||||
|
||||
---
|
||||
|
||||
**注意事项 / Notes:**
|
||||
- 提交前请先搜索现有的issue,避免重复提交
|
||||
- 请尽量提供详细的信息,这有助于我们更好地理解和实现你的需求
|
||||
- 如果可能,请提供mockup、截图或参考链接
|
||||
65
.github/workflows/docker-build-push.yml
vendored
Normal file
65
.github/workflows/docker-build-push.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Build and Push Docker Image to GHCR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}/wrdo
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
# 检出代码
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 设置 Docker Buildx(支持多平台构建)
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 登录到 GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 提取 Docker 镜像元数据(标签、版本等)
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,format=short
|
||||
type=ref,event=branch,prefix=
|
||||
type=ref,event=tag
|
||||
|
||||
# 构建并推送 Docker 镜像
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true # ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
ENVIRONMENT=${{ github.event.inputs.environment || 'production' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
124
.github/workflows/sync.yml
vendored
Normal file
124
.github/workflows/sync.yml
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
name: 上游同步 | Upstream Sync
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
actions: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
# 默认关闭自动同步,仅支持手动触发
|
||||
# schedule:
|
||||
# - cron: '0 */6 * * *' # every 6 hours
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
add_comment:
|
||||
description: '是否在同步后添加评论 | Add comment after sync'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
sync_latest_from_upstream:
|
||||
name: 同步上游最新提交 | Sync latest commits from upstream repo
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: 清理失败通知 | Clean issue notice
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'close-issues'
|
||||
labels: '🚨 Sync Fail'
|
||||
|
||||
- name: 同步上游变更 | Sync upstream changes
|
||||
id: sync
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
with:
|
||||
upstream_sync_repo: oiov/wr.do
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
test_mode: false
|
||||
|
||||
- name: 获取最新提交信息 | Get latest commit info
|
||||
if: success()
|
||||
id: commit_info
|
||||
run: |
|
||||
# 获取最新的 commit SHA 和信息
|
||||
LATEST_COMMIT=$(git rev-parse HEAD)
|
||||
COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s")
|
||||
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an")
|
||||
SYNC_TIME=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
echo "latest_commit=$LATEST_COMMIT" >> $GITHUB_OUTPUT
|
||||
echo "commit_message=$COMMIT_MESSAGE" >> $GITHUB_OUTPUT
|
||||
echo "commit_author=$COMMIT_AUTHOR" >> $GITHUB_OUTPUT
|
||||
echo "sync_time=$SYNC_TIME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 添加同步评论 | Add sync comment to commit
|
||||
if: success() && github.event.inputs.add_comment == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const commitSha = '${{ steps.commit_info.outputs.latest_commit }}';
|
||||
const syncTime = '${{ steps.commit_info.outputs.sync_time }}';
|
||||
const commitMsg = '${{ steps.commit_info.outputs.commit_message }}';
|
||||
const commitAuthor = '${{ steps.commit_info.outputs.commit_author }}';
|
||||
|
||||
const commentBody = `## 🔄 同步成功 | Sync Successful
|
||||
|
||||
**同步时间 | Sync Time:** ${syncTime}
|
||||
**源仓库 | Source Repository:** [oiov/wr.do](https://github.com/oiov/wr.do)
|
||||
**同步分支 | Sync Branch:** main → main
|
||||
**最新提交 | Latest Commit:** ${commitMsg}
|
||||
**提交作者 | Commit Author:** ${commitAuthor}
|
||||
|
||||
---
|
||||
*此评论由 GitHub Actions 自动生成 | This comment was automatically generated by GitHub Actions*`;
|
||||
|
||||
try {
|
||||
await github.rest.repos.createCommitComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: commitSha,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('✅ 成功添加同步评论到 commit:', commitSha);
|
||||
} catch (error) {
|
||||
console.log('❌ 添加评论失败:', error.message);
|
||||
}
|
||||
|
||||
- name: 同步检查 | Sync check
|
||||
if: failure()
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'create-issue'
|
||||
title: '🚨 同步失败 | Sync Fail'
|
||||
labels: '🚨 Sync Fail'
|
||||
body: |
|
||||
## 同步失败详情 | Sync Failure Details
|
||||
|
||||
**失败时间 | Failure Time:** ${{ steps.commit_info.outputs.sync_time || 'Unknown' }}
|
||||
**源仓库 | Source Repository:** [oiov/wr.do](https://github.com/oiov/wr.do)
|
||||
**目标分支 | Target Branch:** main
|
||||
**工作流运行 | Workflow Run:** [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
|
||||
### 可能的原因 | Possible Causes:
|
||||
- 上游仓库的 workflow 文件发生变更
|
||||
- 存在合并冲突
|
||||
- 网络连接问题
|
||||
- 权限不足
|
||||
|
||||
### 解决方案 | Solutions:
|
||||
1. 手动执行 Sync Fork 操作
|
||||
2. 检查是否存在合并冲突
|
||||
3. 重新运行此工作流
|
||||
|
||||
---
|
||||
Due to a change in the workflow file of the [oiov/wr.do](https://github.com/oiov/wr.do) upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork.
|
||||
|
||||
由于 [oiov/wr.do](https://github.com/oiov/wr.do) 上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次。
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -40,4 +40,6 @@ next-env.d.ts
|
||||
/.react-email/
|
||||
|
||||
.vscode
|
||||
.contentlayer
|
||||
.contentlayer
|
||||
|
||||
public/sw.js.map
|
||||
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
COPY . .
|
||||
|
||||
# RUN pnpm config set registry https://registry.npmmirror.com
|
||||
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV IS_DOCKER=true
|
||||
|
||||
RUN pnpm add npm-run-all dotenv prisma@5.17.0 @prisma/client@5.17.0
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Check db
|
||||
COPY scripts/check-db.js /app/scripts/check-db.js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
# CMD ["node", "server.js"]
|
||||
CMD ["pnpm", "start-docker"]
|
||||
199
README-en.md
Normal file
199
README-en.md
Normal file
@@ -0,0 +1,199 @@
|
||||
<div align="center">
|
||||
<img src="https://wr.do/_static/images/x-preview.png" alt="WR.DO" >
|
||||
<h1>WR.DO</h1>
|
||||
<p>All-in-one domain service platform with integrated short link services, temporary email, subdomain management, file storage, and open API</p>
|
||||
<p><a href="https://wr.do">Official Site</a><a href="https://wr.do/docs/developer">Docs</a> · <a href="https://wr.do/feedback">Feedback</a> · English | <a href="/README.md">简体中文</a></p>
|
||||
<img alt="Vercel" src="https://img.shields.io/badge/vercel-online-55b467?labelColor=black&logo=vercel&style=flat-square">
|
||||
<img alt="Release" src="https://img.shields.io/github/actions/workflow/status/oiov/wr.do/docker-build-push.yml?label=release&labelColor=black&logo=githubactions&logoColor=white&style=flat-square">
|
||||
<img alt="Release" src="https://img.shields.io/github/release-date/oiov/wr.do?labelColor=black&style=flat-square">
|
||||
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/oiov/wr.do?style=flat-square&label=latest"><br>
|
||||
<img src="https://img.shields.io/github/contributors/oiov/wr.do?color=c4f042&labelColor=black&style=flat-square" alt="contributors"/>
|
||||
<img src="https://img.shields.io/github/stars/oiov/wr.do.svg?logo=github&style=flat-square" alt="star"/>
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/oiov/wr.do?style=flat-square">
|
||||
<img alt="GitHub Issues or Pull Requests" src="https://img.shields.io/github/issues/oiov/wr.do?style=flat-square"> <br>
|
||||
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/oiov/wr.do/docker-build-push.yml?style=flat-square">
|
||||
<img src="https://img.shields.io/github/license/oiov/wr.do?style=flat-square" alt="MIT"/><br><br>
|
||||
<img width="15" src="https://storage.wr.do/2025/11/20/561763627504_.pic.jpg" /> 免费体验 Sora AI 视频生成 👉 <a href="https://sora.hk/i/5KY5N1FL">点击注册</a>
|
||||
</div>
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://wr.do/_static/images/light-preview.png" /></td>
|
||||
<td><img src="https://wr.do/_static/images/example_02.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://wr.do/_static/images/example_01.png" /></td>
|
||||
<td><img src="https://wr.do/_static/images/realtime-globe.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://wr.do/_static/images/example_03.png" /></td>
|
||||
<td><img src="https://wr.do/_static/images/domains.png" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Features
|
||||
|
||||
- 🔗 **Short Link Service**:
|
||||
- Custom short links
|
||||
- Generate custom QR codes
|
||||
- Password-protected links
|
||||
- Expiration time control
|
||||
- Access analytics (real-time logs, maps, and multi-dimensional data analysis)
|
||||
- API integration for link creation
|
||||
|
||||
- 📮 **Email Service**:
|
||||
- Create custom prefix emails
|
||||
- Filter unread email lists
|
||||
- Unlimited mailbox creation
|
||||
- Receive unlimited emails (powered by Cloudflare Email Worker)
|
||||
- Send emails (powered by Resend)
|
||||
- Support catch-all emails
|
||||
- Support push to telegram groups
|
||||
- API endpoints for mailbox creation
|
||||
- API endpoints for inbox retrieval
|
||||
|
||||
- 🌐 **Subdomain Management Service**:
|
||||
- Manage DNS records across multiple Cloudflare accounts and domains
|
||||
- Create various DNS record types (CNAME, A, TXT, etc.)
|
||||
- Support enabling application mode (user submission, admin approval)
|
||||
- Support email notification of administrator and user domain application status
|
||||
|
||||
- 💳 **Cloud Storage Service**
|
||||
- Connects to multiple channels (S3 API) cloud storage platforms (Cloudflare R2, AWS S3)
|
||||
- Supports single-channel multi-bucket configuration
|
||||
- Dynamic configuration (user quota settings) for file upload size limits
|
||||
- Supports drag-and-drop, batch, and chunked file uploads
|
||||
- Supports batch file deletion
|
||||
- Quickly generates short links and QR codes for files
|
||||
- Supports online preview of certain file types
|
||||
- Supports file uploads via API calls
|
||||
|
||||
- 📡 **Open API Module**:
|
||||
- Website metadata extraction API
|
||||
- Website screenshot capture API
|
||||
- Website QR code generation API
|
||||
- Convert websites to Markdown/Text format
|
||||
- Comprehensive API call logging and statistics
|
||||
- User API key generation for third-party integrations
|
||||
|
||||
- 🔒 **Administrator Module**:
|
||||
- Multi-dimensional dashboard with website analytics
|
||||
- Dynamic service configuration (toggle short links, email, subdomain management)
|
||||
- User management (permissions, quotas, account control)
|
||||
- Dynamically configure login methods (Google, GitHub, Magic Link, Credentials, LinuxDO)
|
||||
- Centralized short link administration
|
||||
- Centralized email management
|
||||
- Centralized subdomain administration
|
||||
|
||||
## Quick Start
|
||||
|
||||
See step by step installation tutorial at [Quick Start for Developer](https://wr.do/docs/developer/quick-start).
|
||||
|
||||
## Self-hosted
|
||||
|
||||
### Deploy with Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
|
||||
|
||||
Remember to fill in the necessary environment variables.
|
||||
|
||||
### Deploy with Docker Compose
|
||||
|
||||
Create a new folder and copy the [`docker-compose.yml`](https://github.com/oiov/wr.do/blob/main/docker-compose.yml)、[`.env`](https://github.com/oiov/wr.do/blob/main/.env.example) file to the folder.
|
||||
|
||||
```yml
|
||||
- wrdo
|
||||
| - docker-compose.yml
|
||||
| - .env
|
||||
```
|
||||
|
||||
Fill in the environment variables in the `.env` file, then:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oiov/wr.do
|
||||
cd wr.do
|
||||
pnpm install
|
||||
```
|
||||
|
||||
copy `.env.example` to `.env` and fill in the necessary environment variables.
|
||||
|
||||
#### Init database
|
||||
|
||||
```bash
|
||||
pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
```bash
|
||||
# run on localhost:3000
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- Default admin account:`admin@admin.com`
|
||||
- Default admin password:`123456`
|
||||
|
||||
#### Setup Admin Panel
|
||||
|
||||
> After v1.0.2, this setup guide is not needed anymore
|
||||
|
||||
Follow https://localhost:3000/setup
|
||||
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Via [Installation For Developer](https://wr.do/docs/developer).
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- Next.js + React + TypeScript
|
||||
- Tailwind CSS for styling and design
|
||||
- Prisma ORM as the database toolkit
|
||||
- Cloudflare as the primary cloud infrastructure
|
||||
- Vercel as the recommended deployment platform
|
||||
- Resend as the primary email service
|
||||
|
||||
## Fork Repository Sync
|
||||
|
||||
This project is configured with a sync workflow for the upstream repository [oiov/wr.do](https://github.com/oiov/wr.do), featuring:
|
||||
|
||||
- 🔄 **Manual Sync Trigger** - Auto-sync disabled by default, full control over sync timing
|
||||
- 💬 **Auto Comment After Sync** - Add detailed sync information to related commits
|
||||
- 🚨 **Smart Error Handling** - Auto-create detailed Issues when sync fails
|
||||
- 🧹 **Auto Cleanup Notifications** - Automatically close previous sync failure Issues
|
||||
|
||||
See [How to Trigger Sync](https://wr.do/docs/developer/sync) for details.
|
||||
|
||||
## Community Group
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||
<img width="300" src="https://wr.do/group" />
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/oiov/wr.do/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=oiov/wr.do" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#oiov/wr.do&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
[MIT](/LICENSE.md)
|
||||
78
README-zh.md
78
README-zh.md
@@ -1,78 +0,0 @@
|
||||
<div align="center">
|
||||
<h1>WR.DO</h1>
|
||||
<p><a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
|
||||
<p>生成短链接, 创建 DNS 记录, 管理临时邮箱</p>
|
||||
<!-- <img src="https://wr.do/_static/images/light-preview.png"/> -->
|
||||
</div>
|
||||
|
||||
## 功能
|
||||
|
||||
- 🔗 **短链生成**:生成附有访问者统计信息的短链接 (支持密码保护, 支持调用 API)
|
||||
- 📮 **临时邮箱**:创建多个临时邮箱接收和发送邮件(支持调用 API)
|
||||
- 🌐 **多租户支持**:无缝管理多个 DNS 记录
|
||||
- 📸 **截图 API**:访问截图 API、网站元数据抓取 API
|
||||
- <20>😀 **权限管理**:方便审核的管理员面板
|
||||
- 🔒 **安全可靠**:基于 Cloudflare 强大的 DNS API
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 快速开始
|
||||
|
||||
查看开发者[快速开始](https://wr.do/docs/developer/quick-start)的详细文档。
|
||||
|
||||
查看有关[快速开始](https://wr.do/docs/quick-start)的文档。
|
||||
|
||||
## 自托管教程
|
||||
|
||||
### 要求
|
||||
|
||||
- [Vercel](https://vercel.com) 账户用于部署应用
|
||||
- 至少一个在 [Cloudflare](https://dash.cloudflare.com/) 托管的 **域名**
|
||||
|
||||
查看[开发文档](https://wr.do/docs/developer/installation)。
|
||||
|
||||
### Email worker
|
||||
|
||||
查看 [email worker](https://wr.do/docs/developer/cloudflare-email-worker) 文档用于邮件接收。
|
||||
|
||||
## 本地开发
|
||||
|
||||
将 `.env.example` 复制为 `.env` 并填写必要的环境变量。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oiov/wr.do
|
||||
cd wr.do
|
||||
pnpm install
|
||||
|
||||
# 在 localhost:3000 上运行
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 社区群组
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||

|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](/LICENSE.md)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#oiov/wr.do&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
242
README.md
242
README.md
@@ -1,81 +1,232 @@
|
||||
<div align="center">
|
||||
<img src="https://wr.do/_static/images/x-preview.png" alt="WR.DO" >
|
||||
<h1>WR.DO</h1>
|
||||
<p><a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
|
||||
<p>Make Short Links, Manage DNS Records, Email Support.</p>
|
||||
<!-- <img src="https://wr.do/_static/images/light-preview.png"/> -->
|
||||
<p>一站式域名服务平台,集成短链服务、临时邮箱、子域名管理、文件存储和开放API接口。</p>
|
||||
<p>
|
||||
<a href="https://wr.do">官方云部署站点</a> · <a href="https://wr.do/docs/developer">部署文档</a> · <a href="https://wr.do/feedback">反馈讨论</a> · <a href="/README-en.md">English</a> | 简体中文
|
||||
</p>
|
||||
<img alt="Vercel" src="https://img.shields.io/badge/vercel-online-55b467?labelColor=black&logo=vercel&style=flat-square">
|
||||
<img alt="Release" src="https://img.shields.io/github/actions/workflow/status/oiov/wr.do/docker-build-push.yml?label=release&labelColor=black&logo=githubactions&logoColor=white&style=flat-square">
|
||||
<img alt="Release" src="https://img.shields.io/github/release-date/oiov/wr.do?labelColor=black&style=flat-square">
|
||||
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/oiov/wr.do?style=flat-square&label=latest"><br>
|
||||
<img src="https://img.shields.io/github/contributors/oiov/wr.do?color=c4f042&labelColor=black&style=flat-square" alt="contributors"/>
|
||||
<img src="https://img.shields.io/github/stars/oiov/wr.do.svg?logo=github&style=flat-square" alt="star"/>
|
||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/oiov/wr.do?style=flat-square">
|
||||
<img alt="GitHub Issues or Pull Requests" src="https://img.shields.io/github/issues/oiov/wr.do?style=flat-square"> <br>
|
||||
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/oiov/wr.do/docker-build-push.yml?style=flat-square">
|
||||
<img src="https://img.shields.io/github/license/oiov/wr.do?style=flat-square" alt="MIT"/><br><br>
|
||||
<img width="15" src="https://storage.wr.do/2025/11/20/561763627504_.pic.jpg" /> 免费体验 Sora AI 视频生成 👉 <a href="https://sora.hk/i/5KY5N1FL">点击注册</a>
|
||||
</div>
|
||||
|
||||
## Features
|
||||
## 截图预览
|
||||
|
||||
- 🔗 **URL Shortening:** Generate short links with visitor analytic and password(support api)
|
||||
- 📮 **Email Support:** Receive emails and send emails(support api)
|
||||
- 💬 **P2P Chat:** Start chat in seconds
|
||||
- 🌐 **Multi-Tenant Support:** Manage multiple DNS records seamlessly
|
||||
- 📸 **Screenshot API:** Access to screenshot api、website meta-info scraping api.
|
||||
- 😀 **Permission Management:** A convenient admin panel for auditing
|
||||
- 🔒 **Secure & Reliable:** Built on Cloudflare's robust DNS API
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://wr.do/_static/images/light-preview.png" /></td>
|
||||
<td><img src="https://wr.do/_static/images/example_02.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://wr.do/_static/images/example_01.png" /></td>
|
||||
<td><img src="https://wr.do/_static/images/realtime-globe.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://wr.do/_static/images/example_03.png" /></td>
|
||||
<td><img src="https://wr.do/_static/images/domains.png" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
## 功能列表
|
||||
|
||||

|
||||
<details>
|
||||
<summary><strong> 🔗 短链服务</strong> - <a href="javascript:;">[功能列表]</a></summary>
|
||||
<ul>
|
||||
<li>支持自定义短链</li>
|
||||
<li>支持生成自定义二维码</li>
|
||||
<li>支持密码保护链接</li>
|
||||
<li>支持设置过期时间</li>
|
||||
<li>支持访问统计(实时日志、地图等多维度数据分析)</li>
|
||||
<li>支持调用 API 创建短链</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||

|
||||
<details>
|
||||
<summary><strong> 📮 域名邮箱服务</strong> - <a href="javascript:;">[功能列表]</a></summary>
|
||||
<ul>
|
||||
<li>支持创建自定义前缀邮箱</li>
|
||||
<li>支持过滤未读邮件列表</li>
|
||||
<li>可创建无限数量邮箱</li>
|
||||
<li>支持接收无限制邮件 (依赖 Cloudflare Email Worker)</li>
|
||||
<li>支持发送邮件(依赖 Resend)</li>
|
||||
<li>支持 Catch-All 配置</li>
|
||||
<li>支持 Telegram 推送(多频道/群组)</li>
|
||||
<li>支持调用 API 创建邮箱</li>
|
||||
<li>支持调用 API 获取收件箱邮件</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||

|
||||
<details>
|
||||
<summary><strong>🌐 子域名管理服务</strong> - <a href="javascript:;">[功能列表]</a></summary>
|
||||
<ul>
|
||||
<li>支持管理多 Cloudflare 账户下的多个域名的 DNS 记录</li>
|
||||
<li>支持创建多种 DNS 记录类型(CNAME、A、TXT 等)</li>
|
||||
<li>支持开启申请模式(用户提交、管理员审批)</li>
|
||||
<li>支持邮件通知管理员、用户域名申请状态</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
<details>
|
||||
<summary><strong>📂 文件存储服务</strong> - <a href="javascript:;">[功能列表]</a></summary>
|
||||
<ul>
|
||||
<li>支持多渠道(S3 API)云存储平台(Cloudflare R2、AWS S3、OSS等)
|
||||
<li>支持单渠道多存储桶配置
|
||||
<li>动态配置文件上传大小限制
|
||||
<li>支持拖拽、批量、粘贴上传文件
|
||||
<li>支持批量删除文件
|
||||
<li>快捷生成文件短链、二维码
|
||||
<li>支持部分文件在线预览内容
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
See usage docs about [guide](https://wr.do/docs/quick-start) for quick start.
|
||||
<details>
|
||||
<summary><strong>📡 开放接口服务</strong> - <a href="javascript:;">[功能列表]</a></summary>
|
||||
<ul>
|
||||
<li>支持调用 API 获取网站元数据
|
||||
<li>支持调用 API 获取网站截图
|
||||
<li>支持调用 API 生成网站二维码
|
||||
<li>支持调用 API 将网站转换为 Markdown、Text
|
||||
<li>支持生成用户 API Key,用于第三方调用开放接口
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
## Self-hosted Tutorial
|
||||
<details>
|
||||
<summary><strong>👑 管理员模块</strong> - <a href="javascript:;">[功能列表]</a></summary>
|
||||
<ul>
|
||||
<li>多维度图表展示网站状态
|
||||
<li>域名服务配置(动态配置各项服务是否启用,包括短链、临时邮箱(收发邮件)
|
||||
<li>用户列表管理(设置权限、分配使用额度、禁用用户等)
|
||||
<li>动态配置登录方式 (支持 Google, GitHub, 邮箱验证, 账户密码, LinuxDO)
|
||||
<li>短链管理(管理所有用户创建的短链)
|
||||
<li>邮箱管理(管理所有用户创建的临时邮箱)
|
||||
<li>子域名管理(管理所有用户创建的子域名)
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
See step by step installation tutorial at [Quick Start for Developer](https://wr.do/docs/developer/quick-start).
|
||||
## 技术栈
|
||||
|
||||
### Requirements
|
||||
- Next.js + React + TypeScript
|
||||
- Tailwind CSS 用于样式设计
|
||||
- Prisma ORM 作为数据库工具
|
||||
- Cloudflare 作为主要的云基础设施
|
||||
- Vercel 作为推荐的部署平台
|
||||
- Resend 作为邮件服务
|
||||
- Next-Intl 作为国际化支持
|
||||
|
||||
- [Vercel](https://vercel.com) to deploy app
|
||||
- A **domain** name hosted on [Cloudflare](https://dash.cloudflare.com/)
|
||||
## 快速开始
|
||||
|
||||
See more docs about [developer](https://wr.do/docs/developer/installation).
|
||||
查看开发者[手把手部署教程](https://wr.do/docs/developer/quick-start-zh)文档。
|
||||
|
||||
### Email worker
|
||||
## 自部署教程
|
||||
|
||||
See docs about [email worker](https://wr.do/docs/developer/cloudflare-email-worker).
|
||||
> 注意,任何部署方式都需要先配置环境变量,若部署后修改了环境变量,需要**重新部署**才会生效。
|
||||
|
||||
## Local development
|
||||
### 使用 Vercel 部署
|
||||
|
||||
copy `.env.example` to `.env` and fill in the necessary environment variables.
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
|
||||
|
||||
记得填写必要的环境变量。
|
||||
|
||||
### 使用 Docker Compose 部署
|
||||
|
||||
在服务器中创建一个文件夹,进入该文件夹并新建 [docker-compose.yml](https://github.com/oiov/wr.do/blob/main/docker-compose.yml)、[.env](https://github.com/oiov/wr.do/blob/main/.env.example) 文件:
|
||||
|
||||
```yml
|
||||
- wrdo
|
||||
| - docker-compose.yml
|
||||
| - .env
|
||||
```
|
||||
|
||||
在 `.env` 中填写必要的环境变量,然后执行:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> 或只创建 docker-compose.yml 文件,环境变量直接填写在yml中,比如将`DATABASE_URL: ${DATABASE_URL}`替换成`DATABASE_URL: your-database-uri`
|
||||
|
||||
### 使用 EdgeOne 部署
|
||||
|
||||
> 此方法部署目前无法build成功,不建议使用
|
||||
|
||||
[](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2Foiov%2Fwr.do)
|
||||
|
||||
## 本地开发
|
||||
|
||||
将 `.env.example` 复制为 `.env` 并填写必要的环境变量。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oiov/wr.do
|
||||
cd wr.do
|
||||
pnpm install
|
||||
```
|
||||
|
||||
# run on localhost:3000
|
||||
#### 初始化数据库
|
||||
|
||||
```bash
|
||||
pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
```bash
|
||||
# 在 localhost:3000 上运行
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Legitimacy review
|
||||
- 默认账号(管理员):`admin@admin.com`
|
||||
- 默认密码:`123456`
|
||||
|
||||
- To avoid abuse, applications without website content will be rejected
|
||||
- To avoid domain name conflicts, please check before applying
|
||||
- Completed website construction or released open source project (ready to build website for open source project)
|
||||
- Political sensitivity, violence, pornography, link jumping, VPN, reverse proxy services, and other illegal or sensitive content must not appear on the website
|
||||
> 登录后请及时修改密码
|
||||
|
||||
**Administrators will conduct domain name checks periodically to clean up domain names that violate the above rules, have no content, and are not open source related**
|
||||
#### 管理员初始化
|
||||
|
||||
## Community Group
|
||||
> 此初始化引导在 v1.0.2 版本后, 不再是必要步骤
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||

|
||||
访问 https://localhost:3000/setup
|
||||
|
||||
## License
|
||||
## 环境变量
|
||||
|
||||
查看 [开发者文档](https://wr.do/docs/developer).
|
||||
|
||||
## Fork 仓库同步
|
||||
|
||||
本项目配置了与上游仓库 [oiov/wr.do](https://github.com/oiov/wr.do) 的同步工作流,支持:
|
||||
|
||||
- 🔄 **手动触发同步** - 默认关闭自动同步,完全控制同步时机
|
||||
- 💬 **同步后自动评论** - 在相关 commit 上添加详细的同步信息
|
||||
- 🚨 **智能错误处理** - 同步失败时自动创建详细的 Issue
|
||||
- 🧹 **自动清理通知** - 自动关闭之前的同步失败 Issue
|
||||
|
||||
前往[如何手动触发同步](https://wr.do/docs/developer/sync)查看详细文档。
|
||||
|
||||
## 社区群组
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||
<img width="300" src="https://wr.do/group" />
|
||||
|
||||
## 贡献者
|
||||
|
||||
<a href="https://github.com/oiov/wr.do/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=oiov/wr.do" />
|
||||
</a>
|
||||
|
||||
## 请作者喝咖啡
|
||||
|
||||
[爱发电主页打赏](wr.do/afdhome)
|
||||
|
||||
<img width="100" src="https://wr.do/bbpt9z?ref=https://github.com/oiov/wr.do" />
|
||||
|
||||
[MIT](/LICENSE.md)
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -85,4 +236,9 @@ pnpm dev
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
</a>
|
||||
|
||||
## 开源协议
|
||||
|
||||
[MIT](/LICENSE.md)
|
||||
|
||||
|
||||
39
actions/update-user-password.ts
Normal file
39
actions/update-user-password.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashPassword } from "@/lib/utils";
|
||||
import { userPasswordSchema } from "@/lib/validations/user";
|
||||
|
||||
export type FormData = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export async function updateUserPassword(userId: string, data: FormData) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || session?.user.id !== userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const { password } = userPasswordSchema.parse(data);
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
password: hashPassword(password),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/settings");
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
// console.log(error)
|
||||
return { status: "error" };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { BackgroundPaths } from "@/components/ui/background-paths";
|
||||
import { FlipWords } from "@/components/shared/flip-words";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -8,11 +14,39 @@ interface AuthLayoutProps {
|
||||
|
||||
export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const user = await getCurrentUser();
|
||||
const t = await getTranslations("Auth");
|
||||
|
||||
if (user) {
|
||||
if (user.role === "ADMIN") redirect("/admin");
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
return <div className="min-h-screen">{children}</div>;
|
||||
// return <div className="min-h-screen">{children}</div>;
|
||||
return (
|
||||
<main className="relative flex h-screen w-full flex-col">
|
||||
<div className="flex-1">
|
||||
<div className="flex min-h-screen w-full">
|
||||
<div className="relative hidden flex-col border-r bg-muted p-16 lg:flex lg:w-1/2">
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
<BackgroundPaths />
|
||||
</div>
|
||||
<h1 className="z-10 flex items-center gap-3 text-2xl font-semibold duration-1000 animate-in fade-in">
|
||||
<Icons.logo className="size-8" />
|
||||
<Link href="/" style={{ fontFamily: "Bahamas Bold" }}>
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
</h1>
|
||||
<div className="flex-1" />
|
||||
|
||||
<FlipWords
|
||||
words={[t("description")]}
|
||||
className="mb-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full p-6 lg:w-1/2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -14,55 +15,49 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("Auth");
|
||||
return (
|
||||
<div className="container flex h-screen w-screen flex-col items-center justify-center">
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"absolute left-4 top-4 md:left-8 md:top-8",
|
||||
"absolute left-4 top-4 md:left-8 md:top-8 lg:hidden",
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<Icons.chevronLeft className="mr-2 size-4" />
|
||||
Back
|
||||
{t("Back")}
|
||||
</>
|
||||
</Link>
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<Icons.logo className="mx-auto size-12" />
|
||||
{/* <Icons.logo className="mx-auto size-12 lg:hidden" /> */}
|
||||
<div className="text-2xl font-semibold tracking-tight">
|
||||
<span>Welcome to</span>{" "}
|
||||
<span style={{ fontFamily: "Bahamas Bold" }}>
|
||||
{siteConfig.name}
|
||||
</span>
|
||||
<span>{t("Welcome to")}</span>{" "}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose your login method to continue
|
||||
{t("Choose your login method to continue")}
|
||||
</p>
|
||||
</div>
|
||||
<Suspense>
|
||||
<UserAuthForm />
|
||||
</Suspense>
|
||||
<p className="mt-4 break-all rounded-md border border-dashed bg-neutral-50 p-2 text-left text-sm text-gray-600 dark:border-neutral-600 dark:bg-neutral-800 dark:text-zinc-400">
|
||||
📢 To keep our free resources accessible to all, we're allowing only
|
||||
200 new account sign-ups each day.
|
||||
</p>
|
||||
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
By clicking continue, you agree to our{" "}
|
||||
<p className="px-2 text-center text-sm text-muted-foreground">
|
||||
{t("By clicking continue, you agree to our")}{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="hover:text-brand underline underline-offset-4"
|
||||
>
|
||||
Terms of Service
|
||||
{t("Terms of Service")}
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
{t("and")}{" "}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="hover:text-brand underline underline-offset-4"
|
||||
>
|
||||
Privacy Policy
|
||||
{t("Privacy Policy")}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DocsPageHeader } from "@/components/docs/page-header";
|
||||
import { DocsPager } from "@/components/docs/pager";
|
||||
import { DashboardTableOfContents } from "@/components/shared/toc";
|
||||
|
||||
import "@/styles/mdx.css";
|
||||
import "@/styles/mdx.scss";
|
||||
|
||||
import { Metadata } from "next";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { allPages } from "contentlayer/generated";
|
||||
|
||||
import { Mdx } from "@/components/content/mdx-components";
|
||||
|
||||
import "@/styles/mdx.css";
|
||||
import "@/styles/mdx.scss";
|
||||
|
||||
import { Metadata } from "next";
|
||||
|
||||
|
||||
9
app/(marketing)/feedback/loading.tsx
Normal file
9
app/(marketing)/feedback/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-full w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/(marketing)/feedback/page.tsx
Normal file
11
app/(marketing)/feedback/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import Comment from "@/components/shared/comment";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Feedback",
|
||||
description: "Help us do better",
|
||||
});
|
||||
|
||||
export default async function Page() {
|
||||
return <Comment />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NavMobile } from "@/components/layout/mobile-nav";
|
||||
import { NavBar } from "@/components/layout/navbar";
|
||||
import { Notification } from "@/components/layout/notification";
|
||||
import { SiteFooter } from "@/components/layout/site-footer";
|
||||
|
||||
interface MarketingLayoutProps {
|
||||
@@ -11,6 +12,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
<div className="flex min-h-screen flex-col dark:bg-black">
|
||||
<NavMobile />
|
||||
<NavBar scroll={true} />
|
||||
<Notification />
|
||||
<main className="flex-1 bg-[radial-gradient(circle_500px_at_50%_300px,#a1fffc36,#ffffff)] dark:bg-[radial-gradient(circle_500px_at_50%_300px,#a1fffc36,#000)]">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import HeroLanding, { LandingImages } from "@/components/sections/hero-landing";
|
||||
import { PricingSection } from "@/components/sections/pricing";
|
||||
|
||||
export default function IndexPage() {
|
||||
export const metadata = constructMetadata({
|
||||
title: "WR.DO - Your All-In-One Domain Services Platform",
|
||||
description:
|
||||
"All-in-one domain platform with short links, temp email, subdomain management, file storage, and open APIs",
|
||||
});
|
||||
|
||||
export default async function IndexPage() {
|
||||
const user = await getCurrentUser();
|
||||
return (
|
||||
<>
|
||||
<HeroLanding />
|
||||
<HeroLanding userId={user?.id} />
|
||||
<LandingImages />
|
||||
<PricingSection />
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Label,
|
||||
PolarGrid,
|
||||
@@ -29,9 +30,8 @@ export function RadialShapeChart({
|
||||
total: number;
|
||||
totalUser: number;
|
||||
}) {
|
||||
const chartData = [
|
||||
{ browser: "safari", actived: total, fill: "var(--color-safari)" },
|
||||
];
|
||||
const t = useTranslations("Components");
|
||||
const chartData = [{ actived: total, fill: "var(--color-safari)" }];
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
@@ -42,7 +42,7 @@ export function RadialShapeChart({
|
||||
>
|
||||
<RadialBarChart
|
||||
data={chartData}
|
||||
endAngle={total}
|
||||
endAngle={(total / totalUser) * 100}
|
||||
innerRadius={80}
|
||||
outerRadius={140}
|
||||
>
|
||||
@@ -77,7 +77,7 @@ export function RadialShapeChart({
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
Users
|
||||
{t("Users")}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
@@ -94,7 +94,7 @@ export function RadialShapeChart({
|
||||
<TrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="leading-none text-muted-foreground">
|
||||
Cumulative proportion of activated <strong>Api Key</strong> users
|
||||
{t("Activated Api Key users")}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ScrapeMeta } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { useElementSize } from "@/hooks/use-element-size";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -54,8 +56,11 @@ export function LineChartMultiple({
|
||||
type1,
|
||||
type2,
|
||||
}: LineChartMultipleProps) {
|
||||
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
|
||||
const processedData = processChartData(chartData, type1, type2);
|
||||
|
||||
const t = useTranslations("Components");
|
||||
|
||||
const chartConfig = {
|
||||
source1: {
|
||||
label: type1,
|
||||
@@ -67,20 +72,22 @@ export function LineChartMultiple({
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const message = type2
|
||||
? t("total-requests-two-types", { type1, type2 })
|
||||
: t("total-requests-one-type", { type1 });
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Total requests of {type1}
|
||||
{type2 && ` and ${type2}`}.
|
||||
</CardDescription>
|
||||
<CardDescription>{message}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent ref={wrapperRef}>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
className="mt-6"
|
||||
accessibilityLayer
|
||||
data={processedData}
|
||||
width={wrapperWidth}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
|
||||
@@ -4,10 +4,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
export default function AdminPanelLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Admin Panel"
|
||||
text="Access only for users with ADMIN role."
|
||||
/>
|
||||
<DashboardHeader heading="Admin Panel" text="" />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
|
||||
@@ -26,7 +26,7 @@ import { RadialShapeChart } from "./api-key-active-chart";
|
||||
import { LineChartMultiple } from "./line-chart-multiple";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Admin – WR.DO",
|
||||
title: "Admin",
|
||||
description: "Admin page for only admin management.",
|
||||
});
|
||||
|
||||
@@ -134,7 +134,6 @@ async function RequestStatsSection() {
|
||||
|
||||
return hasStats ? (
|
||||
<>
|
||||
<h2 className="my-1 text-xl font-semibold">Request Statistics</h2>
|
||||
<DailyPVUVChart
|
||||
data={screenshot_stats
|
||||
.concat(meta_stats)
|
||||
@@ -200,7 +199,6 @@ async function MarkdownTextChartSection() {
|
||||
async function LogsSection({ userId }: { userId: string }) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="my-1 text-xl font-semibold">Request Logs</h2>
|
||||
<LogsTable userId={userId} target={"/api/v1/scraping/admin/logs"} />
|
||||
</>
|
||||
);
|
||||
@@ -213,10 +211,7 @@ export default async function AdminPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Admin Panel"
|
||||
text="Access only for users with ADMIN role."
|
||||
/>
|
||||
<DashboardHeader heading="Admin Panel" text="" />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-3">
|
||||
<ErrorBoundary
|
||||
|
||||
@@ -4,8 +4,16 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="DNS Records" text="" />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { redirect } from "next/navigation";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import { UserRecordStatus } from "@/components/dashboard/status-card";
|
||||
|
||||
import UserRecordsList from "../../dashboard/records/record-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "DNS Records - WR.DO",
|
||||
title: "DNS Records",
|
||||
description: "List and manage records.",
|
||||
});
|
||||
|
||||
@@ -19,17 +20,19 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records."
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records"
|
||||
link="/docs/dns-records"
|
||||
linkText="DNS records."
|
||||
linkText="DNS records"
|
||||
/>
|
||||
<UserRecordStatus action="/api/record/admin" />
|
||||
<UserRecordsList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
}}
|
||||
action="/api/record/admin"
|
||||
/>
|
||||
|
||||
15
app/(protected)/admin/storage/loading.tsx
Normal file
15
app/(protected)/admin/storage/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Cloud Storage"
|
||||
text="List and manage cloud storage"
|
||||
/>
|
||||
<Skeleton className="h-[58px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
app/(protected)/admin/storage/page.tsx
Normal file
39
app/(protected)/admin/storage/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UserFileList from "@/components/file";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Cloud Storage",
|
||||
description: "List and manage cloud storage.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Cloud Storage"
|
||||
text="List and manage cloud storage"
|
||||
link="/docs/developer/cloud-storage"
|
||||
linkText="Cloud Storage"
|
||||
/>
|
||||
<UserFileList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/storage/admin"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
864
app/(protected)/admin/system/app-configs.tsx
Normal file
864
app/(protected)/admin/system/app-configs.tsx
Normal file
@@ -0,0 +1,864 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import pkg from "package.json";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import VersionNotifier from "@/components/shared/version-notifier";
|
||||
|
||||
export default function AppConfigs({}: {}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [loginMethodCount, setLoginMethodCount] = useState(0);
|
||||
|
||||
const {
|
||||
data: configs,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<Record<string, any>>("/api/admin/configs", fetcher);
|
||||
const [notification, setNotification] = useState("");
|
||||
const [catchAllEmails, setCatchAllEmails] = useState("");
|
||||
const [forwardEmailTargets, setForwardEmailTargets] = useState("");
|
||||
const [forwardEmailWhiteList, setForwardEmailWhiteList] = useState("");
|
||||
const [emailSuffix, setEmailSuffix] = useState("");
|
||||
const [tgBotToken, setTgBotToken] = useState("");
|
||||
const [tgChatId, setTgChatId] = useState("");
|
||||
const [tgTemplate, setTgTemplate] = useState("");
|
||||
const [tgWhiteList, setTgWhiteList] = useState("");
|
||||
|
||||
const t = useTranslations("Setting");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && configs) {
|
||||
setNotification(configs?.system_notification);
|
||||
setCatchAllEmails(configs?.catch_all_emails);
|
||||
setEmailSuffix(configs?.email_registration_suffix_limit_white_list);
|
||||
setTgBotToken(configs?.tg_email_bot_token);
|
||||
setTgChatId(configs?.tg_email_chat_id);
|
||||
setTgTemplate(configs?.tg_email_template);
|
||||
setTgWhiteList(configs?.tg_email_target_white_list);
|
||||
setForwardEmailTargets(configs?.email_forward_targets);
|
||||
setForwardEmailWhiteList(configs?.email_forward_white_list);
|
||||
}
|
||||
|
||||
if (!isLoading) {
|
||||
let count = 0;
|
||||
if (configs?.enable_google_oauth) count++;
|
||||
if (configs?.enable_github_oauth) count++;
|
||||
if (configs?.enable_liunxdo_oauth) count++;
|
||||
// if (configs?.enable_resend_email_login) count++;
|
||||
if (configs?.enable_email_password_login) count++;
|
||||
setLoginMethodCount(count);
|
||||
}
|
||||
}, [configs, isLoading]);
|
||||
|
||||
const handleChange = (value: any, key: string, type: string) => {
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/admin/configs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key, value, type }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success("Saved");
|
||||
mutate();
|
||||
} else {
|
||||
toast.error("Failed to save", {
|
||||
description: await res.text(),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-48 w-full rounded-lg" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible className="group" defaultOpen>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
|
||||
<div className="text-lg font-bold">{t("App Configs")}</div>
|
||||
<Icons.chevronDown className="ml-auto size-4" />
|
||||
<Icons.settings className="ml-3 size-4 transition-all group-hover:scale-110" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("User Registration")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Allow users to sign up")}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<Switch
|
||||
defaultChecked={configs.enable_user_registration}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_user_registration", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
<div className="space-y-1 text-start leading-none">
|
||||
<p className="font-medium">{t("Login Methods")}</p>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Select the login methods that users can use to log in")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Icons.chevronDown className="ml-auto mr-2 size-4" />
|
||||
<Badge>{loginMethodCount}</Badge>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-3 rounded-md bg-neutral-100 p-3 dark:bg-neutral-800">
|
||||
{configs && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 transition-all duration-100 hover:cursor-pointer hover:shadow-sm">
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Icons.pwdKey className="size-4" />
|
||||
{t("Email Password")}
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_email_password_login}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(
|
||||
v,
|
||||
"enable_email_password_login",
|
||||
"BOOLEAN",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 transition-all duration-100 hover:cursor-pointer hover:shadow-sm">
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Icons.github className="size-4" /> GitHub OAuth
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_github_oauth}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_github_oauth", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 transition-all duration-100 hover:cursor-pointer hover:shadow-sm">
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Icons.google className="size-4" />
|
||||
Google OAuth
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_google_oauth}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_google_oauth", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 transition-all duration-100 hover:cursor-pointer hover:shadow-sm">
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<img
|
||||
src="/_static/images/linuxdo.webp"
|
||||
alt="linuxdo"
|
||||
className="size-4"
|
||||
/>
|
||||
LinuxDo OAuth
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_liunxdo_oauth}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_liunxdo_oauth", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="flex items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Icons.resend className="size-4" />
|
||||
{t("Resend Email")}
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_resend_email_login}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(
|
||||
v,
|
||||
"enable_resend_email_login",
|
||||
"BOOLEAN",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div> */}
|
||||
</>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{t("Email Suffix Limit")}
|
||||
</p>
|
||||
<p className="text-start text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Enable eamil suffix limit, only works for resend email login and email password login methods",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div
|
||||
className="ml-auto flex items-center gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{configs.enable_email_registration_suffix_limit &&
|
||||
!configs.email_registration_suffix_limit_white_list && (
|
||||
<Badge variant="yellow">
|
||||
<Icons.warning className="mr-1 size-3" />{" "}
|
||||
{t("Need to configure")}
|
||||
</Badge>
|
||||
)}
|
||||
<Switch
|
||||
defaultChecked={
|
||||
configs.enable_email_registration_suffix_limit
|
||||
}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(
|
||||
v,
|
||||
"enable_email_registration_suffix_limit",
|
||||
"BOOLEAN",
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Icons.chevronDown className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-4 rounded-md border p-4 shadow-md">
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">
|
||||
{t("Email Suffix White List")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set email suffix white list, split by comma, such as: gmail-com,yahoo-com,hotmail-com",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
|
||||
placeholder="gmail.com,yahoo.com,hotmail.com"
|
||||
rows={5}
|
||||
value={emailSuffix}
|
||||
disabled={
|
||||
!configs.enable_email_registration_suffix_limit
|
||||
}
|
||||
onChange={(e) => setEmailSuffix(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending ||
|
||||
emailSuffix ===
|
||||
configs.email_registration_suffix_limit_white_list
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(
|
||||
emailSuffix,
|
||||
"email_registration_suffix_limit_white_list",
|
||||
"STRING",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("Notification")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set system notification, this will be displayed in the header",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
|
||||
placeholder="Support HTML format, such as <div class='text-red-500'>Info</div>"
|
||||
rows={5}
|
||||
// defaultValue={configs.system_notification}
|
||||
value={notification}
|
||||
onChange={(e) => setNotification(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending || notification === configs.system_notification
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(
|
||||
notification,
|
||||
"system_notification",
|
||||
"STRING",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground/90"
|
||||
style={{ fontFamily: "Bahamas Bold" }}
|
||||
>
|
||||
Powered by
|
||||
<Link
|
||||
href={siteConfig.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
<Link
|
||||
href={`${siteConfig.links.github}/releases/latest`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-thin underline-offset-2 hover:underline"
|
||||
>
|
||||
v{pkg.version}
|
||||
</Link>
|
||||
<VersionNotifier />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible className="group border-y">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
|
||||
<div className="text-lg font-bold">{t("Email Configs")}</div>
|
||||
<Icons.chevronDown className="ml-auto size-4" />
|
||||
<Icons.mail className="ml-3 size-4 transition-all group-hover:scale-110" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<div className="space-y-6">
|
||||
{/* Catch-All*/}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
Catch-All
|
||||
</p>
|
||||
<p className="text-start text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Enable email catch-all, all user's email address which created on this platform will be redirected to the catch-all email address",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div
|
||||
className="ml-auto flex items-center gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{configs.enable_email_catch_all &&
|
||||
!configs.catch_all_emails && (
|
||||
<Badge variant="yellow">
|
||||
<Icons.warning className="mr-1 size-3" />{" "}
|
||||
{t("Need to configure")}
|
||||
</Badge>
|
||||
)}
|
||||
<Switch
|
||||
defaultChecked={configs.enable_email_catch_all}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_email_catch_all", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
|
||||
<Icons.chevronDown className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-4 rounded-md border p-4 shadow-md">
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">
|
||||
{t("Email Forward White List")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set email forward white list, split by comma, such as: a-wrdo,b-wrdo",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
|
||||
placeholder="example1@wr.do,example2@wr.do"
|
||||
rows={5}
|
||||
value={forwardEmailWhiteList}
|
||||
onChange={(e) =>
|
||||
setForwardEmailWhiteList(e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending ||
|
||||
forwardEmailWhiteList ===
|
||||
configs.email_forward_white_list
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(
|
||||
forwardEmailWhiteList,
|
||||
"email_forward_white_list",
|
||||
"STRING",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">
|
||||
{t("Catch-All Email Address")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set catch-all email address, split by comma if more than one, such as: 1@a-com,2@b-com, Only works when email catch all is enabled",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
|
||||
placeholder="example1@wr.do,example2@wr.do"
|
||||
rows={5}
|
||||
value={catchAllEmails}
|
||||
disabled={!configs.enable_email_catch_all}
|
||||
onChange={(e) => setCatchAllEmails(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending ||
|
||||
catchAllEmails === configs.catch_all_emails
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(
|
||||
catchAllEmails,
|
||||
"catch_all_emails",
|
||||
"STRING",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{/* Forward Email to other email address */}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{t("Email Forwarding")}
|
||||
</p>
|
||||
<p className="text-start text-xs text-muted-foreground">
|
||||
{t(
|
||||
"If enabled, forward all received emails to other platform email addresses (Send with Resend or Brevo)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div
|
||||
className="ml-auto flex items-center gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{configs.enable_email_forward &&
|
||||
!configs.email_forward_targets && (
|
||||
<Badge variant="yellow">
|
||||
<Icons.warning className="mr-1 size-3" />{" "}
|
||||
{t("Need to configure")}
|
||||
</Badge>
|
||||
)}
|
||||
<Switch
|
||||
defaultChecked={configs.enable_email_forward}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_email_forward", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
<Icons.chevronDown className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-4 rounded-md border p-4 shadow-md">
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">
|
||||
{t("Email Forward White List")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set email forward white list, split by comma, such as: a-wrdo,b-wrdo",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
|
||||
placeholder="example1@wr.do,example2@wr.do"
|
||||
rows={5}
|
||||
value={forwardEmailWhiteList}
|
||||
onChange={(e) =>
|
||||
setForwardEmailWhiteList(e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending ||
|
||||
forwardEmailWhiteList ===
|
||||
configs.email_forward_white_list
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(
|
||||
forwardEmailWhiteList,
|
||||
"email_forward_white_list",
|
||||
"STRING",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("Forward Email Targets")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set forward email address targets, split by comma if more than one, such as: 1@a-com,2@b-com, Only works when email forwarding is enabled",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
|
||||
placeholder="example1@wr.do,example2@wr.do"
|
||||
rows={5}
|
||||
value={forwardEmailTargets}
|
||||
disabled={!configs.enable_email_forward}
|
||||
onChange={(e) => setForwardEmailTargets(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending ||
|
||||
forwardEmailTargets === configs.email_forward_targets
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(
|
||||
forwardEmailTargets,
|
||||
"email_forward_targets",
|
||||
"STRING",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Telegram */}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{t("Telegram Pusher")}
|
||||
</p>
|
||||
<p className="text-start text-xs text-muted-foreground">
|
||||
{t("Push message to Telegram groups")}.{" "}
|
||||
<Link
|
||||
href="/docs/developer/telegram-bot"
|
||||
className="text-blue-500"
|
||||
target="_blank"
|
||||
>
|
||||
{t("How to configure Telegram bot")} ?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div
|
||||
className="ml-auto flex items-center gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{configs.enable_tg_email_push &&
|
||||
(!configs.tg_email_bot_token ||
|
||||
!configs.tg_email_chat_id) && (
|
||||
<Badge variant="yellow">
|
||||
<Icons.warning className="mr-1 size-3" />{" "}
|
||||
{t("Need to configure")}
|
||||
</Badge>
|
||||
)}
|
||||
<Switch
|
||||
defaultChecked={configs.enable_tg_email_push}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_tg_email_push", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
<Icons.chevronDown className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4 space-y-4 rounded-md border p-4 shadow-md">
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("Telegram Bot Token")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set Telegram bot token, Only works when Telegram pusher is enabled",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Input
|
||||
className="bg-white dark:bg-neutral-700"
|
||||
placeholder="Enter your Telegram bot token"
|
||||
type="password"
|
||||
value={tgBotToken}
|
||||
disabled={!configs.enable_tg_email_push}
|
||||
onChange={(e) => setTgBotToken(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending || tgBotToken === configs.tg_email_bot_token
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(
|
||||
tgBotToken,
|
||||
"tg_email_bot_token",
|
||||
"STRING",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("Telegram Group ID")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set Telegram group ID, split by comma if more than one, such as: -10054275724,-10045343642",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
|
||||
placeholder=""
|
||||
rows={5}
|
||||
value={tgChatId}
|
||||
disabled={!configs.enable_tg_email_push}
|
||||
onChange={(e) => setTgChatId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending || tgChatId === configs.tg_email_chat_id
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(tgChatId, "tg_email_chat_id", "STRING")
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">
|
||||
{t("Telegram Message Template")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Set Telegram email message template")}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
|
||||
placeholder="Support Markdown, such as: 📧 *New Email* *From:* {{from}} *Subject:* {{subject}} ```content {{text}}```"
|
||||
rows={5}
|
||||
value={tgTemplate}
|
||||
disabled={!configs.enable_tg_email_push}
|
||||
onChange={(e) => setTgTemplate(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending || tgTemplate === configs.tg_email_template
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(
|
||||
tgTemplate,
|
||||
"tg_email_template",
|
||||
"STRING",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">
|
||||
{t("Telegram Push Email White List")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Set Telegram push email white list, split by comma, if not set, will push all emails",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<Textarea
|
||||
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
|
||||
placeholder=""
|
||||
rows={5}
|
||||
value={tgWhiteList}
|
||||
disabled={!configs.enable_tg_email_push}
|
||||
onChange={(e) => setTgWhiteList(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="h-9 text-nowrap"
|
||||
disabled={
|
||||
isPending ||
|
||||
tgWhiteList === configs.tg_email_target_white_list
|
||||
}
|
||||
onClick={() =>
|
||||
handleChange(
|
||||
tgWhiteList,
|
||||
"tg_email_target_white_list",
|
||||
"STRING",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible className="group">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
|
||||
<div className="text-lg font-bold">{t("Subdomain Configs")}</div>
|
||||
<Icons.chevronDown className="ml-auto size-4" />
|
||||
<Icons.globeLock className="ml-3 size-4 transition-all group-hover:scale-110" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("Subdomain Apply Mode")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Enable subdomain apply mode, each submission requires administrator review",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<Switch
|
||||
defaultChecked={configs.enable_subdomain_apply}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(v, "enable_subdomain_apply", "BOOLEAN")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">
|
||||
{t("Application Status Email Notifications")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Send email notifications for subdomain application status updates; Notifies administrators when users submit applications and notifies users of approval results; Only available when subdomain application mode is enabled",
|
||||
)}
|
||||
. (Brevo)
|
||||
</p>
|
||||
</div>
|
||||
{configs && (
|
||||
<Switch
|
||||
defaultChecked={configs.enable_subdomain_status_email_pusher}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(
|
||||
v,
|
||||
"enable_subdomain_status_email_pusher",
|
||||
"BOOLEAN",
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
458
app/(protected)/admin/system/domain-list.tsx
Normal file
458
app/(protected)/admin/system/domain-list.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { User } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { DomainFormData } from "@/lib/dto/domains";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DomainForm } from "@/components/forms/domain-form";
|
||||
import { FormType } from "@/components/forms/record-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
import { TimeAgoIntl } from "@/components/shared/time-ago";
|
||||
|
||||
export interface DomainListProps {
|
||||
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
|
||||
action: string;
|
||||
}
|
||||
|
||||
function TableColumnSekleton() {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-7">
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DomainList({ user, action }: DomainListProps) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const t = useTranslations("List");
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [isShowDuplicateForm, setShowDuplicateForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditDomain, setCurrentEditDomain] =
|
||||
useState<DomainFormData | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(15);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
slug: "",
|
||||
target: "",
|
||||
userName: "",
|
||||
});
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR<{
|
||||
total: number;
|
||||
list: DomainFormData[];
|
||||
}>(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
mutate(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeStatus = async (
|
||||
checked: boolean,
|
||||
target: string,
|
||||
domain: DomainFormData,
|
||||
) => {
|
||||
const res = await fetch(action, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
id: domain.id,
|
||||
enable_short_link:
|
||||
target === "enable_short_link" ? checked : domain.enable_short_link,
|
||||
enable_email: target === "enable_email" ? checked : domain.enable_email,
|
||||
enable_dns: target === "enable_dns" ? checked : domain.enable_dns,
|
||||
active: target === "active" ? checked : domain.active,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data) {
|
||||
toast.success("Saved");
|
||||
handleRefresh();
|
||||
}
|
||||
} else {
|
||||
toast.error("Activation failed!");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
startTransition(async () => {
|
||||
const response = await fetch(`${action}/duplicate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
domain: currentEditDomain?.domain_name,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Duplicate Failed!", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
toast.success(`Duplicate successfully!`);
|
||||
setShowDuplicateForm(false);
|
||||
handleRefresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-lg font-bold">
|
||||
<span className="text-nowrap">{t("Total Domains")}:</span>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-6 w-16" />
|
||||
) : (
|
||||
<span>{data && data.total}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditDomain(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">{t("Add Domain")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder={t("Search by domain name") + "..."}
|
||||
value={searchParams.target}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
target: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.target && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() =>
|
||||
setSearchParams({ ...searchParams, target: "" })
|
||||
}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-7">
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
{t("Domain Name")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Shorten Service")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Email Service")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Subdomain Service")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
{t("Active")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
{t("Updated")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
{t("Actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((domain) => (
|
||||
<div className="border-b" key={domain.id}>
|
||||
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-7">
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Link
|
||||
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
||||
href={`https://${domain.domain_name}`}
|
||||
target="_blank"
|
||||
prefetch={false}
|
||||
title={domain.domain_name}
|
||||
>
|
||||
{domain.domain_name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_short_link}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(
|
||||
value,
|
||||
"enable_short_link",
|
||||
domain,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_email}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "enable_email", domain)
|
||||
}
|
||||
/>
|
||||
{domain.email_provider === "Resend" &&
|
||||
domain.resend_api_key && (
|
||||
<Icons.resend className="mx-0.5 size-4" />
|
||||
)}
|
||||
{domain.email_provider === "Brevo" &&
|
||||
domain.brevo_api_key && (
|
||||
<Icons.brevo className="mx-0.5 size-4" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_dns}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "enable_dns", domain)
|
||||
}
|
||||
/>
|
||||
{domain.cf_zone_id &&
|
||||
domain.cf_api_key &&
|
||||
domain.cf_email && (
|
||||
<Icons.cloudflare className="mx-0.5 size-4" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
disabled
|
||||
defaultChecked={domain.active}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "active", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center truncate">
|
||||
<TimeAgoIntl date={domain.updatedAt as Date} />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="size-[25px] p-1.5"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icons.moreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2 text-nowrap"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setCurrentEditDomain(domain);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
{/* <PenLine className="mx-0.5 size-4" /> */}
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setCurrentEditDomain(domain);
|
||||
setShowDuplicateForm(false);
|
||||
setShowDuplicateForm(!isShowDuplicateForm);
|
||||
}}
|
||||
>
|
||||
{t("Duplicate")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder className="shadow-none">
|
||||
<EmptyPlaceholder.Icon name="globeLock" />
|
||||
<EmptyPlaceholder.Title>
|
||||
{t("No Domains")}
|
||||
</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any domains yet. Start creating one.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* form */}
|
||||
<Modal
|
||||
className="md:max-w-2xl"
|
||||
showModal={isShowForm}
|
||||
setShowModal={setShowForm}
|
||||
>
|
||||
<DomainForm
|
||||
user={{ id: user.id, name: user.name || "" }}
|
||||
isShowForm={isShowForm}
|
||||
setShowForm={setShowForm}
|
||||
type={formType}
|
||||
initData={currentEditDomain}
|
||||
action={action}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
showModal={isShowDuplicateForm}
|
||||
setShowModal={setShowDuplicateForm}
|
||||
>
|
||||
<div className="flex flex-col items-start border-b p-4 pt-8 sm:px-16">
|
||||
<h2 className="mb-2 text-lg font-bold">
|
||||
{t("Confirm duplicate domain")} ?
|
||||
</h2>
|
||||
<p>
|
||||
{t(
|
||||
"This will duplicate all configuration information for the {domain} domain, and create a new domain",
|
||||
{ domain: currentEditDomain?.domain_name || "" },
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex w-full items-center justify-between gap-2">
|
||||
<Button
|
||||
type="reset"
|
||||
variant="destructive"
|
||||
className="w-[100px] px-0"
|
||||
onClick={() => setShowDuplicateForm(false)}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full text-nowrap"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDuplicate()}
|
||||
>
|
||||
{t("Duplicate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DomainInfo({ domain }: { domain: DomainFormData }) {
|
||||
return <>{domain.domain_name}</>;
|
||||
}
|
||||
11
app/(protected)/admin/system/domains/loading.tsx
Normal file
11
app/(protected)/admin/system/domains/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function SystemSettingsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
<Skeleton className="h-56 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
app/(protected)/admin/system/domains/page.tsx
Normal file
38
app/(protected)/admin/system/domains/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
|
||||
import DomainList from "../domain-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "System Settings",
|
||||
description: "",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DomainList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/admin/domain"
|
||||
/>
|
||||
<p className="rounded-md border border-dashed bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<strong>Note</strong>: Once the domain is bound to the project, it will
|
||||
be used as a business domain to provide services, and direct access to
|
||||
the business domain will redirect to the main site you deploy.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
app/(protected)/admin/system/loading.tsx
Normal file
13
app/(protected)/admin/system/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function SystemSettingsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="System Settings" text="" />
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
<Skeleton className="h-56 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
app/(protected)/admin/system/page.tsx
Normal file
29
app/(protected)/admin/system/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import AppConfigs from "./app-configs";
|
||||
import DomainList from "./domain-list";
|
||||
import PlanList from "./plan-list";
|
||||
import S3Configs from "./s3-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "System Settings",
|
||||
description: "",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="System Settings" text="" />
|
||||
<AppConfigs />
|
||||
<S3Configs />
|
||||
</>
|
||||
);
|
||||
}
|
||||
269
app/(protected)/admin/system/plan-list.tsx
Normal file
269
app/(protected)/admin/system/plan-list.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { PlanQuotaFormData } from "@/lib/dto/plan";
|
||||
import { fetcher, nFormatter } from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { PlanForm } from "@/components/forms/plan-form";
|
||||
import { FormType } from "@/components/forms/record-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
import { TimeAgoIntl } from "@/components/shared/time-ago";
|
||||
|
||||
export interface PlanListProps {
|
||||
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
|
||||
action: string;
|
||||
}
|
||||
|
||||
function TableColumnSekleton() {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-8">
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanList({ user, action }: PlanListProps) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const t = useTranslations("List");
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditPlan, setCurrentEditPlan] =
|
||||
useState<PlanQuotaFormData | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(15);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
slug: "",
|
||||
target: "",
|
||||
userName: "",
|
||||
});
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR<{
|
||||
total: number;
|
||||
list: PlanQuotaFormData[];
|
||||
}>(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
mutate(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-lg font-bold">
|
||||
<span className="text-nowrap">{t("Quota Settings")}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditPlan(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">{t("Add Plan")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-8">
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
{t("Plan Name")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Short Limit")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Email Limit")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Send Limit")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
{t("Record Limit")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
{t("Active")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
{t("Updated")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
{t("Actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((plan) => (
|
||||
<div className="border-b" key={plan.id}>
|
||||
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-8">
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
{plan.name}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
{nFormatter(plan.slNewLinks)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
{nFormatter(plan.emEmailAddresses)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
{nFormatter(plan.emSendEmails)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
{nFormatter(plan.rcNewRecords)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
disabled
|
||||
defaultChecked={plan.isActive}
|
||||
// onCheckedChange={(value) =>
|
||||
// handleChangeStatus(value, "active", domain)
|
||||
// }
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center truncate">
|
||||
<TimeAgoIntl date={plan.updatedAt as Date} />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditPlan(plan);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden text-nowrap sm:block">
|
||||
{t("Edit")}
|
||||
</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder className="shadow-none">
|
||||
<EmptyPlaceholder.Icon name="settings" />
|
||||
<EmptyPlaceholder.Title>
|
||||
{t("No Plans")}
|
||||
</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any plans yet. Start creating one.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* form */}
|
||||
<Modal
|
||||
className="md:max-w-2xl"
|
||||
showModal={isShowForm}
|
||||
setShowModal={setShowForm}
|
||||
>
|
||||
<PlanForm
|
||||
user={{ id: user.id, name: user.name || "" }}
|
||||
isShowForm={isShowForm}
|
||||
setShowForm={setShowForm}
|
||||
type={formType}
|
||||
initData={currentEditPlan}
|
||||
action={action}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/(protected)/admin/system/plans/loading.tsx
Normal file
11
app/(protected)/admin/system/plans/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function SystemSettingsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
<Skeleton className="h-56 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
app/(protected)/admin/system/plans/page.tsx
Normal file
33
app/(protected)/admin/system/plans/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
|
||||
import PlanList from "../plan-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "System Settings",
|
||||
description: "",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlanList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/admin/plan"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
711
app/(protected)/admin/system/s3-list.tsx
Normal file
711
app/(protected)/admin/system/s3-list.tsx
Normal file
@@ -0,0 +1,711 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { BucketItem, CloudStorageCredentials } from "@/lib/s3";
|
||||
import { cn, fetcher, formatFileSize } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
export default function S3Configs({}: {}) {
|
||||
const t = useTranslations("Setting");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [s3Configs, setS3Configs] = useState<CloudStorageCredentials[]>([]);
|
||||
|
||||
const {
|
||||
data: configs,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<Record<string, any>>("/api/admin/s3", fetcher);
|
||||
|
||||
const S3_PRVIDERS = [
|
||||
{
|
||||
label: "Cloudflare R2",
|
||||
value: "Cloudflare R2",
|
||||
platform: "cloudflare",
|
||||
channel: "r2",
|
||||
},
|
||||
{ label: "AWS S3", value: "AWS S3", platform: "aws", channel: "s3" },
|
||||
{
|
||||
label: "Tencent COS",
|
||||
value: "Tencent COS",
|
||||
platform: "tencent",
|
||||
channel: "cos",
|
||||
},
|
||||
{ label: "Ali OSS", value: "Ali OSS", platform: "ali", channel: "oss" },
|
||||
{
|
||||
label: "Custom Provider",
|
||||
value: "Custom Provider",
|
||||
platform: "custom provider",
|
||||
channel: "cp",
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (configs && configs?.s3_config_list) {
|
||||
setS3Configs(configs.s3_config_list);
|
||||
}
|
||||
}, [configs]);
|
||||
|
||||
function isProviderNameUnique(array: CloudStorageCredentials[]): boolean {
|
||||
const names = array.map((item) => item.provider_name);
|
||||
return new Set(names).size === names.length;
|
||||
}
|
||||
|
||||
const handleSaveConfigs = (value: any, key: string, type: string) => {
|
||||
if (!isProviderNameUnique(s3Configs)) {
|
||||
toast.error("Provider name must be unique");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/admin/s3", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key, value, type }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success("Saved");
|
||||
mutate();
|
||||
} else {
|
||||
toast.error("Failed to save", {
|
||||
description: await res.text(),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const canSaveR2Credentials = useMemo(() => {
|
||||
if (!configs) return true;
|
||||
|
||||
return (
|
||||
Object.keys(s3Configs).some(
|
||||
(key) => s3Configs[key] !== configs.s3_config_list[key],
|
||||
) || configs.s3_config_list.length !== s3Configs.length
|
||||
);
|
||||
}, [s3Configs, configs]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-48 w-full rounded-lg" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 bg-neutral-50 px-4 py-5 dark:bg-neutral-900">
|
||||
<p className="mr-auto text-lg font-bold">
|
||||
{t("Cloud Storage Configs")}
|
||||
</p>
|
||||
{canSaveR2Credentials && (
|
||||
<Button
|
||||
className="h-7 px-2 py-1 text-xs"
|
||||
size={"sm"}
|
||||
disabled={isPending || !canSaveR2Credentials}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSaveConfigs(s3Configs, "s3_config_list", "OBJECT");
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.spinner className="mr-1 size-4 animate-spin" />
|
||||
) : null}
|
||||
{t("Save Modifications")}
|
||||
</Button>
|
||||
)}
|
||||
<p
|
||||
className="flex h-[30px] items-center gap-1 rounded-md border bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setS3Configs([
|
||||
...s3Configs,
|
||||
{
|
||||
platform: "cloudflare",
|
||||
channel: "s3",
|
||||
provider_name: `Cloudflare R2 (${s3Configs.length + 1})`,
|
||||
account_id: "",
|
||||
access_key_id: "",
|
||||
secret_access_key: "",
|
||||
endpoint: "",
|
||||
enabled: true,
|
||||
buckets: [
|
||||
{
|
||||
bucket: "",
|
||||
prefix: "",
|
||||
file_types: "",
|
||||
region: "auto",
|
||||
custom_domain: "",
|
||||
file_size: "26214400",
|
||||
max_storage: "1073741824",
|
||||
max_files: "1000",
|
||||
public: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-3" />
|
||||
{t("Add Provider")}
|
||||
</p>
|
||||
<Icons.chevronDown className="size-4" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
{s3Configs.map((config, index) => {
|
||||
const updateBucket = (
|
||||
bucketIndex: number,
|
||||
updates: Partial<BucketItem>,
|
||||
) => {
|
||||
const newBuckets = [...config.buckets];
|
||||
newBuckets[bucketIndex] = {
|
||||
...newBuckets[bucketIndex],
|
||||
...updates,
|
||||
};
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
buckets: newBuckets,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn(
|
||||
index !== s3Configs.length - 1 && "border-b pb-3",
|
||||
"group",
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3">
|
||||
<p className="mr-auto font-semibold group-hover:font-bold">
|
||||
{config.provider_name}
|
||||
</p>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{t("{length} Buckets", {
|
||||
length: config.buckets.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Icons.trash
|
||||
className="size-6 rounded border p-1 text-muted-foreground hover:border-red-500 hover:bg-red-50 hover:text-red-500"
|
||||
onClick={() => {
|
||||
setS3Configs(s3Configs.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
<Icons.chevronDown className="size-4" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 space-y-4 rounded-lg border p-6 shadow-md transition-colors duration-75 group-hover:bg-primary-foreground">
|
||||
{/* Base */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Provider")}*</Label>
|
||||
<Select
|
||||
value={`${config.platform} (${config.channel})`}
|
||||
onValueChange={(v) => {
|
||||
const provider = S3_PRVIDERS.find(
|
||||
(p) => `${p.platform} (${p.channel})` === v,
|
||||
);
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
provider_name: `${provider?.value} (${index + 1})`,
|
||||
channel: provider?.channel || "",
|
||||
platform: provider?.platform || "",
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-neutral-100 dark:bg-neutral-800">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{S3_PRVIDERS.map((provider) => (
|
||||
<SelectItem
|
||||
key={`${provider.platform} (${provider.channel})`}
|
||||
value={`${provider.platform} (${provider.channel})`}
|
||||
>
|
||||
{provider.platform} ({provider.channel})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
{t("Provider Unique Name")}* ({t("Unique")})
|
||||
</Label>
|
||||
<Input
|
||||
value={config.provider_name}
|
||||
placeholder="provider display name"
|
||||
onChange={(e) =>
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
provider_name: e.target.value,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Endpoint")}*</Label>
|
||||
<Input
|
||||
value={config.endpoint}
|
||||
placeholder="https://<account_id>.r2.cloudflarestorage.com"
|
||||
onChange={(e) =>
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
endpoint: e.target.value,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Access Key ID")}*</Label>
|
||||
<Input
|
||||
value={config.access_key_id}
|
||||
onChange={(e) =>
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
access_key_id: e.target.value,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Secret Access Key")}*</Label>
|
||||
<Input
|
||||
value={config.secret_access_key}
|
||||
onChange={(e) =>
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
secret_access_key: e.target.value,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center space-y-3">
|
||||
<Label>{t("Enabled")}*</Label>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(e) =>
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
enabled: e,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* buckets */}
|
||||
{config.buckets.map((bucket, index2) => (
|
||||
<motion.div
|
||||
className="relative grid grid-cols-1 gap-4 rounded-lg border border-dashed border-muted-foreground px-3 pb-3 pt-10 text-neutral-600 dark:text-neutral-400 sm:grid-cols-4"
|
||||
key={`bucket-${index2}`}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{
|
||||
layout: { duration: 0.3, ease: "easeInOut" },
|
||||
opacity: { duration: 0.2 },
|
||||
scale: { duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<p className="absolute left-2 top-3 text-xs text-muted-foreground">
|
||||
{t("Bucket")} {index2 + 1}
|
||||
</p>
|
||||
|
||||
{/* 按钮部分 */}
|
||||
<div className="absolute right-2 top-2 flex items-center justify-between space-x-2">
|
||||
{index2 > 0 && (
|
||||
<Button
|
||||
className="h-[30px] px-1.5"
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
onClick={() => {
|
||||
const newBuckets = [...config.buckets];
|
||||
newBuckets.splice(index2, 1);
|
||||
newBuckets.splice(index2 - 1, 0, bucket);
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
buckets: newBuckets,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icons.arrowUp className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{index2 < config.buckets.length - 1 && (
|
||||
<Button
|
||||
className="h-[30px] px-1.5"
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
onClick={() => {
|
||||
const newBuckets = [...config.buckets];
|
||||
newBuckets.splice(index2, 1);
|
||||
newBuckets.splice(index2 + 1, 0, bucket);
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
buckets: newBuckets,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icons.arrowDown className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="ml-auto h-[30px] px-1.5"
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
const newBuckets = [...config.buckets];
|
||||
newBuckets.splice(index2 + 1, 0, {
|
||||
bucket: "",
|
||||
prefix: "",
|
||||
file_types: "",
|
||||
region: "auto",
|
||||
custom_domain: "",
|
||||
file_size: "26214400",
|
||||
max_storage: "1073741824",
|
||||
max_files: "1000",
|
||||
public: true,
|
||||
});
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
buckets: newBuckets,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
</Button>
|
||||
{index2 !== 0 && (
|
||||
<Button
|
||||
className="h-[30px] px-1.5"
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
const newBuckets = [...config.buckets];
|
||||
newBuckets.splice(index2, 1);
|
||||
setS3Configs(
|
||||
s3Configs.map((c, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...c,
|
||||
buckets: newBuckets,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icons.trash className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用 updateBucket 函数的输入字段 */}
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Bucket Name")}*</Label>
|
||||
<Input
|
||||
value={bucket.bucket}
|
||||
placeholder="bucket name"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, { bucket: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Public Domain")}*</Label>
|
||||
<Input
|
||||
value={bucket.custom_domain}
|
||||
placeholder="https://endpoint or custom domain"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, {
|
||||
custom_domain: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Region")}</Label>
|
||||
<Input
|
||||
value={bucket.region}
|
||||
placeholder="auto"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, { region: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
{t("Prefix")} ({t("Optional")})
|
||||
</Label>
|
||||
<Input
|
||||
value={bucket.prefix}
|
||||
placeholder="2025/08/08"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, { prefix: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label>{t("Max File Size")}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<Icons.help className="size-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64 text-wrap">
|
||||
{t("maxFileSizeTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={bucket.file_size}
|
||||
placeholder="26214400"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, {
|
||||
file_size: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{bucket.file_size && (
|
||||
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
|
||||
≈{formatFileSize(Number(bucket.file_size))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("Max File Count")}</Label>
|
||||
<Input
|
||||
value={bucket.max_files}
|
||||
placeholder="1000"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, {
|
||||
max_files: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label>{t("Max Storage")}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<Icons.help className="size-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64 text-wrap">
|
||||
{t("maxStorageTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={bucket.max_storage}
|
||||
placeholder="10737418240"
|
||||
onChange={(e) =>
|
||||
updateBucket(index2, {
|
||||
max_storage: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{bucket.max_storage && (
|
||||
<span className="absolute right-2 top-[11px] text-xs text-muted-foreground">
|
||||
≈{formatFileSize(Number(bucket.max_storage))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center space-y-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Label>{t("Public")}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<Icons.help className="size-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-56 text-wrap">
|
||||
{t(
|
||||
"Publicize this storage bucket, all registered users can upload files to this storage bucket; If not public, only administrators can upload files to this storage bucket",
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Switch
|
||||
checked={bucket.public}
|
||||
onCheckedChange={(e) =>
|
||||
updateBucket(index2, { public: e })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{/* actions */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
className="text-sm text-blue-500 hover:underline"
|
||||
href="/docs/developer/cloud-storage"
|
||||
target="_blank"
|
||||
>
|
||||
{t("How to get the S3 credentials?")}
|
||||
</Link>
|
||||
{/* <Button
|
||||
className="ml-auto"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setS3Configs([
|
||||
{
|
||||
platform: "cloudflare",
|
||||
channel: "r2",
|
||||
provider_name: "Cloudflare R2",
|
||||
endpoint: "",
|
||||
access_key_id: "",
|
||||
secret_access_key: "",
|
||||
buckets: [
|
||||
{
|
||||
bucket: "",
|
||||
prefix: "",
|
||||
file_types: "",
|
||||
region: "auto",
|
||||
custom_domain: "",
|
||||
file_size: "26214400",
|
||||
max_storage: "",
|
||||
public: true,
|
||||
},
|
||||
],
|
||||
account_id: "",
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Button> */}
|
||||
<Button
|
||||
disabled={isPending || !canSaveR2Credentials}
|
||||
onClick={() => {
|
||||
handleSaveConfigs(
|
||||
s3Configs,
|
||||
"s3_config_list",
|
||||
"OBJECT",
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.spinner className="mr-1 size-4 animate-spin" />
|
||||
) : null}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
9
app/(protected)/admin/urls/analytics/loading.tsx
Normal file
9
app/(protected)/admin/urls/analytics/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
app/(protected)/admin/urls/analytics/page.tsx
Normal file
22
app/(protected)/admin/urls/analytics/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import Globe from "@/app/(protected)/dashboard/urls/globe";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Globe Analytics",
|
||||
description: "Display link's globe analytics.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Globe isAdmin />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Short Urls" text="" />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
|
||||
11
app/(protected)/admin/urls/logs/loading.tsx
Normal file
11
app/(protected)/admin/urls/logs/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Live Logs" text="" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
app/(protected)/admin/urls/logs/page.tsx
Normal file
24
app/(protected)/admin/urls/logs/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import LiveLog from "@/app/(protected)/dashboard/urls/live-logs";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Live Logs",
|
||||
description: "Display link's real-time live logs.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Live Logs" text="" />
|
||||
<LiveLog live={true} admin />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,12 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import LiveLog from "../../dashboard/urls/live-logs";
|
||||
import UserUrlsList from "../../dashboard/urls/url-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Short URLs - WR.DO",
|
||||
description: "List and manage records.",
|
||||
title: "Links",
|
||||
description: "List and manage short links.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
@@ -19,12 +17,6 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage Short URLs"
|
||||
text="List and manage short urls."
|
||||
link="/docs/short-urls"
|
||||
linkText="short urls."
|
||||
/>
|
||||
<UserUrlsList
|
||||
user={{
|
||||
id: user.id,
|
||||
@@ -35,7 +27,6 @@ export default async function DashboardPage() {
|
||||
}}
|
||||
action="/api/url/admin"
|
||||
/>
|
||||
<LiveLog admin={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function OrdersLoading() {
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="User Management"
|
||||
text="List and manage all users."
|
||||
text="List and manage all users"
|
||||
/>
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UsersList from "./user-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "User Management – WR.DO",
|
||||
title: "User Management",
|
||||
description: "List and manage all users.",
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function UsersPage() {
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="User Management"
|
||||
text="List and manage all users."
|
||||
text="List and manage all users"
|
||||
/>
|
||||
<UsersList user={{ id: user.id, name: user.name || "" }} />
|
||||
</>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { PenLine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { fetcher, timeAgo } from "@/lib/utils";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -33,13 +34,11 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import StatusDot from "@/components/dashboard/status-dot";
|
||||
import { UserForm } from "@/components/forms/user-form";
|
||||
import { FormType, UserForm } from "@/components/forms/user-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
|
||||
import CountUpFn from "../../../../components/dashboard/count-up";
|
||||
import { TimeAgoIntl } from "@/components/shared/time-ago";
|
||||
|
||||
export interface UrlListProps {
|
||||
user: Pick<User, "id" | "name">;
|
||||
@@ -74,15 +73,19 @@ function TableColumnSekleton({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
export default function UsersList({ user }: UrlListProps) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [currentEditUser, setcurrentEditUser] = useState<User | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(15);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
email: "",
|
||||
userName: "",
|
||||
});
|
||||
|
||||
const t = useTranslations("List");
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR<{ total: number; list: User[] }>(
|
||||
`/api/user/admin?page=${currentPage}&size=${pageSize}&email=${searchParams.email}&userName=${searchParams.userName}`,
|
||||
@@ -104,10 +107,8 @@ export default function UsersList({ user }: UrlListProps) {
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
<CardDescription className="text-balance text-lg font-bold">
|
||||
<span>Total Users:</span>{" "}
|
||||
<span className="font-bold">
|
||||
{data && <CountUpFn count={data.total} />}
|
||||
</span>
|
||||
<span>{t("Total Users")}:</span>{" "}
|
||||
<span className="font-bold">{data && data.total}</span>
|
||||
</CardDescription>
|
||||
<div className="ml-auto flex items-center justify-end gap-3">
|
||||
<Button
|
||||
@@ -116,11 +117,24 @@ export default function UsersList({ user }: UrlListProps) {
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setcurrentEditUser(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">{t("Add User")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -179,25 +193,25 @@ export default function UsersList({ user }: UrlListProps) {
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-8">
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Name
|
||||
{t("Name")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Email
|
||||
{t("Email")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
|
||||
Role
|
||||
{t("Role")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
|
||||
Plan
|
||||
{t("Plan")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
|
||||
Status
|
||||
{t("Status")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
|
||||
Join
|
||||
{t("Join")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center justify-center font-bold">
|
||||
Actions
|
||||
{t("Actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -240,19 +254,19 @@ export default function UsersList({ user }: UrlListProps) {
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{user.role}
|
||||
{t(user.role)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{user.team?.toLocaleUpperCase()}
|
||||
{user.team}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
<Switch defaultChecked={user.active === 1} />
|
||||
<Switch defaultChecked={user.active === 1} disabled />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
{timeAgo(user.createdAt || "")}
|
||||
<TimeAgoIntl date={user.updatedAt as Date} />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex justify-center">
|
||||
<Button
|
||||
@@ -262,17 +276,18 @@ export default function UsersList({ user }: UrlListProps) {
|
||||
onClick={() => {
|
||||
setcurrentEditUser(user);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p>Edit</p>
|
||||
<p className="text-nowrap">{t("Edit")}</p>
|
||||
<PenLine className="ml-1 size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder className="shadow-none">
|
||||
<EmptyPlaceholder.Icon name="users" />
|
||||
<EmptyPlaceholder.Title>No users</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
@@ -283,6 +298,7 @@ export default function UsersList({ user }: UrlListProps) {
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
@@ -303,7 +319,7 @@ export default function UsersList({ user }: UrlListProps) {
|
||||
user={{ id: user.id, name: user.name || "" }}
|
||||
isShowForm={isShowForm}
|
||||
setShowForm={setShowForm}
|
||||
type="edit"
|
||||
type={formType}
|
||||
initData={currentEditUser}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Dashboard" text="" />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { UserRole } from "@prisma/client";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getAllUserEmailsCount } from "@/lib/dto/email";
|
||||
import { getPlanQuota, PlanQuota } from "@/lib/dto/plan";
|
||||
import { getUserShortUrlCount } from "@/lib/dto/short-urls";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
@@ -13,24 +13,22 @@ import {
|
||||
DashboardInfoCard,
|
||||
HeroCard,
|
||||
} from "@/components/dashboard/dashboard-info-card";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import { ErrorBoundary } from "@/components/shared/error-boundary";
|
||||
|
||||
import UserRecordsList from "./records/record-list";
|
||||
import LiveLog from "./urls/live-logs";
|
||||
import UserUrlsList from "./urls/url-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Dashboard - WR.DO",
|
||||
title: "Dashboard",
|
||||
description: "List and manage records.",
|
||||
});
|
||||
|
||||
async function EmailHeroCardSection({
|
||||
userId,
|
||||
team,
|
||||
plan,
|
||||
}: {
|
||||
userId: string;
|
||||
team: string;
|
||||
plan: PlanQuota;
|
||||
}) {
|
||||
const email_count = await getAllUserEmailsCount(userId);
|
||||
|
||||
@@ -38,17 +36,17 @@ async function EmailHeroCardSection({
|
||||
<HeroCard
|
||||
total={email_count.total}
|
||||
monthTotal={email_count.month_total}
|
||||
limit={TeamPlanQuota[team].EM_EmailAddresses}
|
||||
limit={plan.emEmailAddresses}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function ShortUrlsCardSection({
|
||||
userId,
|
||||
team,
|
||||
plan,
|
||||
}: {
|
||||
userId: string;
|
||||
team: string;
|
||||
plan: PlanQuota;
|
||||
}) {
|
||||
const url_count = await getUserShortUrlCount(userId);
|
||||
|
||||
@@ -58,7 +56,7 @@ async function ShortUrlsCardSection({
|
||||
title="Short URLs"
|
||||
total={url_count.total}
|
||||
monthTotal={url_count.month_total}
|
||||
limit={TeamPlanQuota[team].SL_NewLinks}
|
||||
limit={plan.slNewLinks}
|
||||
link="/dashboard/urls"
|
||||
icon="link"
|
||||
/>
|
||||
@@ -67,10 +65,10 @@ async function ShortUrlsCardSection({
|
||||
|
||||
async function DnsRecordsCardSection({
|
||||
userId,
|
||||
team,
|
||||
plan,
|
||||
}: {
|
||||
userId: string;
|
||||
team: string;
|
||||
plan: PlanQuota;
|
||||
}) {
|
||||
const record_count = await getUserRecordCount(userId);
|
||||
|
||||
@@ -80,17 +78,13 @@ async function DnsRecordsCardSection({
|
||||
title="DNS Records"
|
||||
total={record_count.total}
|
||||
monthTotal={record_count.month_total}
|
||||
limit={TeamPlanQuota[team].RC_NewRecords}
|
||||
limit={plan.rcNewRecords}
|
||||
link="/dashboard/records"
|
||||
icon="globeLock"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function LiveLogSection() {
|
||||
return <LiveLog admin={false} />;
|
||||
}
|
||||
|
||||
async function UserUrlsListSection({
|
||||
user,
|
||||
}: {
|
||||
@@ -119,7 +113,13 @@ async function UserUrlsListSection({
|
||||
async function UserRecordsListSection({
|
||||
user,
|
||||
}: {
|
||||
user: { id: string; name: string; apiKey: string; email: string };
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<UserRecordsList
|
||||
@@ -128,6 +128,7 @@ async function UserRecordsListSection({
|
||||
name: user.name,
|
||||
apiKey: user.apiKey,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
}}
|
||||
action="/api/record"
|
||||
/>
|
||||
@@ -139,9 +140,10 @@ export default async function DashboardPage() {
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
const plan = await getPlanQuota(user.team);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Dashboard" text="" />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-3">
|
||||
<ErrorBoundary
|
||||
@@ -150,7 +152,7 @@ export default async function DashboardPage() {
|
||||
<Suspense
|
||||
fallback={<Skeleton className="h-32 w-full rounded-lg" />}
|
||||
>
|
||||
<EmailHeroCardSection userId={user.id} team={user.team} />
|
||||
<EmailHeroCardSection userId={user.id} plan={plan} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary
|
||||
@@ -159,7 +161,7 @@ export default async function DashboardPage() {
|
||||
<Suspense
|
||||
fallback={<Skeleton className="h-32 w-full rounded-lg" />}
|
||||
>
|
||||
<ShortUrlsCardSection userId={user.id} team={user.team} />
|
||||
<ShortUrlsCardSection userId={user.id} plan={plan} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary
|
||||
@@ -168,17 +170,25 @@ export default async function DashboardPage() {
|
||||
<Suspense
|
||||
fallback={<Skeleton className="h-32 w-full rounded-lg" />}
|
||||
>
|
||||
<DnsRecordsCardSection userId={user.id} team={user.team} />
|
||||
<DnsRecordsCardSection userId={user.id} plan={plan} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<ErrorBoundary
|
||||
fallback={<Skeleton className="h-[200px] w-full rounded-lg" />}
|
||||
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
|
||||
>
|
||||
<Suspense
|
||||
fallback={<Skeleton className="h-[200px] w-full rounded-lg" />}
|
||||
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
|
||||
>
|
||||
<LiveLogSection />
|
||||
<UserRecordsListSection
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary
|
||||
@@ -198,22 +208,6 @@ export default async function DashboardPage() {
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary
|
||||
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
|
||||
>
|
||||
<Suspense
|
||||
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
|
||||
>
|
||||
<UserRecordsListSection
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,15 @@ export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records."
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records"
|
||||
/>
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { redirect } from "next/navigation";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import { UserRecordStatus } from "@/components/dashboard/status-card";
|
||||
|
||||
import UserRecordsList from "./record-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "DNS Records - WR.DO",
|
||||
title: "DNS Records",
|
||||
description: "List and manage records.",
|
||||
});
|
||||
|
||||
@@ -19,17 +20,19 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records."
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records"
|
||||
link="/docs/dns-records"
|
||||
linkText="DNS records."
|
||||
linkText="DNS records"
|
||||
/>
|
||||
<UserRecordStatus action="/api/record" />
|
||||
<UserRecordsList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
}}
|
||||
action="/api/record"
|
||||
/>
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { PenLine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { TTL_ENUMS } from "@/lib/enums";
|
||||
import { fetcher, timeAgo } from "@/lib/utils";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
ClickableTooltip,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
@@ -39,20 +42,18 @@ import {
|
||||
import { FormType, RecordForm } from "@/components/forms/record-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import {
|
||||
LinkInfoPreviewer,
|
||||
LinkPreviewer,
|
||||
} from "@/components/shared/link-previewer";
|
||||
import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
import { TimeAgoIntl } from "@/components/shared/time-ago";
|
||||
|
||||
export interface RecordListProps {
|
||||
user: Pick<User, "id" | "name" | "apiKey" | "email">;
|
||||
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role">;
|
||||
action: string;
|
||||
}
|
||||
|
||||
function TableColumnSekleton() {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-8">
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-9">
|
||||
<TableCell className="col-span-1">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</TableCell>
|
||||
@@ -71,6 +72,9 @@ function TableColumnSekleton() {
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex justify-center">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
@@ -79,16 +83,20 @@ function TableColumnSekleton() {
|
||||
}
|
||||
|
||||
export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditRecord, setCurrentEditRecord] =
|
||||
useState<UserRecordFormData | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(15);
|
||||
const isAdmin = action.includes("/admin");
|
||||
|
||||
const t = useTranslations("List");
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { data, error, isLoading } = useSWR<{
|
||||
const { data, isLoading } = useSWR<{
|
||||
total: number;
|
||||
list: UserRecordFormData[];
|
||||
}>(`${action}?page=${currentPage}&size=${pageSize}`, fetcher, {
|
||||
@@ -105,7 +113,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
setChecked: (value: boolean) => void,
|
||||
) => {
|
||||
const originalState = record.active === 1;
|
||||
setChecked(checked); // 立即更新 UI
|
||||
setChecked(checked);
|
||||
|
||||
const res = await fetch(`/api/record/update`, {
|
||||
method: "PUT",
|
||||
@@ -138,32 +146,32 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
{action.includes("/admin") ? (
|
||||
{isAdmin ? (
|
||||
<CardDescription className="text-balance text-lg font-bold">
|
||||
<span>Total Records:</span>{" "}
|
||||
<span>{t("Total Subdomains")}:</span>{" "}
|
||||
<span className="font-bold">{data && data.total}</span>
|
||||
</CardDescription>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<CardTitle>DNS Records</CardTitle>
|
||||
<CardTitle>{t("Subdomain List")}</CardTitle>
|
||||
<CardDescription className="hidden text-balance sm:block">
|
||||
Please read the{" "}
|
||||
{t("Before using please read the")}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="font-semibold text-yellow-600 after:content-['↗'] hover:underline"
|
||||
href="/docs/dns-records#legitimacy-review"
|
||||
>
|
||||
Legitimacy review
|
||||
</Link>{" "}
|
||||
before using. See{" "}
|
||||
{t("legitimacy review")}
|
||||
</Link>
|
||||
. {t("See")}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="text-blue-500 hover:underline"
|
||||
href="/docs/examples/vercel"
|
||||
>
|
||||
examples
|
||||
</Link>{" "}
|
||||
for more usage.
|
||||
{t("examples")}
|
||||
</Link>
|
||||
{t("for more usage")}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
)}
|
||||
@@ -174,13 +182,13 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-[120px] shrink-0 gap-1"
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditRecord(null);
|
||||
@@ -189,34 +197,38 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add record
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">{t("Add Record")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-8">
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-9">
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Type
|
||||
{t("Type")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Name
|
||||
{t("Name")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-2 hidden items-center font-bold sm:flex">
|
||||
Content
|
||||
{t("Content")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
TTL
|
||||
{t("TTL")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
|
||||
Status
|
||||
{t("Status")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
{t("User")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
|
||||
Updated
|
||||
{t("Updated")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center justify-center font-bold">
|
||||
Actions
|
||||
{t("Actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -233,7 +245,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
data.list.map((record) => (
|
||||
<TableRow
|
||||
key={record.id}
|
||||
className="grid animate-fade-in grid-cols-3 items-center animate-in sm:grid-cols-8"
|
||||
className="grid animate-fade-in grid-cols-3 items-center animate-in sm:grid-cols-9"
|
||||
>
|
||||
<TableCell className="col-span-1">
|
||||
<Badge className="text-xs" variant="outline">
|
||||
@@ -241,11 +253,15 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1">
|
||||
<LinkInfoPreviewer
|
||||
apiKey={user.apiKey ?? ""}
|
||||
url={"https://" + record.name}
|
||||
formatUrl={record.name}
|
||||
/>
|
||||
{[0, 1].includes(record.active) ? (
|
||||
<LinkInfoPreviewer
|
||||
apiKey={user.apiKey ?? ""}
|
||||
url={"https://" + record.name}
|
||||
formatUrl={record.name}
|
||||
/>
|
||||
) : (
|
||||
record.name
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-2 hidden truncate text-nowrap sm:inline-block">
|
||||
<TooltipProvider>
|
||||
@@ -264,68 +280,167 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center justify-center gap-1 sm:flex">
|
||||
<SwitchWrapper
|
||||
record={record}
|
||||
onChangeStatu={handleChangeStatu}
|
||||
/>
|
||||
{!record.active && (
|
||||
{[0, 1].includes(record.active) && (
|
||||
<SwitchWrapper
|
||||
record={record}
|
||||
onChangeStatu={handleChangeStatu}
|
||||
/>
|
||||
)}
|
||||
{record.active === 2 && (
|
||||
<Badge
|
||||
className="text-nowrap rounded-md"
|
||||
variant={"yellow"}
|
||||
>
|
||||
{t("Pending")}
|
||||
</Badge>
|
||||
)}
|
||||
{record.active === 3 && (
|
||||
<Badge
|
||||
className="text-nowrap rounded-md"
|
||||
variant={"outline"}
|
||||
>
|
||||
{t("Rejected")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{![1, 3].includes(record.active) && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger className="truncate">
|
||||
<Icons.help className="size-4 cursor-pointer text-yellow-500 opacity-90" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<ul className="list-disc px-3">
|
||||
<li>The target is currently inaccessible.</li>
|
||||
<li>Please check the target and try again.</li>
|
||||
<li>
|
||||
If the target is not activated within 3 days,{" "}
|
||||
<br />
|
||||
the administrator will{" "}
|
||||
<strong className="text-red-500">
|
||||
delete this record
|
||||
</strong>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
{record.active === 0 && (
|
||||
<ul className="list-disc px-3">
|
||||
<li>
|
||||
{t("The target is currently inaccessible")}.
|
||||
</li>
|
||||
<li>
|
||||
{t("Please check the target and try again")}
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"If the target is not activated within 3 days",
|
||||
)}
|
||||
, <br />
|
||||
{t("the administrator will")}{" "}
|
||||
<strong className="text-red-500">
|
||||
{t("delete this record")}
|
||||
</strong>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
{record.active === 2 && (
|
||||
<ul className="list-disc px-3">
|
||||
<li>
|
||||
{t(
|
||||
"The record is currently pending for admin approval",
|
||||
)}
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
<ClickableTooltip
|
||||
className="cursor-pointer truncate"
|
||||
content={
|
||||
<div className="px-2 py-1">
|
||||
<p>{record.user.name}</p>
|
||||
<p>{record.user?.email}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{record.user.name || record.user.email}
|
||||
</ClickableTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
{timeAgo(record.modified_on as unknown as Date)}
|
||||
<TimeAgoIntl
|
||||
date={record.modified_on as unknown as Date}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex justify-center">
|
||||
<Button
|
||||
className="text-sm hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditRecord(record);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p>Edit</p>
|
||||
<PenLine className="ml-1 size-4" />
|
||||
</Button>
|
||||
{record.active === 3 ? (
|
||||
<Button
|
||||
className="h-7 text-nowrap px-1 text-xs sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditRecord(record);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden text-nowrap sm:block">
|
||||
{t("Reject")}
|
||||
</p>
|
||||
<Icons.close className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
) : [0, 1].includes(record.active) ? (
|
||||
<Button
|
||||
className="h-7 text-nowrap px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditRecord(record);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden text-nowrap sm:block">
|
||||
{t("Edit")}
|
||||
</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
) : record.active === 2 &&
|
||||
user.role === "ADMIN" &&
|
||||
isAdmin ? (
|
||||
<Button
|
||||
className="h-7 text-nowrap px-1 text-xs hover:bg-blue-400 dark:hover:text-primary-foreground sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"blue"}
|
||||
onClick={() => {
|
||||
setCurrentEditRecord(record);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden text-nowrap sm:block">
|
||||
{t("Review")}
|
||||
</p>
|
||||
<Icons.eye className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="globeLock" />
|
||||
<EmptyPlaceholder.Title>No records</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder className="shadow-none">
|
||||
<EmptyPlaceholder.Icon name="globe" />
|
||||
<EmptyPlaceholder.Title>
|
||||
{t("No Subdomains")}
|
||||
</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any record yet. Start creating record.
|
||||
You don't have any subdomain yet. Start creating
|
||||
record.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
getScrapeStatsByTypeAndUserId,
|
||||
getScrapeStatsByUserId,
|
||||
getScrapeStatsByUserId1,
|
||||
} from "@/lib/dto/scrape";
|
||||
|
||||
@@ -21,10 +20,7 @@ export default async function DashboardScrapeCharts({ id }: { id: string }) {
|
||||
return (
|
||||
<>
|
||||
{all_user_logs && all_user_logs.length > 0 && (
|
||||
<>
|
||||
<h2 className="my-1 text-xl font-semibold">Request Statistics</h2>
|
||||
<DailyPVUVChart data={all_user_logs} />
|
||||
</>
|
||||
<DailyPVUVChart data={all_user_logs} />
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{(screenshot_stats.length > 0 || meta_stats.length > 0) && (
|
||||
@@ -43,7 +39,6 @@ export default async function DashboardScrapeCharts({ id }: { id: string }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="my-1 text-xl font-semibold">Request Logs</h2>
|
||||
<LogsTable userId={id} target={"/api/v1/scraping/logs"} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { ScrapeMeta } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { isLink, nFormatter, removeUrlSuffix, timeAgo } from "@/lib/utils";
|
||||
import { isLink, nFormatter, removeUrlPrefix } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import CountUp from "@/components/dashboard/count-up";
|
||||
import { TimeAgoIntl } from "@/components/shared/time-ago";
|
||||
|
||||
const chartConfig = {
|
||||
request: {
|
||||
@@ -102,18 +103,21 @@ export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
|
||||
(a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
|
||||
);
|
||||
const latestEntry = sort_data[sort_data.length - 1];
|
||||
const latestDate = timeAgo(latestEntry.updatedAt);
|
||||
const latestFrom = latestEntry.type;
|
||||
|
||||
const t = useTranslations("Components");
|
||||
|
||||
const lastRequestInfo = t.rich("last-request-info", {
|
||||
location: latestFrom,
|
||||
timeAgo: () => <TimeAgoIntl date={latestEntry.updatedAt} />,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-5 py-4">
|
||||
<CardTitle>Total Requests of APIs in Last 30 Days</CardTitle>
|
||||
<CardDescription>
|
||||
Last request from <strong>{latestFrom}</strong> api about{" "}
|
||||
{latestDate}.
|
||||
</CardDescription>
|
||||
<CardTitle>{t("Total Requests of APIs in Last 30 Days")}</CardTitle>
|
||||
<CardDescription>{lastRequestInfo}</CardDescription>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{["request", "ip"].map((key) => {
|
||||
@@ -125,8 +129,8 @@ export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
|
||||
className="relative z-30 flex flex-1 flex-col items-center justify-center gap-1 border-t px-6 py-2 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-3"
|
||||
onClick={() => setActiveChart(chart)}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chartConfig[chart].label}
|
||||
<span className="text-nowrap text-xs text-muted-foreground">
|
||||
{t(chartConfig[chart].label)}
|
||||
</span>
|
||||
<span className="text-lg font-bold leading-none">
|
||||
{nFormatter(dataTotal[key])}
|
||||
@@ -264,7 +268,7 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
|
||||
className="truncate font-medium hover:opacity-70 hover:after:content-['↗']"
|
||||
href={ref.dimension}
|
||||
>
|
||||
{removeUrlSuffix(ref.dimension)}
|
||||
{removeUrlPrefix(ref.dimension)}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-medium">{decodeURIComponent(ref.dimension)}</p>
|
||||
|
||||
@@ -5,8 +5,8 @@ export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Scraping API Overview"
|
||||
text="Quickly extract valuable structured website data. It's free and unlimited to use!"
|
||||
heading="Scraping API Overview"
|
||||
text="Quickly extract valuable structured website data"
|
||||
/>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { nFormatter } from "@/lib/utils";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
|
||||
export interface LogsTableData {
|
||||
@@ -49,6 +50,8 @@ const LogsTable = ({ userId, target }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const t = useTranslations("Components");
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
type: "",
|
||||
ip: "",
|
||||
@@ -98,12 +101,12 @@ const LogsTable = ({ userId, target }) => {
|
||||
onChange={(e) => handleFilterChange("type", e.target.value)}
|
||||
className="h-8 max-w-xs placeholder:text-xs"
|
||||
/>
|
||||
<Input
|
||||
{/* <Input
|
||||
placeholder="Filter by IP..."
|
||||
value={filters.ip}
|
||||
onChange={(e) => handleFilterChange("ip", e.target.value)}
|
||||
className="h-8 max-w-xs placeholder:text-xs"
|
||||
/>
|
||||
/> */}
|
||||
{
|
||||
<>
|
||||
<Input
|
||||
@@ -129,9 +132,9 @@ const LogsTable = ({ userId, target }) => {
|
||||
className="ml-2 h-8 px-2 py-0"
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className={`size-4 animate-spin`} />
|
||||
<Icons.refreshCw className={`size-4 animate-spin`} />
|
||||
) : (
|
||||
<RefreshCwIcon className={`size-4`} />
|
||||
<Icons.refreshCw className={`size-4`} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -139,16 +142,19 @@ const LogsTable = ({ userId, target }) => {
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
<TableRow className="">
|
||||
<TableRow className="grid grid-cols-5 items-center sm:grid-cols-6">
|
||||
<TableHead className="hidden items-center justify-start px-2 sm:flex">
|
||||
Date
|
||||
{t("Date")}
|
||||
</TableHead>
|
||||
<TableHead className="px-2">Type</TableHead>
|
||||
<TableHead className="hidden items-center justify-start px-2 sm:flex">
|
||||
IP
|
||||
<TableHead className="flex items-center px-2">
|
||||
{t("Type")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-3 flex items-center px-2">
|
||||
{t("Link")}
|
||||
</TableHead>
|
||||
<TableHead className="flex items-center px-2">
|
||||
{t("User")}
|
||||
</TableHead>
|
||||
<TableHead className="px-2">Link</TableHead>
|
||||
<TableHead className="px-2">User</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -161,9 +167,6 @@ const LogsTable = ({ userId, target }) => {
|
||||
<TableCell>
|
||||
<Skeleton className="h-2 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:inline-block">
|
||||
<Skeleton className="h-2 w-[120px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-2 w-[200px]" />
|
||||
</TableCell>
|
||||
@@ -173,15 +176,15 @@ const LogsTable = ({ userId, target }) => {
|
||||
</TableRow>
|
||||
))
|
||||
: logs.map((log) => (
|
||||
<TableRow className="text-xs hover:bg-muted" key={log.id}>
|
||||
<TableRow
|
||||
className="grid grid-cols-5 items-center text-xs hover:bg-muted sm:grid-cols-6"
|
||||
key={log.id}
|
||||
>
|
||||
<TableCell className="hidden truncate p-2 sm:inline-block">
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">{log.type}</TableCell>
|
||||
<TableCell className="hidden p-2 sm:inline-block">
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md truncate p-2">
|
||||
<TableCell className="col-span-3 max-w-full truncate p-2">
|
||||
{log.link}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md truncate p-2">
|
||||
@@ -193,12 +196,13 @@ const LogsTable = ({ userId, target }) => {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-start justify-between gap-2 sm:items-center">
|
||||
<p className="ml-auto text-nowrap text-sm">
|
||||
{nFormatter(data?.total || 0)} logs
|
||||
</p>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
className="m-0"
|
||||
total={data.total}
|
||||
currentPage={page}
|
||||
setCurrentPage={setPage}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Scraping API" text="" />
|
||||
<DashboardHeader
|
||||
heading="Url to Markdown"
|
||||
text="Quickly extract website content and convert it to Markdown format"
|
||||
/>
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
|
||||
@@ -8,7 +8,7 @@ import ApiReference from "@/components/shared/api-reference";
|
||||
import { MarkdownScraping, TextScraping } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to Markdown API - WR.DO",
|
||||
title: "Url to Markdown API",
|
||||
description:
|
||||
"Quickly extract website content and convert it to Markdown format",
|
||||
});
|
||||
@@ -21,10 +21,10 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Url to Markdown"
|
||||
text="Quickly extract website content and convert it to Markdown format."
|
||||
heading="Url to Markdown"
|
||||
text="Quickly extract website content and convert it to Markdown format"
|
||||
link="/docs/open-api/markdown"
|
||||
linkText="Markdown API."
|
||||
linkText="Markdown API"
|
||||
/>
|
||||
<ApiReference
|
||||
badge="GET /api/v1/scraping/markdown"
|
||||
|
||||
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Scraping API" text="" />
|
||||
<DashboardHeader
|
||||
heading="Url to Meta Info"
|
||||
text="Quickly extract valuable structured website data"
|
||||
/>
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
|
||||
@@ -5,11 +5,10 @@ import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import ApiReference from "@/components/shared/api-reference";
|
||||
|
||||
import DashboardScrapeCharts from "../charts";
|
||||
import { MetaScraping } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to Meta API - WR.DO",
|
||||
title: "Url to Meta API",
|
||||
description: "Quickly extract valuable structured website data",
|
||||
});
|
||||
|
||||
@@ -21,10 +20,10 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Url to Meta Info"
|
||||
text="Quickly extract valuable structured website data."
|
||||
heading="Url to Meta Info"
|
||||
text="Quickly extract valuable structured website data"
|
||||
link="/docs/open-api/meta-info"
|
||||
linkText="Meta Info API."
|
||||
linkText="Meta Info API"
|
||||
/>
|
||||
<ApiReference
|
||||
badge="GET /api/v1/scraping/meta"
|
||||
|
||||
@@ -2,13 +2,13 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { ScrapeInfoCard } from "@/components/dashboard/dashboard-info-card";
|
||||
import { StaticInfoCard } from "@/components/dashboard/dashboard-info-card";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import DashboardScrapeCharts from "./charts";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Scraping API - WR.DO",
|
||||
title: "Scraping API",
|
||||
description: "Quickly extract valuable structured website data",
|
||||
});
|
||||
|
||||
@@ -20,46 +20,41 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Scraping API Overview"
|
||||
text="Quickly extract valuable structured website data. It's free and unlimited to use!"
|
||||
heading="Scraping API Overview"
|
||||
text="Quickly extract valuable structured website data"
|
||||
link="/docs/open-api"
|
||||
linkText="Open API."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Screenshot"
|
||||
desc="Take a screenshot of the webpage."
|
||||
desc="Take a screenshot of the webpage"
|
||||
link="/dashboard/scrape/screenshot"
|
||||
icon="camera"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Meta Info"
|
||||
desc="Extract website metadata."
|
||||
desc="Extract website metadata"
|
||||
link="/dashboard/scrape/meta-info"
|
||||
icon="globe"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to QR Code"
|
||||
desc="Generate QR Code from URL."
|
||||
desc="Generate QR Code from URL"
|
||||
link="/dashboard/scrape/qrcode"
|
||||
icon="qrcode"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Markdown"
|
||||
desc="Convert website content to Markdown format."
|
||||
desc="Convert website content to Markdown format"
|
||||
link="/dashboard/scrape/markdown"
|
||||
icon="heading1"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Text"
|
||||
desc="Extract website text."
|
||||
desc="Convert website content to text"
|
||||
link="/dashboard/scrape/markdown"
|
||||
icon="fileText"
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Scraping API" text="" />
|
||||
<DashboardHeader
|
||||
heading="Url to QR Code"
|
||||
text="Generate QR Code from URL"
|
||||
/>
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
|
||||
@@ -6,10 +6,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import ApiReference from "@/components/shared/api-reference";
|
||||
import QRCodeEditor from "@/components/shared/qr";
|
||||
|
||||
import { CodeLight, QrCodeScraping } from "../scrapes";
|
||||
import { CodeLight } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to QR Code API - WR.DO",
|
||||
title: "Url to QR Code API",
|
||||
description: "Generate QR Code from URL",
|
||||
});
|
||||
|
||||
@@ -21,10 +21,10 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Url to QR Code"
|
||||
heading="Url to QR Code"
|
||||
text="Generate QR Code from URL"
|
||||
link="/docs/open-api/qrcode"
|
||||
linkText="QR Code API."
|
||||
linkText="QR Code API"
|
||||
/>
|
||||
<ApiReference
|
||||
badge="GET /api/v1/scraping/qrcode"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import JsonView from "@uiw/react-json-view";
|
||||
import { githubLightTheme } from "@uiw/react-json-view/githubLight";
|
||||
import { vscodeTheme } from "@uiw/react-json-view/vscode";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -50,6 +51,7 @@ export function ScreenshotScraping({
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const t = useTranslations("Scrape");
|
||||
const { theme } = useTheme();
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
|
||||
@@ -87,10 +89,12 @@ export function ScreenshotScraping({
|
||||
<CodeLight content={`https://wr.do/api/v1/scraping/screenshot`} />
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Playground</CardTitle>
|
||||
<CardTitle>{t("Playground")}</CardTitle>
|
||||
<CardDescription>
|
||||
Automate your website screenshots and turn them into stunning
|
||||
visuals for your applications.
|
||||
{t(
|
||||
"Automate your website screenshots and turn them into stunning visuals for your applications",
|
||||
)}
|
||||
.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -126,9 +130,9 @@ export function ScreenshotScraping({
|
||||
variant="blue"
|
||||
onClick={handleScrapingScreenshot}
|
||||
disabled={isShoting}
|
||||
className="rounded-l-none"
|
||||
className="w-28 rounded-l-none"
|
||||
>
|
||||
{isShoting ? "Scraping..." : "Send"}
|
||||
{isShoting ? t("Scraping") : t("Start")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +168,7 @@ export function MetaScraping({
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const t = useTranslations("Scrape");
|
||||
const { theme } = useTheme();
|
||||
const [currentLink, setCurrentLink] = useState("wr.do");
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
@@ -203,8 +208,10 @@ export function MetaScraping({
|
||||
<CodeLight content={`https://wr.do/api/v1/scraping/meta`} />
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Playground</CardTitle>
|
||||
<CardDescription>Scrape the meta data of a website.</CardDescription>
|
||||
<CardTitle>{t("Playground")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("Scrape the meta data of a website")}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
@@ -239,9 +246,9 @@ export function MetaScraping({
|
||||
variant="blue"
|
||||
onClick={handleScrapingMeta}
|
||||
disabled={isScraping}
|
||||
className="rounded-l-none"
|
||||
className="w-28 rounded-l-none"
|
||||
>
|
||||
{isScraping ? "Scraping..." : "Send"}
|
||||
{isScraping ? t("Scraping") : t("Start")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -264,6 +271,7 @@ export function MarkdownScraping({
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const t = useTranslations("Scrape");
|
||||
const { theme } = useTheme();
|
||||
const [currentLink, setCurrentLink] = useState("wr.do");
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
@@ -334,9 +342,9 @@ export function MarkdownScraping({
|
||||
variant="blue"
|
||||
onClick={handleScrapingMeta}
|
||||
disabled={isScraping}
|
||||
className="rounded-l-none"
|
||||
className="w-28 rounded-l-none"
|
||||
>
|
||||
{isScraping ? "Scraping..." : "Send"}
|
||||
{isScraping ? t("Scraping") : t("Start")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -359,6 +367,7 @@ export function TextScraping({
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const t = useTranslations("Scrape");
|
||||
const { theme } = useTheme();
|
||||
const [currentLink, setCurrentLink] = useState("wr.do");
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
@@ -394,7 +403,7 @@ export function TextScraping({
|
||||
<CodeLight content={`https://wr.do/api/v1/scraping/text`} />
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Text</CardTitle>
|
||||
<CardTitle>{t("Text")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
@@ -429,9 +438,9 @@ export function TextScraping({
|
||||
variant="blue"
|
||||
onClick={handleScrapingMeta}
|
||||
disabled={isScraping}
|
||||
className="rounded-l-none"
|
||||
className="w-28 rounded-l-none"
|
||||
>
|
||||
{isScraping ? "Scraping..." : "Send"}
|
||||
{isScraping ? t("Scraping") : t("Start")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -454,6 +463,7 @@ export function QrCodeScraping({
|
||||
}: {
|
||||
user: { id: string; apiKey: string };
|
||||
}) {
|
||||
const t = useTranslations("Scrape");
|
||||
const { theme } = useTheme();
|
||||
const [protocol, setProtocol] = useState("https://");
|
||||
|
||||
@@ -487,11 +497,7 @@ export function QrCodeScraping({
|
||||
<CodeLight content={`https://wr.do/api/v1/scraping/qrcode`} />
|
||||
<Card className="bg-gray-50 dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Playground</CardTitle>
|
||||
<CardDescription>
|
||||
Automate your website screenshots and turn them into stunning
|
||||
visuals for your applications.
|
||||
</CardDescription>
|
||||
<CardTitle>{t("Playground")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
@@ -573,21 +579,7 @@ export const CodeLight = ({ content }: { content: string }) => {
|
||||
{i + 1}
|
||||
</span>
|
||||
{/* Code content */}
|
||||
<span className="text-blue-400">
|
||||
{line
|
||||
.replace(
|
||||
/function/,
|
||||
(match) => `<span class="text-purple-400">${match}</span>`,
|
||||
)
|
||||
.replace(
|
||||
/"[^"]*"/,
|
||||
(match) => `<span class="text-green-400">${match}</span>`,
|
||||
)
|
||||
.replace(
|
||||
/console/,
|
||||
(match) => `<span class="text-yellow-400">${match}</span>`,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-blue-400">{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
|
||||
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Scraping API" text="" />
|
||||
<DashboardHeader
|
||||
heading="Url to Screenshot"
|
||||
text="Quickly extract website screenshots"
|
||||
/>
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
|
||||
@@ -9,7 +9,7 @@ import DashboardScrapeCharts from "../charts";
|
||||
import { ScreenshotScraping } from "../scrapes";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Url to Screenshot API - WR.DO",
|
||||
title: "Url to Screenshot API",
|
||||
description:
|
||||
"Quickly extract website screenshots. It's free and unlimited to use!",
|
||||
});
|
||||
@@ -22,10 +22,10 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Url to Screenshot"
|
||||
text="Quickly extract website screenshots."
|
||||
heading="Url to Screenshot"
|
||||
text="Quickly extract website screenshots"
|
||||
link="/docs/open-api/screenshot"
|
||||
linkText="Screenshot API."
|
||||
linkText="Screenshot API"
|
||||
/>
|
||||
<ApiReference
|
||||
badge="GET /api/v1/scraping/screenshot"
|
||||
|
||||
@@ -5,10 +5,11 @@ export default function DashboardSettingsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Settings"
|
||||
text="Manage account and website settings."
|
||||
heading="Account Settings"
|
||||
text="Manage account and website settings"
|
||||
/>
|
||||
<div className="divide-y divide-muted pb-10">
|
||||
<SkeletonSection />
|
||||
<SkeletonSection />
|
||||
<SkeletonSection />
|
||||
<SkeletonSection card />
|
||||
|
||||
@@ -5,7 +5,9 @@ import { constructMetadata } from "@/lib/utils";
|
||||
import { DeleteAccountSection } from "@/components/dashboard/delete-account";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import { UserApiKeyForm } from "@/components/forms/user-api-key-form";
|
||||
import { UserEmailForm } from "@/components/forms/user-email-form";
|
||||
import { UserNameForm } from "@/components/forms/user-name-form";
|
||||
import { UserPasswordForm } from "@/components/forms/user-password-form";
|
||||
import { UserRoleForm } from "@/components/forms/user-role-form";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
@@ -22,13 +24,22 @@ export default async function SettingsPage() {
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Account Settings"
|
||||
text="Manage account and website settings."
|
||||
text="Manage account and website settings"
|
||||
/>
|
||||
<div className="divide-y divide-muted pb-10">
|
||||
<UserEmailForm
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
email: user.email || "",
|
||||
emailVerified: user.emailVerified,
|
||||
}}
|
||||
/>
|
||||
<UserNameForm user={{ id: user.id, name: user.name || "" }} />
|
||||
{user.role === "ADMIN" && (
|
||||
<UserRoleForm user={{ id: user.id, role: user.role }} />
|
||||
)}
|
||||
<UserPasswordForm user={{ id: user.id, name: user.name || "" }} />
|
||||
<UserApiKeyForm
|
||||
user={{
|
||||
id: user.id,
|
||||
|
||||
15
app/(protected)/dashboard/storage/loading.tsx
Normal file
15
app/(protected)/dashboard/storage/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Cloud Storage"
|
||||
text="List and manage cloud storage"
|
||||
/>
|
||||
<Skeleton className="h-[58px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
app/(protected)/dashboard/storage/page.tsx
Normal file
39
app/(protected)/dashboard/storage/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import UserFileList from "@/components/file";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Cloud Storage",
|
||||
description: "List and manage cloud storage.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Cloud Storage"
|
||||
text="List and manage cloud storage"
|
||||
link="/docs/cloud-storage"
|
||||
linkText="Cloud Storage"
|
||||
/>
|
||||
<UserFileList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/storage"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
app/(protected)/dashboard/urls/analytics/loading.tsx
Normal file
9
app/(protected)/dashboard/urls/analytics/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
app/(protected)/dashboard/urls/analytics/page.tsx
Normal file
24
app/(protected)/dashboard/urls/analytics/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import Globe from "../globe";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Globe Analytics",
|
||||
description: "Display link's globe analytics.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Globe />
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
app/(protected)/dashboard/urls/api/loading.tsx
Normal file
10
app/(protected)/dashboard/urls/api/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-[120px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
app/(protected)/dashboard/urls/api/page.tsx
Normal file
47
app/(protected)/dashboard/urls/api/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import ApiReference from "@/components/shared/api-reference";
|
||||
|
||||
import { CodeLight } from "../../scrape/scrapes";
|
||||
import LiveLog from "../live-logs";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Live Logs",
|
||||
description: "Display link's real-time live logs.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<ApiReference
|
||||
badge="POST /api/v1/short"
|
||||
target="creating short urls"
|
||||
link="/docs/short-urls#api-reference"
|
||||
/>
|
||||
<CodeLight
|
||||
content={`
|
||||
curl -X POST \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "wrdo-api-key: YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"target": "https://www.oiov.dev",
|
||||
"url": "abc123",
|
||||
"expiration": "-1",
|
||||
"prefix": "wr.do",
|
||||
"visible": 1,
|
||||
"active": 1,
|
||||
"password": ""
|
||||
}' \\
|
||||
https://wr.do/api/v1/short
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
204
app/(protected)/dashboard/urls/export.tsx
Normal file
204
app/(protected)/dashboard/urls/export.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
interface ExportConfig {
|
||||
filename?: string;
|
||||
fields?: (keyof ShortUrlFormData)[];
|
||||
filter?: (item: ShortUrlFormData) => boolean;
|
||||
sort?: (a: ShortUrlFormData, b: ShortUrlFormData) => number;
|
||||
format?: "pretty" | "compact";
|
||||
}
|
||||
|
||||
export const exportToJsonAdvanced = (
|
||||
data: ShortUrlFormData[],
|
||||
config: ExportConfig = {},
|
||||
) => {
|
||||
const {
|
||||
filename = "short-urls",
|
||||
fields,
|
||||
filter,
|
||||
sort,
|
||||
format = "pretty",
|
||||
} = config;
|
||||
|
||||
try {
|
||||
let processedData = [...data];
|
||||
|
||||
// 应用过滤器
|
||||
if (filter) {
|
||||
processedData = processedData.filter(filter);
|
||||
}
|
||||
|
||||
// 应用排序
|
||||
if (sort) {
|
||||
processedData = processedData.sort(sort);
|
||||
}
|
||||
|
||||
// 选择特定字段
|
||||
if (fields) {
|
||||
processedData = processedData.map((item) => {
|
||||
const filteredItem: Partial<ShortUrlFormData> = {};
|
||||
fields.forEach((field) => {
|
||||
filteredItem[field] = item[field] as any;
|
||||
});
|
||||
return filteredItem as ShortUrlFormData;
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化 JSON
|
||||
const indent = format === "pretty" ? 2 : 0;
|
||||
const jsonString = JSON.stringify(processedData, null, indent);
|
||||
|
||||
// 创建并下载文件
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${filename}.json`;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
exportedCount: processedData.length,
|
||||
totalCount: data.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const useJsonExport = () => {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportStatus, setExportStatus] = useState<string>("");
|
||||
|
||||
const exportData = async (
|
||||
data: ShortUrlFormData[],
|
||||
config?: ExportConfig,
|
||||
) => {
|
||||
setIsExporting(true);
|
||||
setExportStatus("正在导出...");
|
||||
|
||||
try {
|
||||
const result = exportToJsonAdvanced(data, config);
|
||||
|
||||
if (result.success) {
|
||||
setExportStatus(`成功导出 ${result.exportedCount} 条记录`);
|
||||
} else {
|
||||
setExportStatus(`导出失败: ${result.error}`);
|
||||
}
|
||||
|
||||
// 3秒后清除状态
|
||||
setTimeout(() => {
|
||||
setExportStatus("");
|
||||
}, 3000);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
exportData,
|
||||
isExporting,
|
||||
exportStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export const UrlExporter: React.FC<{
|
||||
data: ShortUrlFormData[];
|
||||
}> = ({ data }) => {
|
||||
const { exportData } = useJsonExport();
|
||||
|
||||
const t = useTranslations("List");
|
||||
|
||||
const handleExportAll = () => {
|
||||
exportData(data, {
|
||||
filename: "all-urls",
|
||||
format: "pretty",
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportActive = () => {
|
||||
exportData(data, {
|
||||
filename: "active-urls",
|
||||
filter: (item) => item.active === 1,
|
||||
sort: (a, b) =>
|
||||
new Date(b.createdAt!).getTime() - new Date(a.createdAt!).getTime(),
|
||||
format: "pretty",
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportBasicInfo = () => {
|
||||
exportData(data, {
|
||||
filename: "urls",
|
||||
fields: ["prefix", "url", "target"],
|
||||
format: "compact",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-2 text-nowrap"
|
||||
variant={"outline"}
|
||||
>
|
||||
{t("Export")}
|
||||
<Icons.chevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2 text-nowrap"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleExportAll}
|
||||
>
|
||||
{t("Export All")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleExportActive}
|
||||
>
|
||||
{t("Export Active")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleExportBasicInfo}
|
||||
>
|
||||
{t("Export Basic Info")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
461
app/(protected)/dashboard/urls/globe/index.tsx
Normal file
461
app/(protected)/dashboard/urls/globe/index.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { differenceInMinutes, format } from "date-fns";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { DAILY_DIMENSION_ENUMS } from "@/lib/enums";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { RealtimeChart } from "./realtime-chart";
|
||||
import RealtimeLogs from "./realtime-logs";
|
||||
|
||||
const RealtimeGlobe = dynamic(() => import("./realtime-globe"), { ssr: false });
|
||||
|
||||
export interface Location {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
count: number;
|
||||
city?: string;
|
||||
country?: string;
|
||||
lastUpdate?: Date;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
device?: string;
|
||||
browser?: string;
|
||||
userUrl?: {
|
||||
url: string;
|
||||
target: string;
|
||||
prefix: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DatabaseLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
count: number;
|
||||
city: string;
|
||||
country: string;
|
||||
lastUpdate: Date;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
device?: string;
|
||||
browser?: string;
|
||||
userUrl?: {
|
||||
url: string;
|
||||
target: string;
|
||||
prefix: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
time: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function date2unix(date: Date): number {
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
}
|
||||
|
||||
export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
const mountedRef = useRef(true);
|
||||
const locationDataRef = useRef<Map<string, Location>>(new Map());
|
||||
const lastUpdateRef = useRef<string>();
|
||||
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [timeRange, setTimeRange] = useState<string>("30min");
|
||||
const [time, setTime] = useState(() => {
|
||||
const now = new Date();
|
||||
return {
|
||||
startAt: date2unix(new Date(now.getTime() - 30 * 60 * 1000)),
|
||||
endAt: date2unix(now),
|
||||
};
|
||||
});
|
||||
const [filters, setFilters] = useState<Record<string, any>>({});
|
||||
const [locations, setLocations] = useState<Location[]>([]);
|
||||
const [chartData, setChartData] = useState<ChartData[]>([]);
|
||||
const [stats, setStats] = useState({
|
||||
totalClicks: 0,
|
||||
uniqueLocations: 0,
|
||||
rawRecords: 0,
|
||||
lastFetch: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createLocationKey = (lat: number, lng: number) => {
|
||||
return `${Math.round(lat * 100) / 100},${Math.round(lng * 100) / 100}`;
|
||||
};
|
||||
|
||||
const processChartDataOptimized = (locations: Location[]): ChartData[] => {
|
||||
const validLocations = locations.filter((loc) => loc.createdAt);
|
||||
if (validLocations.length === 0) return [];
|
||||
|
||||
// 如果数据量很少,直接按原始时间点展示
|
||||
if (validLocations.length <= 10) {
|
||||
return validLocations.map((loc, index) => ({
|
||||
time: format(new Date(loc.createdAt!), "HH:mm:ss"),
|
||||
count: loc.count,
|
||||
}));
|
||||
}
|
||||
|
||||
// 否则使用智能分组
|
||||
const dates = validLocations.map((loc) => new Date(loc.createdAt!));
|
||||
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
|
||||
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
|
||||
const totalMinutes = differenceInMinutes(maxDate, minDate);
|
||||
|
||||
// 根据数据量和时间跨度动态调整分组
|
||||
const targetGroups = Math.min(validLocations.length, 20); // 目标分组数量
|
||||
let groupMinutes: number;
|
||||
|
||||
if (totalMinutes <= 60) {
|
||||
groupMinutes = Math.max(1, Math.ceil(totalMinutes / targetGroups));
|
||||
} else {
|
||||
groupMinutes = Math.max(5, Math.ceil(totalMinutes / targetGroups));
|
||||
}
|
||||
|
||||
const groupByFn = (date: Date) => {
|
||||
const minutes =
|
||||
Math.floor(date.getMinutes() / groupMinutes) * groupMinutes;
|
||||
const grouped = new Date(date);
|
||||
grouped.setMinutes(minutes, 0, 0);
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const groupedData = new Map<string, number>();
|
||||
validLocations.forEach((loc) => {
|
||||
const date = new Date(loc.createdAt!);
|
||||
const groupedDate = groupByFn(date);
|
||||
const key = groupedDate.getTime().toString();
|
||||
groupedData.set(key, (groupedData.get(key) || 0) + loc.count);
|
||||
});
|
||||
|
||||
return Array.from(groupedData.entries())
|
||||
.sort(([a], [b]) => parseInt(a) - parseInt(b))
|
||||
.map(([key, count]) => ({
|
||||
time: format(new Date(parseInt(key)), "MM-dd HH:mm"),
|
||||
count: count,
|
||||
}));
|
||||
};
|
||||
|
||||
const appendLocationData = (
|
||||
newData: DatabaseLocation[],
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
const locationMap = isInitialLoad
|
||||
? new Map()
|
||||
: new Map(locationDataRef.current);
|
||||
let totalNewClicks = 0;
|
||||
|
||||
newData.forEach((item) => {
|
||||
const lat = Math.round(item.latitude * 100) / 100;
|
||||
const lng = Math.round(item.longitude * 100) / 100;
|
||||
const key = createLocationKey(lat, lng);
|
||||
const clickCount = item.count || 1;
|
||||
|
||||
if (locationMap.has(key)) {
|
||||
const existing = locationMap.get(key)!;
|
||||
existing.count += clickCount;
|
||||
existing.lastUpdate = new Date(item.lastUpdate);
|
||||
} else {
|
||||
locationMap.set(key, {
|
||||
lat,
|
||||
lng,
|
||||
count: clickCount,
|
||||
city: item.city,
|
||||
country: item.country,
|
||||
lastUpdate: new Date(item.lastUpdate),
|
||||
device: item.device,
|
||||
browser: item.browser,
|
||||
userUrl: item.userUrl,
|
||||
updatedAt: item.updatedAt,
|
||||
createdAt: item.createdAt,
|
||||
});
|
||||
}
|
||||
totalNewClicks += clickCount;
|
||||
});
|
||||
|
||||
locationDataRef.current = locationMap;
|
||||
const updatedLocations = Array.from(locationMap.values());
|
||||
const totalCount = updatedLocations.reduce(
|
||||
(sum, loc) => sum + loc.count,
|
||||
0,
|
||||
);
|
||||
|
||||
const normalizedLocations = updatedLocations.map((loc) => ({
|
||||
...loc,
|
||||
count: Math.max(0.1, loc.count / Math.max(totalCount, 1)),
|
||||
}));
|
||||
|
||||
const chartData = processChartDataOptimized(updatedLocations);
|
||||
|
||||
return {
|
||||
locations: normalizedLocations,
|
||||
chartData,
|
||||
totalNewClicks,
|
||||
totalCount,
|
||||
};
|
||||
};
|
||||
|
||||
const getLiveLocations = async (isInitialLoad = true) => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
startAt: time.startAt.toString(),
|
||||
endAt: time.endAt.toString(),
|
||||
isAdmin: isAdmin ? "true" : "false",
|
||||
...filters,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/url/admin/locations?${params}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
// console.error("API Error:", result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawData: DatabaseLocation[] = result.data || [];
|
||||
const {
|
||||
locations: processedLocations,
|
||||
chartData,
|
||||
totalNewClicks,
|
||||
totalCount,
|
||||
} = appendLocationData(rawData, isInitialLoad);
|
||||
|
||||
setStats({
|
||||
totalClicks: result.totalClicks || totalCount,
|
||||
uniqueLocations: processedLocations.length,
|
||||
rawRecords: result.rawRecords || rawData.length,
|
||||
lastFetch: result.timestamp,
|
||||
});
|
||||
|
||||
if (mountedRef.current) {
|
||||
setLocations(processedLocations);
|
||||
setChartData(chartData);
|
||||
lastUpdateRef.current = result.timestamp;
|
||||
}
|
||||
|
||||
if (!isInitialLoad) {
|
||||
rawData.forEach((item, index) => {
|
||||
setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
createTrafficEvent(
|
||||
item.latitude,
|
||||
item.longitude,
|
||||
item.city || "Unknown",
|
||||
);
|
||||
}
|
||||
}, index * 100);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching live locations:", error);
|
||||
if (mountedRef.current) {
|
||||
setLocations([]);
|
||||
setChartData([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getRealtimeUpdates = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/url/admin/locations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
lastUpdate: lastUpdateRef.current,
|
||||
startAt: time.startAt,
|
||||
endAt: time.endAt,
|
||||
isAdmin,
|
||||
...filters,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error || !result.data || result.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
locations: processedLocations,
|
||||
chartData,
|
||||
totalNewClicks,
|
||||
} = appendLocationData(result.data, false);
|
||||
|
||||
setStats((prev) => ({
|
||||
totalClicks: prev.totalClicks + totalNewClicks,
|
||||
uniqueLocations: processedLocations.length,
|
||||
rawRecords: prev.rawRecords + result.data.length,
|
||||
lastFetch: result.timestamp,
|
||||
}));
|
||||
|
||||
if (mountedRef.current) {
|
||||
setLocations(processedLocations);
|
||||
setChartData(chartData);
|
||||
lastUpdateRef.current = result.timestamp;
|
||||
|
||||
result.data.forEach((item: DatabaseLocation, index: number) => {
|
||||
setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
createTrafficEvent(
|
||||
item.latitude,
|
||||
item.longitude,
|
||||
item.city || "Unknown",
|
||||
);
|
||||
}
|
||||
}, index * 100);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching realtime updates:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetLocationData = () => {
|
||||
locationDataRef.current.clear();
|
||||
setLocations([]);
|
||||
setChartData([]);
|
||||
setStats({
|
||||
totalClicks: 0,
|
||||
uniqueLocations: 0,
|
||||
rawRecords: 0,
|
||||
lastFetch: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
realtimeIntervalRef.current = setInterval(() => {
|
||||
if (mountedRef.current) {
|
||||
getRealtimeUpdates();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) {
|
||||
resetLocationData();
|
||||
getLiveLocations(true);
|
||||
}
|
||||
}, [time, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
const restoreTimeRange = () => {
|
||||
setTimeRange("30min");
|
||||
const now = new Date();
|
||||
setTime({
|
||||
startAt: date2unix(new Date(now.getTime() - 30 * 60 * 1000)),
|
||||
endAt: date2unix(now),
|
||||
});
|
||||
};
|
||||
|
||||
(window as any).restoreTimeRange = restoreTimeRange;
|
||||
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
if (mountedRef.current) {
|
||||
restoreTimeRange();
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
delete (window as any).restoreTimeRange;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTrafficEventRef = useRef<
|
||||
(lat: number, lng: number, city: string) => void
|
||||
>(() => {});
|
||||
|
||||
const createTrafficEvent = (lat: number, lng: number, city: string) => {
|
||||
if (handleTrafficEventRef.current) {
|
||||
handleTrafficEventRef.current(lat, lng, city);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (value: string) => {
|
||||
setTimeRange(value);
|
||||
const now = new Date();
|
||||
const selectedRange = DAILY_DIMENSION_ENUMS.find((e) => e.value === value);
|
||||
if (!selectedRange) return;
|
||||
|
||||
const minutes = selectedRange.key;
|
||||
const startAt = date2unix(new Date(now.getTime() - minutes * 60 * 1000));
|
||||
const endAt = date2unix(now);
|
||||
|
||||
setTime({ startAt, endAt });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="sm:relative sm:p-4">
|
||||
<RealtimeTimePicker
|
||||
timeRange={timeRange}
|
||||
setTimeRange={handleTimeRangeChange}
|
||||
/>
|
||||
<RealtimeChart
|
||||
className="left-0 top-9 z-10 rounded-t-none text-left sm:absolute"
|
||||
chartData={chartData}
|
||||
totalClicks={stats.totalClicks}
|
||||
/>
|
||||
<RealtimeGlobe
|
||||
time={time}
|
||||
filters={filters}
|
||||
locations={locations}
|
||||
stats={stats}
|
||||
setHandleTrafficEvent={(fn) => (handleTrafficEventRef.current = fn)}
|
||||
/>
|
||||
<RealtimeLogs
|
||||
className="right-0 top-0 z-10 sm:absolute"
|
||||
locations={locations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RealtimeTimePicker({
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
}: {
|
||||
timeRange: string;
|
||||
setTimeRange: (value: string) => void;
|
||||
}) {
|
||||
const t = useTranslations("Components");
|
||||
return (
|
||||
<Select onValueChange={setTimeRange} name="time range" value={timeRange}>
|
||||
<SelectTrigger className="left-0 top-0 z-10 h-9 rounded-b-none border-b-0 bg-transparent text-left backdrop-blur-2xl sm:absolute sm:w-[326px]">
|
||||
<SelectValue placeholder="Select a time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAILY_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem value={e.value}>
|
||||
<span className="flex items-center gap-1">{t(e.label)}</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DAILY_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
97
app/(protected)/dashboard/urls/globe/realtime-chart.tsx
Normal file
97
app/(protected)/dashboard/urls/globe/realtime-chart.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Bar, BarChart, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import StatusDot from "@/components/dashboard/status-dot";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
interface ChartData {
|
||||
time: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface RealtimeChartProps {
|
||||
className?: string;
|
||||
chartData: ChartData[];
|
||||
totalClicks: number;
|
||||
}
|
||||
|
||||
export const RealtimeChart = ({
|
||||
className,
|
||||
chartData,
|
||||
totalClicks,
|
||||
}: RealtimeChartProps) => {
|
||||
const t = useTranslations("Components");
|
||||
const getTickInterval = (dataLength: number) => {
|
||||
if (dataLength <= 6) return 0;
|
||||
if (dataLength <= 12) return 1;
|
||||
if (dataLength <= 24) return Math.ceil(dataLength / 8);
|
||||
return Math.ceil(dataLength / 6);
|
||||
};
|
||||
|
||||
const filteredChartData = chartData.filter((item, index) => {
|
||||
return item.count !== 0 || index === chartData.length - 1;
|
||||
});
|
||||
const tickInterval = getTickInterval(filteredChartData.length);
|
||||
|
||||
return (
|
||||
<div className={cn(`rounded-lg border p-3 backdrop-blur-2xl`, className)}>
|
||||
<div className="mb-1 flex items-center text-base font-semibold">
|
||||
<StatusDot status={1} />
|
||||
<h3 className="ml-2">{t("Realtime Visits")}</h3>
|
||||
<Icons.mousePointerClick className="ml-auto size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mb-2 text-lg font-semibold">{totalClicks}</p>
|
||||
<BarChart
|
||||
width={300}
|
||||
height={200}
|
||||
data={filteredChartData}
|
||||
margin={{ top: 10, right: 0, left: -20, bottom: 0 }}
|
||||
barCategoryGap={1}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={tickInterval}
|
||||
tickCount={Math.min(filteredChartData.length, 10)}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
type="category"
|
||||
scale="point"
|
||||
padding={{ left: 14, right: 20 }}
|
||||
tickFormatter={(value) =>
|
||||
value.split(" ")[1] ? value.split(" ")[1] : value
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, "dataMax"]}
|
||||
tickCount={5}
|
||||
tick={{ fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-md border border-primary-foreground bg-primary py-2 text-primary-foreground backdrop-blur">
|
||||
<p className="label px-2 text-base font-medium">{`${label}`}</p>
|
||||
<p className="label px-2 text-sm">{`Visits: ${payload[0].value}`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="#2d9af9"
|
||||
radius={[1, 1, 0, 0]}
|
||||
maxBarSize={40}
|
||||
/>
|
||||
</BarChart>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
314
app/(protected)/dashboard/urls/globe/realtime-globe.tsx
Normal file
314
app/(protected)/dashboard/urls/globe/realtime-globe.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { scaleSequentialSqrt } from "d3-scale";
|
||||
import { interpolateTurbo } from "d3-scale-chromatic";
|
||||
import { GlobeInstance } from "globe.gl";
|
||||
import { debounce } from "lodash-es";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Location } from "./index";
|
||||
|
||||
interface GlobeProps {
|
||||
time: {
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
};
|
||||
filters: Record<string, any>;
|
||||
locations: Location[];
|
||||
stats: {
|
||||
totalClicks: number;
|
||||
uniqueLocations: number;
|
||||
rawRecords: number;
|
||||
lastFetch: string;
|
||||
};
|
||||
setHandleTrafficEvent: (
|
||||
fn: (lat: number, lng: number, city: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function RealtimeGlobe({
|
||||
time,
|
||||
filters,
|
||||
locations,
|
||||
stats,
|
||||
}: GlobeProps) {
|
||||
const { theme } = useTheme();
|
||||
const globeRef = useRef<HTMLDivElement>(null);
|
||||
const globeInstanceRef = useRef<any>(null);
|
||||
const mountedRef = useRef(true);
|
||||
let globe: GlobeInstance;
|
||||
const [countries, setCountries] = useState<any>({});
|
||||
const [currentLocation, setCurrentLocation] = useState<any>({});
|
||||
const [hexAltitude, setHexAltitude] = useState(0.001);
|
||||
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
const highest =
|
||||
locations.reduce((acc, curr) => Math.max(acc, curr.count), 0) || 1;
|
||||
const weightColor = scaleSequentialSqrt(interpolateTurbo).domain([
|
||||
0,
|
||||
highest * 15,
|
||||
]);
|
||||
|
||||
const loadGlobe = useCallback(async () => {
|
||||
try {
|
||||
const GlobeModule = await import("globe.gl");
|
||||
const Globe = GlobeModule.default;
|
||||
const { MeshPhongMaterial } = await import("three");
|
||||
return { Globe, MeshPhongMaterial };
|
||||
} catch (err) {
|
||||
// console.error("Failed to load Globe.gl:", err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getGlobeJSON = async () => {
|
||||
try {
|
||||
const response = await fetch("/countries.geojson");
|
||||
const data = await response.json();
|
||||
if (mountedRef.current) {
|
||||
setCountries(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching globe JSON:", error);
|
||||
if (mountedRef.current) {
|
||||
setCountries({ type: "FeatureCollection", features: [] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentLocation = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/location");
|
||||
const data = await response.json();
|
||||
if (mountedRef.current) {
|
||||
setCurrentLocation(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current location:", error);
|
||||
if (mountedRef.current) {
|
||||
setCurrentLocation({ latitude: 0, longitude: 0 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initGlobe = useCallback(async () => {
|
||||
if (
|
||||
!globeRef.current ||
|
||||
!countries.features ||
|
||||
globeInstanceRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const modules = await loadGlobe();
|
||||
if (!modules || !mountedRef.current) return;
|
||||
|
||||
const { Globe, MeshPhongMaterial } = modules;
|
||||
|
||||
const container = globeRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
setTimeout(initGlobe, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
globe = new Globe(container)
|
||||
.width(wrapperWidth)
|
||||
.height(wrapperWidth > 728 ? wrapperWidth * 0.9 : wrapperWidth)
|
||||
.globeOffset([0, -80])
|
||||
.atmosphereColor("rgba(170, 170, 200, 0.7)")
|
||||
.backgroundColor("rgba(0,0,0,0)")
|
||||
.globeMaterial(
|
||||
new MeshPhongMaterial({
|
||||
color: theme === "dark" ? "rgb(65, 65, 65)" : "rgb(228, 228, 231)",
|
||||
transparent: false,
|
||||
opacity: 1,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
if (countries.features && countries.features.length > 0) {
|
||||
globe
|
||||
.hexPolygonsData(countries.features)
|
||||
.hexPolygonResolution(3)
|
||||
.hexPolygonMargin(0.2)
|
||||
.hexPolygonAltitude(() => hexAltitude)
|
||||
.hexPolygonColor(
|
||||
() => `rgba(45, 154, 249, ${Math.random() / 1.5 + 0.5})`,
|
||||
);
|
||||
}
|
||||
|
||||
globe
|
||||
.hexBinResolution(4)
|
||||
.hexBinPointsData(locations)
|
||||
.hexBinMerge(true)
|
||||
.hexBinPointWeight("count")
|
||||
.hexTopColor((d: any) => {
|
||||
const intensity = d.sumWeight || 0;
|
||||
return weightColor(intensity);
|
||||
})
|
||||
.hexSideColor((d: any) => {
|
||||
const intensity = d.sumWeight || 0;
|
||||
return weightColor(intensity * 0.8);
|
||||
})
|
||||
.hexAltitude((d: any) => {
|
||||
const intensity = d.sumWeight || 0;
|
||||
return Math.max(0.01, intensity * 0.8);
|
||||
});
|
||||
|
||||
globe.onGlobeReady(() => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const lat = currentLocation.latitude || 0;
|
||||
const lng = currentLocation.longitude || 0;
|
||||
|
||||
globe.pointOfView({
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
altitude: rect.width > 768 ? 2.5 : 3.5,
|
||||
});
|
||||
|
||||
if (globe.controls()) {
|
||||
globe.controls().autoRotate = true;
|
||||
globe.controls().autoRotateSpeed = 0.5;
|
||||
globe.controls().enableDamping = true;
|
||||
globe.controls().dampingFactor = 0.1;
|
||||
}
|
||||
|
||||
setIsLoaded(true);
|
||||
});
|
||||
|
||||
if (globe.controls()) {
|
||||
globe.controls().addEventListener(
|
||||
"end",
|
||||
debounce(() => {
|
||||
if (!mountedRef.current || !globeInstanceRef.current) return;
|
||||
|
||||
try {
|
||||
const distance = Math.round(globe.controls().getDistance());
|
||||
let nextAlt = 0.005;
|
||||
if (distance <= 300) nextAlt = 0.001;
|
||||
else if (distance >= 600) nextAlt = 0.02;
|
||||
|
||||
if (nextAlt !== hexAltitude) {
|
||||
setHexAltitude(nextAlt);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Error in controls event:", err);
|
||||
}
|
||||
}, 200),
|
||||
);
|
||||
}
|
||||
|
||||
globeInstanceRef.current = globe;
|
||||
} catch (err) {}
|
||||
}, [
|
||||
countries,
|
||||
locations,
|
||||
currentLocation,
|
||||
hexAltitude,
|
||||
loadGlobe,
|
||||
weightColor,
|
||||
]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (globeInstanceRef.current) {
|
||||
try {
|
||||
if (typeof globeInstanceRef.current._destructor === "function") {
|
||||
globeInstanceRef.current._destructor();
|
||||
}
|
||||
|
||||
if (globeRef.current) {
|
||||
globeRef.current.innerHTML = "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Error during cleanup:", err);
|
||||
}
|
||||
globeInstanceRef.current = null;
|
||||
}
|
||||
|
||||
setIsLoaded(false);
|
||||
}, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (globeInstanceRef.current) {
|
||||
// globeInstanceRef.current.width(wrapperWidth);
|
||||
// globeInstanceRef.current.height(
|
||||
// wrapperWidth > 728 ? wrapperWidth * 0.8 : wrapperWidth,
|
||||
// );
|
||||
// }
|
||||
// }, [globeInstanceRef.current, wrapperWidth, wrapperHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
globeInstanceRef.current &&
|
||||
mountedRef.current &&
|
||||
locations.length > 0
|
||||
) {
|
||||
try {
|
||||
globeInstanceRef.current.hexBinPointsData(locations);
|
||||
} catch (err) {
|
||||
console.warn("Error updating locations:", err);
|
||||
}
|
||||
}
|
||||
}, [locations]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeData = async () => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
try {
|
||||
await Promise.all([getCurrentLocation(), getGlobeJSON()]);
|
||||
} catch (error) {
|
||||
console.error("Error initializing data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
countries.features &&
|
||||
currentLocation &&
|
||||
!globeInstanceRef.current &&
|
||||
mountedRef.current
|
||||
) {
|
||||
const timer = setTimeout(initGlobe, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countries, currentLocation, initGlobe]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative -mt-8 max-h-screen overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={globeRef}
|
||||
className="flex justify-center"
|
||||
style={{
|
||||
maxWidth: `${wrapperWidth}px`, // 比较疑惑
|
||||
minHeight: "100px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
app/(protected)/dashboard/urls/globe/realtime-logs.tsx
Normal file
127
app/(protected)/dashboard/urls/globe/realtime-logs.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import ReactCountryFlag from "react-country-flag";
|
||||
|
||||
import { formatTime } from "@/lib/utils";
|
||||
|
||||
import { Location } from "./index";
|
||||
|
||||
const RealtimeLogs = ({
|
||||
className,
|
||||
locations,
|
||||
}: {
|
||||
className?: string;
|
||||
locations: Location[];
|
||||
}) => {
|
||||
const [displayedLocations, setDisplayedLocations] = useState<Location[]>([]);
|
||||
const [pendingLocations, setPendingLocations] = useState<Location[]>([]);
|
||||
|
||||
// 生成唯一标识用于去重
|
||||
const generateUniqueKey = (loc: Location): string => {
|
||||
return `${loc.userUrl?.url || ""}-${loc.userUrl?.target || ""}-${loc.userUrl?.prefix || ""}-${loc.country || ""}-${loc.city || ""}-${loc.browser || ""}-${loc.device || ""}-${loc.updatedAt?.toString() || ""}`;
|
||||
};
|
||||
|
||||
// 当外部 locations 更新时,更新预备列表
|
||||
useEffect(() => {
|
||||
const sortedLocations = [...locations].sort((a, b) => {
|
||||
const timeA = new Date(a.updatedAt?.toString() || "").getTime() || 0;
|
||||
const timeB = new Date(b.updatedAt?.toString() || "").getTime() || 0;
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
setPendingLocations((prev) => {
|
||||
// 去重:基于多个字段判断
|
||||
const newLocations = sortedLocations.filter(
|
||||
(loc) =>
|
||||
!prev.some((p) => generateUniqueKey(p) === generateUniqueKey(loc)) &&
|
||||
!displayedLocations.some(
|
||||
(d) => generateUniqueKey(d) === generateUniqueKey(loc),
|
||||
),
|
||||
);
|
||||
return [...prev, ...newLocations];
|
||||
});
|
||||
}, [locations]);
|
||||
|
||||
// 每 2 秒从预备列表插入一条数据到显示列表
|
||||
useEffect(() => {
|
||||
if (pendingLocations.length === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDisplayedLocations((prev) => {
|
||||
if (pendingLocations.length > 0) {
|
||||
const newLocation = pendingLocations[0];
|
||||
// 插入新数据到顶部,限制显示列表最多 8 条
|
||||
const newDisplayed = [newLocation, ...prev].slice(0, 8);
|
||||
// 从预备列表移除已插入的数据
|
||||
setPendingLocations((pending) => pending.slice(1));
|
||||
return newDisplayed;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pendingLocations]);
|
||||
|
||||
// 动画配置
|
||||
const itemVariants = {
|
||||
initial: { opacity: 0, scale: 0.1, x: "25%", y: "25%" }, // 从中心缩放
|
||||
animate: { opacity: 1, scale: 1, x: 0, y: 0 },
|
||||
// exit: { opacity: 0, transition: { duration: 0.3 } }, // 渐出
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto ${className}`}
|
||||
style={{ minHeight: "200px", maxHeight: "80vh" }}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{displayedLocations.length > 0 &&
|
||||
displayedLocations.map((loc) => (
|
||||
<motion.div
|
||||
key={generateUniqueKey(loc)}
|
||||
variants={itemVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mb-2 flex w-full items-center justify-start gap-3 rounded-lg border p-3 text-xs shadow-inner backdrop-blur-xl sm:w-60"
|
||||
>
|
||||
<ReactCountryFlag
|
||||
style={{ fontSize: "16px" }}
|
||||
countryCode={loc.country || "US"}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
className="text-sm font-semibold"
|
||||
href={`https://${loc.userUrl?.prefix}/${loc.userUrl?.url}`}
|
||||
target="_blank"
|
||||
>
|
||||
{loc.userUrl?.url}
|
||||
</Link>
|
||||
<span className="font-semibold">·</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTime(loc.updatedAt?.toString() || "")}
|
||||
</span>
|
||||
</div>
|
||||
{loc.browser && loc.browser !== "Unknown" && (
|
||||
<div className="mt-1 line-clamp-1 break-words font-medium text-muted-foreground">
|
||||
{loc.browser}
|
||||
{loc.device &&
|
||||
loc.device !== "Unknown" &&
|
||||
`${", "}${loc.device}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealtimeLogs;
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -47,14 +47,22 @@ export interface LogEntry {
|
||||
isNew?: boolean; // New property to track newly added logs
|
||||
}
|
||||
|
||||
export default function LiveLog({ admin }: { admin: boolean }) {
|
||||
export default function LiveLog({
|
||||
admin = false,
|
||||
live = false,
|
||||
}: {
|
||||
admin?: boolean;
|
||||
live?: boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
const [isLive, setIsLive] = useState(live);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [limitDiplay, setLimitDisplay] = useState(100);
|
||||
const newLogsRef = useRef<Set<string>>(new Set()); // Track new log keys
|
||||
|
||||
const t = useTranslations("Components");
|
||||
|
||||
const {
|
||||
data: newLogs,
|
||||
error,
|
||||
@@ -152,10 +160,10 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-base text-gray-800 dark:text-gray-100">
|
||||
Live Log
|
||||
{t("Live Logs")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Real-time logs of short link visits.
|
||||
{t("Real-time logs of short link visits")}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -163,11 +171,12 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
||||
onClick={toggleLive}
|
||||
variant={"outline"}
|
||||
size="sm"
|
||||
className={`ml-auto gap-2 bg-primary-foreground transition-colors hover:border-blue-600 hover:text-blue-600 ${
|
||||
className={`ml-auto gap-2 text-nowrap bg-primary-foreground transition-colors hover:border-blue-600 hover:text-blue-600 ${
|
||||
isLive ? "border-dashed border-blue-600 text-blue-500" : ""
|
||||
}`}
|
||||
>
|
||||
<Icons.CirclePlay className="h-4 w-4" /> {isLive ? "Stop" : "Live"}
|
||||
<Icons.CirclePlay className="h-4 w-4" />{" "}
|
||||
{isLive ? t("Stop") : t("Live")}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-primary-foreground"
|
||||
@@ -177,9 +186,9 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
||||
disabled={!isLive}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -201,19 +210,30 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
||||
{error ? (
|
||||
<div className="text-center text-red-500">{error.message}</div>
|
||||
) : logs.length === 0 && !newLogs ? (
|
||||
// <Skeleton className="h-8 w-full" />
|
||||
<></>
|
||||
) : (
|
||||
<div className="scrollbar-hidden h-96 overflow-y-auto bg-primary-foreground">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-100/50 text-sm dark:bg-primary-foreground">
|
||||
<TableHead className="h-8 w-1/6 px-1">Time</TableHead>
|
||||
<TableHead className="h-8 w-1/12 px-1">Slug</TableHead>
|
||||
<TableHead className="h-8 px-1">Target</TableHead>
|
||||
<TableHead className="h-8 w-1/12 px-1">IP</TableHead>
|
||||
<TableHead className="h-8 w-1/6 px-1">Location</TableHead>
|
||||
<TableHead className="h-8 w-1/12 px-1">Clicks</TableHead>
|
||||
<TableRow className="grid grid-cols-5 bg-gray-100/50 text-sm dark:bg-primary-foreground sm:grid-cols-9">
|
||||
<TableHead className="col-span-2 flex h-8 items-center">
|
||||
{t("Time")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex h-8 items-center">
|
||||
{t("Slug")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-3 hidden h-8 items-center sm:flex">
|
||||
{t("Target")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden h-8 items-center sm:flex">
|
||||
IP
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex h-8 items-center">
|
||||
{t("Location")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex h-8 items-center">
|
||||
{t("Clicks")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -235,23 +255,24 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
||||
ease: "linear",
|
||||
},
|
||||
}}
|
||||
className="font-mono text-xs hover:bg-gray-200 dark:border-gray-800"
|
||||
className="grid grid-cols-5 font-mono text-xs hover:bg-gray-200 dark:border-gray-800 sm:grid-cols-9"
|
||||
>
|
||||
<TableCell className="whitespace-nowrap px-1 py-1.5">
|
||||
<TableCell className="col-span-2 truncate py-1.5">
|
||||
{new Date(log.updatedAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-midium px-1 py-1.5 text-green-700">
|
||||
<TableCell className="font-midium col-span-1 truncate py-1.5 text-green-700">
|
||||
{log.slug}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-10 truncate px-1 py-1.5 hover:underline">
|
||||
<TableCell className="col-span-3 hidden max-w-full truncate py-1.5 hover:underline sm:flex">
|
||||
<a href={log.target} target="_blank" title={log.target}>
|
||||
{log.target}
|
||||
</a>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-1 py-1.5">{log.ip}</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate py-1.5 sm:flex">
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-6 truncate px-1 py-1.5"
|
||||
className="col-span-1 truncate py-1.5"
|
||||
title={getCountryName(log.country || "")}
|
||||
>
|
||||
{decodeURIComponent(
|
||||
@@ -260,7 +281,7 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
||||
: "-",
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-1 py-1.5 text-green-700">
|
||||
<TableCell className="col-span-1 py-1.5 text-green-700">
|
||||
{log.click}
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
@@ -272,7 +293,7 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
||||
)}
|
||||
{isLive && (
|
||||
<div className="flex w-full items-center justify-end gap-2 border-t border-dashed pt-4 text-sm text-gray-500">
|
||||
<p>{logs.length}</p> of
|
||||
<p>{logs.length}</p> {t("of")}
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setLimitDisplay(Number(value));
|
||||
@@ -291,7 +312,7 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p>total logs</p>
|
||||
<p>{t("total logs")}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage Short URLs"
|
||||
text="List and manage short urls."
|
||||
/>
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
|
||||
11
app/(protected)/dashboard/urls/logs/loading.tsx
Normal file
11
app/(protected)/dashboard/urls/logs/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardUrlsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Live Logs" text="" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
app/(protected)/dashboard/urls/logs/page.tsx
Normal file
25
app/(protected)/dashboard/urls/logs/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import LiveLog from "../live-logs";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Live Logs",
|
||||
description: "Display link's real-time live logs.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Live Logs" text="" />
|
||||
<LiveLog live={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,21 @@ import { UrlMeta, User } from "@prisma/client";
|
||||
import { VisSingleContainer, VisTooltip, VisTopoJSONMap } from "@unovis/react";
|
||||
import { TopoJSONMap } from "@unovis/ts";
|
||||
import { WorldMapTopoJSON } from "@unovis/ts/maps";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { getCountryName, getDeviceVendor } from "@/lib/contries";
|
||||
import {
|
||||
getBotName,
|
||||
getCountryName,
|
||||
getDeviceVendor,
|
||||
getEngineName,
|
||||
getLanguageName,
|
||||
getRegionName,
|
||||
} from "@/lib/contries";
|
||||
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
|
||||
import { isLink, removeUrlSuffix, timeAgo } from "@/lib/utils";
|
||||
import { fetcher, isLink, removeUrlPrefix } from "@/lib/utils";
|
||||
import { useElementSize } from "@/hooks/use-element-size";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -30,10 +39,13 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { TimeAgoIntl } from "@/components/shared/time-ago";
|
||||
|
||||
const chartConfig = {
|
||||
pv: {
|
||||
@@ -116,7 +128,15 @@ function generateStatsList(
|
||||
? getCountryName(rawValue as string) // 国家代码转为国家名称
|
||||
: dimension === "device"
|
||||
? getDeviceVendor(rawValue as string) // 设备型号转为厂商名称
|
||||
: rawValue; // 其他维度直接使用原始值
|
||||
: dimension === "engine"
|
||||
? getEngineName(rawValue as string) // 引擎名称
|
||||
: dimension === "region"
|
||||
? getRegionName(rawValue as string) // 区域名称
|
||||
: dimension === "lang"
|
||||
? getLanguageName(rawValue as string) // 语言名称
|
||||
: dimension === "isBot"
|
||||
? getBotName(rawValue as boolean) // 是否为机器人
|
||||
: rawValue; // 其他维度直接使用原始值
|
||||
|
||||
const click = record.click || 0; // 确保 click 是数字,默认 0 如果未定义
|
||||
|
||||
@@ -153,26 +173,32 @@ export function DailyPVUVChart({
|
||||
setTimeRange: React.Dispatch<React.SetStateAction<string>>;
|
||||
user: Pick<User, "id" | "name" | "team">;
|
||||
}) {
|
||||
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
|
||||
const [activeChart, setActiveChart] =
|
||||
React.useState<keyof typeof chartConfig>("pv");
|
||||
|
||||
const t = useTranslations("Components");
|
||||
|
||||
const { data: plan } = useSWR<{ slAnalyticsRetention: number }>(
|
||||
`/api/plan?team=${user.team}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const processedData = processUrlMeta(data).map((entry) => ({
|
||||
date: entry.date,
|
||||
pv: entry.clicks,
|
||||
uv: new Set(entry.ips).size,
|
||||
}));
|
||||
// .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
const dataTotal = calculateUVAndPV(data);
|
||||
|
||||
const latestEntry = data[data.length - 1];
|
||||
const latestDate = timeAgo(latestEntry.updatedAt);
|
||||
const latestFrom = [
|
||||
latestEntry.city ? decodeURIComponent(latestEntry.city) : "",
|
||||
latestEntry.country ? `(${getCountryName(latestEntry.country)})` : "",
|
||||
latestEntry.country ? `${getCountryName(latestEntry.country)}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
.join(",");
|
||||
|
||||
// const pointData = data.map((item) => ({
|
||||
// id: item.id,
|
||||
@@ -213,47 +239,59 @@ export function DailyPVUVChart({
|
||||
const deviceStats = generateStatsList(data, "device");
|
||||
const browserStats = generateStatsList(data, "browser");
|
||||
const countryStats = generateStatsList(data, "country");
|
||||
const osStats = generateStatsList(data, "os");
|
||||
const cpuStats = generateStatsList(data, "cpu");
|
||||
const engineStats = generateStatsList(data, "engine");
|
||||
const languageStats = generateStatsList(data, "lang");
|
||||
const regionStats = generateStatsList(data, "region");
|
||||
const isBotStats = generateStatsList(data, "isBot");
|
||||
|
||||
const lastVisitorInfo = t.rich("last-visitor-info", {
|
||||
location: latestFrom,
|
||||
timeAgo: () => <TimeAgoIntl date={latestEntry.updatedAt} />,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="rounded-t-none border-t-0">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-2 sm:py-3">
|
||||
<CardTitle>Link Analytics</CardTitle>
|
||||
<CardDescription>
|
||||
Last visitor from {latestFrom} about {latestDate}.
|
||||
</CardDescription>
|
||||
<CardTitle>{t("Link Analytics")}</CardTitle>
|
||||
<CardDescription>{lastVisitorInfo}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setTimeRange(value);
|
||||
}}
|
||||
name="time range"
|
||||
defaultValue={timeRange}
|
||||
>
|
||||
<SelectTrigger className="mx-4 w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e) => (
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
key={e.value}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
{plan && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setTimeRange(value);
|
||||
}}
|
||||
name="time range"
|
||||
defaultValue={timeRange}
|
||||
>
|
||||
<SelectTrigger className="mx-4 w-full min-w-28 shadow-inner">
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={e.key > plan.slAnalyticsRetention}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{t(e.label)}
|
||||
{e.key > plan.slAnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{["pv", "uv"].map((key) => {
|
||||
const chart = key as keyof typeof chartConfig;
|
||||
return (
|
||||
@@ -263,8 +301,8 @@ export function DailyPVUVChart({
|
||||
className="relative z-30 flex flex-1 flex-col items-center justify-center gap-1 border-t px-6 py-2 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-3"
|
||||
onClick={() => setActiveChart(chart)}
|
||||
>
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{chartConfig[chart].label}
|
||||
<span className="text-nowrap text-sm font-semibold text-muted-foreground">
|
||||
{t(chartConfig[chart].label)}
|
||||
</span>
|
||||
<span className="text-lg font-bold leading-none">
|
||||
{dataTotal[key as keyof typeof dataTotal].toLocaleString()}
|
||||
@@ -274,7 +312,7 @@ export function DailyPVUVChart({
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<CardContent className="px-2 sm:p-6" ref={wrapperRef}>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[225px] w-full"
|
||||
@@ -346,9 +384,6 @@ export function DailyPVUVChart({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* <Bar dataKey="uv" fill={`var(--color-uv)`} stackId="a" />
|
||||
<Bar dataKey="pv" fill={`var(--color-pv)`} stackId="a" /> */}
|
||||
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="uv"
|
||||
@@ -366,31 +401,103 @@ export function DailyPVUVChart({
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
|
||||
<VisSingleContainer data={{ areas: areaData }}>
|
||||
<VisTopoJSONMap
|
||||
topojson={WorldMapTopoJSON}
|
||||
// pointRadius={1.6}
|
||||
// mapFitToPoints={true}
|
||||
/>
|
||||
<VisSingleContainer
|
||||
data={{ areas: areaData }}
|
||||
width={wrapperWidth * 0.99}
|
||||
>
|
||||
<VisTopoJSONMap topojson={WorldMapTopoJSON} />
|
||||
<VisTooltip triggers={triggers} />
|
||||
</VisSingleContainer>
|
||||
|
||||
<div className="my-5 grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{refererStats.length > 0 && (
|
||||
<StatsList data={refererStats} title="Referrers" />
|
||||
)}
|
||||
{countryStats.length > 0 && (
|
||||
<StatsList data={countryStats} title="Countries" />
|
||||
)}
|
||||
{cityStats.length > 0 && (
|
||||
<StatsList data={cityStats} title="Cities" />
|
||||
)}
|
||||
{browserStats.length > 0 && (
|
||||
<StatsList data={browserStats} title="Browsers" />
|
||||
)}
|
||||
{deviceStats.length > 0 && (
|
||||
<StatsList data={deviceStats} title="Devices" />
|
||||
)}
|
||||
{/* Referrers、isBotStats */}
|
||||
<Tabs defaultValue="referrer">
|
||||
<TabsList>
|
||||
<TabsTrigger value="referrer">{t("Referrers")}</TabsTrigger>
|
||||
<TabsTrigger value="isBot">{t("Traffic Type")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="referrer">
|
||||
{refererStats.length > 0 && (
|
||||
<StatsList data={refererStats} title="Referrers" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="isBot">
|
||||
{isBotStats.length > 0 && (
|
||||
<StatsList data={isBotStats} title="Is Bot" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* 国家、城市 */}
|
||||
<Tabs defaultValue="country">
|
||||
<TabsList>
|
||||
<TabsTrigger value="country">{t("Country")}</TabsTrigger>
|
||||
<TabsTrigger value="city">{t("City")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="country">
|
||||
{countryStats.length > 0 && (
|
||||
<StatsList data={countryStats} title="Countries" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="city">
|
||||
{cityStats.length > 0 && (
|
||||
<StatsList data={cityStats} title="Cities" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* browserStats、engineStats */}
|
||||
<Tabs defaultValue="browser">
|
||||
<TabsList>
|
||||
<TabsTrigger value="browser">{t("Browser")}</TabsTrigger>
|
||||
<TabsTrigger value="engine">{t("Engine")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="browser">
|
||||
{browserStats.length > 0 && (
|
||||
<StatsList data={browserStats} title="Browsers" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="engine">
|
||||
{engineStats.length > 0 && (
|
||||
<StatsList data={engineStats} title="Engines" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Languages、regionStats */}
|
||||
<Tabs className="h-full" defaultValue="language">
|
||||
<TabsList>
|
||||
<TabsTrigger value="language">{t("Language")}</TabsTrigger>
|
||||
<TabsTrigger value="region">{t("Region")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="language">
|
||||
{languageStats.length > 0 && (
|
||||
<StatsList data={languageStats} title="Languages" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="region">
|
||||
{regionStats.length > 0 && (
|
||||
<StatsList data={regionStats} title="Regions" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* deviceStats、osStats、cpuStats */}
|
||||
<Tabs defaultValue="device">
|
||||
<TabsList>
|
||||
<TabsTrigger value="device">{t("Device")}</TabsTrigger>
|
||||
<TabsTrigger value="os">{t("OS")}</TabsTrigger>
|
||||
<TabsTrigger value="cpu">CPU</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="device">
|
||||
{deviceStats.length > 0 && (
|
||||
<StatsList data={deviceStats} title="Devices" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="os">
|
||||
{osStats.length > 0 && <StatsList data={osStats} title="OS" />}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[calc(100%-40px)]" value="cpu">
|
||||
{cpuStats.length > 0 && <StatsList data={cpuStats} title="CPU" />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -400,14 +507,17 @@ export function DailyPVUVChart({
|
||||
export function StatsList({ data, title }: { data: Stat[]; title: string }) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayedData = showAll ? data.slice(0, 50) : data.slice(0, 8);
|
||||
|
||||
const t = useTranslations("Components");
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h1 className="text-lg font-bold">{title}</h1>
|
||||
<div className="h-full rounded-lg border">
|
||||
<div className="flex items-center justify-between border-b px-5 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("Name")}</span>
|
||||
<span className="">{t("Visitors")}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`scrollbar-hidden overflow-hidden overflow-y-auto transition-all duration-500 ease-in-out`}
|
||||
className={`scrollbar-hidden overflow-hidden overflow-y-auto px-4 pb-4 pt-2 transition-all duration-500 ease-in-out`}
|
||||
style={{
|
||||
maxHeight: "18rem", // 动态计算最大高度
|
||||
maxHeight: "18rem",
|
||||
}}
|
||||
>
|
||||
{displayedData.map((ref) => (
|
||||
@@ -422,7 +532,7 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
|
||||
href={ref.dimension}
|
||||
target="_blank"
|
||||
>
|
||||
{removeUrlSuffix(ref.dimension)}
|
||||
{removeUrlPrefix(ref.dimension)}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-medium">
|
||||
@@ -449,7 +559,7 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
|
||||
</div>
|
||||
|
||||
{data.length > 8 && (
|
||||
<div className="mt-3 text-center">
|
||||
<div className="mb-3 mt-1 text-center">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { UrlMeta, User } from "@prisma/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -27,57 +28,70 @@ export interface UrlMetaProps {
|
||||
}
|
||||
|
||||
export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
|
||||
const [timeRange, setTimeRange] = useState<string>("24h");
|
||||
const t = useTranslations("Components");
|
||||
const [timeRange, setTimeRange] = useState<string>("7d");
|
||||
const { data, isLoading } = useSWR<UrlMeta[]>(
|
||||
`${action}?id=${urlId}&range=${timeRange}`,
|
||||
fetcher,
|
||||
{ focusThrottleInterval: 30000 }, // 30 seconds,
|
||||
);
|
||||
|
||||
const { data: plan } = useSWR<{ slAnalyticsRetention: number }>(
|
||||
`/api/plan?team=${user.team}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="space-y-2 p-2">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Title>No Visits</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder className="shadow-none">
|
||||
<EmptyPlaceholder.Title>{t("No Visits")}</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any visits yet in last {timeRange}.
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setTimeRange(value);
|
||||
}}
|
||||
name="time range"
|
||||
defaultValue={timeRange}
|
||||
>
|
||||
<SelectTrigger className="mt-4 w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e) => (
|
||||
<SelectItem
|
||||
className=""
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
key={e.value}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
{t("You don't have any visits yet in")}{" "}
|
||||
{t(
|
||||
DATE_DIMENSION_ENUMS.find((e) => e.value === timeRange)?.label ||
|
||||
"",
|
||||
)}
|
||||
.
|
||||
{plan && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setTimeRange(value);
|
||||
}}
|
||||
name="time range"
|
||||
defaultValue={timeRange}
|
||||
>
|
||||
<SelectTrigger className="mt-4 w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={e.key > plan.slAnalyticsRetention}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{t(e.label)}
|
||||
{e.key > plan.slAnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,12 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import ApiReference from "../../../../components/shared/api-reference";
|
||||
import LiveLog from "./live-logs";
|
||||
import UserUrlsList from "./url-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Short URLs - WR.DO",
|
||||
description: "List and manage records.",
|
||||
title: "Links",
|
||||
description: "List and manage short links.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
@@ -20,12 +17,6 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage Short URLs"
|
||||
text="List and manage short urls."
|
||||
link="/docs/short-urls"
|
||||
linkText="short urls."
|
||||
/>
|
||||
<UserUrlsList
|
||||
user={{
|
||||
id: user.id,
|
||||
@@ -36,12 +27,6 @@ export default async function DashboardPage() {
|
||||
}}
|
||||
action="/api/url"
|
||||
/>
|
||||
<LiveLog admin={false} />
|
||||
<ApiReference
|
||||
badge="POST /api/v1/short"
|
||||
target="creating short urls"
|
||||
link="/docs/short-urls#api-reference"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { PenLine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
||||
import {
|
||||
addUrlPrefix,
|
||||
cn,
|
||||
expirationTime,
|
||||
extractHostname,
|
||||
fetcher,
|
||||
removeUrlSuffix,
|
||||
timeAgo,
|
||||
nFormatter,
|
||||
removeUrlPrefix,
|
||||
} from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -35,24 +49,22 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ClickableTooltip } from "@/components/ui/tooltip";
|
||||
import { UrlStatus } from "@/components/dashboard/status-card";
|
||||
import { FormType } from "@/components/forms/record-form";
|
||||
import { UrlForm } from "@/components/forms/url-form";
|
||||
import ApiReference from "@/components/shared/api-reference";
|
||||
import BlurImage from "@/components/shared/blur-image";
|
||||
import { CopyButton } from "@/components/shared/copy-button";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import {
|
||||
LinkInfoPreviewer,
|
||||
LinkPreviewer,
|
||||
} from "@/components/shared/link-previewer";
|
||||
import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
import QRCodeEditor from "@/components/shared/qr";
|
||||
import { TimeAgoIntl } from "@/components/shared/time-ago";
|
||||
|
||||
import { UrlExporter } from "./export";
|
||||
import UserUrlMetaInfo from "./meta";
|
||||
|
||||
export interface UrlListProps {
|
||||
@@ -92,13 +104,17 @@ function TableColumnSekleton() {
|
||||
}
|
||||
|
||||
export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
const pathname = usePathname();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const t = useTranslations("List");
|
||||
const [currentView, setCurrentView] = useState<string>("List");
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditUrl, setCurrentEditUrl] = useState<ShortUrlFormData | null>(
|
||||
null,
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(15);
|
||||
const [isShowStats, setShowStats] = useState(false);
|
||||
const [isShowQrcode, setShowQrcode] = useState(false);
|
||||
const [selectedUrl, setSelectedUrl] = useState<ShortUrlFormData | null>(null);
|
||||
@@ -107,6 +123,14 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
target: "",
|
||||
userName: "",
|
||||
});
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [currentListClickData, setCurrentListClickData] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
|
||||
const [searchType, setSearchType] = useState<"slug" | "target" | "userName">(
|
||||
"slug",
|
||||
);
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR<{
|
||||
@@ -120,6 +144,29 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const currentListIds = useMemo(() => {
|
||||
return data?.list?.map((item) => item.id ?? "") ?? [];
|
||||
}, [data?.list]);
|
||||
|
||||
useEffect(() => {
|
||||
handleGetUrlClicks();
|
||||
}, [currentListIds]);
|
||||
|
||||
const handleGetUrlClicks = async () => {
|
||||
startTransition(async () => {
|
||||
if (currentListIds.length > 0 && currentView !== "Realtime") {
|
||||
const res = await fetch(action, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids: currentListIds }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCurrentListClickData(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
mutate(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
|
||||
@@ -145,227 +192,414 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
{action.includes("/admin") ? (
|
||||
<CardDescription className="text-balance text-lg font-bold">
|
||||
<span>Total URLs:</span>{" "}
|
||||
<span className="font-bold">{data && data.total}</span>
|
||||
</CardDescription>
|
||||
) : (
|
||||
<CardTitle>Short URLs</CardTitle>
|
||||
const rendeEmpty = () => (
|
||||
<EmptyPlaceholder className="col-span-full shadow-none">
|
||||
<EmptyPlaceholder.Icon name="link" />
|
||||
<EmptyPlaceholder.Title>{t("No urls")}</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any url yet. Start creating url.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
);
|
||||
|
||||
const renderSearchInputs = () => {
|
||||
const getCurrentSearchValue = () => {
|
||||
switch (searchType) {
|
||||
case "slug":
|
||||
return searchParams.slug;
|
||||
case "target":
|
||||
return searchParams.target;
|
||||
case "userName":
|
||||
return searchParams.userName;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
slug: searchType === "slug" ? value : "",
|
||||
target: searchType === "target" ? value : "",
|
||||
userName: searchType === "userName" ? value : "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
handleSearchChange("");
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
switch (searchType) {
|
||||
case "slug":
|
||||
return t("Search by slug") + "...";
|
||||
case "target":
|
||||
return t("Search by target") + "...";
|
||||
case "userName":
|
||||
return t("Search by username") + "...";
|
||||
default:
|
||||
return "Filter...";
|
||||
}
|
||||
};
|
||||
|
||||
const searchOptions = [
|
||||
{ value: "slug", label: t("Link Slug") },
|
||||
{ value: "target", label: t("Link Target") },
|
||||
...(user.role === "ADMIN"
|
||||
? [{ value: "userName", label: t("Username") }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const currentSearchValue = getCurrentSearchValue();
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex items-center">
|
||||
<Select
|
||||
value={searchType}
|
||||
onValueChange={(value: typeof searchType) => setSearchType(value)}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-[85px] rounded-r-none bg-muted text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{searchOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="text-sm"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="h-10 rounded-l-none border-l-0 pr-8 text-sm"
|
||||
placeholder={getPlaceholder()}
|
||||
value={currentSearchValue}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
{currentSearchValue && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={handleClearSearch}
|
||||
variant="ghost"
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-[120px] shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add url
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by slug..."
|
||||
value={searchParams.slug}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
slug: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.slug && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by target..."
|
||||
value={searchParams.target}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
target: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.target && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() =>
|
||||
setSearchParams({ ...searchParams, target: "" })
|
||||
}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
const rendeClicks = (short: ShortUrlFormData) => (
|
||||
<>
|
||||
<Icons.mousePointerClick className="size-[14px]" />
|
||||
{isPending ? (
|
||||
<Skeleton className="h-4 w-6 rounded" />
|
||||
) : (
|
||||
<p className="text-xs font-medium text-gray-700 dark:text-gray-50">
|
||||
{(short.id && nFormatter(currentListClickData[short.id], 2)) || "-"}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
{user.role === "ADMIN" && (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by user name..."
|
||||
value={searchParams.userName}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
userName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.userName && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() =>
|
||||
setSearchParams({ ...searchParams, userName: "" })
|
||||
}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
const rendeStats = (short: ShortUrlFormData) =>
|
||||
isShowStats &&
|
||||
selectedUrl?.id === short.id && (
|
||||
<UserUrlMetaInfo
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url/meta"
|
||||
urlId={short.id!}
|
||||
/>
|
||||
);
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
const rendeList = () => (
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
{t("Slug")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
{t("Target")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
{t("User")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
{t("Enabled")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
{t("Expiration")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
{t("Clicks")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
{t("Updated")}
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
{t("Actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((short) => (
|
||||
<div className="border-b" key={short.id}>
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Slug
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Target
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
User
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Enabled
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Expiration
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Updated
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Created
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Actions
|
||||
</TableHead>
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
<Link
|
||||
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
||||
href={`https://${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
target="_blank"
|
||||
prefetch={false}
|
||||
title={short.url}
|
||||
>
|
||||
<Badge variant="outline">
|
||||
{short.prefix}/{short.url}
|
||||
</Badge>
|
||||
</Link>
|
||||
<CopyButton
|
||||
value={`${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
className={cn(
|
||||
"size-[25px]",
|
||||
"duration-250 transition-all group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
{short.password && (
|
||||
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
|
||||
<LinkInfoPreviewer
|
||||
apiKey={user.apiKey ?? ""}
|
||||
url={addUrlPrefix(short.target)}
|
||||
formatUrl={removeUrlPrefix(short.target)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
<ClickableTooltip
|
||||
className="cursor-pointer truncate"
|
||||
content={
|
||||
<div className="px-2 py-1">
|
||||
<p>{short.user?.name}</p>
|
||||
<p>{short.user?.email}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{short.user?.name || short.user?.email}
|
||||
</ClickableTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Switch
|
||||
defaultChecked={short.active === 1}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatu(value, short.id || "")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
{expirationTime(short.expiration, short.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
<div className="flex items-center gap-1 rounded-lg border bg-gray-50 px-2 py-1 dark:bg-gray-600/50">
|
||||
{rendeClicks(short)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
<TimeAgoIntl date={short.updatedAt as Date} />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(short);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden text-nowrap sm:block">{t("Edit")}</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
setShowQrcode(!isShowQrcode);
|
||||
}}
|
||||
>
|
||||
<Icons.qrcode className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
setCurrentView(short.id!);
|
||||
if (isShowStats && selectedUrl?.id !== short.id) {
|
||||
} else {
|
||||
setShowStats(!isShowStats);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icons.lineChart className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((short) => (
|
||||
<div className="border-b" key={short.id}>
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
<Link
|
||||
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
||||
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
target="_blank"
|
||||
prefetch={false}
|
||||
title={short.url}
|
||||
>
|
||||
{short.url}
|
||||
</Link>
|
||||
<CopyButton
|
||||
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
className={cn(
|
||||
"size-[25px]",
|
||||
"duration-250 transition-all group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
{short.password && (
|
||||
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
||||
{/* {rendeStats(short)} */}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
rendeEmpty()
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
|
||||
const rendeGrid = () => (
|
||||
<>
|
||||
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{isLoading ? (
|
||||
<>
|
||||
{[1, 2, 3, 4, 5, 6].map((v) => (
|
||||
<Skeleton key={v} className="h-24 w-full" />
|
||||
))}
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((short) => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-24 rounded-lg border p-1 shadow-inner dark:bg-neutral-800",
|
||||
)}
|
||||
key={short.id}
|
||||
>
|
||||
<div className="flex h-full flex-col rounded-lg border border-dotted bg-white px-3 py-1.5 backdrop-blur-lg dark:bg-black">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<BlurImage
|
||||
src={`https://unavatar.io/${extractHostname(short.target)}?fallback=https://wr.do/logo.png`}
|
||||
alt="logo"
|
||||
width={30}
|
||||
height={30}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="ml-2 mr-auto flex flex-col justify-between truncate">
|
||||
{/* url */}
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
className="overflow-hidden text-ellipsis whitespace-normal text-sm font-semibold text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-300"
|
||||
href={`https://${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
target="_blank"
|
||||
prefetch={false}
|
||||
title={short.url}
|
||||
>
|
||||
{short.prefix}/{short.url}
|
||||
</Link>
|
||||
<CopyButton
|
||||
value={`https://${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
className={cn(
|
||||
"size-[25px]",
|
||||
"duration-250 transition-all group-hover:opacity-100",
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
|
||||
<LinkInfoPreviewer
|
||||
apiKey={user.apiKey ?? ""}
|
||||
url={short.target}
|
||||
formatUrl={removeUrlSuffix(short.target)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger className="truncate">
|
||||
{short.userName ?? "Anonymous"}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{short.userName ?? "Anonymous"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Switch
|
||||
className="data-[state=checked]:bg-blue-500"
|
||||
defaultChecked={short.active === 1}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatu(value, short.id || "")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
{expirationTime(short.expiration, short.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
{timeAgo(short.updatedAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
{timeAgo(short.createdAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
/>
|
||||
<Button
|
||||
className="duration-250 size-[26px] p-1.5 text-foreground transition-all hover:border hover:text-foreground dark:text-foreground"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
setShowQrcode(!isShowQrcode);
|
||||
}}
|
||||
>
|
||||
<Icons.qrcode className="size-4" />
|
||||
</Button>
|
||||
{short.password && (
|
||||
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* target */}
|
||||
<div className="flex items-center gap-1 overflow-hidden truncate text-sm text-muted-foreground">
|
||||
<Icons.forwardArrow className="size-4 shrink-0 text-gray-400" />
|
||||
<LinkInfoPreviewer
|
||||
apiKey={user.apiKey ?? ""}
|
||||
url={short.target}
|
||||
formatUrl={removeUrlPrefix(short.target)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex items-center gap-1 rounded-md border bg-gray-50 px-2 py-1 dark:bg-gray-600/50">
|
||||
{rendeClicks(short)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="size-[25px] p-1.5"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icons.moreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
className="flex w-full items-center gap-2"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
setCurrentView(short.id!);
|
||||
if (isShowStats && selectedUrl?.id !== short.id) {
|
||||
} else {
|
||||
setShowStats(!isShowStats);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icons.lineChart className="size-4" />
|
||||
{t("Analytics")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex w-full items-center gap-2"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(short);
|
||||
setShowForm(false);
|
||||
@@ -373,71 +607,152 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden sm:block">Edit</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
<PenLine className="size-4" />
|
||||
{t("Edit URL")}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
setShowQrcode(!isShowQrcode);
|
||||
}}
|
||||
>
|
||||
<Icons.qrcode className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
if (isShowStats && selectedUrl?.id !== short.id) {
|
||||
} else {
|
||||
setShowStats(!isShowStats);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icons.lineChart className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isShowStats && selectedUrl?.id === short.id && (
|
||||
<UserUrlMetaInfo
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url/meta"
|
||||
urlId={short.id!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="link" />
|
||||
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any url yet. Start creating url.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<ClickableTooltip
|
||||
className="cursor-pointer truncate"
|
||||
content={
|
||||
<div className="px-2 py-1">
|
||||
<p>{short.user?.name}</p>
|
||||
<p>{short.user?.email}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{short.user?.name || short.user?.email}
|
||||
</ClickableTooltip>
|
||||
<Separator
|
||||
className="h-4/5"
|
||||
orientation="vertical"
|
||||
></Separator>
|
||||
{short.expiration !== "-1" && (
|
||||
<>
|
||||
<span>
|
||||
Expiration:{" "}
|
||||
{expirationTime(short.expiration, short.updatedAt)}
|
||||
</span>
|
||||
<Separator
|
||||
className="h-4/5"
|
||||
orientation="vertical"
|
||||
></Separator>
|
||||
</>
|
||||
)}
|
||||
<TimeAgoIntl date={short.updatedAt as Date} />
|
||||
<Switch
|
||||
className="scale-[0.6]"
|
||||
defaultChecked={short.active === 1}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatu(value, short.id || "")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
rendeEmpty()
|
||||
)}
|
||||
</section>
|
||||
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
className={cn(
|
||||
"space-y-3 rounded-lg",
|
||||
pathname === "/dashboard" && "border p-6",
|
||||
)}
|
||||
value={currentView}
|
||||
>
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{pathname === "/dashboard" && (
|
||||
<h2 className="mr-auto text-lg font-semibold">{t("Short URLs")}</h2>
|
||||
)}
|
||||
<TabsList>
|
||||
<TabsTrigger onClick={() => setCurrentView("List")} value="List">
|
||||
<Icons.list className="size-4" />
|
||||
{/* List */}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger onClick={() => setCurrentView("Grid")} value="Grid">
|
||||
<Icons.layoutGrid className="size-4" />
|
||||
{/* Grid */}
|
||||
</TabsTrigger>
|
||||
{selectedUrl?.id && (
|
||||
<TabsTrigger
|
||||
className="flex items-center gap-1 text-muted-foreground"
|
||||
value={selectedUrl.id}
|
||||
onClick={() => setCurrentView(selectedUrl.id!)}
|
||||
>
|
||||
<Icons.lineChart className="size-4" />
|
||||
{selectedUrl.url}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsList>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{renderSearchInputs()}
|
||||
<UrlExporter data={data?.list || []} />
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Icons.refreshCw className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.refreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{action.indexOf("admin") === -1 && (
|
||||
<Button
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">{t("Add URL")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pathname !== "/dashboard" && <UrlStatus action={action} />}
|
||||
|
||||
<TabsContent className="mt-0 space-y-3" value="List">
|
||||
{rendeList()}
|
||||
</TabsContent>
|
||||
<TabsContent className="mt-0 space-y-3" value="Grid">
|
||||
{rendeGrid()}
|
||||
</TabsContent>
|
||||
{selectedUrl?.id && (
|
||||
<TabsContent value={selectedUrl.id}>
|
||||
{rendeStats(selectedUrl)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* QR code editor */}
|
||||
<Modal
|
||||
@@ -448,7 +763,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
{selectedUrl && (
|
||||
<QRCodeEditor
|
||||
user={{ id: user.id, apiKey: user.apiKey || "", team: user.team! }}
|
||||
url={`https://${selectedUrl.prefix}/s/${selectedUrl.url}`}
|
||||
url={`https://${selectedUrl.prefix}/${selectedUrl.url}`}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
57
app/(protected)/dashboard/urls/wrapper.tsx
Normal file
57
app/(protected)/dashboard/urls/wrapper.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import ApiReference from "@/components/shared/api-reference";
|
||||
|
||||
import Globe from "./globe";
|
||||
import LiveLog from "./live-logs";
|
||||
import UserUrlsList from "./url-list";
|
||||
|
||||
export function Wrapper({
|
||||
user,
|
||||
}: {
|
||||
user: Pick<User, "id" | "name" | "apiKey" | "role" | "team">;
|
||||
}) {
|
||||
const [tab, setTab] = useState("Links");
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChangeCapture={(e) => console.log(e)}
|
||||
defaultValue={tab}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="Links" onClick={() => setTab("Links")}>
|
||||
Links
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="Realtime" onClick={() => setTab("Realtime")}>
|
||||
Realtime
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)
|
||||
<TabsContent className="space-y-3" value="Links">
|
||||
<UserUrlsList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url"
|
||||
/>
|
||||
<LiveLog admin={false} />
|
||||
<ApiReference
|
||||
badge="POST /api/v1/short"
|
||||
target="creating short urls"
|
||||
link="/docs/short-urls#api-reference"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="Realtime">
|
||||
<Globe />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MobileSheetSidebar,
|
||||
} from "@/components/layout/dashboard-sidebar";
|
||||
import { ModeToggle } from "@/components/layout/mode-toggle";
|
||||
import { Notification } from "@/components/layout/notification";
|
||||
import { UserAccountNav } from "@/components/layout/user-account-nav";
|
||||
import MaxWidthWrapper from "@/components/shared/max-width-wrapper";
|
||||
|
||||
@@ -32,6 +33,7 @@ export default async function Dashboard({ children }: ProtectedLayoutProps) {
|
||||
<DashboardSidebar links={filteredLinks} />
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Notification />
|
||||
<header className="sticky top-0 z-50 flex h-14 bg-background px-4 lg:h-[60px] xl:px-8">
|
||||
<MaxWidthWrapper className="flex max-w-7xl items-center gap-x-3 px-0">
|
||||
<MobileSheetSidebar links={filteredLinks} />
|
||||
|
||||
352
app/(protected)/setup/guide.tsx
Normal file
352
app/(protected)/setup/guide.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { cn, removeUrlPrefix } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { FormSectionColumns } from "@/components/dashboard/form-section-columns";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
export default function StepGuide({
|
||||
user,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [direction, setDirection] = useState(0);
|
||||
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
|
||||
|
||||
const t = useTranslations("Common");
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
title: t("Set up an administrator"),
|
||||
component: () => <SetAdminRole id={user.id} email={user.email} />,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t("Add the first domain"),
|
||||
component: () => <AddDomain onNextStep={goToNextStep} />,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t("Congrats on completing setup 🎉"),
|
||||
component: () => <Congrats />,
|
||||
},
|
||||
];
|
||||
|
||||
const goToNextStep = () => {
|
||||
if (currentStep < steps.length) {
|
||||
setDirection(1);
|
||||
setCurrentStep(currentStep + 1);
|
||||
if (!completedSteps.includes(currentStep)) {
|
||||
setCompletedSteps([...completedSteps, currentStep]);
|
||||
}
|
||||
} else if (currentStep === steps.length) {
|
||||
router.push("/admin");
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousStep = () => {
|
||||
if (currentStep > 1) {
|
||||
setDirection(-1);
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const currentStepData =
|
||||
steps.find((step) => step.id === currentStep) || steps[0];
|
||||
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 100 : -100,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction < 0 ? 100 : -100,
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal className="md:max-w-2xl">
|
||||
<div className="w-full px-4 py-2 md:px-8 md:py-4">
|
||||
<div className="mb-6 mt-3 flex items-center justify-between gap-4">
|
||||
<h2 className="text-2xl font-bold">{t("Admin Setup Guide")}</h2>
|
||||
<div className="flex items-center gap-2 rounded-full bg-muted/50 px-3 py-1.5 text-sm font-medium">
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
{currentStep}
|
||||
</span>
|
||||
<span className="text-muted-foreground">of</span>
|
||||
<span>{steps.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="relative w-full rounded-lg">
|
||||
<AnimatePresence custom={direction} mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||
className="flex flex-col justify-center gap-6"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="mb-2 flex items-center gap-1 rounded-lg bg-neutral-100 p-2 dark:bg-neutral-800">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{currentStep}
|
||||
</span>
|
||||
<motion.h3
|
||||
className="text-base font-semibold"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
{currentStepData.title}
|
||||
</motion.h3>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="h-full"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
{currentStepData.component()}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="mt-auto flex justify-between px-4 pb-4 pt-3 md:px-8 md:pb-6"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
>
|
||||
<button
|
||||
onClick={goToPreviousStep}
|
||||
disabled={currentStep === 1}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 transition-colors",
|
||||
currentStep === 1
|
||||
? "cursor-not-allowed bg-muted text-muted-foreground"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t("Previous")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNextStep}
|
||||
// disabled={currentStep === steps.length}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90",
|
||||
)}
|
||||
>
|
||||
{currentStep === steps.length ? t("🚀 Start") : t("Next")}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SetAdminRole({ id, email }: { id: string; email: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const t = useTranslations("Common");
|
||||
const handleSetAdmin = async () => {
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/setup");
|
||||
if (res.ok) {
|
||||
setIsAdmin(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ReadyBadge = (
|
||||
<Badge className="text-xs font-semibold" variant="green">
|
||||
<Icons.check className="mr-1 size-3" />
|
||||
{t("Ready")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{t("Allow Sign Up")}:
|
||||
</span>
|
||||
{ReadyBadge}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{t("Set {email} as ADMIN", { email })}:
|
||||
</span>
|
||||
{isAdmin ? (
|
||||
ReadyBadge
|
||||
) : (
|
||||
<Button
|
||||
variant={"default"}
|
||||
size={"sm"}
|
||||
onClick={handleSetAdmin}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && (
|
||||
<Icons.spinner className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t("Active Now")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
|
||||
<p className="flex items-start gap-1">
|
||||
• {t("After v1-0-2, this setup guide is not needed anymore")}.
|
||||
</p>
|
||||
<p className="flex items-start gap-1">
|
||||
•{" "}
|
||||
{t(
|
||||
"Only by becoming an administrator can one access the admin panel and add domain names",
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
<p className="my-1">
|
||||
•{" "}
|
||||
{t(
|
||||
"Administrators can set all user permissions, allocate quotas, view and edit all resources (short links, subdomains, email), etc",
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
•{t("Via")}{" "}
|
||||
<a
|
||||
className="text-blue-500 after:content-['_↗']"
|
||||
target="_blank"
|
||||
href="/docs/developer/quick-start"
|
||||
>
|
||||
{t("quick start")}
|
||||
</a>{" "}
|
||||
{t("docs to get more information")}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDomain({ onNextStep }: { onNextStep: () => void }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [domain, setDomain] = useState("");
|
||||
const t = useTranslations("Common");
|
||||
const handleCreateDomain = async () => {
|
||||
if (!domain) {
|
||||
toast.warning("Domain name cannot be empty");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/admin/domain", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
domain_name: removeUrlPrefix(domain),
|
||||
enable_short_link: true,
|
||||
enable_email: true,
|
||||
enable_dns: true,
|
||||
cf_zone_id: "",
|
||||
cf_api_key: "",
|
||||
cf_email: "",
|
||||
cf_api_key_encrypted: false,
|
||||
max_short_links: 0,
|
||||
max_email_forwards: 0,
|
||||
max_dns_records: 0,
|
||||
active: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
onNextStep();
|
||||
} else {
|
||||
toast.error("Created Failed!", {
|
||||
description: await res.text(),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
|
||||
<FormSectionColumns title={t("Domain Name")}>
|
||||
<div className="flex w-full flex-col items-start justify-between gap-2">
|
||||
<Label className="sr-only" htmlFor="domain_name">
|
||||
{t("Domain Name")}
|
||||
</Label>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner dark:bg-neutral-600"
|
||||
size={32}
|
||||
placeholder="example.com"
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"Please enter a valid domain name (must be hosted on Cloudflare)",
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex w-full items-center justify-end gap-3">
|
||||
<Button
|
||||
className="text-xs text-muted-foreground"
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
onClick={onNextStep}
|
||||
>
|
||||
{t("Or add later")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
size={"sm"}
|
||||
variant={"blue"}
|
||||
disabled={isPending}
|
||||
onClick={handleCreateDomain}
|
||||
>
|
||||
{isPending && (
|
||||
<Icons.spinner className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t("Submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Congrats() {
|
||||
return <></>;
|
||||
}
|
||||
12
app/(protected)/setup/loading.tsx
Normal file
12
app/(protected)/setup/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Setup Guide" text="" />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
app/(protected)/setup/page.tsx
Normal file
28
app/(protected)/setup/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getAllUsersCount } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
|
||||
import SetupGuide from "./guide";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Setup Guide",
|
||||
description: "Setup Guide",
|
||||
});
|
||||
|
||||
export default async function SetupPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
if (user.role === "ADMIN") redirect("/admin");
|
||||
|
||||
const count = await getAllUsersCount();
|
||||
|
||||
if (count === 1 && user.role === "USER") {
|
||||
return <SetupGuide user={{ id: user.id, email: user.email! }} />;
|
||||
}
|
||||
|
||||
return redirect("/admin");
|
||||
}
|
||||
73
app/api/admin/configs/route.ts
Normal file
73
app/api/admin/configs/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
getMultipleConfigs,
|
||||
updateSystemConfig,
|
||||
} from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs([
|
||||
"enable_user_registration",
|
||||
"enable_subdomain_apply",
|
||||
"system_notification",
|
||||
"enable_github_oauth",
|
||||
"enable_google_oauth",
|
||||
"enable_liunxdo_oauth",
|
||||
"enable_resend_email_login",
|
||||
"enable_email_password_login",
|
||||
"enable_email_catch_all",
|
||||
"catch_all_emails",
|
||||
"enable_tg_email_push",
|
||||
"tg_email_bot_token",
|
||||
"tg_email_chat_id",
|
||||
"tg_email_template",
|
||||
"tg_email_target_white_list",
|
||||
"enable_email_registration_suffix_limit",
|
||||
"email_registration_suffix_limit_white_list",
|
||||
"enable_subdomain_status_email_pusher",
|
||||
"enable_email_forward",
|
||||
"email_forward_targets",
|
||||
"email_forward_white_list",
|
||||
]);
|
||||
|
||||
return Response.json(configs, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { key, value, type } = await req.json();
|
||||
if (!key || !type) {
|
||||
return Response.json("key and value is required", { status: 400 });
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs([key]);
|
||||
|
||||
if (key in configs) {
|
||||
await updateSystemConfig(key, { value, type });
|
||||
return Response.json("Success", { status: 200 });
|
||||
}
|
||||
return Response.json("Invalid key", { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
56
app/api/admin/domain/duplicate/route.ts
Normal file
56
app/api/admin/domain/duplicate/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { createDomain, getDomainByName } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { domain } = await req.json();
|
||||
if (!domain) {
|
||||
return Response.json("Domain name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const target_domain = await getDomainByName(domain);
|
||||
if (!target_domain) {
|
||||
return Response.json("Domain not found", { status: 404 });
|
||||
}
|
||||
|
||||
const newDomain = await createDomain({
|
||||
domain_name: target_domain.domain_name + "-copy",
|
||||
enable_short_link: !!target_domain.enable_short_link,
|
||||
enable_email: !!target_domain.enable_email,
|
||||
enable_dns: !!target_domain.enable_dns,
|
||||
cf_zone_id: target_domain.cf_zone_id,
|
||||
cf_api_key: target_domain.cf_api_key,
|
||||
cf_email: target_domain.cf_email,
|
||||
cf_record_types: target_domain.cf_record_types,
|
||||
cf_api_key_encrypted: false,
|
||||
email_provider: target_domain.email_provider,
|
||||
resend_api_key: target_domain.resend_api_key,
|
||||
brevo_api_key: target_domain.brevo_api_key,
|
||||
max_short_links: target_domain.max_short_links,
|
||||
max_email_forwards: target_domain.max_email_forwards,
|
||||
max_dns_records: target_domain.max_dns_records,
|
||||
min_url_length: target_domain.min_url_length,
|
||||
min_email_length: target_domain.min_email_length,
|
||||
min_record_length: target_domain.min_record_length,
|
||||
active: true,
|
||||
});
|
||||
|
||||
if (!newDomain) {
|
||||
return Response.json("Failed to create domain", { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json("Success", { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
166
app/api/admin/domain/route.ts
Normal file
166
app/api/admin/domain/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
createDomain,
|
||||
deleteDomain,
|
||||
getAllDomains,
|
||||
updateDomain,
|
||||
} from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
// Get domains list
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const page = url.searchParams.get("page");
|
||||
const size = url.searchParams.get("size");
|
||||
const target = url.searchParams.get("target") || "";
|
||||
|
||||
const data = await getAllDomains(
|
||||
Number(page || "1"),
|
||||
Number(size || "10"),
|
||||
target,
|
||||
);
|
||||
|
||||
return Response.json(data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Create domain
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { data } = await req.json();
|
||||
if (!data || !data.domain_name) {
|
||||
return Response.json("domain_name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const newDomain = await createDomain({
|
||||
domain_name: data.domain_name,
|
||||
enable_short_link: !!data.enable_short_link,
|
||||
enable_email: !!data.enable_email,
|
||||
enable_dns: !!data.enable_dns,
|
||||
cf_zone_id: data.cf_zone_id,
|
||||
cf_api_key: data.cf_api_key,
|
||||
cf_email: data.cf_email,
|
||||
cf_record_types: data.cf_record_types,
|
||||
cf_api_key_encrypted: false,
|
||||
email_provider: data.email_provider,
|
||||
resend_api_key: data.resend_api_key,
|
||||
brevo_api_key: data.brevo_api_key,
|
||||
max_short_links: data.max_short_links,
|
||||
max_email_forwards: data.max_email_forwards,
|
||||
max_dns_records: data.max_dns_records,
|
||||
min_url_length: data.min_url_length,
|
||||
min_email_length: data.min_email_length,
|
||||
min_record_length: data.min_record_length,
|
||||
active: true,
|
||||
});
|
||||
|
||||
return Response.json(newDomain, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const {
|
||||
domain_name,
|
||||
enable_short_link,
|
||||
enable_email,
|
||||
enable_dns,
|
||||
cf_zone_id,
|
||||
cf_api_key,
|
||||
cf_email,
|
||||
cf_record_types,
|
||||
email_provider,
|
||||
resend_api_key,
|
||||
brevo_api_key,
|
||||
min_url_length,
|
||||
min_email_length,
|
||||
min_record_length,
|
||||
max_short_links,
|
||||
max_email_forwards,
|
||||
max_dns_records,
|
||||
active,
|
||||
id,
|
||||
} = await req.json();
|
||||
if (!id) {
|
||||
return Response.json("domain id is required", { status: 400 });
|
||||
}
|
||||
|
||||
const updatedDomain = await updateDomain(id, {
|
||||
domain_name,
|
||||
enable_short_link: !!enable_short_link,
|
||||
enable_email: !!enable_email,
|
||||
enable_dns: !!enable_dns,
|
||||
active: !!active,
|
||||
cf_zone_id,
|
||||
cf_api_key,
|
||||
cf_email,
|
||||
cf_record_types,
|
||||
cf_api_key_encrypted: false,
|
||||
email_provider,
|
||||
brevo_api_key,
|
||||
resend_api_key,
|
||||
min_url_length,
|
||||
min_email_length,
|
||||
min_record_length,
|
||||
max_short_links,
|
||||
max_email_forwards,
|
||||
max_dns_records,
|
||||
});
|
||||
|
||||
return Response.json(updatedDomain, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete domain
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { domain_name } = await req.json();
|
||||
if (!domain_name) {
|
||||
return Response.json("domain_name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const deletedDomain = await deleteDomain(domain_name);
|
||||
|
||||
return Response.json(deletedDomain, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
144
app/api/admin/plan/route.ts
Normal file
144
app/api/admin/plan/route.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
createPlan,
|
||||
deletePlan,
|
||||
getAllPlans,
|
||||
updatePlanQuota,
|
||||
} from "@/lib/dto/plan";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// const url = new URL(req.url);
|
||||
// const page = url.searchParams.get("page");
|
||||
// const size = url.searchParams.get("size");
|
||||
// const target = url.searchParams.get("target") || "";
|
||||
|
||||
const data = await getAllPlans();
|
||||
|
||||
return Response.json(data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { plan } = await req.json();
|
||||
|
||||
const data = await createPlan({
|
||||
name: plan.name,
|
||||
slTrackedClicks: plan.slTrackedClicks,
|
||||
slNewLinks: plan.slNewLinks,
|
||||
slAnalyticsRetention: plan.slAnalyticsRetention,
|
||||
slDomains: plan.slDomains,
|
||||
slAdvancedAnalytics: plan.slAdvancedAnalytics,
|
||||
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
|
||||
rcNewRecords: plan.rcNewRecords,
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
return Response.json(data, { status: 200 });
|
||||
}
|
||||
|
||||
return Response.json(null, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { plan } = await req.json();
|
||||
if (!plan) {
|
||||
return Response.json("Invalid request body", { status: 400 });
|
||||
}
|
||||
|
||||
const res = await updatePlanQuota({
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
slTrackedClicks: plan.slTrackedClicks,
|
||||
slNewLinks: plan.slNewLinks,
|
||||
slAnalyticsRetention: plan.slAnalyticsRetention,
|
||||
slDomains: plan.slDomains,
|
||||
slAdvancedAnalytics: plan.slAdvancedAnalytics,
|
||||
slCustomQrCodeLogo: plan.slCustomQrCodeLogo,
|
||||
rcNewRecords: plan.rcNewRecords,
|
||||
emEmailAddresses: plan.emEmailAddresses,
|
||||
emDomains: plan.emDomains,
|
||||
emSendEmails: plan.emSendEmails,
|
||||
stMaxFileSize: plan.stMaxFileSize,
|
||||
stMaxTotalSize: plan.stMaxTotalSize,
|
||||
stMaxFileCount: plan.stMaxFileCount,
|
||||
appSupport: plan.appSupport.toUpperCase() as any,
|
||||
appApiAccess: plan.appApiAccess,
|
||||
isActive: plan.isActive,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
return Response.json(res, { status: 200 });
|
||||
}
|
||||
|
||||
return Response.json(null, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await req.json();
|
||||
if (!id) {
|
||||
return Response.json("id is required", { status: 400 });
|
||||
}
|
||||
|
||||
const data = await deletePlan(id);
|
||||
|
||||
if (data) {
|
||||
return Response.json(data, { status: 200 });
|
||||
}
|
||||
|
||||
return Response.json(null, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
51
app/api/admin/s3/route.ts
Normal file
51
app/api/admin/s3/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
getMultipleConfigs,
|
||||
updateSystemConfig,
|
||||
} from "@/lib/dto/system-config";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(["s3_config_list"]);
|
||||
|
||||
return Response.json(configs, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { key, value, type } = await req.json();
|
||||
if (!key || !type) {
|
||||
return Response.json("key and value is required", { status: 400 });
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs([key]);
|
||||
|
||||
if (key in configs) {
|
||||
await updateSystemConfig(key, { value, type });
|
||||
return Response.json("Success", { status: 200 });
|
||||
}
|
||||
return Response.json("Invalid key", { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
53
app/api/auth/credentials/route.ts
Normal file
53
app/api/auth/credentials/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
import { hashPassword, verifyPassword } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { email, password, name } = await req.json();
|
||||
if (!email || !password) {
|
||||
return Response.json("email and password is required", { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const configs = await getMultipleConfigs(["enable_user_registration"]);
|
||||
if (!configs.enable_user_registration) {
|
||||
return Response.json("User registration is disabled", { status: 403 });
|
||||
}
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
name: "",
|
||||
email,
|
||||
password: hashPassword(password),
|
||||
active: 1,
|
||||
role: "USER",
|
||||
team: "free",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return Response.json(newUser, { status: 200 });
|
||||
} else {
|
||||
if (user.active === 0) {
|
||||
return Response.json(null, { status: 403 });
|
||||
}
|
||||
const passwordCorrect = verifyPassword(password, user.password || "");
|
||||
if (passwordCorrect) {
|
||||
return Response.json(user, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json(null, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Auth Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
35
app/api/configs/route.ts
Normal file
35
app/api/configs/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const allowed_keys = [
|
||||
"enable_user_registration",
|
||||
"enable_subdomain_apply",
|
||||
"system_notification",
|
||||
];
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const keys = url.searchParams.getAll("key") || [];
|
||||
|
||||
if (keys.length === 0) {
|
||||
return Response.json("key is required", { status: 400 });
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (!allowed_keys.includes(key)) {
|
||||
return Response.json("Invalid key", { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const configs = await getMultipleConfigs(keys);
|
||||
|
||||
return Response.json(configs, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user