237 Commits

Author SHA1 Message Date
oiov
02eee5ac36 upd: add likeDo footer link 2025-11-28 14:32:34 +08:00
oiov
ae06f1a98d docs: add invite link 2025-11-20 16:42:47 +08:00
oiov
02ef7a627e fix: add send email content length limits 2025-11-20 11:09:49 +08:00
oiov
33f7b3f653 fix: text color inversion on dark theme(#79) 2025-11-11 19:10:55 +08:00
oiov
ffa1f03b52 chore: tg message content length 2025-11-09 20:54:08 +08:00
oiov
baf87f1cd8 chore: update tg message for email 2025-11-09 20:41:55 +08:00
oiov
405174220f chore: display full short link in list 2025-11-09 12:07:02 +08:00
oiov
0cb3946b39 feat: support admin to transfer links to other accounts 2025-11-08 19:56:58 +08:00
oiov
3d24f9bd71 chore: display user name and email on table list 2025-11-07 16:56:34 +08:00
oiov
a4b4bc7e4e style: change landing slider to domains 2025-11-02 18:13:57 +08:00
oiov
46a791c849 docs: add r2 env 2025-11-02 14:15:05 +08:00
oiov
ace732a558 fixup crash 2025-11-01 17:10:34 +08:00
oiov
2ddafd6aec chore: better url list 2025-11-01 17:01:22 +08:00
oiov
e935adf4b6 chore: split email module 2025-10-30 20:37:40 +08:00
oiov
802db8724a add url analytics and logs routers 2025-10-30 17:39:18 +08:00
oiov
7ee88c8026 refact: mult levels dashboard sidebar 2025-10-30 17:11:49 +08:00
oiov
d56ddaf105 fix logo url 2025-10-29 15:34:19 +08:00
oiov
1be15598ef fix logo url 2025-10-29 15:32:51 +08:00
oiov
bd70198b04 chore: add notifiction area 2025-10-29 15:16:14 +08:00
oiov
3da9930247 style: change app logo 2025-10-29 14:59:05 +08:00
oiov
67c814f194 style: adjust landing text 2025-10-21 11:38:46 +08:00
oiov
d75ec5fda2 fix eslint type error 2025-10-20 12:13:14 +08:00
oiov
b9a80d4df4 fix: Change CSS import to SCSS 2025-10-20 11:43:27 +08:00
oiov
7e5c0d9de6 refact: email sending editor 2025-10-20 11:34:05 +08:00
oiov
349a91cd0c docs: add afd link and qrcode 2025-10-19 19:14:49 +08:00
oiov
9e3249eda4 debug 2025-10-19 15:10:34 +08:00
oiov
4524f5ab95 debug 2025-10-19 15:06:40 +08:00
oiov
4e6c6b77b6 debug 2025-10-19 14:59:11 +08:00
oiov
be672722bf debug 2025-10-19 14:52:04 +08:00
oiov
440d310076 chore: business domain auto redirect to host domain 2025-10-19 14:47:19 +08:00
oiov
c4a43a3a8d chore: change default email from name 2025-10-18 17:10:47 +08:00
oiov
9d2d730987 style: refact feature section 2025-10-18 16:33:38 +08:00
oiov
814610cd59 style: adjust logo and scroll area 2025-10-16 20:51:40 +08:00
oiov
70857c7fec refact: better landing and login page 2025-10-16 20:37:26 +08:00
oiov
940b02313c feats: support muti email providers 2025-10-16 15:48:04 +08:00
oiov
9c3e9ddc0f docs: remove test domain 2025-10-13 11:12:20 +08:00
oiov
76f792cc7d feat: add localstorage support for QR code editor 2025-09-29 10:45:07 +08:00
oiov
bfb1a14045 fix barchart calcuate 2025-09-16 14:51:08 +08:00
oiov
044356ad29 refact: remove /s for short link 2025-09-14 16:10:43 +08:00
oiov
a43135f088 chore: replace default avatar image 2025-08-12 16:26:12 +08:00
oiov
2d44423e14 fix: file link copy value 2025-08-12 09:48:31 +08:00
oiov
eac50a40f8 docs: update aff links 2025-08-11 17:02:58 +08:00
oiov
55a46c184b chore forwading email info 2025-08-08 14:46:44 +08:00
oiov
c4e62e9322 Add forward email tips 2025-08-08 14:38:56 +08:00
oiov
25ac692268 Improve chinese locale translation 2025-08-06 17:35:42 +08:00
oiov
8f4847e13c adjust link preview image size 2025-08-06 11:22:35 +08:00
oiov
d47689adcf Improve link previewer cpn 2025-08-06 10:56:30 +08:00
oiov
bc20bb2fec fix file list user info display 2025-08-05 20:17:28 +08:00
oiov
14a2f4a940 fix storage doc link 2025-08-05 17:34:00 +08:00
oiov
f96c3d4f7e feat: add email forwrading white list config 2025-08-05 10:25:19 +08:00
oiov
5335834809 feat: add export url functions 2025-08-04 11:03:43 +08:00
oiov
ecca6abdf7 bump version to 1.1.4 2025-08-04 10:22:11 +08:00
oiov
97097b143a fix: disabled users are not allowed to log in 2025-08-04 10:20:21 +08:00
oiov
ad0c4b2b6e enhance locale translation and add contributors section in readme 2025-07-30 15:36:54 +08:00
oiov
1fd837d4cb fix feedback comment path 2025-07-28 11:33:20 +08:00
oiov
f12c9e326b change default forward sender 2025-07-28 11:04:34 +08:00
oiov
2d2a30cd8b fix resend send error 2025-07-28 11:03:15 +08:00
oiov
17df046ca1 chore 2025-07-28 10:52:10 +08:00
oiov
d23cabeebb chore 2025-07-28 10:46:16 +08:00
oiov
cfcbc3fe26 feat: add email forwarding configs 2025-07-28 09:49:36 +08:00
oiov
3f5d2a8364 upd: improve chinese and english localization 2025-07-24 10:54:49 +08:00
oiov
e52b5b82fb chore(storage): upd file list display name 2025-07-24 10:44:19 +08:00
oiov
5c8d79f78f fix: create dns record content too long(#63) 2025-07-23 09:54:04 +08:00
oiov
0d91760ef6 replace plan limit to bucket limit 2025-07-22 17:16:00 +08:00
oiov
3cc2df46f8 Merge pull request #65 from oiov/dev
Dev
2025-07-22 17:06:05 +08:00
oiov
28367890a1 refact: bucket storage limit rules 2025-07-22 16:58:24 +08:00
oiov
4afa235a17 Merge branch 'dev' of https://github.com/oiov/wr.do into dev 2025-07-22 14:26:31 +08:00
oiov
75d1ab0c83 Merge pull request #62 from weiruchenai1/dev
feat: 添加存储桶容量限制和上传验证功能
2025-07-22 14:25:47 +08:00
weiruchenai1
509ad15652 feat: 实现分层存储配额管理和UI优化
🆕 新增功能:
- 添加存储桶容量限制配置功能,支持按存储桶设置最大容量
- 新增存储桶使用情况API端点 (/api/storage/bucket-usage)
- 实现Plan配额和存储桶配额的分层显示

🎨 界面优化:
- 存储使用情况面板支持双层配额显示
- 上传错误提示改为右对齐显示
- Toast通知位置改为右下角
- 为存储桶配置添加帮助提示

🔧 功能改进:
- 优化上传前容量检查逻辑
- 改进错误信息显示,使用中文提示
- 智能显示更严格的配额限制
- 完善国际化支持

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 12:47:23 +08:00
oiov
356d5e2b4a format readme styles 2025-07-22 11:14:23 +08:00
oiov
9a3163883d chore docs 2025-07-21 21:20:44 +08:00
oiov
f23c6e5159 docs: upd readme docker deploy detail 2025-07-21 21:17:32 +08:00
oiov
c9efd7f0ef docs: change default readme language to zh 2025-07-21 19:45:09 +08:00
oiov
457e161281 chore error status code and custom provider name 2025-07-21 10:47:37 +08:00
weiruchenai1
d25de8d0f7 feat: improve file upload error handling and add Chinese localization 2025-07-21 00:24:44 +08:00
oiov
877e826d23 feat(s3): add custom provider section 2025-07-20 19:53:38 +08:00
oiov
0a128254d3 chore empty bucket tip 2025-07-17 10:15:23 +08:00
oiov
8aa0609c95 upd file links 2025-07-15 16:18:19 +08:00
oiov
968f764af7 docs: add cloud storage docs 2025-07-15 15:30:01 +08:00
oiov
e2748c00a0 Merge branch 'dev' 2025-07-14 17:39:17 +08:00
oiov
83792c98e9 fixup download file method 2025-07-14 17:38:40 +08:00
oiov
6f0b5eb6ba Merge pull request #56 from oiov/dev
Dev
2025-07-14 16:16:03 +08:00
oiov
64ad667002 adjust file list style 2025-07-14 16:14:47 +08:00
oiov
596b7622bf bump version to v1.1.3 2025-07-14 15:51:40 +08:00
oiov
6bb5adadab Merge pull request #55 from oiov/dev
Refact s3 configs and support muti s3 provider
2025-07-14 15:40:28 +08:00
oiov
f1f9fc1d63 refact s3 configs and support muti s3 providers 2025-07-14 15:14:54 +08:00
oiov
1973132c52 update storage size tooltip to clickable 2025-07-13 16:49:45 +08:00
oiov
ee54fb101e Merge pull request #54 from oiov/dev
Dev
2025-07-13 10:52:05 +08:00
oiov
5600b85e6d chore 2025-07-13 10:45:59 +08:00
oiov
34f73ca0e5 Support paste file to upload 2025-07-13 10:44:16 +08:00
oiov
4c42874b1a Merge pull request #52 from oiov/dev
fixup bucket crash
2025-07-12 20:15:00 +08:00
oiov
9eb296cf51 fixup bucket crash 2025-07-12 20:12:31 +08:00
oiov
b705b90763 Merge pull request #51 from oiov/dev
chore file size column type
2025-07-12 19:40:42 +08:00
oiov
bb37643e6d chore file size column type 2025-07-12 19:39:55 +08:00
oiov
ade3aa2093 Merge pull request #50 from oiov/dev
refact file size caculate
2025-07-12 16:32:32 +08:00
oiov
0b72ced8d6 refact file size caculate 2025-07-12 16:31:30 +08:00
oiov
5520a004e1 Merge pull request #49 from oiov/dev
Add clickable tooltip
2025-07-12 11:35:34 +08:00
oiov
01e3b7ddd3 Add clickable tooltip 2025-07-12 11:32:16 +08:00
oiov
2310fcfc3e Merge pull request #48 from oiov/dev
bump version
2025-07-11 20:30:14 +08:00
oiov
8c502bd6ac chore 2025-07-11 20:29:27 +08:00
oiov
4df8e985c1 bump version to v1.1.2 2025-07-11 20:26:47 +08:00
oiov
0e28b20dbe Merge pull request #47 from oiov/dev
refact file uploader
2025-07-11 20:24:35 +08:00
oiov
1556248883 fix: plan form default value 0 2025-07-11 20:20:25 +08:00
oiov
4433ad57f4 refact: file uploader cpn 2025-07-11 19:59:02 +08:00
oiov
a167ff8dd9 fixup locale typo 2025-07-11 14:23:05 +08:00
oiov
22defdfb62 chore meun styles 2025-07-10 16:42:53 +08:00
oiov
597fa4c2b6 Adjust file list styles 2025-07-10 16:34:12 +08:00
oiov
67ba274377 chore: pagenation show total num 2025-07-10 16:07:44 +08:00
oiov
2aabc9505c style: adjust file list styles 2025-07-10 16:00:51 +08:00
oiov
3bf702e1d5 Merge branch 'main' of https://github.com/oiov/wr.do 2025-07-10 15:52:29 +08:00
oiov
785bad134e feat: add search input for storage 2025-07-10 15:51:53 +08:00
oiov
e7401640a6 feat: support admin upload files 2025-07-10 11:14:09 +08:00
oiov
f9c517150d Merge pull request #45 from oiov/s3/cloudflare-r2
bump version to v1.1.1
2025-07-09 17:30:29 +08:00
oiov
6a79424ef1 bump version to v1.1.1 2025-07-09 17:29:15 +08:00
oiov
b9b64ba69d Merge pull request #44 from oiov/s3/cloudflare-r2
fix: storage crash
2025-07-09 17:21:13 +08:00
oiov
d5dd129f7b fix: storage crash 2025-07-09 17:17:08 +08:00
oiov
37df8141cf Merge pull request #42 from oiov/s3/cloudflare-r2
S3/cloudflare r2
2025-07-09 16:33:19 +08:00
oiov
1277a3132b chore image 2025-07-09 16:26:32 +08:00
oiov
feb0e31eb2 chore: add public filed for bucket 2025-07-09 16:24:06 +08:00
oiov
709a56609e feat: add admin storage manager 2025-07-08 21:22:10 +08:00
oiov
6e6bc22177 chore icon codes 2025-07-08 11:16:27 +08:00
oiov
639cae5821 docs: add storage feature 2025-07-07 21:43:08 +08:00
oiov
436a30e9d0 feat: add file upload limit on plan 2025-07-07 21:30:01 +08:00
oiov
92bbb4468a chore: add file links cpm 2025-07-07 19:49:41 +08:00
oiov
d5acf4f34d feat: add qrcode for files 2025-07-07 11:22:11 +08:00
oiov
4382b0b045 bump version to v1.1.0 2025-07-06 22:57:23 +08:00
oiov
5e931e50a5 fixup build error 2025-07-06 22:56:48 +08:00
oiov
17b7577a9a fixup build error 2025-07-06 22:51:58 +08:00
oiov
c65d49635b chore storage styles 2025-07-06 22:43:27 +08:00
oiov
a0d7c1071a feats: file list render 2025-07-06 18:56:41 +08:00
oiov
dc06fa446e refact sitmap generation 2025-07-06 11:24:07 +08:00
oiov
bb957c9ee6 feat: add user-files schema and crud 2025-07-05 21:39:03 +08:00
oiov
f21bb49b49 feat: add file list display 2025-07-04 16:44:05 +08:00
oiov
f90ccca8ba feat: add s3 crud and s3 confgs 2025-07-02 17:25:07 +08:00
oiov
33a98bf83e Merge pull request #41 from oiov/s3/cloudflare-r2
Add umami env for analytics
2025-07-01 19:20:25 +08:00
oiov
5890b78b5b Add umami env for analytics 2025-07-01 19:16:45 +08:00
oiov
0faca9eda3 chore readme 2025-07-01 10:46:47 +08:00
oiov
5f9ccd8ef3 chore docs link 2025-06-30 17:24:12 +08:00
oiov
a442d46755 docs(sync): move sync docs to web page 2025-06-30 17:16:38 +08:00
oiov
9dd7b315d9 Merge branch 'main' of https://github.com/oiov/wr.do 2025-06-30 16:57:17 +08:00
oiov
ac7d5a7552 chore code 2025-06-30 16:56:53 +08:00
oiov
9cbe4f47f6 Merge pull request #39 from h7ml/feat/action_sync
docs(README): 添加 Fork 仓库同步说明
2025-06-30 16:55:09 +08:00
唐炜炜
4d8c12a324 docs(README): 添加 Fork 仓库同步说明
- 新增 Fork 仓库同步功能的详细说明,包括手动触发同步的方法和同步状态查看方式
- 添加常见问题解决指南,涵盖合并冲突和权限问题的处理方法
- 在 README-zh.md 和 README.md 中分别添加中文和英文说明
2025-06-30 16:47:22 +08:00
oiov
d19a2c161a Update error message on check resend config 2025-06-29 23:36:41 +08:00
oiov
14968a7b87 remove status cpm on dashboard 2025-06-29 21:52:43 +08:00
oiov
2e0df57687 fix status display empty 2025-06-29 20:45:13 +08:00
oiov
1acf785219 fix(email): get resend key by domain when send email(#37) 2025-06-29 20:32:23 +08:00
oiov
6406579f03 feats: add url status componet 2025-06-29 19:27:27 +08:00
oiov
bbe3dc4956 bump version to v1.0.8 2025-06-29 18:01:26 +08:00
oiov
d0ba3a1686 feats: add error short link page 2025-06-29 17:54:18 +08:00
oiov
c102955cd5 feats(configs): add email push config for subdomain apply 2025-06-29 16:44:18 +08:00
oiov
51e3403b1d feat(admin): add delete user method 2025-06-29 16:17:08 +08:00
oiov
a4a7d500a2 Add bug issue template 2025-06-29 16:06:57 +08:00
oiov
75dc7f478c chore: add allowed keys for config api 2025-06-28 11:06:08 +08:00
oiov
9ea94d53bd Add issue template 2025-06-27 22:22:12 +08:00
oiov
9184a1740e fix: remove auth check for config api 2025-06-27 21:31:57 +08:00
oiov
01b9bbfd16 enhance locale 2025-06-27 21:14:50 +08:00
oiov
11e57a2374 feats: add app_name and support_email env 2025-06-27 21:13:24 +08:00
oiov
192b6c0077 fix: default value of suffix config 2025-06-27 10:24:53 +08:00
oiov
16ed4932a0 feat: regist email white list 2025-06-27 10:16:53 +08:00
oiov
8aa4602390 feat: support admin create new user account 2025-06-26 16:53:42 +08:00
oiov
c1743c2840 feat(record): add status card 2025-06-24 19:31:34 +08:00
oiov
dcb8479017 feat(domain): add duplicate feature 2025-06-23 10:54:12 +08:00
oiov
cdee24b5c9 chore reserve domain rule 2025-06-23 10:07:54 +08:00
oiov
0721a06c49 docs: add repo badges 2025-06-22 14:05:57 +08:00
oiov
09a9ebbe22 enhance chinese translate and fix error message 2025-06-21 20:59:02 +08:00
oiov
acc3b33d77 Add tg docs and github page custom domain docs 2025-06-21 20:12:11 +08:00
oiov
cd767f1aa0 debug whitelist email config 2025-06-21 17:31:41 +08:00
oiov
fc9deeb715 chore: remove md escaper 2025-06-21 17:19:53 +08:00
oiov
4b770ad07c feats(email): add telegram pusher 2025-06-21 16:52:19 +08:00
oiov
8e13fec132 fixup tw css error 2025-06-20 20:36:17 +08:00
oiov
a9156f505c fix: modal scroller on mobile 2025-06-20 20:31:30 +08:00
oiov
b342c8c373 feat: add version check cpn 2025-06-20 20:13:52 +08:00
oiov
367da79ed9 feats: support catch-all for email 2025-06-20 16:49:37 +08:00
oiov
ae97fe895e feats(domain): configurable limit configs 2025-06-19 21:15:35 +08:00
oiov
fd3567d48e chore(url): change url target format rule 2025-06-19 19:52:51 +08:00
oiov
76558d7703 bump version to v1.0.3 2025-06-19 19:16:34 +08:00
oiov
13171986af feats: send email to user after apply record 2025-06-19 19:10:49 +08:00
oiov
8e57a4cbb9 chore: send email when new record apply 2025-06-19 19:00:23 +08:00
oiov
0d177f7c33 feats: add feedback comment 2025-06-19 18:51:00 +08:00
oiov
a2dc432c97 chore: remove umami script 2025-06-18 11:28:05 +08:00
oiov
4573373544 feat: enhance user form password 2025-06-18 11:08:58 +08:00
oiov
858b02fa0c fix(record): reject error 2025-06-18 10:48:35 +08:00
oiov
2f8baab19a Merge pull request #32 from oiov/pwd
Feats: support login with email and password
2025-06-17 20:57:02 +08:00
oiov
75fe4ab9eb docs: add demo site url 2025-06-17 20:55:27 +08:00
oiov
f4a3386c7d refact(auth): change pwd encrypt method 2025-06-17 20:38:20 +08:00
oiov
7c77234026 fixup 2025-06-17 20:19:08 +08:00
oiov
ad11f8e155 docs: add default account info 2025-06-17 20:13:29 +08:00
oiov
a7a7d2a878 fixup update user error 2025-06-17 19:51:08 +08:00
oiov
d038169846 feats(auth): login with email and password 2025-06-17 19:46:43 +08:00
oiov
2f40312ff1 chore format style 2025-06-17 10:32:24 +08:00
oiov
6c784d255d chore(record) 2025-06-16 16:00:44 +08:00
oiov
8f44b8ae11 feats: configurable login methods 2025-06-13 17:35:19 +08:00
oiov
6ede43b9fb feat: support custom config record types in domain config 2025-06-13 17:16:28 +08:00
oiov
eeb45f67b2 chore 2025-06-12 16:50:18 +08:00
oiov
45542289f5 Merge pull request #30 from oiov/system-config
System config
2025-06-11 20:25:52 +08:00
oiov
0d4d0fdb0e bump version to v1.0.0 2025-06-11 20:24:50 +08:00
oiov
cbdccaa60e add notification and chore list codes 2025-06-11 20:08:32 +08:00
oiov
87009bfd05 touch docker workflow 2025-06-11 17:50:54 +08:00
oiov
94a79dd26b chore: remove app config env 2025-06-11 17:48:54 +08:00
oiov
5ef68eec63 chore(record): reject statu display 2025-06-11 17:41:48 +08:00
oiov
e01886e505 feats(record): add reject statu 2025-06-11 17:31:43 +08:00
oiov
67672624e3 feat: configurable app settings 2025-06-11 17:06:20 +08:00
oiov
d937858ce3 chore: remove titles 2025-06-11 15:12:04 +08:00
oiov
468a6f5645 feats: configurable plan quota 2025-06-11 15:06:36 +08:00
oiov
1640f263b1 Improved user interface translations and clarity in Simplified 2025-06-09 17:40:21 +08:00
oiov
cc4515cc6d Improved user interface translations and clarity in Simplified 2025-06-09 17:26:09 +08:00
oiov
6a79ab40b1 docs: add recommond deploy docs 2025-06-09 16:55:51 +08:00
oiov
a11373a90b add tencent verify txt 2025-06-08 18:31:49 +08:00
oiov
26020cf572 update footer copyright 2025-06-08 17:29:35 +08:00
oiov
b7221d31c1 Merge branch 'i18n' 2025-06-08 15:41:22 +08:00
oiov
5ffa4bda8f fixup intl fetch error 2025-06-08 15:39:57 +08:00
oiov
7dcf93f759 bump version to v0.7.0 2025-06-08 15:19:36 +08:00
oiov
e63e9b9922 Merge pull request #29 from oiov/i18n
Support I18n
2025-06-08 15:18:13 +08:00
oiov
8d9bccfae8 Merge branch 'main' into i18n 2025-06-08 15:14:49 +08:00
oiov
e55e6378e1 Merge branch 'main' of https://github.com/oiov/wr.do 2025-06-08 15:14:05 +08:00
oiov
bbf15a0551 Enhance chinese transition and email list select 2025-06-08 15:08:54 +08:00
oiov
6c0be291e0 Add delete inbox email method 2025-06-08 14:54:28 +08:00
oiov
351dd9b559 fix empty domain selector 2025-06-08 14:30:18 +08:00
oiov
8e4530236c Improved user interface translations and clarity in Simplified 2025-06-08 12:22:19 +08:00
oiov
c51a0443a8 Add dartnode badge 2025-06-08 11:51:20 +08:00
oiov
657cd32aaa Improved user interface translations and clarity in Simplified 2025-06-07 22:04:46 +08:00
oiov
7d629e9cd4 Improved user interface translations and clarity in Simplified 2025-06-06 23:21:37 +08:00
oiov
ca35d96925 Improved user interface translations and clarity in Simplified 2025-06-06 15:10:11 +08:00
oiov
dc60a00103 Improved user interface translations and clarity in Simplified 2025-06-06 11:14:56 +08:00
oiov
d1d1044bbc Improved user interface translations and clarity in Simplified 2025-06-05 19:27:32 +08:00
oiov
f68dda63af fixup typo 2025-06-05 16:27:56 +08:00
oiov
ab8e0619c4 feats: init i18n config 2025-06-05 16:25:15 +08:00
oiov
99ad3f6f8e chore 2025-06-05 15:25:58 +08:00
oiov
217166d3aa add deploy paids and vercel build config 2025-06-04 21:36:40 +08:00
oiov
cdee1a227a docs: add db check env 2025-06-04 18:37:03 +08:00
oiov
e6582f26c8 fixup type error 2025-06-04 17:40:57 +08:00
oiov
80a873d56a fix(record): create type error 2025-06-04 17:35:47 +08:00
oiov
9dc3d3d697 update compose env 2025-06-04 17:29:35 +08:00
oiov
0d1ea92965 fix(record): comment field not display 2025-06-04 17:27:31 +08:00
oiov
bba4c6bae7 feats: add apply mode for subdomain service 2025-06-04 16:47:06 +08:00
oiov
61ff34464f Merge pull request #27 from oiov/fix/docker
Fix/docker
2025-06-03 16:06:03 +08:00
450 changed files with 36085 additions and 3242 deletions

View File

@@ -2,6 +2,12 @@
# App - Don't add "/" in the end of the url (same in production) # App - Don't add "/" in the end of the url (same in production)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_NAME=WR.DO
# -----------------------------------------------------------------------------
# Database
# -----------------------------------------------------------------------------
DATABASE_URL='postgres://[user]:[password]@[hostname]:5432/[dbname]'
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Authentication (NextAuth.js 5.0.x) # Authentication (NextAuth.js 5.0.x)
@@ -17,24 +23,21 @@ LinuxDo_CLIENT_ID=
LinuxDo_CLIENT_SECRET= LinuxDo_CLIENT_SECRET=
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Database, example: DATABASE_URL='postgres://[user]:[password]@[hostname]:5432/[dbname]' # Email api for send email (https://resend.com, https://www.brevo.com)
# -----------------------------------------------------------------------------
DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=require'
# -----------------------------------------------------------------------------
# Email api (https://resend.com) for login and send email
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
RESEND_API_KEY= RESEND_API_KEY=
RESEND_FROM_EMAIL="wrdo <support@wr.do>" BREVO_API_KEY=
EMAIL_FROM=service@wr.do
EMAIL_FROM_NAME=WRDO
# Open Signup # Cloudflare R2 Storage custom domain
NEXT_PUBLIC_OPEN_SIGNUP=1 NEXT_PUBLIC_EMAIL_R2_DOMAIN=
# Enable subdomain apply, default is false(0). If set to 1, will enable subdomain apply
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY=1
# Google Analytics # Google Analytics
NEXT_PUBLIC_GOOGLE_ID= NEXT_PUBLIC_GOOGLE_ID=
# Umami Script
NEXT_PUBLIC_UMAMI_SCRIPT=
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
# ScreenShot API # ScreenShot API
SCREENSHOTONE_BASE_URL=https://shot.wr.do SCREENSHOTONE_BASE_URL=https://shot.wr.do
@@ -42,7 +45,9 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
# GitHub api token for getting gitHub stars count # GitHub api token for getting gitHub stars count
GITHUB_TOKEN= GITHUB_TOKEN=
# Skip DB check and migration (only for docker), default is true. if false, will check and migrate database each time start docker compose. # Skip DB check and migration. if false, will check and migrate database each time start docker compose.
SKIP_DB_CHECK=true SKIP_DB_CHECK=false
SKIP_DB_MIGRATION=true SKIP_DB_MIGRATION=false
# Support email
NEXT_PUBLIC_SUPPORT_EMAIL=your_support_email@gmail.com

View File

@@ -15,7 +15,9 @@
"tailwindcss/enforces-shorthand": "off", "tailwindcss/enforces-shorthand": "off",
"react/no-unescaped-entities": "off", "react/no-unescaped-entities": "off",
"@next/next/no-img-element": "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": { "settings": {
"tailwindcss": { "tailwindcss": {

81
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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

View 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、截图或参考链接

View File

@@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- main - main
- fix/docker - dev
tags: tags:
- "v*.*.*" - "v*.*.*"
pull_request: pull_request:

124
.github/workflows/sync.yml vendored Normal file
View 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
View File

@@ -40,4 +40,6 @@ next-env.d.ts
/.react-email/ /.react-email/
.vscode .vscode
.contentlayer .contentlayer
public/sw.js.map

View File

@@ -11,7 +11,7 @@ RUN npm install -g pnpm
COPY . . COPY . .
RUN pnpm config set registry https://registry.npmmirror.com # RUN pnpm config set registry https://registry.npmmirror.com
RUN pnpm i --frozen-lockfile RUN pnpm i --frozen-lockfile

199
README-en.md Normal file
View 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
[![Deploy with Vercel](https://vercel.com/button)](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)

View File

@@ -1,131 +0,0 @@
<div align="center">
<h1>WR.DO</h1>
<p><a href="https://wr.do/docs/developer">开发文档</a> · <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>
## 简介
WR.DO 是一个一站式网络工具平台集成短链服务、临时邮箱、子域名管理和开放API接口。支持自定义链接、密码保护、访问统计提供无限制临时邮箱收发管理多域名DNS记录内置网站截图、元数据提取等实用API。完整的管理后台支持用户权限控制和服务配置。
## 功能列表
- 🔗 **短链服务**
- 支持自定义短链
- 支持生成自定义二维码
- 支持密码保护链接
- 支持设置过期时间
- 支持访问统计(实时日志、地图等多维度数据分析)
- 支持调用 API 创建短链
- 📮 **临时邮箱服务**
- 支持创建自定义前缀邮箱
- 支持过滤未读邮件列表
- 可创建无限数量邮箱
- 支持接收无限制邮件 (依赖 Cloudflare Email Worker
- 支持发送邮件(依赖 Resend
- 支持调用 API 创建邮箱
- 支持调用 API 获取收件箱邮件
-
- 🌐 **子域名管理服务**
- 支持管理多 Cloudflare 账户下的多个域名的 DNS 记录
- 支持创建多种 DNS 记录类型CNAME、A、TXT 等)
- 📡 **开放接口模块**
- 获取网站元数据 API
- 获取网站截图 API
- 生成网站二维码 API
- 将网站转换为 Markdown、Text
- 支持所有类型 API 调用统计日志
- 支持生成用户 API Key用于第三方调用开放接口
- 🔒 **管理员模块**
- 多维度图表展示网站状态
- 域名服务配置(动态配置各项服务是否启用,包括短链、临时邮箱(收发邮件)、子域名管理)
- 用户列表管理(设置权限、分配使用额度、禁用用户等)
- 短链管理(管理所有用户创建的短链)
- 邮箱管理(管理所有用户创建的临时邮箱)
- 子域名管理(管理所有用户创建的子域名)
## 截图预览
<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>
## 快速开始
查看开发者[快速开始](https://wr.do/docs/developer/quick-start)详细文档。
## 自部署教程
### 使用 Vercel 部署
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
记得填写必要的环境变量。
### 使用 Docker Compose 部署
在服务器中创建一个文件夹,进入该文件夹并新建`docker-compose.yml`文件,填写必要的环境变量,然后执行:
```bash
docker compose up -d
```
## 本地开发
`.env.example` 复制为 `.env` 并填写必要的环境变量。
```bash
git clone https://github.com/oiov/wr.do
cd wr.do
pnpm install
# 在 localhost:3000 上运行
pnpm dev
```
#### 初始化数据库
```bash
pnpm postinstall
pnpm db:push
```
#### 管理员初始化
Follow https://localhost:3000/setup
## 社区群组
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
![](https://wr.do/s/group)
## 许可证
[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>

245
README.md
View File

@@ -1,53 +1,24 @@
<div align="center"> <div align="center">
<img src="https://wr.do/_static/images/x-preview.png" alt="WR.DO" >
<h1>WR.DO</h1> <h1>WR.DO</h1>
<p><a href="https://wr.do/docs/developer">Docs</a> · <a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p> <p>一站式域名服务平台集成短链服务、临时邮箱、子域名管理、文件存储和开放API接口。</p>
<p>Make Short Links, Manage DNS Records, Receive Emails.</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> </div>
## Introduction ## 截图预览
WR.DO is a all-in-one web utility platform featuring short links with analytics, temporary email service, subdomain management, open APIs for screenshots and metadata extraction, plus comprehensive admin dashboard.
## 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)
- 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.)
- 📡 **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)
- Centralized short link administration
- Centralized email management
- Centralized subdomain administration
## Screenshots
<table> <table>
<tr> <tr>
@@ -65,21 +36,109 @@ WR.DO is a all-in-one web utility platform featuring short links with analytics,
</table> </table>
## Quick Start ## 功能列表
See step by step installation tutorial at [Quick Start for Developer](https://wr.do/docs/developer/quick-start). <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>
## Self-hosted <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>
### Deploy with Vercel <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>
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN) <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>
Remember to fill in the necessary environment variables. <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>
### Deploy with Docker Compose <details>
<summary><strong>👑 管理员模块</strong> - <a href="javascript:;">[功能列表]</a></summary>
<ul>
<li>多维度图表展示网站状态
<li>域名服务配置(动态配置各项服务是否启用,包括短链、临时邮箱(收发邮件)
<li>用户列表管理(设置权限、分配使用额度、禁用用户等)
<li>动态配置登录方式 (支持 Google, GitHub, 邮箱验证, 账户密码, LinuxDO)
<li>短链管理(管理所有用户创建的短链)
<li>邮箱管理(管理所有用户创建的临时邮箱)
<li>子域名管理(管理所有用户创建的子域名)
</ul>
</details>
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. ## 技术栈
- Next.js + React + TypeScript
- Tailwind CSS 用于样式设计
- Prisma ORM 作为数据库工具
- Cloudflare 作为主要的云基础设施
- Vercel 作为推荐的部署平台
- Resend 作为邮件服务
- Next-Intl 作为国际化支持
## 快速开始
查看开发者[手把手部署教程](https://wr.do/docs/developer/quick-start-zh)文档。
## 自部署教程
> 注意,任何部署方式都需要先配置环境变量,若部署后修改了环境变量,需要**重新部署**才会生效。
### 使用 Vercel 部署
[![Deploy with Vercel](https://vercel.com/button)](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 ```yml
- wrdo - wrdo
@@ -87,13 +146,23 @@ Create a new folder and copy the [`docker-compose.yml`](https://github.com/oiov/
| - .env | - .env
``` ```
Fill in the environment variables in the `.env` file, then: `.env` 中填写必要的环境变量,然后执行:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
## Local development > 或只创建 docker-compose.yml 文件环境变量直接填写在yml中比如将`DATABASE_URL: ${DATABASE_URL}`替换成`DATABASE_URL: your-database-uri`
### 使用 EdgeOne 部署
> 此方法部署目前无法build成功不建议使用
[![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2Foiov%2Fwr.do)
## 本地开发
`.env.example` 复制为 `.env` 并填写必要的环境变量。
```bash ```bash
git clone https://github.com/oiov/wr.do git clone https://github.com/oiov/wr.do
@@ -101,34 +170,63 @@ cd wr.do
pnpm install pnpm install
``` ```
copy `.env.example` to `.env` and fill in the necessary environment variables. #### 初始化数据库
```bash
# run on localhost:3000
pnpm dev
```
#### Init database
```bash ```bash
pnpm postinstall pnpm postinstall
pnpm db:push pnpm db:push
``` ```
#### Setup Admin Panel ```bash
# 在 localhost:3000 上运行
pnpm dev
```
Follow https://localhost:3000/setup - 默认账号(管理员)`admin@admin.com`
- 默认密码:`123456`
## Community Group > 登录后请及时修改密码
#### 管理员初始化
> 此初始化引导在 v1.0.2 版本后, 不再是必要步骤
访问 https://localhost:3000/setup
## 环境变量
查看 [开发者文档](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 - Discord: https://discord.gg/AHPQYuZu3m
- 微信群: - 微信群:
<img width="300" src="https://wr.do/s/group" /> <img width="300" src="https://wr.do/group" />
## License ## 贡献者
<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 ## Star History
@@ -138,4 +236,9 @@ Follow https://localhost:3000/setup
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date" /> <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" /> <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oiov/wr.do&type=Date" />
</picture> </picture>
</a> </a>
## 开源协议
[MIT](/LICENSE.md)

View 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" };
}
}

View File

@@ -1,6 +1,12 @@
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { siteConfig } from "@/config/site";
import { getCurrentUser } from "@/lib/session"; 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 { interface AuthLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -8,11 +14,39 @@ interface AuthLayoutProps {
export default async function AuthLayout({ children }: AuthLayoutProps) { export default async function AuthLayout({ children }: AuthLayoutProps) {
const user = await getCurrentUser(); const user = await getCurrentUser();
const t = await getTranslations("Auth");
if (user) { if (user) {
if (user.role === "ADMIN") redirect("/admin"); if (user.role === "ADMIN") redirect("/admin");
redirect("/dashboard"); 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>
);
} }

View File

@@ -1,6 +1,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { Metadata } from "next"; import { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -14,55 +15,49 @@ export const metadata: Metadata = {
}; };
export default function LoginPage() { export default function LoginPage() {
const t = useTranslations("Auth");
return ( 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 <Link
href="/" href="/"
className={cn( className={cn(
buttonVariants({ variant: "outline", size: "sm" }), 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" /> <Icons.chevronLeft className="mr-2 size-4" />
Back {t("Back")}
</> </>
</Link> </Link>
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"> <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"> <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"> <div className="text-2xl font-semibold tracking-tight">
<span>Welcome to</span>{" "} <span>{t("Welcome to")}</span>{" "}
<span style={{ fontFamily: "Bahamas Bold" }}>
{siteConfig.name}
</span>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Choose your login method to continue {t("Choose your login method to continue")}
</p> </p>
</div> </div>
<Suspense> <Suspense>
<UserAuthForm /> <UserAuthForm />
</Suspense> </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"> <p className="px-2 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our{" "} {t("By clicking continue, you agree to our")}{" "}
<Link <Link
href="/terms" href="/terms"
className="hover:text-brand underline underline-offset-4" className="hover:text-brand underline underline-offset-4"
> >
Terms of Service {t("Terms of Service")}
</Link>{" "} </Link>{" "}
and{" "} {t("and")}{" "}
<Link <Link
href="/privacy" href="/privacy"
className="hover:text-brand underline underline-offset-4" className="hover:text-brand underline underline-offset-4"
> >
Privacy Policy {t("Privacy Policy")}
</Link> </Link>
. .
</p> </p>

View File

@@ -7,7 +7,7 @@ import { DocsPageHeader } from "@/components/docs/page-header";
import { DocsPager } from "@/components/docs/pager"; import { DocsPager } from "@/components/docs/pager";
import { DashboardTableOfContents } from "@/components/shared/toc"; import { DashboardTableOfContents } from "@/components/shared/toc";
import "@/styles/mdx.css"; import "@/styles/mdx.scss";
import { Metadata } from "next"; import { Metadata } from "next";

View File

@@ -3,7 +3,7 @@ import { allPages } from "contentlayer/generated";
import { Mdx } from "@/components/content/mdx-components"; import { Mdx } from "@/components/content/mdx-components";
import "@/styles/mdx.css"; import "@/styles/mdx.scss";
import { Metadata } from "next"; import { Metadata } from "next";

View File

@@ -0,0 +1,9 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardLoading() {
return (
<>
<Skeleton className="h-full w-full rounded-lg" />
</>
);
}

View 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 />;
}

View File

@@ -1,5 +1,6 @@
import { NavMobile } from "@/components/layout/mobile-nav"; import { NavMobile } from "@/components/layout/mobile-nav";
import { NavBar } from "@/components/layout/navbar"; import { NavBar } from "@/components/layout/navbar";
import { Notification } from "@/components/layout/notification";
import { SiteFooter } from "@/components/layout/site-footer"; import { SiteFooter } from "@/components/layout/site-footer";
interface MarketingLayoutProps { interface MarketingLayoutProps {
@@ -11,6 +12,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
<div className="flex min-h-screen flex-col dark:bg-black"> <div className="flex min-h-screen flex-col dark:bg-black">
<NavMobile /> <NavMobile />
<NavBar scroll={true} /> <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)]"> <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} {children}
</main> </main>

View File

@@ -1,10 +1,19 @@
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import HeroLanding, { LandingImages } from "@/components/sections/hero-landing"; import HeroLanding, { LandingImages } from "@/components/sections/hero-landing";
import { PricingSection } from "@/components/sections/pricing"; 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 ( return (
<> <>
<HeroLanding /> <HeroLanding userId={user?.id} />
<LandingImages /> <LandingImages />
<PricingSection /> <PricingSection />
</> </>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { TrendingUp } from "lucide-react"; import { TrendingUp } from "lucide-react";
import { useTranslations } from "next-intl";
import { import {
Label, Label,
PolarGrid, PolarGrid,
@@ -29,9 +30,8 @@ export function RadialShapeChart({
total: number; total: number;
totalUser: number; totalUser: number;
}) { }) {
const chartData = [ const t = useTranslations("Components");
{ browser: "safari", actived: total, fill: "var(--color-safari)" }, const chartData = [{ actived: total, fill: "var(--color-safari)" }];
];
return ( return (
<Card className="flex flex-col"> <Card className="flex flex-col">
@@ -42,7 +42,7 @@ export function RadialShapeChart({
> >
<RadialBarChart <RadialBarChart
data={chartData} data={chartData}
endAngle={total} endAngle={(total / totalUser) * 100}
innerRadius={80} innerRadius={80}
outerRadius={140} outerRadius={140}
> >
@@ -77,7 +77,7 @@ export function RadialShapeChart({
y={(viewBox.cy || 0) + 24} y={(viewBox.cy || 0) + 24}
className="fill-muted-foreground" className="fill-muted-foreground"
> >
Users {t("Users")}
</tspan> </tspan>
</text> </text>
); );
@@ -94,7 +94,7 @@ export function RadialShapeChart({
<TrendingUp className="size-4" /> <TrendingUp className="size-4" />
</div> </div>
<div className="leading-none text-muted-foreground"> <div className="leading-none text-muted-foreground">
Cumulative proportion of activated <strong>Api Key</strong> users {t("Activated Api Key users")}
</div> </div>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { ScrapeMeta } from "@prisma/client"; import { ScrapeMeta } from "@prisma/client";
import { useTranslations } from "next-intl";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { useElementSize } from "@/hooks/use-element-size"; import { useElementSize } from "@/hooks/use-element-size";
@@ -58,6 +59,8 @@ export function LineChartMultiple({
const { ref: wrapperRef, width: wrapperWidth } = useElementSize(); const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const processedData = processChartData(chartData, type1, type2); const processedData = processChartData(chartData, type1, type2);
const t = useTranslations("Components");
const chartConfig = { const chartConfig = {
source1: { source1: {
label: type1, label: type1,
@@ -69,13 +72,14 @@ export function LineChartMultiple({
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
const message = type2
? t("total-requests-two-types", { type1, type2 })
: t("total-requests-one-type", { type1 });
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardDescription> <CardDescription>{message}</CardDescription>
Total requests of {type1}
{type2 && ` and ${type2}`}.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent ref={wrapperRef}> <CardContent ref={wrapperRef}>
<ChartContainer config={chartConfig}> <ChartContainer config={chartConfig}>

View File

@@ -4,10 +4,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function AdminPanelLoading() { export default function AdminPanelLoading() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader heading="Admin Panel" text="" />
heading="Admin Panel"
text="Access only for users with ADMIN role."
/>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">
<Skeleton className="h-32 w-full rounded-lg" /> <Skeleton className="h-32 w-full rounded-lg" />

View File

@@ -26,7 +26,7 @@ import { RadialShapeChart } from "./api-key-active-chart";
import { LineChartMultiple } from "./line-chart-multiple"; import { LineChartMultiple } from "./line-chart-multiple";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Admin WR.DO", title: "Admin",
description: "Admin page for only admin management.", description: "Admin page for only admin management.",
}); });
@@ -134,7 +134,6 @@ async function RequestStatsSection() {
return hasStats ? ( return hasStats ? (
<> <>
<h2 className="my-1 text-xl font-semibold">Request Statistics</h2>
<DailyPVUVChart <DailyPVUVChart
data={screenshot_stats data={screenshot_stats
.concat(meta_stats) .concat(meta_stats)
@@ -200,7 +199,6 @@ async function MarkdownTextChartSection() {
async function LogsSection({ userId }: { userId: string }) { async function LogsSection({ userId }: { userId: string }) {
return ( return (
<> <>
<h2 className="my-1 text-xl font-semibold">Request Logs</h2>
<LogsTable userId={userId} target={"/api/v1/scraping/admin/logs"} /> <LogsTable userId={userId} target={"/api/v1/scraping/admin/logs"} />
</> </>
); );
@@ -213,10 +211,7 @@ export default async function AdminPage() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader heading="Admin Panel" text="" />
heading="Admin Panel"
text="Access only for users with ADMIN role."
/>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-3">
<ErrorBoundary <ErrorBoundary

View File

@@ -4,8 +4,16 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() { export default function DashboardRecordsLoading() {
return ( return (
<> <>
<DashboardHeader heading="DNS Records" text="" /> <DashboardHeader
<Skeleton className="h-32 w-full rounded-lg" /> 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" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>
); );

View File

@@ -3,11 +3,12 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils"; import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header"; import { DashboardHeader } from "@/components/dashboard/header";
import { UserRecordStatus } from "@/components/dashboard/status-card";
import UserRecordsList from "../../dashboard/records/record-list"; import UserRecordsList from "../../dashboard/records/record-list";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "DNS Records - WR.DO", title: "DNS Records",
description: "List and manage records.", description: "List and manage records.",
}); });
@@ -19,17 +20,19 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Manage&nbsp;&nbsp;DNS&nbsp;&nbsp;Records" heading="Manage DNS Records"
text="List and manage records." text="List and manage records"
link="/docs/dns-records" link="/docs/dns-records"
linkText="DNS records." linkText="DNS records"
/> />
<UserRecordStatus action="/api/record/admin" />
<UserRecordsList <UserRecordsList
user={{ user={{
id: user.id, id: user.id,
name: user.name || "", name: user.name || "",
apiKey: user.apiKey || "", apiKey: user.apiKey || "",
email: user.email || "", email: user.email || "",
role: user.role,
}} }}
action="/api/record/admin" action="/api/record/admin"
/> />

View File

@@ -4,8 +4,11 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() { export default function DashboardRecordsLoading() {
return ( return (
<> <>
<DashboardHeader heading="Domains Management" text="" /> <DashboardHeader
<Skeleton className="h-32 w-full rounded-lg" /> 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" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>
); );

View 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"
/>
</>
);
}

View 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>
);
}

View File

@@ -1,22 +1,24 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useTransition } from "react";
import Link from "next/link"; import Link from "next/link";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
import { DomainFormData } from "@/lib/dto/domains"; import { DomainFormData } from "@/lib/dto/domains";
import { fetcher, timeAgo } from "@/lib/utils"; import { fetcher } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { import {
Card, DropdownMenu,
CardContent, DropdownMenuContent,
CardDescription, DropdownMenuItem,
CardHeader, DropdownMenuSeparator,
} from "@/components/ui/card"; DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -34,6 +36,7 @@ import { FormType } from "@/components/forms/record-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons"; import { Icons } from "@/components/shared/icons";
import { PaginationWrapper } from "@/components/shared/pagination"; import { PaginationWrapper } from "@/components/shared/pagination";
import { TimeAgoIntl } from "@/components/shared/time-ago";
export interface DomainListProps { export interface DomainListProps {
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">; user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
@@ -70,12 +73,15 @@ function TableColumnSekleton() {
export default function DomainList({ user, action }: DomainListProps) { export default function DomainList({ user, action }: DomainListProps) {
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [isPending, startTransition] = useTransition();
const t = useTranslations("List");
const [isShowForm, setShowForm] = useState(false); const [isShowForm, setShowForm] = useState(false);
const [isShowDuplicateForm, setShowDuplicateForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add"); const [formType, setFormType] = useState<FormType>("add");
const [currentEditDomain, setCurrentEditDomain] = const [currentEditDomain, setCurrentEditDomain] =
useState<DomainFormData | null>(null); useState<DomainFormData | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(15);
const [searchParams, setSearchParams] = useState({ const [searchParams, setSearchParams] = useState({
slug: "", slug: "",
target: "", target: "",
@@ -117,7 +123,7 @@ export default function DomainList({ user, action }: DomainListProps) {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data) { if (data) {
toast.success("Successed!"); toast.success("Saved");
handleRefresh(); handleRefresh();
} }
} else { } else {
@@ -125,12 +131,32 @@ export default function DomainList({ user, action }: DomainListProps) {
} }
}; };
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 ( return (
<> <>
<Card className="xl:col-span-2"> <Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center gap-2"> <CardHeader className="flex flex-row items-center gap-2">
<div className="flex items-center gap-1 text-lg font-bold"> <div className="flex items-center gap-1 text-lg font-bold">
<span className="text-nowrap">Total Domains:</span> <span className="text-nowrap">{t("Total Domains")}:</span>
{isLoading ? ( {isLoading ? (
<Skeleton className="h-6 w-16" /> <Skeleton className="h-6 w-16" />
) : ( ) : (
@@ -145,9 +171,9 @@ export default function DomainList({ user, action }: DomainListProps) {
disabled={isLoading} disabled={isLoading}
> >
{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>
<Button <Button
@@ -161,7 +187,7 @@ export default function DomainList({ user, action }: DomainListProps) {
}} }}
> >
<Icons.add className="size-4" /> <Icons.add className="size-4" />
<span className="hidden sm:inline">Add Domain</span> <span className="hidden sm:inline">{t("Add Domain")}</span>
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -170,7 +196,7 @@ export default function DomainList({ user, action }: DomainListProps) {
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
className="h-8 text-xs md:text-xs" className="h-8 text-xs md:text-xs"
placeholder="Search by domain name..." placeholder={t("Search by domain name") + "..."}
value={searchParams.target} value={searchParams.target}
onChange={(e) => { onChange={(e) => {
setSearchParams({ setSearchParams({
@@ -197,25 +223,25 @@ export default function DomainList({ user, action }: DomainListProps) {
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground"> <TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-7"> <TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-7">
<TableHead className="col-span-1 flex items-center font-bold"> <TableHead className="col-span-1 flex items-center font-bold">
Domain {t("Domain Name")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Shorten {t("Shorten Service")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Email {t("Email Service")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Subdomain {t("Subdomain Service")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold"> <TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Active {t("Active")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex items-center font-bold"> <TableHead className="col-span-1 flex items-center font-bold">
Updated {t("Updated")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex items-center font-bold"> <TableHead className="col-span-1 flex items-center font-bold">
Actions {t("Actions")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -262,6 +288,14 @@ export default function DomainList({ user, action }: DomainListProps) {
handleChangeStatus(value, "enable_email", domain) 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>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex"> <TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch <Switch
@@ -270,6 +304,11 @@ export default function DomainList({ user, action }: DomainListProps) {
handleChangeStatus(value, "enable_dns", domain) 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>
<TableCell className="col-span-1 flex items-center gap-1"> <TableCell className="col-span-1 flex items-center gap-1">
<Switch <Switch
@@ -281,45 +320,63 @@ export default function DomainList({ user, action }: DomainListProps) {
/> />
</TableCell> </TableCell>
<TableCell className="col-span-1 flex items-center truncate"> <TableCell className="col-span-1 flex items-center truncate">
{timeAgo(domain.updatedAt as Date)} <TimeAgoIntl date={domain.updatedAt as Date} />
</TableCell> </TableCell>
<TableCell className="col-span-1 flex items-center gap-1"> <TableCell className="col-span-1 flex items-center gap-1">
<Button <DropdownMenu>
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground" <DropdownMenuTrigger asChild>
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditDomain(domain);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">Edit</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
{domain.cf_zone_id &&
domain.cf_api_key &&
domain.cf_email && (
<Button <Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground" className="size-[25px] p-1.5"
size="sm" size="sm"
variant="ghost" variant="ghost"
> >
<Icons.cloudflare className="mx-0.5 size-4" /> <Icons.moreVertical className="size-4" />
</Button> </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> </TableCell>
</TableRow> </TableRow>
{/* {isShowDomainInfo && selectedDomain?.id === domain.id && (
<DomainInfo domain={domain} />
)} */}
</div> </div>
)) ))
) : ( ) : (
<EmptyPlaceholder className="shadow-none"> <EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="globeLock" /> <EmptyPlaceholder.Icon name="globeLock" />
<EmptyPlaceholder.Title>No Domains</EmptyPlaceholder.Title> <EmptyPlaceholder.Title>
{t("No Domains")}
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description> <EmptyPlaceholder.Description>
You don&apos;t have any domains yet. Start creating one. You don&apos;t have any domains yet. Start creating one.
</EmptyPlaceholder.Description> </EmptyPlaceholder.Description>
@@ -342,7 +399,7 @@ export default function DomainList({ user, action }: DomainListProps) {
{/* form */} {/* form */}
<Modal <Modal
className="max-h-[90vh] overflow-y-auto md:max-w-2xl" className="md:max-w-2xl"
showModal={isShowForm} showModal={isShowForm}
setShowModal={setShowForm} setShowModal={setShowForm}
> >
@@ -356,6 +413,42 @@ export default function DomainList({ user, action }: DomainListProps) {
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />
</Modal> </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>
</> </>
); );
} }

View 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" />
</>
);
}

View 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>
</>
);
}

View 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" />
</>
);
}

View 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 />
</>
);
}

View 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&apos;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>
</>
);
}

View 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" />
</>
);
}

View 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"
/>
</>
);
}

View 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>
);
}

View File

@@ -0,0 +1,9 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardUrlsLoading() {
return (
<>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View 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 />
</>
);
}

View File

@@ -1,11 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardUrlsLoading() { export default function DashboardUrlsLoading() {
return ( return (
<> <>
<DashboardHeader heading="Short Urls" text="" /> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
<Skeleton className="h-32 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" />
<Skeleton className="h-[102px] w-full rounded-lg" />
</div>
<Skeleton className="h-[400px] w-full rounded-lg" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>
); );

View 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" />
</>
);
}

View 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 />
</>
);
}

View File

@@ -2,13 +2,12 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils"; import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import UserUrlsList from "../../dashboard/urls/url-list"; import UserUrlsList from "../../dashboard/urls/url-list";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Short URLs - WR.DO", title: "Links",
description: "List and manage records.", description: "List and manage short links.",
}); });
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -18,13 +17,6 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
link="/docs/short-urls"
linkText="short urls."
/>
<UserUrlsList <UserUrlsList
user={{ user={{
id: user.id, id: user.id,

View File

@@ -6,7 +6,7 @@ export default function OrdersLoading() {
<> <>
<DashboardHeader <DashboardHeader
heading="User Management" 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-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" /> <Skeleton className="h-[400px] w-full rounded-lg" />

View File

@@ -7,7 +7,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
import UsersList from "./user-list"; import UsersList from "./user-list";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "User Management  WR.DO", title: "User Management",
description: "List and manage all users.", description: "List and manage all users.",
}); });
@@ -20,7 +20,7 @@ export default async function UsersPage() {
<> <>
<DashboardHeader <DashboardHeader
heading="User Management" heading="User Management"
text="List and manage all users." text="List and manage all users"
/> />
<UsersList user={{ id: user.id, name: user.name || "" }} /> <UsersList user={{ id: user.id, name: user.name || "" }} />
</> </>

View File

@@ -2,10 +2,11 @@
import { useState } from "react"; import { useState } from "react";
import { User } from "@prisma/client"; 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 useSWR, { useSWRConfig } from "swr";
import { fetcher, timeAgo } from "@/lib/utils"; import { fetcher } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -33,12 +34,11 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { UserForm } from "@/components/forms/user-form"; import { FormType, UserForm } from "@/components/forms/user-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons"; import { Icons } from "@/components/shared/icons";
import { PaginationWrapper } from "@/components/shared/pagination"; import { PaginationWrapper } from "@/components/shared/pagination";
import { TimeAgoIntl } from "@/components/shared/time-ago";
import CountUpFn from "../../../../components/dashboard/count-up";
export interface UrlListProps { export interface UrlListProps {
user: Pick<User, "id" | "name">; user: Pick<User, "id" | "name">;
@@ -74,15 +74,18 @@ function TableColumnSekleton({ className }: { className?: string }) {
export default function UsersList({ user }: UrlListProps) { export default function UsersList({ user }: UrlListProps) {
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [formType, setFormType] = useState<FormType>("add");
const [isShowForm, setShowForm] = useState(false); const [isShowForm, setShowForm] = useState(false);
const [currentEditUser, setcurrentEditUser] = useState<User | null>(null); const [currentEditUser, setcurrentEditUser] = useState<User | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(15);
const [searchParams, setSearchParams] = useState({ const [searchParams, setSearchParams] = useState({
email: "", email: "",
userName: "", userName: "",
}); });
const t = useTranslations("List");
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { data, isLoading } = useSWR<{ total: number; list: User[] }>( const { data, isLoading } = useSWR<{ total: number; list: User[] }>(
`/api/user/admin?page=${currentPage}&size=${pageSize}&email=${searchParams.email}&userName=${searchParams.userName}`, `/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"> <Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center"> <CardHeader className="flex flex-row items-center">
<CardDescription className="text-balance text-lg font-bold"> <CardDescription className="text-balance text-lg font-bold">
<span>Total Users:</span>{" "} <span>{t("Total Users")}:</span>{" "}
<span className="font-bold"> <span className="font-bold">{data && data.total}</span>
{data && <CountUpFn count={data.total} />}
</span>
</CardDescription> </CardDescription>
<div className="ml-auto flex items-center justify-end gap-3"> <div className="ml-auto flex items-center justify-end gap-3">
<Button <Button
@@ -116,11 +117,24 @@ export default function UsersList({ user }: UrlListProps) {
disabled={isLoading} disabled={isLoading}
> >
{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>
<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> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -179,25 +193,25 @@ export default function UsersList({ user }: UrlListProps) {
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground"> <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-8">
<TableHead className="col-span-1 flex items-center font-bold"> <TableHead className="col-span-1 flex items-center font-bold">
Name {t("Name")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2"> <TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Email {t("Email")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
Role {t("Role")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
Plan {t("Plan")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
Status {t("Status")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
Join {t("Join")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex items-center justify-center font-bold"> <TableHead className="col-span-1 flex items-center justify-center font-bold">
Actions {t("Actions")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -240,19 +254,19 @@ export default function UsersList({ user }: UrlListProps) {
</TableCell> </TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex"> <TableCell className="col-span-1 hidden justify-center sm:flex">
<Badge className="text-xs" variant="outline"> <Badge className="text-xs" variant="outline">
{user.role} {t(user.role)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex"> <TableCell className="col-span-1 hidden justify-center sm:flex">
<Badge className="text-xs" variant="outline"> <Badge className="text-xs" variant="outline">
{user.team?.toLocaleUpperCase()} {user.team}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex"> <TableCell className="col-span-1 hidden justify-center sm:flex">
<Switch defaultChecked={user.active === 1} /> <Switch defaultChecked={user.active === 1} disabled />
</TableCell> </TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex"> <TableCell className="col-span-1 hidden justify-center sm:flex">
{timeAgo(user.createdAt || "")} <TimeAgoIntl date={user.updatedAt as Date} />
</TableCell> </TableCell>
<TableCell className="col-span-1 flex justify-center"> <TableCell className="col-span-1 flex justify-center">
<Button <Button
@@ -262,10 +276,11 @@ export default function UsersList({ user }: UrlListProps) {
onClick={() => { onClick={() => {
setcurrentEditUser(user); setcurrentEditUser(user);
setShowForm(false); setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm); setShowForm(!isShowForm);
}} }}
> >
<p>Edit</p> <p className="text-nowrap">{t("Edit")}</p>
<PenLine className="ml-1 size-4" /> <PenLine className="ml-1 size-4" />
</Button> </Button>
</TableCell> </TableCell>
@@ -304,7 +319,7 @@ export default function UsersList({ user }: UrlListProps) {
user={{ id: user.id, name: user.name || "" }} user={{ id: user.id, name: user.name || "" }}
isShowForm={isShowForm} isShowForm={isShowForm}
setShowForm={setShowForm} setShowForm={setShowForm}
type="edit" type={formType}
initData={currentEditUser} initData={currentEditUser}
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />

View File

@@ -1,10 +1,8 @@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardLoading() { export default function DashboardLoading() {
return ( return (
<> <>
<DashboardHeader heading="Dashboard" text="" />
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">
<Skeleton className="h-32 w-full rounded-lg" /> <Skeleton className="h-32 w-full rounded-lg" />

View File

@@ -2,9 +2,9 @@ import { Suspense } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
import { TeamPlanQuota } from "@/config/team";
import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record"; import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record";
import { getAllUserEmailsCount } from "@/lib/dto/email"; import { getAllUserEmailsCount } from "@/lib/dto/email";
import { getPlanQuota, PlanQuota } from "@/lib/dto/plan";
import { getUserShortUrlCount } from "@/lib/dto/short-urls"; import { getUserShortUrlCount } from "@/lib/dto/short-urls";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils"; import { constructMetadata } from "@/lib/utils";
@@ -13,24 +13,22 @@ import {
DashboardInfoCard, DashboardInfoCard,
HeroCard, HeroCard,
} from "@/components/dashboard/dashboard-info-card"; } from "@/components/dashboard/dashboard-info-card";
import { DashboardHeader } from "@/components/dashboard/header";
import { ErrorBoundary } from "@/components/shared/error-boundary"; import { ErrorBoundary } from "@/components/shared/error-boundary";
import UserRecordsList from "./records/record-list"; import UserRecordsList from "./records/record-list";
import LiveLog from "./urls/live-logs";
import UserUrlsList from "./urls/url-list"; import UserUrlsList from "./urls/url-list";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Dashboard - WR.DO", title: "Dashboard",
description: "List and manage records.", description: "List and manage records.",
}); });
async function EmailHeroCardSection({ async function EmailHeroCardSection({
userId, userId,
team, plan,
}: { }: {
userId: string; userId: string;
team: string; plan: PlanQuota;
}) { }) {
const email_count = await getAllUserEmailsCount(userId); const email_count = await getAllUserEmailsCount(userId);
@@ -38,17 +36,17 @@ async function EmailHeroCardSection({
<HeroCard <HeroCard
total={email_count.total} total={email_count.total}
monthTotal={email_count.month_total} monthTotal={email_count.month_total}
limit={TeamPlanQuota[team].EM_EmailAddresses} limit={plan.emEmailAddresses}
/> />
); );
} }
async function ShortUrlsCardSection({ async function ShortUrlsCardSection({
userId, userId,
team, plan,
}: { }: {
userId: string; userId: string;
team: string; plan: PlanQuota;
}) { }) {
const url_count = await getUserShortUrlCount(userId); const url_count = await getUserShortUrlCount(userId);
@@ -58,7 +56,7 @@ async function ShortUrlsCardSection({
title="Short URLs" title="Short URLs"
total={url_count.total} total={url_count.total}
monthTotal={url_count.month_total} monthTotal={url_count.month_total}
limit={TeamPlanQuota[team].SL_NewLinks} limit={plan.slNewLinks}
link="/dashboard/urls" link="/dashboard/urls"
icon="link" icon="link"
/> />
@@ -67,10 +65,10 @@ async function ShortUrlsCardSection({
async function DnsRecordsCardSection({ async function DnsRecordsCardSection({
userId, userId,
team, plan,
}: { }: {
userId: string; userId: string;
team: string; plan: PlanQuota;
}) { }) {
const record_count = await getUserRecordCount(userId); const record_count = await getUserRecordCount(userId);
@@ -80,17 +78,13 @@ async function DnsRecordsCardSection({
title="DNS Records" title="DNS Records"
total={record_count.total} total={record_count.total}
monthTotal={record_count.month_total} monthTotal={record_count.month_total}
limit={TeamPlanQuota[team].RC_NewRecords} limit={plan.rcNewRecords}
link="/dashboard/records" link="/dashboard/records"
icon="globeLock" icon="globeLock"
/> />
); );
} }
async function LiveLogSection() {
return <LiveLog admin={false} />;
}
async function UserUrlsListSection({ async function UserUrlsListSection({
user, user,
}: { }: {
@@ -119,7 +113,13 @@ async function UserUrlsListSection({
async function UserRecordsListSection({ async function UserRecordsListSection({
user, user,
}: { }: {
user: { id: string; name: string; apiKey: string; email: string }; user: {
id: string;
name: string;
apiKey: string;
email: string;
role: UserRole;
};
}) { }) {
return ( return (
<UserRecordsList <UserRecordsList
@@ -128,6 +128,7 @@ async function UserRecordsListSection({
name: user.name, name: user.name,
apiKey: user.apiKey, apiKey: user.apiKey,
email: user.email, email: user.email,
role: user.role,
}} }}
action="/api/record" action="/api/record"
/> />
@@ -139,9 +140,10 @@ export default async function DashboardPage() {
if (!user?.id) redirect("/login"); if (!user?.id) redirect("/login");
const plan = await getPlanQuota(user.team);
return ( return (
<> <>
<DashboardHeader heading="Dashboard" text="" />
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-3">
<ErrorBoundary <ErrorBoundary
@@ -150,7 +152,7 @@ export default async function DashboardPage() {
<Suspense <Suspense
fallback={<Skeleton className="h-32 w-full rounded-lg" />} fallback={<Skeleton className="h-32 w-full rounded-lg" />}
> >
<EmailHeroCardSection userId={user.id} team={user.team} /> <EmailHeroCardSection userId={user.id} plan={plan} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
<ErrorBoundary <ErrorBoundary
@@ -159,7 +161,7 @@ export default async function DashboardPage() {
<Suspense <Suspense
fallback={<Skeleton className="h-32 w-full rounded-lg" />} fallback={<Skeleton className="h-32 w-full rounded-lg" />}
> >
<ShortUrlsCardSection userId={user.id} team={user.team} /> <ShortUrlsCardSection userId={user.id} plan={plan} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
<ErrorBoundary <ErrorBoundary
@@ -168,7 +170,7 @@ export default async function DashboardPage() {
<Suspense <Suspense
fallback={<Skeleton className="h-32 w-full rounded-lg" />} fallback={<Skeleton className="h-32 w-full rounded-lg" />}
> >
<DnsRecordsCardSection userId={user.id} team={user.team} /> <DnsRecordsCardSection userId={user.id} plan={plan} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
@@ -184,6 +186,7 @@ export default async function DashboardPage() {
name: user.name || "", name: user.name || "",
apiKey: user.apiKey || "", apiKey: user.apiKey || "",
email: user.email || "", email: user.email || "",
role: user.role,
}} }}
/> />
</Suspense> </Suspense>

View File

@@ -5,10 +5,15 @@ export default function DashboardRecordsLoading() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Manage&nbsp;&nbsp;DNS&nbsp;&nbsp;Records" heading="Manage DNS Records"
text="List and manage 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" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>
); );

View File

@@ -3,11 +3,12 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils"; import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header"; import { DashboardHeader } from "@/components/dashboard/header";
import { UserRecordStatus } from "@/components/dashboard/status-card";
import UserRecordsList from "./record-list"; import UserRecordsList from "./record-list";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "DNS Records - WR.DO", title: "DNS Records",
description: "List and manage records.", description: "List and manage records.",
}); });
@@ -19,17 +20,19 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Manage&nbsp;&nbsp;DNS&nbsp;&nbsp;Records" heading="Manage DNS Records"
text="List and manage records." text="List and manage records"
link="/docs/dns-records" link="/docs/dns-records"
linkText="DNS records." linkText="DNS records"
/> />
<UserRecordStatus action="/api/record" />
<UserRecordsList <UserRecordsList
user={{ user={{
id: user.id, id: user.id,
name: user.name || "", name: user.name || "",
apiKey: user.apiKey || "", apiKey: user.apiKey || "",
email: user.email || "", email: user.email || "",
role: user.role,
}} }}
action="/api/record" action="/api/record"
/> />

View File

@@ -3,13 +3,14 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { User } from "@prisma/client"; 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 { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record"; import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
import { TTL_ENUMS } from "@/lib/enums"; 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 { useMediaQuery } from "@/hooks/use-media-query";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -32,6 +33,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import {
ClickableTooltip,
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
@@ -40,20 +42,18 @@ import {
import { FormType, RecordForm } from "@/components/forms/record-form"; import { FormType, RecordForm } from "@/components/forms/record-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons"; import { Icons } from "@/components/shared/icons";
import { import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
LinkInfoPreviewer,
LinkPreviewer,
} from "@/components/shared/link-previewer";
import { PaginationWrapper } from "@/components/shared/pagination"; import { PaginationWrapper } from "@/components/shared/pagination";
import { TimeAgoIntl } from "@/components/shared/time-ago";
export interface RecordListProps { export interface RecordListProps {
user: Pick<User, "id" | "name" | "apiKey" | "email">; user: Pick<User, "id" | "name" | "apiKey" | "email" | "role">;
action: string; action: string;
} }
function TableColumnSekleton() { function TableColumnSekleton() {
return ( 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"> <TableCell className="col-span-1">
<Skeleton className="h-5 w-24" /> <Skeleton className="h-5 w-24" />
</TableCell> </TableCell>
@@ -72,6 +72,9 @@ function TableColumnSekleton() {
<TableCell className="col-span-1 hidden justify-center sm:flex"> <TableCell className="col-span-1 hidden justify-center sm:flex">
<Skeleton className="h-5 w-16" /> <Skeleton className="h-5 w-16" />
</TableCell> </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"> <TableCell className="col-span-1 flex justify-center">
<Skeleton className="h-5 w-16" /> <Skeleton className="h-5 w-16" />
</TableCell> </TableCell>
@@ -86,13 +89,14 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
const [currentEditRecord, setCurrentEditRecord] = const [currentEditRecord, setCurrentEditRecord] =
useState<UserRecordFormData | null>(null); useState<UserRecordFormData | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(15);
const [tab, setTab] = useState("app");
const isAdmin = action.includes("/admin"); const isAdmin = action.includes("/admin");
const t = useTranslations("List");
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { data, error, isLoading } = useSWR<{ const { data, isLoading } = useSWR<{
total: number; total: number;
list: UserRecordFormData[]; list: UserRecordFormData[];
}>(`${action}?page=${currentPage}&size=${pageSize}`, fetcher, { }>(`${action}?page=${currentPage}&size=${pageSize}`, fetcher, {
@@ -109,7 +113,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
setChecked: (value: boolean) => void, setChecked: (value: boolean) => void,
) => { ) => {
const originalState = record.active === 1; const originalState = record.active === 1;
setChecked(checked); // 立即更新 UI setChecked(checked);
const res = await fetch(`/api/record/update`, { const res = await fetch(`/api/record/update`, {
method: "PUT", method: "PUT",
@@ -138,38 +142,36 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
} }
}; };
const rendeApplyList = () => {};
return ( return (
<> <>
<Card className="xl:col-span-2"> <Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center"> <CardHeader className="flex flex-row items-center">
{isAdmin ? ( {isAdmin ? (
<CardDescription className="text-balance text-lg font-bold"> <CardDescription className="text-balance text-lg font-bold">
<span>Total Subdomains:</span>{" "} <span>{t("Total Subdomains")}:</span>{" "}
<span className="font-bold">{data && data.total}</span> <span className="font-bold">{data && data.total}</span>
</CardDescription> </CardDescription>
) : ( ) : (
<div className="grid gap-2"> <div className="grid gap-2">
<CardTitle>Subdomains</CardTitle> <CardTitle>{t("Subdomain List")}</CardTitle>
<CardDescription className="hidden text-balance sm:block"> <CardDescription className="hidden text-balance sm:block">
Please read the{" "} {t("Before using please read the")}
<Link <Link
target="_blank" target="_blank"
className="font-semibold text-yellow-600 after:content-['↗'] hover:underline" className="font-semibold text-yellow-600 after:content-['↗'] hover:underline"
href="/docs/dns-records#legitimacy-review" href="/docs/dns-records#legitimacy-review"
> >
Legitimacy review {t("legitimacy review")}
</Link>{" "} </Link>
before using. See{" "} . {t("See")}
<Link <Link
target="_blank" target="_blank"
className="text-blue-500 hover:underline" className="text-blue-500 hover:underline"
href="/docs/examples/vercel" href="/docs/examples/vercel"
> >
examples {t("examples")}
</Link>{" "} </Link>
for more usage. {t("for more usage")}.
</CardDescription> </CardDescription>
</div> </div>
)} )}
@@ -180,9 +182,9 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
disabled={isLoading} disabled={isLoading}
> >
{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>
<Button <Button
@@ -196,34 +198,37 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
}} }}
> >
<Icons.add className="size-4" /> <Icons.add className="size-4" />
<span className="hidden sm:inline">Add Record</span> <span className="hidden sm:inline">{t("Add Record")}</span>
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground"> <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"> <TableHead className="col-span-1 flex items-center font-bold">
Type {t("Type")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex items-center font-bold"> <TableHead className="col-span-1 flex items-center font-bold">
Name {t("Name")}
</TableHead> </TableHead>
<TableHead className="col-span-2 hidden items-center font-bold sm:flex"> <TableHead className="col-span-2 hidden items-center font-bold sm:flex">
Content {t("Content")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center font-bold sm:flex">
TTL {t("TTL")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex"> <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>
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
Updated {t("Updated")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex items-center justify-center font-bold"> <TableHead className="col-span-1 flex items-center justify-center font-bold">
Actions {t("Actions")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -240,7 +245,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
data.list.map((record) => ( data.list.map((record) => (
<TableRow <TableRow
key={record.id} 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"> <TableCell className="col-span-1">
<Badge className="text-xs" variant="outline"> <Badge className="text-xs" variant="outline">
@@ -248,11 +253,15 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="col-span-1"> <TableCell className="col-span-1">
<LinkInfoPreviewer {[0, 1].includes(record.active) ? (
apiKey={user.apiKey ?? ""} <LinkInfoPreviewer
url={"https://" + record.name} apiKey={user.apiKey ?? ""}
formatUrl={record.name} url={"https://" + record.name}
/> formatUrl={record.name}
/>
) : (
record.name
)}
</TableCell> </TableCell>
<TableCell className="col-span-2 hidden truncate text-nowrap sm:inline-block"> <TableCell className="col-span-2 hidden truncate text-nowrap sm:inline-block">
<TooltipProvider> <TooltipProvider>
@@ -271,60 +280,157 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
} }
</TableCell> </TableCell>
<TableCell className="col-span-1 hidden items-center justify-center gap-1 sm:flex"> <TableCell className="col-span-1 hidden items-center justify-center gap-1 sm:flex">
<SwitchWrapper {[0, 1].includes(record.active) && (
record={record} <SwitchWrapper
onChangeStatu={handleChangeStatu} record={record}
/> onChangeStatu={handleChangeStatu}
{!record.active && ( />
)}
{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> <TooltipProvider>
<Tooltip delayDuration={200}> <Tooltip delayDuration={200}>
<TooltipTrigger className="truncate"> <TooltipTrigger className="truncate">
<Icons.help className="size-4 cursor-pointer text-yellow-500 opacity-90" /> <Icons.help className="size-4 cursor-pointer text-yellow-500 opacity-90" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<ul className="list-disc px-3"> {record.active === 0 && (
<li>The target is currently inaccessible.</li> <ul className="list-disc px-3">
<li>Please check the target and try again.</li> <li>
<li> {t("The target is currently inaccessible")}.
If the target is not activated within 3 days,{" "} </li>
<br /> <li>
the administrator will{" "} {t("Please check the target and try again")}
<strong className="text-red-500"> .
delete this record </li>
</strong> <li>
. {t(
</li> "If the target is not activated within 3 days",
</ul> )}
, <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> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
</TableCell> </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"> <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>
<TableCell className="col-span-1 flex justify-center"> <TableCell className="col-span-1 flex justify-center">
<Button {record.active === 3 ? (
className="text-sm hover:bg-slate-100 dark:hover:text-primary-foreground" <Button
size="sm" className="h-7 text-nowrap px-1 text-xs sm:px-1.5"
variant={"outline"} size="sm"
onClick={() => { variant={"outline"}
setCurrentEditRecord(record); onClick={() => {
setShowForm(false); setCurrentEditRecord(record);
setFormType("edit"); setShowForm(false);
setShowForm(!isShowForm); setFormType("edit");
}} setShowForm(!isShowForm);
> }}
<p>Edit</p> >
<PenLine className="ml-1 size-4" /> <p className="hidden text-nowrap sm:block">
</Button> {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> </TableCell>
</TableRow> </TableRow>
)) ))
) : ( ) : (
<EmptyPlaceholder className="shadow-none"> <EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="globe" /> <EmptyPlaceholder.Icon name="globe" />
<EmptyPlaceholder.Title>No Subdomain</EmptyPlaceholder.Title> <EmptyPlaceholder.Title>
{t("No Subdomains")}
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description> <EmptyPlaceholder.Description>
You don&apos;t have any subdomain yet. Start creating You don&apos;t have any subdomain yet. Start creating
record. record.

View File

@@ -1,6 +1,5 @@
import { import {
getScrapeStatsByTypeAndUserId, getScrapeStatsByTypeAndUserId,
getScrapeStatsByUserId,
getScrapeStatsByUserId1, getScrapeStatsByUserId1,
} from "@/lib/dto/scrape"; } from "@/lib/dto/scrape";
@@ -21,10 +20,7 @@ export default async function DashboardScrapeCharts({ id }: { id: string }) {
return ( return (
<> <>
{all_user_logs && all_user_logs.length > 0 && ( {all_user_logs && all_user_logs.length > 0 && (
<> <DailyPVUVChart data={all_user_logs} />
<h2 className="my-1 text-xl font-semibold">Request Statistics</h2>
<DailyPVUVChart data={all_user_logs} />
</>
)} )}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{(screenshot_stats.length > 0 || meta_stats.length > 0) && ( {(screenshot_stats.length > 0 || meta_stats.length > 0) && (
@@ -43,7 +39,6 @@ export default async function DashboardScrapeCharts({ id }: { id: string }) {
)} )}
</div> </div>
<h2 className="my-1 text-xl font-semibold">Request Logs</h2>
<LogsTable userId={id} target={"/api/v1/scraping/logs"} /> <LogsTable userId={id} target={"/api/v1/scraping/logs"} />
</> </>
); );

View File

@@ -3,9 +3,10 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { ScrapeMeta } from "@prisma/client"; import { ScrapeMeta } from "@prisma/client";
import { useTranslations } from "next-intl";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { isLink, nFormatter, removeUrlSuffix, timeAgo } from "@/lib/utils"; import { isLink, nFormatter, removeUrlPrefix } from "@/lib/utils";
import { import {
Card, Card,
CardContent, CardContent,
@@ -18,7 +19,7 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from "@/components/ui/chart"; } from "@/components/ui/chart";
import CountUp from "@/components/dashboard/count-up"; import { TimeAgoIntl } from "@/components/shared/time-ago";
const chartConfig = { const chartConfig = {
request: { request: {
@@ -102,18 +103,21 @@ export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
(a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(), (a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
); );
const latestEntry = sort_data[sort_data.length - 1]; const latestEntry = sort_data[sort_data.length - 1];
const latestDate = timeAgo(latestEntry.updatedAt);
const latestFrom = latestEntry.type; const latestFrom = latestEntry.type;
const t = useTranslations("Components");
const lastRequestInfo = t.rich("last-request-info", {
location: latestFrom,
timeAgo: () => <TimeAgoIntl date={latestEntry.updatedAt} />,
});
return ( return (
<Card> <Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row"> <CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-5 py-4"> <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> <CardTitle>{t("Total Requests of APIs in Last 30 Days")}</CardTitle>
<CardDescription> <CardDescription>{lastRequestInfo}</CardDescription>
Last request from <strong>{latestFrom}</strong> api about{" "}
{latestDate}.
</CardDescription>
</div> </div>
<div className="flex"> <div className="flex">
{["request", "ip"].map((key) => { {["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" 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)} onClick={() => setActiveChart(chart)}
> >
<span className="text-xs text-muted-foreground"> <span className="text-nowrap text-xs text-muted-foreground">
{chartConfig[chart].label} {t(chartConfig[chart].label)}
</span> </span>
<span className="text-lg font-bold leading-none"> <span className="text-lg font-bold leading-none">
{nFormatter(dataTotal[key])} {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-['↗']" className="truncate font-medium hover:opacity-70 hover:after:content-['↗']"
href={ref.dimension} href={ref.dimension}
> >
{removeUrlSuffix(ref.dimension)} {removeUrlPrefix(ref.dimension)}
</Link> </Link>
) : ( ) : (
<p className="font-medium">{decodeURIComponent(ref.dimension)}</p> <p className="font-medium">{decodeURIComponent(ref.dimension)}</p>

View File

@@ -5,8 +5,8 @@ export default function DashboardRecordsLoading() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Scraping&nbsp;&nbsp;API&nbsp;&nbsp;Overview" heading="Scraping API Overview"
text="Quickly extract valuable structured website data. It's free and unlimited to use!" text="Quickly extract valuable structured website data"
/> />
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { RefreshCwIcon } from "lucide-react"; import { useTranslations } from "next-intl";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
import { nFormatter } from "@/lib/utils"; import { nFormatter } from "@/lib/utils";
@@ -16,6 +16,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Icons } from "@/components/shared/icons";
import { PaginationWrapper } from "@/components/shared/pagination"; import { PaginationWrapper } from "@/components/shared/pagination";
export interface LogsTableData { export interface LogsTableData {
@@ -49,6 +50,8 @@ const LogsTable = ({ userId, target }) => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
const t = useTranslations("Components");
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
type: "", type: "",
ip: "", ip: "",
@@ -129,9 +132,9 @@ const LogsTable = ({ userId, target }) => {
className="ml-2 h-8 px-2 py-0" className="ml-2 h-8 px-2 py-0"
> >
{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>
</div> </div>
@@ -141,13 +144,17 @@ const LogsTable = ({ userId, target }) => {
<TableHeader className="bg-muted"> <TableHeader className="bg-muted">
<TableRow className="grid grid-cols-5 items-center sm:grid-cols-6"> <TableRow className="grid grid-cols-5 items-center sm:grid-cols-6">
<TableHead className="hidden items-center justify-start px-2 sm:flex"> <TableHead className="hidden items-center justify-start px-2 sm:flex">
Date {t("Date")}
</TableHead>
<TableHead className="flex items-center px-2">
{t("Type")}
</TableHead> </TableHead>
<TableHead className="flex items-center px-2">Type</TableHead>
<TableHead className="col-span-3 flex items-center px-2"> <TableHead className="col-span-3 flex items-center px-2">
Link {t("Link")}
</TableHead>
<TableHead className="flex items-center px-2">
{t("User")}
</TableHead> </TableHead>
<TableHead className="flex items-center px-2">User</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>

View File

@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() { export default function DashboardRecordsLoading() {
return ( 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-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>

View File

@@ -8,7 +8,7 @@ import ApiReference from "@/components/shared/api-reference";
import { MarkdownScraping, TextScraping } from "../scrapes"; import { MarkdownScraping, TextScraping } from "../scrapes";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Url to Markdown API - WR.DO", title: "Url to Markdown API",
description: description:
"Quickly extract website content and convert it to Markdown format", "Quickly extract website content and convert it to Markdown format",
}); });
@@ -21,10 +21,10 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;Markdown" heading="Url to Markdown"
text="Quickly extract website content and convert it to Markdown format." text="Quickly extract website content and convert it to Markdown format"
link="/docs/open-api/markdown" link="/docs/open-api/markdown"
linkText="Markdown API." linkText="Markdown API"
/> />
<ApiReference <ApiReference
badge="GET /api/v1/scraping/markdown" badge="GET /api/v1/scraping/markdown"

View File

@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() { export default function DashboardRecordsLoading() {
return ( 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-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>

View File

@@ -5,11 +5,10 @@ import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header"; import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "@/components/shared/api-reference"; import ApiReference from "@/components/shared/api-reference";
import DashboardScrapeCharts from "../charts";
import { MetaScraping } from "../scrapes"; import { MetaScraping } from "../scrapes";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Url to Meta API - WR.DO", title: "Url to Meta API",
description: "Quickly extract valuable structured website data", description: "Quickly extract valuable structured website data",
}); });
@@ -21,10 +20,10 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;Meta&nbsp;&nbsp;Info" heading="Url to Meta Info"
text="Quickly extract valuable structured website data." text="Quickly extract valuable structured website data"
link="/docs/open-api/meta-info" link="/docs/open-api/meta-info"
linkText="Meta Info API." linkText="Meta Info API"
/> />
<ApiReference <ApiReference
badge="GET /api/v1/scraping/meta" badge="GET /api/v1/scraping/meta"

View File

@@ -8,7 +8,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
import DashboardScrapeCharts from "./charts"; import DashboardScrapeCharts from "./charts";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Scraping API - WR.DO", title: "Scraping API",
description: "Quickly extract valuable structured website data", description: "Quickly extract valuable structured website data",
}); });
@@ -20,27 +20,27 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Scraping&nbsp;&nbsp;API&nbsp;&nbsp;Overview" heading="Scraping API Overview"
text="Quickly extract valuable structured website data. It's free and unlimited to use!" text="Quickly extract valuable structured website data"
link="/docs/open-api" link="/docs/open-api"
linkText="Open API." linkText="Open API."
/> />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StaticInfoCard <StaticInfoCard
title="Url to Screenshot" title="Url to Screenshot"
desc="Take a screenshot of the webpage." desc="Take a screenshot of the webpage"
link="/dashboard/scrape/screenshot" link="/dashboard/scrape/screenshot"
icon="camera" icon="camera"
/> />
<StaticInfoCard <StaticInfoCard
title="Url to Meta Info" title="Url to Meta Info"
desc="Extract website metadata." desc="Extract website metadata"
link="/dashboard/scrape/meta-info" link="/dashboard/scrape/meta-info"
icon="globe" icon="globe"
/> />
<StaticInfoCard <StaticInfoCard
title="Url to QR Code" title="Url to QR Code"
desc="Generate QR Code from URL." desc="Generate QR Code from URL"
link="/dashboard/scrape/qrcode" link="/dashboard/scrape/qrcode"
icon="qrcode" icon="qrcode"
/> />
@@ -48,13 +48,13 @@ export default async function DashboardPage() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StaticInfoCard <StaticInfoCard
title="Url to Markdown" title="Url to Markdown"
desc="Convert website content to Markdown format." desc="Convert website content to Markdown format"
link="/dashboard/scrape/markdown" link="/dashboard/scrape/markdown"
icon="heading1" icon="heading1"
/> />
<StaticInfoCard <StaticInfoCard
title="Url to Text" title="Url to Text"
desc="Extract website text." desc="Convert website content to text"
link="/dashboard/scrape/markdown" link="/dashboard/scrape/markdown"
icon="fileText" icon="fileText"
/> />

View File

@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() { export default function DashboardRecordsLoading() {
return ( 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-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>

View File

@@ -6,10 +6,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "@/components/shared/api-reference"; import ApiReference from "@/components/shared/api-reference";
import QRCodeEditor from "@/components/shared/qr"; import QRCodeEditor from "@/components/shared/qr";
import { CodeLight, QrCodeScraping } from "../scrapes"; import { CodeLight } from "../scrapes";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Url to QR Code API - WR.DO", title: "Url to QR Code API",
description: "Generate QR Code from URL", description: "Generate QR Code from URL",
}); });
@@ -21,10 +21,10 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;QR&nbsp;&nbsp;Code" heading="Url to QR Code"
text="Generate QR Code from URL" text="Generate QR Code from URL"
link="/docs/open-api/qrcode" link="/docs/open-api/qrcode"
linkText="QR Code API." linkText="QR Code API"
/> />
<ApiReference <ApiReference
badge="GET /api/v1/scraping/qrcode" badge="GET /api/v1/scraping/qrcode"

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import JsonView from "@uiw/react-json-view"; import JsonView from "@uiw/react-json-view";
import { githubLightTheme } from "@uiw/react-json-view/githubLight"; import { githubLightTheme } from "@uiw/react-json-view/githubLight";
import { vscodeTheme } from "@uiw/react-json-view/vscode"; import { vscodeTheme } from "@uiw/react-json-view/vscode";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -50,6 +51,7 @@ export function ScreenshotScraping({
}: { }: {
user: { id: string; apiKey: string }; user: { id: string; apiKey: string };
}) { }) {
const t = useTranslations("Scrape");
const { theme } = useTheme(); const { theme } = useTheme();
const [protocol, setProtocol] = useState("https://"); const [protocol, setProtocol] = useState("https://");
@@ -87,10 +89,12 @@ export function ScreenshotScraping({
<CodeLight content={`https://wr.do/api/v1/scraping/screenshot`} /> <CodeLight content={`https://wr.do/api/v1/scraping/screenshot`} />
<Card className="bg-gray-50 dark:bg-gray-900"> <Card className="bg-gray-50 dark:bg-gray-900">
<CardHeader> <CardHeader>
<CardTitle>Playground</CardTitle> <CardTitle>{t("Playground")}</CardTitle>
<CardDescription> <CardDescription>
Automate your website screenshots and turn them into stunning {t(
visuals for your applications. "Automate your website screenshots and turn them into stunning visuals for your applications",
)}
.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -126,9 +130,9 @@ export function ScreenshotScraping({
variant="blue" variant="blue"
onClick={handleScrapingScreenshot} onClick={handleScrapingScreenshot}
disabled={isShoting} disabled={isShoting}
className="rounded-l-none" className="w-28 rounded-l-none"
> >
{isShoting ? "Scraping..." : "Send"} {isShoting ? t("Scraping") : t("Start")}
</Button> </Button>
</div> </div>
@@ -164,6 +168,7 @@ export function MetaScraping({
}: { }: {
user: { id: string; apiKey: string }; user: { id: string; apiKey: string };
}) { }) {
const t = useTranslations("Scrape");
const { theme } = useTheme(); const { theme } = useTheme();
const [currentLink, setCurrentLink] = useState("wr.do"); const [currentLink, setCurrentLink] = useState("wr.do");
const [protocol, setProtocol] = useState("https://"); const [protocol, setProtocol] = useState("https://");
@@ -203,8 +208,10 @@ export function MetaScraping({
<CodeLight content={`https://wr.do/api/v1/scraping/meta`} /> <CodeLight content={`https://wr.do/api/v1/scraping/meta`} />
<Card className="bg-gray-50 dark:bg-gray-900"> <Card className="bg-gray-50 dark:bg-gray-900">
<CardHeader> <CardHeader>
<CardTitle>Playground</CardTitle> <CardTitle>{t("Playground")}</CardTitle>
<CardDescription>Scrape the meta data of a website.</CardDescription> <CardDescription>
{t("Scrape the meta data of a website")}.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center"> <div className="flex items-center">
@@ -239,9 +246,9 @@ export function MetaScraping({
variant="blue" variant="blue"
onClick={handleScrapingMeta} onClick={handleScrapingMeta}
disabled={isScraping} disabled={isScraping}
className="rounded-l-none" className="w-28 rounded-l-none"
> >
{isScraping ? "Scraping..." : "Send"} {isScraping ? t("Scraping") : t("Start")}
</Button> </Button>
</div> </div>
@@ -264,6 +271,7 @@ export function MarkdownScraping({
}: { }: {
user: { id: string; apiKey: string }; user: { id: string; apiKey: string };
}) { }) {
const t = useTranslations("Scrape");
const { theme } = useTheme(); const { theme } = useTheme();
const [currentLink, setCurrentLink] = useState("wr.do"); const [currentLink, setCurrentLink] = useState("wr.do");
const [protocol, setProtocol] = useState("https://"); const [protocol, setProtocol] = useState("https://");
@@ -334,9 +342,9 @@ export function MarkdownScraping({
variant="blue" variant="blue"
onClick={handleScrapingMeta} onClick={handleScrapingMeta}
disabled={isScraping} disabled={isScraping}
className="rounded-l-none" className="w-28 rounded-l-none"
> >
{isScraping ? "Scraping..." : "Send"} {isScraping ? t("Scraping") : t("Start")}
</Button> </Button>
</div> </div>
@@ -359,6 +367,7 @@ export function TextScraping({
}: { }: {
user: { id: string; apiKey: string }; user: { id: string; apiKey: string };
}) { }) {
const t = useTranslations("Scrape");
const { theme } = useTheme(); const { theme } = useTheme();
const [currentLink, setCurrentLink] = useState("wr.do"); const [currentLink, setCurrentLink] = useState("wr.do");
const [protocol, setProtocol] = useState("https://"); const [protocol, setProtocol] = useState("https://");
@@ -394,7 +403,7 @@ export function TextScraping({
<CodeLight content={`https://wr.do/api/v1/scraping/text`} /> <CodeLight content={`https://wr.do/api/v1/scraping/text`} />
<Card className="bg-gray-50 dark:bg-gray-900"> <Card className="bg-gray-50 dark:bg-gray-900">
<CardHeader> <CardHeader>
<CardTitle>Text</CardTitle> <CardTitle>{t("Text")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center"> <div className="flex items-center">
@@ -429,9 +438,9 @@ export function TextScraping({
variant="blue" variant="blue"
onClick={handleScrapingMeta} onClick={handleScrapingMeta}
disabled={isScraping} disabled={isScraping}
className="rounded-l-none" className="w-28 rounded-l-none"
> >
{isScraping ? "Scraping..." : "Send"} {isScraping ? t("Scraping") : t("Start")}
</Button> </Button>
</div> </div>
@@ -454,6 +463,7 @@ export function QrCodeScraping({
}: { }: {
user: { id: string; apiKey: string }; user: { id: string; apiKey: string };
}) { }) {
const t = useTranslations("Scrape");
const { theme } = useTheme(); const { theme } = useTheme();
const [protocol, setProtocol] = useState("https://"); const [protocol, setProtocol] = useState("https://");
@@ -487,11 +497,7 @@ export function QrCodeScraping({
<CodeLight content={`https://wr.do/api/v1/scraping/qrcode`} /> <CodeLight content={`https://wr.do/api/v1/scraping/qrcode`} />
<Card className="bg-gray-50 dark:bg-gray-900"> <Card className="bg-gray-50 dark:bg-gray-900">
<CardHeader> <CardHeader>
<CardTitle>Playground</CardTitle> <CardTitle>{t("Playground")}</CardTitle>
<CardDescription>
Automate your website screenshots and turn them into stunning
visuals for your applications.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center"> <div className="flex items-center">
@@ -573,21 +579,7 @@ export const CodeLight = ({ content }: { content: string }) => {
{i + 1} {i + 1}
</span> </span>
{/* Code content */} {/* Code content */}
<span className="text-blue-400"> <span className="text-blue-400">{line}</span>
{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>
</div> </div>
))} ))}
</code> </code>

View File

@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() { export default function DashboardRecordsLoading() {
return ( 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-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>

View File

@@ -9,7 +9,7 @@ import DashboardScrapeCharts from "../charts";
import { ScreenshotScraping } from "../scrapes"; import { ScreenshotScraping } from "../scrapes";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Url to Screenshot API - WR.DO", title: "Url to Screenshot API",
description: description:
"Quickly extract website screenshots. It's free and unlimited to use!", "Quickly extract website screenshots. It's free and unlimited to use!",
}); });
@@ -22,10 +22,10 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;Screenshot" heading="Url to Screenshot"
text="Quickly extract website screenshots." text="Quickly extract website screenshots"
link="/docs/open-api/screenshot" link="/docs/open-api/screenshot"
linkText="Screenshot API." linkText="Screenshot API"
/> />
<ApiReference <ApiReference
badge="GET /api/v1/scraping/screenshot" badge="GET /api/v1/scraping/screenshot"

View File

@@ -5,10 +5,11 @@ export default function DashboardSettingsLoading() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Settings" heading="Account Settings"
text="Manage account and website settings." text="Manage account and website settings"
/> />
<div className="divide-y divide-muted pb-10"> <div className="divide-y divide-muted pb-10">
<SkeletonSection />
<SkeletonSection /> <SkeletonSection />
<SkeletonSection /> <SkeletonSection />
<SkeletonSection card /> <SkeletonSection card />

View File

@@ -5,7 +5,9 @@ import { constructMetadata } from "@/lib/utils";
import { DeleteAccountSection } from "@/components/dashboard/delete-account"; import { DeleteAccountSection } from "@/components/dashboard/delete-account";
import { DashboardHeader } from "@/components/dashboard/header"; import { DashboardHeader } from "@/components/dashboard/header";
import { UserApiKeyForm } from "@/components/forms/user-api-key-form"; 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 { UserNameForm } from "@/components/forms/user-name-form";
import { UserPasswordForm } from "@/components/forms/user-password-form";
import { UserRoleForm } from "@/components/forms/user-role-form"; import { UserRoleForm } from "@/components/forms/user-role-form";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
@@ -22,13 +24,22 @@ export default async function SettingsPage() {
<> <>
<DashboardHeader <DashboardHeader
heading="Account Settings" heading="Account Settings"
text="Manage account and website settings." text="Manage account and website settings"
/> />
<div className="divide-y divide-muted pb-10"> <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 || "" }} /> <UserNameForm user={{ id: user.id, name: user.name || "" }} />
{user.role === "ADMIN" && ( {user.role === "ADMIN" && (
<UserRoleForm user={{ id: user.id, role: user.role }} /> <UserRoleForm user={{ id: user.id, role: user.role }} />
)} )}
<UserPasswordForm user={{ id: user.id, name: user.name || "" }} />
<UserApiKeyForm <UserApiKeyForm
user={{ user={{
id: user.id, id: user.id,

View 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" />
</>
);
}

View File

@@ -3,12 +3,11 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils"; import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header"; import { DashboardHeader } from "@/components/dashboard/header";
import UserFileList from "@/components/file";
import DomainList from "./domain-list";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Domains - WR.DO", title: "Cloud Storage",
description: "List and manage domains.", description: "List and manage cloud storage.",
}); });
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -19,12 +18,12 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader
heading="Manage&nbsp;&nbsp;Domains" heading="Cloud Storage"
text="List and manage domains." text="List and manage cloud storage"
link="/docs/developer/cloudflare" link="/docs/cloud-storage"
linkText="domains." linkText="Cloud Storage"
/> />
<DomainList <UserFileList
user={{ user={{
id: user.id, id: user.id,
name: user.name || "", name: user.name || "",
@@ -33,7 +32,7 @@ export default async function DashboardPage() {
role: user.role, role: user.role,
team: user.team, team: user.team,
}} }}
action="/api/admin/domain" action="/api/storage"
/> />
</> </>
); );

View File

@@ -0,0 +1,9 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardUrlsLoading() {
return (
<>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View 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 />
</>
);
}

View 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" />
</>
);
}

View 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
`}
/>
</>
);
}

View 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>
);
};

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { differenceInMinutes, format } from "date-fns"; import { differenceInMinutes, format } from "date-fns";
import { useTranslations } from "next-intl";
import { DAILY_DIMENSION_ENUMS } from "@/lib/enums"; import { DAILY_DIMENSION_ENUMS } from "@/lib/enums";
import { import {
@@ -437,6 +438,7 @@ export function RealtimeTimePicker({
timeRange: string; timeRange: string;
setTimeRange: (value: string) => void; setTimeRange: (value: string) => void;
}) { }) {
const t = useTranslations("Components");
return ( return (
<Select onValueChange={setTimeRange} name="time range" value={timeRange}> <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]"> <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]">
@@ -446,7 +448,7 @@ export function RealtimeTimePicker({
{DAILY_DIMENSION_ENUMS.map((e, i) => ( {DAILY_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}> <div key={e.value}>
<SelectItem value={e.value}> <SelectItem value={e.value}>
<span className="flex items-center gap-1">{e.label}</span> <span className="flex items-center gap-1">{t(e.label)}</span>
</SelectItem> </SelectItem>
{i % 2 === 0 && i !== DAILY_DIMENSION_ENUMS.length - 1 && ( {i % 2 === 0 && i !== DAILY_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator /> <SelectSeparator />

View File

@@ -1,13 +1,7 @@
"use client"; "use client";
import { import { useTranslations } from "next-intl";
Bar, import { Bar, BarChart, Tooltip, XAxis, YAxis } from "recharts";
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import StatusDot from "@/components/dashboard/status-dot"; import StatusDot from "@/components/dashboard/status-dot";
@@ -29,6 +23,7 @@ export const RealtimeChart = ({
chartData, chartData,
totalClicks, totalClicks,
}: RealtimeChartProps) => { }: RealtimeChartProps) => {
const t = useTranslations("Components");
const getTickInterval = (dataLength: number) => { const getTickInterval = (dataLength: number) => {
if (dataLength <= 6) return 0; if (dataLength <= 6) return 0;
if (dataLength <= 12) return 1; if (dataLength <= 12) return 1;
@@ -45,7 +40,7 @@ export const RealtimeChart = ({
<div className={cn(`rounded-lg border p-3 backdrop-blur-2xl`, className)}> <div className={cn(`rounded-lg border p-3 backdrop-blur-2xl`, className)}>
<div className="mb-1 flex items-center text-base font-semibold"> <div className="mb-1 flex items-center text-base font-semibold">
<StatusDot status={1} /> <StatusDot status={1} />
<h3 className="ml-2">Realtime Visits</h3> <h3 className="ml-2">{t("Realtime Visits")}</h3>
<Icons.mousePointerClick className="ml-auto size-4 text-muted-foreground" /> <Icons.mousePointerClick className="ml-auto size-4 text-muted-foreground" />
</div> </div>
<p className="mb-2 text-lg font-semibold">{totalClicks}</p> <p className="mb-2 text-lg font-semibold">{totalClicks}</p>
@@ -66,7 +61,9 @@ export const RealtimeChart = ({
type="category" type="category"
scale="point" scale="point"
padding={{ left: 14, right: 20 }} padding={{ left: 14, right: 20 }}
tickFormatter={(value) => value.split(" ")[1]} tickFormatter={(value) =>
value.split(" ")[1] ? value.split(" ")[1] : value
}
/> />
<YAxis <YAxis
domain={[0, "dataMax"]} domain={[0, "dataMax"]}

View File

@@ -98,7 +98,7 @@ const RealtimeLogs = ({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link <Link
className="text-sm font-semibold" className="text-sm font-semibold"
href={`https://${loc.userUrl?.prefix}/s/${loc.userUrl?.url}`} href={`https://${loc.userUrl?.prefix}/${loc.userUrl?.url}`}
target="_blank" target="_blank"
> >
{loc.userUrl?.url} {loc.userUrl?.url}

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { RefreshCwIcon } from "lucide-react"; import { RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
@@ -46,14 +47,22 @@ export interface LogEntry {
isNew?: boolean; // New property to track newly added logs isNew?: boolean; // New property to track newly added logs
} }
export default function LiveLog({ admin = false }: { admin?: boolean }) { export default function LiveLog({
admin = false,
live = false,
}: {
admin?: boolean;
live?: boolean;
}) {
const { theme } = useTheme(); const { theme } = useTheme();
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const [isLive, setIsLive] = useState(false); const [isLive, setIsLive] = useState(live);
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [limitDiplay, setLimitDisplay] = useState(100); const [limitDiplay, setLimitDisplay] = useState(100);
const newLogsRef = useRef<Set<string>>(new Set()); // Track new log keys const newLogsRef = useRef<Set<string>>(new Set()); // Track new log keys
const t = useTranslations("Components");
const { const {
data: newLogs, data: newLogs,
error, error,
@@ -151,10 +160,10 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<CardTitle className="text-base text-gray-800 dark:text-gray-100"> <CardTitle className="text-base text-gray-800 dark:text-gray-100">
Live Log {t("Live Logs")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Real-time logs of short link visits. {t("Real-time logs of short link visits")}.
</CardDescription> </CardDescription>
</div> </div>
@@ -162,11 +171,12 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
onClick={toggleLive} onClick={toggleLive}
variant={"outline"} variant={"outline"}
size="sm" 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" : "" 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>
<Button <Button
className="bg-primary-foreground" className="bg-primary-foreground"
@@ -176,9 +186,9 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
disabled={!isLive} disabled={!isLive}
> >
{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>
<Button <Button
@@ -207,22 +217,22 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
<TableHeader> <TableHeader>
<TableRow className="grid grid-cols-5 bg-gray-100/50 text-sm dark:bg-primary-foreground sm:grid-cols-9"> <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"> <TableHead className="col-span-2 flex h-8 items-center">
Time {t("Time")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex h-8 items-center"> <TableHead className="col-span-1 flex h-8 items-center">
Slug {t("Slug")}
</TableHead> </TableHead>
<TableHead className="col-span-3 hidden h-8 items-center sm:flex"> <TableHead className="col-span-3 hidden h-8 items-center sm:flex">
Target {t("Target")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden h-8 items-center sm:flex"> <TableHead className="col-span-1 hidden h-8 items-center sm:flex">
IP IP
</TableHead> </TableHead>
<TableHead className="col-span-1 flex h-8 items-center"> <TableHead className="col-span-1 flex h-8 items-center">
Location {t("Location")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex h-8 items-center"> <TableHead className="col-span-1 flex h-8 items-center">
Clicks {t("Clicks")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -283,7 +293,7 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
)} )}
{isLive && ( {isLive && (
<div className="flex w-full items-center justify-end gap-2 border-t border-dashed pt-4 text-sm text-gray-500"> <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 <Select
onValueChange={(value: string) => { onValueChange={(value: string) => {
setLimitDisplay(Number(value)); setLimitDisplay(Number(value));
@@ -302,7 +312,7 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p>total logs</p> <p>{t("total logs")}</p>
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -1,14 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardUrlsLoading() { export default function DashboardUrlsLoading() {
return ( return (
<> <>
<DashboardHeader <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
heading="Manage&nbsp;Short&nbsp;URLs" <Skeleton className="h-[102px] w-full rounded-lg" />
text="List and manage short urls." <Skeleton className="h-[102px] w-full rounded-lg" />
/> <Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" /> <Skeleton className="h-[102px] w-full rounded-lg" />
</div>
<Skeleton className="h-[400px] w-full rounded-lg" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>
); );

View 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" />
</>
);
}

View 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} />
</>
);
}

View File

@@ -7,9 +7,10 @@ import { UrlMeta, User } from "@prisma/client";
import { VisSingleContainer, VisTooltip, VisTopoJSONMap } from "@unovis/react"; import { VisSingleContainer, VisTooltip, VisTopoJSONMap } from "@unovis/react";
import { TopoJSONMap } from "@unovis/ts"; import { TopoJSONMap } from "@unovis/ts";
import { WorldMapTopoJSON } from "@unovis/ts/maps"; import { WorldMapTopoJSON } from "@unovis/ts/maps";
import { useTranslations } from "next-intl";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import useSWR from "swr";
import { TeamPlanQuota } from "@/config/team";
import { import {
getBotName, getBotName,
getCountryName, getCountryName,
@@ -19,7 +20,7 @@ import {
getRegionName, getRegionName,
} from "@/lib/contries"; } from "@/lib/contries";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums"; 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 { useElementSize } from "@/hooks/use-element-size";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -44,6 +45,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Icons } from "@/components/shared/icons"; import { Icons } from "@/components/shared/icons";
import { TimeAgoIntl } from "@/components/shared/time-ago";
const chartConfig = { const chartConfig = {
pv: { pv: {
@@ -175,6 +177,13 @@ export function DailyPVUVChart({
const [activeChart, setActiveChart] = const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("pv"); 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) => ({ const processedData = processUrlMeta(data).map((entry) => ({
date: entry.date, date: entry.date,
pv: entry.clicks, pv: entry.clicks,
@@ -184,13 +193,12 @@ export function DailyPVUVChart({
const dataTotal = calculateUVAndPV(data); const dataTotal = calculateUVAndPV(data);
const latestEntry = data[data.length - 1]; const latestEntry = data[data.length - 1];
const latestDate = timeAgo(latestEntry.updatedAt);
const latestFrom = [ const latestFrom = [
latestEntry.city ? decodeURIComponent(latestEntry.city) : "", latestEntry.city ? decodeURIComponent(latestEntry.city) : "",
latestEntry.country ? `(${getCountryName(latestEntry.country)})` : "", latestEntry.country ? `${getCountryName(latestEntry.country)}` : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(",");
// const pointData = data.map((item) => ({ // const pointData = data.map((item) => ({
// id: item.id, // id: item.id,
@@ -238,50 +246,52 @@ export function DailyPVUVChart({
const regionStats = generateStatsList(data, "region"); const regionStats = generateStatsList(data, "region");
const isBotStats = generateStatsList(data, "isBot"); const isBotStats = generateStatsList(data, "isBot");
const lastVisitorInfo = t.rich("last-visitor-info", {
location: latestFrom,
timeAgo: () => <TimeAgoIntl date={latestEntry.updatedAt} />,
});
return ( return (
<Card> <Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row"> <CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-2 sm:py-3"> <div className="flex flex-1 flex-col justify-center gap-1 px-6 py-2 sm:py-3">
<CardTitle>Link Analytics</CardTitle> <CardTitle>{t("Link Analytics")}</CardTitle>
<CardDescription> <CardDescription>{lastVisitorInfo}</CardDescription>
Last visitor from {latestFrom} about {latestDate}.
</CardDescription>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Select {plan && (
onValueChange={(value: string) => { <Select
setTimeRange(value); onValueChange={(value: string) => {
}} setTimeRange(value);
name="time range" }}
defaultValue={timeRange} name="time range"
> defaultValue={timeRange}
<SelectTrigger className="mx-4 w-full shadow-inner"> >
<SelectValue placeholder="Select a time" /> <SelectTrigger className="mx-4 w-full min-w-28 shadow-inner">
</SelectTrigger> <SelectValue placeholder="Select a time" />
<SelectContent> </SelectTrigger>
{DATE_DIMENSION_ENUMS.map((e, i) => ( <SelectContent>
<div key={e.value}> {DATE_DIMENSION_ENUMS.map((e, i) => (
<SelectItem <div key={e.value}>
disabled={ <SelectItem
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention disabled={e.key > plan.slAnalyticsRetention}
} value={e.value}
value={e.value} >
> <span className="flex items-center gap-1">
<span className="flex items-center gap-1"> {t(e.label)}
{e.label} {e.key > plan.slAnalyticsRetention && (
{e.key > <Icons.crown className="size-3" />
TeamPlanQuota[user.team!].SL_AnalyticsRetention && ( )}
<Icons.crown className="size-3" /> </span>
)} </SelectItem>
</span> {i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
</SelectItem> <SelectSeparator />
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && ( )}
<SelectSeparator /> </div>
)} ))}
</div> </SelectContent>
))} </Select>
</SelectContent> )}
</Select>
{["pv", "uv"].map((key) => { {["pv", "uv"].map((key) => {
const chart = key as keyof typeof chartConfig; const chart = key as keyof typeof chartConfig;
return ( return (
@@ -291,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" 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)} onClick={() => setActiveChart(chart)}
> >
<span className="text-sm font-semibold text-muted-foreground"> <span className="text-nowrap text-sm font-semibold text-muted-foreground">
{chartConfig[chart].label} {t(chartConfig[chart].label)}
</span> </span>
<span className="text-lg font-bold leading-none"> <span className="text-lg font-bold leading-none">
{dataTotal[key as keyof typeof dataTotal].toLocaleString()} {dataTotal[key as keyof typeof dataTotal].toLocaleString()}
@@ -403,8 +413,8 @@ export function DailyPVUVChart({
{/* Referrers、isBotStats */} {/* Referrers、isBotStats */}
<Tabs defaultValue="referrer"> <Tabs defaultValue="referrer">
<TabsList> <TabsList>
<TabsTrigger value="referrer">Referrers</TabsTrigger> <TabsTrigger value="referrer">{t("Referrers")}</TabsTrigger>
<TabsTrigger value="isBot">Traffic Type</TabsTrigger> <TabsTrigger value="isBot">{t("Traffic Type")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="referrer"> <TabsContent className="h-[calc(100%-40px)]" value="referrer">
{refererStats.length > 0 && ( {refererStats.length > 0 && (
@@ -420,8 +430,8 @@ export function DailyPVUVChart({
{/* 国家、城市 */} {/* 国家、城市 */}
<Tabs defaultValue="country"> <Tabs defaultValue="country">
<TabsList> <TabsList>
<TabsTrigger value="country">Country</TabsTrigger> <TabsTrigger value="country">{t("Country")}</TabsTrigger>
<TabsTrigger value="city">City</TabsTrigger> <TabsTrigger value="city">{t("City")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="country"> <TabsContent className="h-[calc(100%-40px)]" value="country">
{countryStats.length > 0 && ( {countryStats.length > 0 && (
@@ -437,8 +447,8 @@ export function DailyPVUVChart({
{/* browserStats、engineStats */} {/* browserStats、engineStats */}
<Tabs defaultValue="browser"> <Tabs defaultValue="browser">
<TabsList> <TabsList>
<TabsTrigger value="browser">Browser</TabsTrigger> <TabsTrigger value="browser">{t("Browser")}</TabsTrigger>
<TabsTrigger value="engine">Browser Engine</TabsTrigger> <TabsTrigger value="engine">{t("Engine")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="browser"> <TabsContent className="h-[calc(100%-40px)]" value="browser">
{browserStats.length > 0 && ( {browserStats.length > 0 && (
@@ -455,8 +465,8 @@ export function DailyPVUVChart({
{/* Languages、regionStats */} {/* Languages、regionStats */}
<Tabs className="h-full" defaultValue="language"> <Tabs className="h-full" defaultValue="language">
<TabsList> <TabsList>
<TabsTrigger value="language">Language</TabsTrigger> <TabsTrigger value="language">{t("Language")}</TabsTrigger>
<TabsTrigger value="region">Region</TabsTrigger> <TabsTrigger value="region">{t("Region")}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="language"> <TabsContent className="h-[calc(100%-40px)]" value="language">
{languageStats.length > 0 && ( {languageStats.length > 0 && (
@@ -472,8 +482,8 @@ export function DailyPVUVChart({
{/* deviceStats、osStats、cpuStats */} {/* deviceStats、osStats、cpuStats */}
<Tabs defaultValue="device"> <Tabs defaultValue="device">
<TabsList> <TabsList>
<TabsTrigger value="device">Device</TabsTrigger> <TabsTrigger value="device">{t("Device")}</TabsTrigger>
<TabsTrigger value="os">OS</TabsTrigger> <TabsTrigger value="os">{t("OS")}</TabsTrigger>
<TabsTrigger value="cpu">CPU</TabsTrigger> <TabsTrigger value="cpu">CPU</TabsTrigger>
</TabsList> </TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="device"> <TabsContent className="h-[calc(100%-40px)]" value="device">
@@ -497,12 +507,12 @@ export function DailyPVUVChart({
export function StatsList({ data, title }: { data: Stat[]; title: string }) { export function StatsList({ data, title }: { data: Stat[]; title: string }) {
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const displayedData = showAll ? data.slice(0, 50) : data.slice(0, 8); const displayedData = showAll ? data.slice(0, 50) : data.slice(0, 8);
const t = useTranslations("Components");
return ( return (
<div className="h-full rounded-lg border"> <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"> <div className="flex items-center justify-between border-b px-5 py-2 text-xs font-medium text-muted-foreground">
<span></span> <span>{t("Name")}</span>
<span className=""></span> <span className="">{t("Visitors")}</span>
</div> </div>
<div <div
className={`scrollbar-hidden overflow-hidden overflow-y-auto px-4 pb-4 pt-2 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`}
@@ -522,7 +532,7 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
href={ref.dimension} href={ref.dimension}
target="_blank" target="_blank"
> >
{removeUrlSuffix(ref.dimension)} {removeUrlPrefix(ref.dimension)}
</Link> </Link>
) : ( ) : (
<p className="font-medium"> <p className="font-medium">

View File

@@ -2,9 +2,9 @@
import { useState } from "react"; import { useState } from "react";
import { UrlMeta, User } from "@prisma/client"; import { UrlMeta, User } from "@prisma/client";
import { useTranslations } from "next-intl";
import useSWR from "swr"; import useSWR from "swr";
import { TeamPlanQuota } from "@/config/team";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums"; import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { fetcher } from "@/lib/utils"; import { fetcher } from "@/lib/utils";
import { import {
@@ -28,13 +28,19 @@ export interface UrlMetaProps {
} }
export default function UserUrlMetaInfo({ user, action, urlId }: 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[]>( const { data, isLoading } = useSWR<UrlMeta[]>(
`${action}?id=${urlId}&range=${timeRange}`, `${action}?id=${urlId}&range=${timeRange}`,
fetcher, fetcher,
{ focusThrottleInterval: 30000 }, // 30 seconds, { focusThrottleInterval: 30000 }, // 30 seconds,
); );
const { data: plan } = useSWR<{ slAnalyticsRetention: number }>(
`/api/plan?team=${user.team}`,
fetcher,
);
if (isLoading) if (isLoading)
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -45,47 +51,47 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
if (!data || data.length === 0) { if (!data || data.length === 0) {
return ( return (
<EmptyPlaceholder className="shadow-none"> <EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Title>No Visits</EmptyPlaceholder.Title> <EmptyPlaceholder.Title>{t("No Visits")}</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description> <EmptyPlaceholder.Description>
You don&apos;t have any visits yet in{" "} {t("You don't have any visits yet in")}{" "}
{DATE_DIMENSION_ENUMS.find( {t(
(e) => e.value === timeRange, DATE_DIMENSION_ENUMS.find((e) => e.value === timeRange)?.label ||
)?.label.toLowerCase()} "",
)}
. .
<Select {plan && (
onValueChange={(value: string) => { <Select
setTimeRange(value); onValueChange={(value: string) => {
}} setTimeRange(value);
name="time range" }}
defaultValue={timeRange} name="time range"
> defaultValue={timeRange}
<SelectTrigger className="mt-4 w-full shadow-inner"> >
<SelectValue placeholder="Select a time" /> <SelectTrigger className="mt-4 w-full shadow-inner">
</SelectTrigger> <SelectValue placeholder="Select a time" />
<SelectContent> </SelectTrigger>
{DATE_DIMENSION_ENUMS.map((e, i) => ( <SelectContent>
<div key={e.value}> {DATE_DIMENSION_ENUMS.map((e, i) => (
<SelectItem <div key={e.value}>
disabled={ <SelectItem
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention disabled={e.key > plan.slAnalyticsRetention}
} value={e.value}
value={e.value} >
> <span className="flex items-center gap-1">
<span className="flex items-center gap-1"> {t(e.label)}
{e.label} {e.key > plan.slAnalyticsRetention && (
{e.key > <Icons.crown className="size-3" />
TeamPlanQuota[user.team!].SL_AnalyticsRetention && ( )}
<Icons.crown className="size-3" /> </span>
)} </SelectItem>
</span> {i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
</SelectItem> <SelectSeparator />
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && ( )}
<SelectSeparator /> </div>
)} ))}
</div> </SelectContent>
))} </Select>
</SelectContent> )}
</Select>
</EmptyPlaceholder.Description> </EmptyPlaceholder.Description>
</EmptyPlaceholder> </EmptyPlaceholder>
); );

View File

@@ -2,13 +2,12 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils"; import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import UserUrlsList from "./url-list"; import UserUrlsList from "./url-list";
export const metadata = constructMetadata({ export const metadata = constructMetadata({
title: "Short URLs - WR.DO", title: "Links",
description: "List and manage records.", description: "List and manage short links.",
}); });
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -18,12 +17,6 @@ export default async function DashboardPage() {
return ( return (
<> <>
<DashboardHeader
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
link="/docs/short-urls"
linkText="short urls."
/>
<UserUrlsList <UserUrlsList
user={{ user={{
id: user.id, id: user.id,

View File

@@ -4,21 +4,23 @@ import { useEffect, useMemo, useState, useTransition } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { User } from "@prisma/client"; 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 { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
import { ShortUrlFormData } from "@/lib/dto/short-urls"; import { ShortUrlFormData } from "@/lib/dto/short-urls";
import { import {
addUrlPrefix,
cn, cn,
expirationTime, expirationTime,
extractHostname, extractHostname,
fetcher, fetcher,
nFormatter, nFormatter,
removeUrlSuffix, removeUrlPrefix,
timeAgo,
} from "@/lib/utils"; } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -29,6 +31,13 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -41,12 +50,8 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import { ClickableTooltip } from "@/components/ui/tooltip";
Tooltip, import { UrlStatus } from "@/components/dashboard/status-card";
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FormType } from "@/components/forms/record-form"; import { FormType } from "@/components/forms/record-form";
import { UrlForm } from "@/components/forms/url-form"; import { UrlForm } from "@/components/forms/url-form";
import ApiReference from "@/components/shared/api-reference"; import ApiReference from "@/components/shared/api-reference";
@@ -57,9 +62,9 @@ import { Icons } from "@/components/shared/icons";
import { LinkInfoPreviewer } from "@/components/shared/link-previewer"; import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
import { PaginationWrapper } from "@/components/shared/pagination"; import { PaginationWrapper } from "@/components/shared/pagination";
import QRCodeEditor from "@/components/shared/qr"; import QRCodeEditor from "@/components/shared/qr";
import { TimeAgoIntl } from "@/components/shared/time-ago";
import Globe from "./globe"; import { UrlExporter } from "./export";
import LiveLog from "./live-logs";
import UserUrlMetaInfo from "./meta"; import UserUrlMetaInfo from "./meta";
export interface UrlListProps { export interface UrlListProps {
@@ -101,6 +106,7 @@ function TableColumnSekleton() {
export default function UserUrlsList({ user, action }: UrlListProps) { export default function UserUrlsList({ user, action }: UrlListProps) {
const pathname = usePathname(); const pathname = usePathname();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const t = useTranslations("List");
const [currentView, setCurrentView] = useState<string>("List"); const [currentView, setCurrentView] = useState<string>("List");
const [isShowForm, setShowForm] = useState(false); const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add"); const [formType, setFormType] = useState<FormType>("add");
@@ -108,7 +114,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
null, null,
); );
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(15);
const [isShowStats, setShowStats] = useState(false); const [isShowStats, setShowStats] = useState(false);
const [isShowQrcode, setShowQrcode] = useState(false); const [isShowQrcode, setShowQrcode] = useState(false);
const [selectedUrl, setSelectedUrl] = useState<ShortUrlFormData | null>(null); const [selectedUrl, setSelectedUrl] = useState<ShortUrlFormData | null>(null);
@@ -122,6 +128,10 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
Record<string, number> Record<string, number>
>({}); >({});
const [searchType, setSearchType] = useState<"slug" | "target" | "userName">(
"slug",
);
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { data, isLoading } = useSWR<{ const { data, isLoading } = useSWR<{
total: number; total: number;
@@ -185,87 +195,104 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
const rendeEmpty = () => ( const rendeEmpty = () => (
<EmptyPlaceholder className="col-span-full shadow-none"> <EmptyPlaceholder className="col-span-full shadow-none">
<EmptyPlaceholder.Icon name="link" /> <EmptyPlaceholder.Icon name="link" />
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title> <EmptyPlaceholder.Title>{t("No urls")}</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description> <EmptyPlaceholder.Description>
You don&apos;t have any url yet. Start creating url. You don&apos;t have any url yet. Start creating url.
</EmptyPlaceholder.Description> </EmptyPlaceholder.Description>
</EmptyPlaceholder> </EmptyPlaceholder>
); );
const rendeSeachInputs = () => ( const renderSearchInputs = () => {
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0"> const getCurrentSearchValue = () => {
<div className="relative w-full"> switch (searchType) {
<Input case "slug":
className="h-8 text-xs md:text-xs" return searchParams.slug;
placeholder="Search by slug..." case "target":
value={searchParams.slug} return searchParams.target;
onChange={(e) => { case "userName":
setSearchParams({ return searchParams.userName;
...searchParams, default:
slug: e.target.value, return "";
}); }
}} };
/>
{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 className="relative w-full"> const handleSearchChange = (value: string) => {
<Input setSearchParams({
className="h-8 text-xs md:text-xs" ...searchParams,
placeholder="Search by target..." slug: searchType === "slug" ? value : "",
value={searchParams.target} target: searchType === "target" ? value : "",
onChange={(e) => { userName: searchType === "userName" ? value : "",
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>
{user.role === "ADMIN" && ( const handleClearSearch = () => {
<div className="relative w-full"> 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 <Input
className="h-8 text-xs md:text-xs" className="h-10 rounded-l-none border-l-0 pr-8 text-sm"
placeholder="Search by user name..." placeholder={getPlaceholder()}
value={searchParams.userName} value={currentSearchValue}
onChange={(e) => { onChange={(e) => handleSearchChange(e.target.value)}
setSearchParams({
...searchParams,
userName: e.target.value,
});
}}
/> />
{searchParams.userName && ( {currentSearchValue && (
<Button <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" 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: "" })} onClick={handleClearSearch}
variant={"ghost"} variant="ghost"
> >
<Icons.close className="size-3" /> <Icons.close className="size-3" />
</Button> </Button>
)} )}
</div> </div>
)} </div>
</div> );
); };
const rendeClicks = (short: ShortUrlFormData) => ( const rendeClicks = (short: ShortUrlFormData) => (
<> <>
@@ -299,28 +326,28 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground"> <TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11"> <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"> <TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Slug {t("Slug")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2"> <TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Target {t("Target")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center font-bold sm:flex">
User {t("User")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Enabled {t("Enabled")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Expiration {t("Expiration")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Clicks {t("Clicks")}
</TableHead> </TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex"> <TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Updated {t("Updated")}
</TableHead> </TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2"> <TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Actions {t("Actions")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -340,15 +367,17 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2"> <TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Link <Link
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400" 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}` : ""}`} href={`https://${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank" target="_blank"
prefetch={false} prefetch={false}
title={short.url} title={short.url}
> >
{short.url} <Badge variant="outline">
{short.prefix}/{short.url}
</Badge>
</Link> </Link>
<CopyButton <CopyButton
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`} value={`${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
className={cn( className={cn(
"size-[25px]", "size-[25px]",
"duration-250 transition-all group-hover:opacity-100", "duration-250 transition-all group-hover:opacity-100",
@@ -361,21 +390,22 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2"> <TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
<LinkInfoPreviewer <LinkInfoPreviewer
apiKey={user.apiKey ?? ""} apiKey={user.apiKey ?? ""}
url={short.target} url={addUrlPrefix(short.target)}
formatUrl={removeUrlSuffix(short.target)} formatUrl={removeUrlPrefix(short.target)}
/> />
</TableCell> </TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex"> <TableCell className="col-span-1 hidden truncate sm:flex">
<TooltipProvider> <ClickableTooltip
<Tooltip delayDuration={200}> className="cursor-pointer truncate"
<TooltipTrigger className="truncate"> content={
{short.userName ?? "Anonymous"} <div className="px-2 py-1">
</TooltipTrigger> <p>{short.user?.name}</p>
<TooltipContent> <p>{short.user?.email}</p>
{short.userName ?? "Anonymous"} </div>
</TooltipContent> }
</Tooltip> >
</TooltipProvider> {short.user?.name || short.user?.email}
</ClickableTooltip>
</TableCell> </TableCell>
<TableCell className="col-span-1 hidden sm:flex"> <TableCell className="col-span-1 hidden sm:flex">
<Switch <Switch
@@ -394,11 +424,11 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</div> </div>
</TableCell> </TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex"> <TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.updatedAt as Date)} <TimeAgoIntl date={short.updatedAt as Date} />
</TableCell> </TableCell>
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2"> <TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Button <Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground" className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
size="sm" size="sm"
variant={"outline"} variant={"outline"}
onClick={() => { onClick={() => {
@@ -408,7 +438,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
setShowForm(!isShowForm); setShowForm(!isShowForm);
}} }}
> >
<p className="hidden sm:block">Edit</p> <p className="hidden text-nowrap sm:block">{t("Edit")}</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" /> <PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button> </Button>
<Button <Button
@@ -490,15 +520,15 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<div className="flex items-center"> <div className="flex items-center">
<Link <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" 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}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`} href={`https://${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank" target="_blank"
prefetch={false} prefetch={false}
title={short.url} title={short.url}
> >
{short.url} {short.prefix}/{short.url}
</Link> </Link>
<CopyButton <CopyButton
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`} value={`https://${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
className={cn( className={cn(
"size-[25px]", "size-[25px]",
"duration-250 transition-all group-hover:opacity-100", "duration-250 transition-all group-hover:opacity-100",
@@ -526,7 +556,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<LinkInfoPreviewer <LinkInfoPreviewer
apiKey={user.apiKey ?? ""} apiKey={user.apiKey ?? ""}
url={short.target} url={short.target}
formatUrl={removeUrlSuffix(short.target)} formatUrl={removeUrlPrefix(short.target)}
/> />
</div> </div>
</div> </div>
@@ -561,7 +591,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
}} }}
> >
<Icons.lineChart className="size-4" /> <Icons.lineChart className="size-4" />
Analytics {t("Analytics")}
</Button> </Button>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -578,7 +608,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
}} }}
> >
<PenLine className="size-4" /> <PenLine className="size-4" />
Edit URL {t("Edit URL")}
</Button> </Button>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -586,16 +616,17 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</div> </div>
<div className="mt-auto flex items-center justify-end gap-1.5 text-xs text-muted-foreground"> <div className="mt-auto flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
<TooltipProvider> <ClickableTooltip
<Tooltip delayDuration={200}> className="cursor-pointer truncate"
<TooltipTrigger className="truncate"> content={
{short.userName ?? "Anonymous"} <div className="px-2 py-1">
</TooltipTrigger> <p>{short.user?.name}</p>
<TooltipContent> <p>{short.user?.email}</p>
{short.userName ?? "Anonymous"} </div>
</TooltipContent> }
</Tooltip> >
</TooltipProvider> {short.user?.name || short.user?.email}
</ClickableTooltip>
<Separator <Separator
className="h-4/5" className="h-4/5"
orientation="vertical" orientation="vertical"
@@ -612,7 +643,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
></Separator> ></Separator>
</> </>
)} )}
{timeAgo(short.updatedAt as Date)} <TimeAgoIntl date={short.updatedAt as Date} />
<Switch <Switch
className="scale-[0.6]" className="scale-[0.6]"
defaultChecked={short.active === 1} defaultChecked={short.active === 1}
@@ -642,27 +673,19 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</> </>
); );
const rendLogs = () => (
<div className="mt-6 space-y-3">
{action.indexOf("admin") > -1 ? <LiveLog admin={true} /> : <LiveLog />}
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</div>
);
return ( return (
<> <>
<Tabs <Tabs
className={cn("rounded-lg", pathname === "/dashboard" && "border p-6")} className={cn(
"space-y-3 rounded-lg",
pathname === "/dashboard" && "border p-6",
)}
value={currentView} value={currentView}
> >
{/* Tabs */} {/* Tabs */}
<div className="mb-4 flex items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
{pathname === "/dashboard" && ( {pathname === "/dashboard" && (
<h2 className="mr-3 text-lg font-semibold">Short URLs</h2> <h2 className="mr-auto text-lg font-semibold">{t("Short URLs")}</h2>
)} )}
<TabsList> <TabsList>
<TabsTrigger onClick={() => setCurrentView("List")} value="List"> <TabsTrigger onClick={() => setCurrentView("List")} value="List">
@@ -673,13 +696,6 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<Icons.layoutGrid className="size-4" /> <Icons.layoutGrid className="size-4" />
{/* Grid */} {/* Grid */}
</TabsTrigger> </TabsTrigger>
<TabsTrigger
onClick={() => setCurrentView("Realtime")}
value="Realtime"
>
<Icons.globe className="size-4 text-blue-500" />
{/* Realtime */}
</TabsTrigger>
{selectedUrl?.id && ( {selectedUrl?.id && (
<TabsTrigger <TabsTrigger
className="flex items-center gap-1 text-muted-foreground" className="flex items-center gap-1 text-muted-foreground"
@@ -691,47 +707,45 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</TabsTrigger> </TabsTrigger>
)} )}
</TabsList> </TabsList>
{/* <p>Total: {data?.total || 0}</p> */} <div className="flex items-center justify-end gap-3">
<div className="ml-auto flex items-center justify-end gap-3"> {renderSearchInputs()}
<UrlExporter data={data?.list || []} />
<Button <Button
variant={"outline"} variant={"outline"}
onClick={() => handleRefresh()} onClick={() => handleRefresh()}
disabled={isLoading} disabled={isLoading}
> >
{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>
<Button {action.indexOf("admin") === -1 && (
className="flex shrink-0 gap-1" <Button
variant="default" className="flex shrink-0 gap-1"
onClick={() => { variant="default"
setCurrentEditUrl(null); onClick={() => {
setShowForm(false); setCurrentEditUrl(null);
setFormType("add"); setShowForm(false);
setShowForm(!isShowForm); setFormType("add");
}} setShowForm(!isShowForm);
> }}
<Icons.add className="size-4" /> >
<span className="hidden sm:inline">Add URL</span> <Icons.add className="size-4" />
</Button> <span className="hidden sm:inline">{t("Add URL")}</span>
</Button>
)}
</div> </div>
</div> </div>
<TabsContent className="space-y-3" value="List"> {pathname !== "/dashboard" && <UrlStatus action={action} />}
{rendeSeachInputs()}
<TabsContent className="mt-0 space-y-3" value="List">
{rendeList()} {rendeList()}
{rendLogs()}
</TabsContent> </TabsContent>
<TabsContent className="space-y-3" value="Grid"> <TabsContent className="mt-0 space-y-3" value="Grid">
{rendeSeachInputs()}
{rendeGrid()} {rendeGrid()}
{rendLogs()}
</TabsContent>
<TabsContent value="Realtime">
{action.indexOf("admin") > -1 ? <Globe isAdmin={true} /> : <Globe />}
</TabsContent> </TabsContent>
{selectedUrl?.id && ( {selectedUrl?.id && (
<TabsContent value={selectedUrl.id}> <TabsContent value={selectedUrl.id}>
@@ -749,7 +763,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
{selectedUrl && ( {selectedUrl && (
<QRCodeEditor <QRCodeEditor
user={{ id: user.id, apiKey: user.apiKey || "", team: user.team! }} user={{ id: user.id, apiKey: user.apiKey || "", team: user.team! }}
url={`https://${selectedUrl.prefix}/s/${selectedUrl.url}`} url={`https://${selectedUrl.prefix}/${selectedUrl.url}`}
/> />
)} )}
</Modal> </Modal>

View File

@@ -8,6 +8,7 @@ import {
MobileSheetSidebar, MobileSheetSidebar,
} from "@/components/layout/dashboard-sidebar"; } from "@/components/layout/dashboard-sidebar";
import { ModeToggle } from "@/components/layout/mode-toggle"; import { ModeToggle } from "@/components/layout/mode-toggle";
import { Notification } from "@/components/layout/notification";
import { UserAccountNav } from "@/components/layout/user-account-nav"; import { UserAccountNav } from "@/components/layout/user-account-nav";
import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; import MaxWidthWrapper from "@/components/shared/max-width-wrapper";
@@ -32,6 +33,7 @@ export default async function Dashboard({ children }: ProtectedLayoutProps) {
<DashboardSidebar links={filteredLinks} /> <DashboardSidebar links={filteredLinks} />
<div className="flex flex-1 flex-col"> <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"> <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"> <MaxWidthWrapper className="flex max-w-7xl items-center gap-x-3 px-0">
<MobileSheetSidebar links={filteredLinks} /> <MobileSheetSidebar links={filteredLinks} />

View File

@@ -1,19 +1,18 @@
"use client"; "use client";
import { useEffect, useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import { siteConfig } from "@/config/site"; import { cn, removeUrlPrefix } from "@/lib/utils";
import { cn, removeUrlSuffix } from "@/lib/utils";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { FormSectionColumns } from "@/components/dashboard/form-section-columns"; import { FormSectionColumns } from "@/components/dashboard/form-section-columns";
import { Icons } from "@/components/shared/icons"; import { Icons } from "@/components/shared/icons";
@@ -27,26 +26,22 @@ export default function StepGuide({
const [direction, setDirection] = useState(0); const [direction, setDirection] = useState(0);
const [completedSteps, setCompletedSteps] = useState<number[]>([]); const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const t = useTranslations("Common");
const steps = [ const steps = [
{ {
id: 1, id: 1,
title: "Set up an administrator", title: t("Set up an administrator"),
description:
"Begin by entering your website URL or selecting an example site to reimagine your website with modern themes.",
component: () => <SetAdminRole id={user.id} email={user.email} />, component: () => <SetAdminRole id={user.id} email={user.email} />,
}, },
{ {
id: 2, id: 2,
title: "Add the first domain", title: t("Add the first domain"),
description:
"Check out your reimagined site and click to Migrate & Download.",
component: () => <AddDomain onNextStep={goToNextStep} />, component: () => <AddDomain onNextStep={goToNextStep} />,
}, },
{ {
id: 3, id: 3,
title: "Congrats on completing setup 🎉", title: t("Congrats on completing setup 🎉"),
description:
"Navigate to your GitHub dashboard where you'll manage your repository and project files.",
component: () => <Congrats />, component: () => <Congrats />,
}, },
]; ];
@@ -92,7 +87,7 @@ export default function StepGuide({
<Modal className="md:max-w-2xl"> <Modal className="md:max-w-2xl">
<div className="w-full px-4 py-2 md:px-8 md:py-4"> <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"> <div className="mb-6 mt-3 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold">Admin Setup Guide</h2> <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"> <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"> <span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
{currentStep} {currentStep}
@@ -161,7 +156,7 @@ export default function StepGuide({
)} )}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
Previous {t("Previous")}
</button> </button>
<button <button
@@ -171,7 +166,7 @@ export default function StepGuide({
"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90", "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 ? "🚀 Start" : "Next"} {currentStep === steps.length ? t("🚀 Start") : t("Next")}
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</button> </button>
</motion.div> </motion.div>
@@ -182,6 +177,7 @@ export default function StepGuide({
function SetAdminRole({ id, email }: { id: string; email: string }) { function SetAdminRole({ id, email }: { id: string; email: string }) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const t = useTranslations("Common");
const handleSetAdmin = async () => { const handleSetAdmin = async () => {
startTransition(async () => { startTransition(async () => {
const res = await fetch("/api/setup"); const res = await fetch("/api/setup");
@@ -194,7 +190,7 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
const ReadyBadge = ( const ReadyBadge = (
<Badge className="text-xs font-semibold" variant="green"> <Badge className="text-xs font-semibold" variant="green">
<Icons.check className="mr-1 size-3" /> <Icons.check className="mr-1 size-3" />
Ready {t("Ready")}
</Badge> </Badge>
); );
@@ -202,14 +198,14 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900"> <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"> <div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground"> <span className="text-sm font-semibold text-muted-foreground">
Allow Sign Up: {t("Allow Sign Up")}:
</span> </span>
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />} {ReadyBadge}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground"> <span className="text-sm font-semibold text-muted-foreground">
Set {email} as ADMIN: {t("Set {email} as ADMIN", { email })}:
</span> </span>
{isAdmin ? ( {isAdmin ? (
ReadyBadge ReadyBadge
@@ -223,30 +219,39 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
{isPending && ( {isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" /> <Icons.spinner className="mr-2 size-4 animate-spin" />
)} )}
Active Now {t("Active Now")}
</Button> </Button>
)} )}
</div> </div>
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground"> <div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
<p className="flex items-start gap-1"> <p className="flex items-start gap-1">
📢 Only by becoming an administrator can one access the admin panel {t("After v1-0-2, this setup guide is not needed anymore")}.
and add domain names. </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>
<p className="my-1"> <p className="my-1">
📢 Administrators can set all user permissions, allocate quotas, view {" "}
and edit all resources (short links, subdomains, email), etc. {t(
"Administrators can set all user permissions, allocate quotas, view and edit all resources (short links, subdomains, email), etc",
)}
.
</p> </p>
<p> <p>
📢 Via{" "} {t("Via")}{" "}
<a <a
className="text-blue-500" className="text-blue-500 after:content-['_↗']"
target="_blank" target="_blank"
href="/docs/developer/quick-start" href="/docs/developer/quick-start"
> >
quick start {t("quick start")}
</a>{" "} </a>{" "}
docs to get more information. {t("docs to get more information")}.
</p> </p>
</div> </div>
</div> </div>
@@ -256,6 +261,7 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
function AddDomain({ onNextStep }: { onNextStep: () => void }) { function AddDomain({ onNextStep }: { onNextStep: () => void }) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [domain, setDomain] = useState(""); const [domain, setDomain] = useState("");
const t = useTranslations("Common");
const handleCreateDomain = async () => { const handleCreateDomain = async () => {
if (!domain) { if (!domain) {
toast.warning("Domain name cannot be empty"); toast.warning("Domain name cannot be empty");
@@ -266,7 +272,7 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
data: { data: {
domain_name: removeUrlSuffix(domain), domain_name: removeUrlPrefix(domain),
enable_short_link: true, enable_short_link: true,
enable_email: true, enable_email: true,
enable_dns: true, enable_dns: true,
@@ -292,10 +298,10 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
}; };
return ( return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900"> <div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<FormSectionColumns title="Domain Name"> <FormSectionColumns title={t("Domain Name")}>
<div className="flex w-full flex-col items-start justify-between gap-2"> <div className="flex w-full flex-col items-start justify-between gap-2">
<Label className="sr-only" htmlFor="domain_name"> <Label className="sr-only" htmlFor="domain_name">
Domain Name {t("Domain Name")}
</Label> </Label>
<div className="w-full"> <div className="w-full">
<Input <Input
@@ -307,7 +313,10 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Please enter a valid domain name (must be hosted on Cloudflare). {t(
"Please enter a valid domain name (must be hosted on Cloudflare)",
)}
.
</p> </p>
</div> </div>
@@ -318,7 +327,7 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
size={"sm"} size={"sm"}
onClick={onNextStep} onClick={onNextStep}
> >
Or add later {t("Or add later")}
</Button> </Button>
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
@@ -330,7 +339,7 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
{isPending && ( {isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" /> <Icons.spinner className="mr-2 size-4 animate-spin" />
)} )}
Submit {t("Submit")}
</Button> </Button>
</div> </div>
</FormSectionColumns> </FormSectionColumns>

View File

@@ -4,10 +4,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardLoading() { export default function DashboardLoading() {
return ( return (
<> <>
<DashboardHeader <DashboardHeader heading="Setup Guide" text="" />
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
/>
<Skeleton className="h-32 w-full rounded-lg" /> <Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" /> <Skeleton className="h-[400px] w-full rounded-lg" />
</> </>

View 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 });
}
}

View 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 });
}
}

View File

@@ -58,11 +58,17 @@ export async function POST(req: NextRequest) {
cf_zone_id: data.cf_zone_id, cf_zone_id: data.cf_zone_id,
cf_api_key: data.cf_api_key, cf_api_key: data.cf_api_key,
cf_email: data.cf_email, cf_email: data.cf_email,
cf_record_types: data.cf_record_types,
cf_api_key_encrypted: false, cf_api_key_encrypted: false,
email_provider: data.email_provider,
resend_api_key: data.resend_api_key, resend_api_key: data.resend_api_key,
brevo_api_key: data.brevo_api_key,
max_short_links: data.max_short_links, max_short_links: data.max_short_links,
max_email_forwards: data.max_email_forwards, max_email_forwards: data.max_email_forwards,
max_dns_records: data.max_dns_records, 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, active: true,
}); });
@@ -90,7 +96,13 @@ export async function PUT(req: NextRequest) {
cf_zone_id, cf_zone_id,
cf_api_key, cf_api_key,
cf_email, cf_email,
cf_record_types,
email_provider,
resend_api_key, resend_api_key,
brevo_api_key,
min_url_length,
min_email_length,
min_record_length,
max_short_links, max_short_links,
max_email_forwards, max_email_forwards,
max_dns_records, max_dns_records,
@@ -110,8 +122,14 @@ export async function PUT(req: NextRequest) {
cf_zone_id, cf_zone_id,
cf_api_key, cf_api_key,
cf_email, cf_email,
cf_record_types,
cf_api_key_encrypted: false, cf_api_key_encrypted: false,
email_provider,
brevo_api_key,
resend_api_key, resend_api_key,
min_url_length,
min_email_length,
min_record_length,
max_short_links, max_short_links,
max_email_forwards, max_email_forwards,
max_dns_records, max_dns_records,

144
app/api/admin/plan/route.ts Normal file
View 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
View 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 });
}
}

View 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
View 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 });
}
}

View File

@@ -26,8 +26,10 @@ export async function GET(req: NextRequest) {
}); });
if (error) { if (error) {
console.error(error); console.log("Resend error:", error);
return Response.json(400, { status: 400 }); return Response.json(`${error.message}`, {
status: 400,
});
} }
return Response.json(200, { status: 200 }); return Response.json(200, { status: 200 });

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getEmailsByEmailAddress } from "@/lib/dto/email"; import { deleteEmailsByIds, getEmailsByEmailAddress } from "@/lib/dto/email";
import { checkUserStatus } from "@/lib/dto/user"; import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
@@ -35,3 +35,22 @@ export async function GET(req: NextRequest) {
); );
} }
} }
export async function DELETE(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { ids } = await req.json();
if (!ids) {
return Response.json("ids is required", { status: 400 });
}
await deleteEmailsByIds(ids);
return Response.json("success", { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { TeamPlanQuota } from "@/config/team";
import { createUserEmail, getAllUserEmails } from "@/lib/dto/email"; import { createUserEmail, getAllUserEmails } from "@/lib/dto/email";
import { getPlanQuota } from "@/lib/dto/plan";
import { checkUserStatus } from "@/lib/dto/user"; import { checkUserStatus } from "@/lib/dto/user";
import { reservedAddressSuffix } from "@/lib/enums"; import { reservedAddressSuffix } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
@@ -43,11 +43,13 @@ export async function POST(req: NextRequest) {
const user = checkUserStatus(await getCurrentUser()); const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user; if (user instanceof Response) return user;
const plan = await getPlanQuota(user.team);
// check limit // check limit
const limit = await restrictByTimeRange({ const limit = await restrictByTimeRange({
model: "userEmail", model: "userEmail",
userId: user.id, userId: user.id,
limit: TeamPlanQuota[user.team].EM_EmailAddresses, limit: plan.emEmailAddresses,
rangeType: "month", rangeType: "month",
}); });
if (limit) if (limit)
@@ -60,12 +62,6 @@ export async function POST(req: NextRequest) {
} }
const prefix = emailAddress.split("@")[0]; const prefix = emailAddress.split("@")[0];
if (!prefix || prefix.length < 5) {
return NextResponse.json("Email address length must be at least 5", {
status: 400,
});
}
if (reservedAddressSuffix.includes(prefix)) { if (reservedAddressSuffix.includes(prefix)) {
return NextResponse.json("Invalid email address", { status: 400 }); return NextResponse.json("Invalid email address", { status: 400 });
} }

View File

@@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { Resend } from "resend";
import { TeamPlanQuota } from "@/config/team"; import { checkDomainIsConfiguratedEmailProvider } from "@/lib/dto/domains";
import { checkDomainIsConfiguratedResend } from "@/lib/dto/domains";
import { getUserSendEmailCount, saveUserSendEmail } from "@/lib/dto/email"; import { getUserSendEmailCount, saveUserSendEmail } from "@/lib/dto/email";
import { getPlanQuota } from "@/lib/dto/plan";
import { checkUserStatus } from "@/lib/dto/user"; import { checkUserStatus } from "@/lib/dto/user";
import { resend } from "@/lib/email"; import { brevoSendEmail } from "@/lib/email/brevo";
import { getCurrentUser } from "@/lib/session"; import { getCurrentUser } from "@/lib/session";
import { restrictByTimeRange } from "@/lib/team"; import { restrictByTimeRange } from "@/lib/team";
import { isValidEmail } from "@/lib/utils"; import { isValidEmail } from "@/lib/utils";
@@ -14,11 +15,13 @@ export async function POST(req: NextRequest) {
const user = checkUserStatus(await getCurrentUser()); const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user; if (user instanceof Response) return user;
const plan = await getPlanQuota(user.team);
// check limit // check limit
const limit = await restrictByTimeRange({ const limit = await restrictByTimeRange({
model: "userSendEmail", model: "userSendEmail",
userId: user.id, userId: user.id,
limit: TeamPlanQuota[user.team].EM_SendEmails, limit: plan.emSendEmails,
rangeType: "month", rangeType: "month",
}); });
if (limit) if (limit)
@@ -34,23 +37,32 @@ export async function POST(req: NextRequest) {
return NextResponse.json("Invalid email address", { status: 403 }); return NextResponse.json("Invalid email address", { status: 403 });
} }
if (!(await checkDomainIsConfiguratedResend(from.split("@")[1]))) { const { email_key, provider } =
await checkDomainIsConfiguratedEmailProvider(from.split("@")[1]);
if (!email_key) {
return NextResponse.json( return NextResponse.json(
"This domain is not configured for sending emails", "This domain is not configured for sending emails",
{ status: 400 }, { status: 400 },
); );
} }
const { error } = await resend.emails.send({ switch (provider) {
from, case "Resend":
to, const resend = new Resend(email_key);
subject, await resend.emails.send({ from, to, subject, html });
html, break;
}); case "Brevo":
await brevoSendEmail({
if (error) { from,
console.log("Resend error:", error); fromName: from.split("@")[0],
return NextResponse.json("Failed to send email", { status: 500 }); to,
subject,
html,
});
break;
default:
break;
} }
await saveUserSendEmail(user.id, from, to, subject, html); await saveUserSendEmail(user.id, from, to, subject, html);

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