Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e0df57687 | ||
|
|
1acf785219 | ||
|
|
6406579f03 | ||
|
|
bbe3dc4956 | ||
|
|
d0ba3a1686 | ||
|
|
c102955cd5 | ||
|
|
51e3403b1d | ||
|
|
a4a7d500a2 | ||
|
|
75dc7f478c | ||
|
|
9ea94d53bd | ||
|
|
9184a1740e | ||
|
|
01b9bbfd16 | ||
|
|
11e57a2374 | ||
|
|
192b6c0077 | ||
|
|
16ed4932a0 | ||
|
|
8aa4602390 | ||
|
|
c1743c2840 | ||
|
|
dcb8479017 | ||
|
|
cdee24b5c9 | ||
|
|
0721a06c49 | ||
|
|
09a9ebbe22 | ||
|
|
acc3b33d77 | ||
|
|
cd767f1aa0 | ||
|
|
fc9deeb715 | ||
|
|
4b770ad07c | ||
|
|
8e13fec132 | ||
|
|
a9156f505c | ||
|
|
b342c8c373 | ||
|
|
367da79ed9 | ||
|
|
ae97fe895e | ||
|
|
fd3567d48e | ||
|
|
76558d7703 | ||
|
|
13171986af | ||
|
|
8e57a4cbb9 | ||
|
|
0d177f7c33 | ||
|
|
a2dc432c97 | ||
|
|
4573373544 | ||
|
|
858b02fa0c | ||
|
|
2f8baab19a | ||
|
|
75fe4ab9eb | ||
|
|
f4a3386c7d | ||
|
|
7c77234026 | ||
|
|
ad11f8e155 | ||
|
|
a7a7d2a878 | ||
|
|
d038169846 | ||
|
|
2f40312ff1 | ||
|
|
6c784d255d | ||
|
|
8f44b8ae11 | ||
|
|
6ede43b9fb | ||
|
|
eeb45f67b2 | ||
|
|
45542289f5 | ||
|
|
0d4d0fdb0e | ||
|
|
cbdccaa60e | ||
|
|
87009bfd05 | ||
|
|
94a79dd26b | ||
|
|
5ef68eec63 | ||
|
|
e01886e505 | ||
|
|
67672624e3 | ||
|
|
d937858ce3 | ||
|
|
468a6f5645 | ||
|
|
1640f263b1 | ||
|
|
cc4515cc6d | ||
|
|
6a79ab40b1 | ||
|
|
a11373a90b | ||
|
|
26020cf572 | ||
|
|
b7221d31c1 | ||
|
|
5ffa4bda8f | ||
|
|
7dcf93f759 | ||
|
|
e63e9b9922 | ||
|
|
8d9bccfae8 | ||
|
|
e55e6378e1 | ||
|
|
bbf15a0551 | ||
|
|
6c0be291e0 | ||
|
|
351dd9b559 | ||
|
|
8e4530236c | ||
|
|
c51a0443a8 | ||
|
|
657cd32aaa | ||
|
|
7d629e9cd4 | ||
|
|
ca35d96925 | ||
|
|
dc60a00103 | ||
|
|
d1d1044bbc | ||
|
|
f68dda63af | ||
|
|
ab8e0619c4 | ||
|
|
99ad3f6f8e | ||
|
|
217166d3aa | ||
|
|
cdee1a227a | ||
|
|
e6582f26c8 | ||
|
|
80a873d56a | ||
|
|
9dc3d3d697 | ||
|
|
0d1ea92965 | ||
|
|
bba4c6bae7 | ||
|
|
61ff34464f | ||
|
|
f4c9bad648 | ||
|
|
80796cdcca | ||
|
|
970dc5bbe8 | ||
|
|
6cade53ec5 | ||
|
|
34981f821d | ||
|
|
22f1686ff7 | ||
|
|
5d34f3707a | ||
|
|
d8ec5683d1 | ||
|
|
06a70b6680 | ||
|
|
938fcd4422 | ||
|
|
d86467674e | ||
|
|
a21ce6e8d6 | ||
|
|
0a4507bbd0 | ||
|
|
2f27c330a1 | ||
|
|
cff4579ff1 | ||
|
|
f2de129ba8 | ||
|
|
2a9a242f50 | ||
|
|
4e74053017 | ||
|
|
bb1fcd8c37 | ||
|
|
fb694cc749 | ||
|
|
778b18dd35 | ||
|
|
c80de8800f | ||
|
|
54c0ba67c8 | ||
|
|
2f19553dec | ||
|
|
d7c213c110 | ||
|
|
b832de9194 | ||
|
|
a781e84537 | ||
|
|
17881b5b0e | ||
|
|
40ece4e764 | ||
|
|
a983da20e5 | ||
|
|
43555a9985 | ||
|
|
adb0ce31c0 | ||
|
|
c619931386 | ||
|
|
026dfb2ffe | ||
|
|
5396a4e628 | ||
|
|
3e801fe85a | ||
|
|
ec3372a3c0 | ||
|
|
157c07c747 | ||
|
|
6505d1876a | ||
|
|
2a3ff9db9b | ||
|
|
434e326991 | ||
|
|
16b66f83da | ||
|
|
7b1c21e972 |
26
.env.example
26
.env.example
@@ -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,17 +23,10 @@ LinuxDo_CLIENT_ID=
|
|||||||
LinuxDo_CLIENT_SECRET=
|
LinuxDo_CLIENT_SECRET=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Database, example: DATABASE_URL='postgres://[user]:[password]@[hostname]:5432/[dbname]'
|
# Email api (https://resend.com) for Auth login (NextAuth) and send email
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
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>"
|
||||||
# Open Signup
|
|
||||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
|
||||||
|
|
||||||
# Google Analytics
|
# Google Analytics
|
||||||
NEXT_PUBLIC_GOOGLE_ID=
|
NEXT_PUBLIC_GOOGLE_ID=
|
||||||
@@ -38,6 +37,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
|
||||||
81
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
81
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: 报告软件缺陷或问题
|
||||||
|
title: '[BUG] '
|
||||||
|
labels: 'bug, needs-triage'
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题描述 / Bug Description
|
||||||
|
<!-- 请简洁明了地描述遇到的问题 -->
|
||||||
|
|
||||||
|
## 复现步骤 / Steps to Reproduce
|
||||||
|
<!-- 请详细描述如何重现这个问题 -->
|
||||||
|
1. 前往 '...'
|
||||||
|
2. 点击 '...'
|
||||||
|
3. 滚动到 '...'
|
||||||
|
4. 看到错误 '...'
|
||||||
|
|
||||||
|
## 期望行为 / Expected Behavior
|
||||||
|
<!-- 描述你期望发生什么 -->
|
||||||
|
|
||||||
|
## 实际行为 / Actual Behavior
|
||||||
|
<!-- 描述实际发生了什么 -->
|
||||||
|
|
||||||
|
## 截图/视频 / Screenshots/Videos
|
||||||
|
<!-- 如果适用,请添加截图或视频来帮助解释问题 -->
|
||||||
|
|
||||||
|
## 环境信息 / Environment
|
||||||
|
**桌面环境 / Desktop:**
|
||||||
|
- 操作系统 / OS: [例如 Windows 11, macOS 14.0, Ubuntu 22.04]
|
||||||
|
- 浏览器 / Browser: [例如 Chrome 120, Firefox 119, Safari 17]
|
||||||
|
- 版本 / Version: [例如 v1.2.3]
|
||||||
|
|
||||||
|
**移动设备 / Mobile:**
|
||||||
|
- 设备 / Device: [例如 iPhone 15, Samsung Galaxy S24]
|
||||||
|
- 操作系统 / OS: [例如 iOS 17.1, Android 14]
|
||||||
|
- 浏览器 / Browser: [例如 Safari, Chrome Mobile]
|
||||||
|
- 版本 / Version: [例如 v1.2.3]
|
||||||
|
|
||||||
|
## 控制台日志 / Console Logs
|
||||||
|
<!-- 如果有相关的错误日志,请贴在这里 -->
|
||||||
|
```
|
||||||
|
请粘贴相关的错误信息或日志
|
||||||
|
```
|
||||||
|
|
||||||
|
## 网络信息 / Network Info
|
||||||
|
<!-- 如果问题可能与网络相关 -->
|
||||||
|
- 网络类型 / Network Type: [例如 WiFi, 4G, 5G, 有线]
|
||||||
|
- 网络状况 / Network Status: [例如 正常, 缓慢, 不稳定]
|
||||||
|
|
||||||
|
## 频率 / Frequency
|
||||||
|
请选择问题发生的频率:
|
||||||
|
- [ ] 总是发生 / Always
|
||||||
|
- [ ] 经常发生 / Often
|
||||||
|
- [ ] 偶尔发生 / Sometimes
|
||||||
|
- [ ] 很少发生 / Rarely
|
||||||
|
- [ ] 只发生过一次 / Once
|
||||||
|
|
||||||
|
## 严重程度 / Severity
|
||||||
|
请选择问题的严重程度:
|
||||||
|
- [ ] 严重 / Critical - 系统崩溃或数据丢失
|
||||||
|
- [ ] 高 / High - 核心功能无法使用
|
||||||
|
- [ ] 中 / Medium - 部分功能受影响
|
||||||
|
- [ ] 低 / Low - 界面问题或小的功能缺陷
|
||||||
|
|
||||||
|
## 变通方法 / Workaround
|
||||||
|
<!-- 如果你找到了临时解决方案,请描述 -->
|
||||||
|
|
||||||
|
## 附加信息 / Additional Context
|
||||||
|
<!-- 添加任何其他有助于理解问题的信息 -->
|
||||||
|
|
||||||
|
## 相关链接 / Related Links
|
||||||
|
<!-- 如果有相关的Issue、PR或讨论,请链接 -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**提交前检查清单 / Pre-submission Checklist:**
|
||||||
|
- [ ] 我已经搜索了现有的issues,确认这不是重复报告
|
||||||
|
- [ ] 我已经尝试了最新版本,问题依然存在
|
||||||
|
- [ ] 我已经提供了足够的信息来重现问题
|
||||||
|
- [ ] 我已经检查了文档和FAQ
|
||||||
45
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
45
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: New Features
|
||||||
|
about: 提交新功能需求
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: 'enhancement, feature-request'
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能描述 / Feature Description
|
||||||
|
<!-- 请简洁明了地描述你希望添加的新功能 -->
|
||||||
|
|
||||||
|
## 使用场景 / Use Case
|
||||||
|
<!-- 描述在什么情况下需要这个功能,解决什么问题 -->
|
||||||
|
|
||||||
|
## 期望行为 / Expected Behavior
|
||||||
|
<!-- 详细描述你期望这个功能如何工作 -->
|
||||||
|
|
||||||
|
## 替代方案 / Alternatives Considered
|
||||||
|
<!-- 你是否考虑过其他解决方案?如果有,请描述 -->
|
||||||
|
|
||||||
|
## 附加信息 / Additional Context
|
||||||
|
<!-- 提供任何其他有助于理解这个功能需求的信息 -->
|
||||||
|
|
||||||
|
## 优先级 / Priority
|
||||||
|
请选择一个优先级:
|
||||||
|
- [ ] 低 (Low) - 有了更好,没有也行
|
||||||
|
- [ ] 中 (Medium) - 比较重要,希望能实现
|
||||||
|
- [ ] 高 (High) - 非常重要,急需这个功能
|
||||||
|
|
||||||
|
## 愿意贡献 / Willing to Contribute
|
||||||
|
- [ ] 我愿意提交PR来实现这个功能
|
||||||
|
- [ ] 我可以帮助测试这个功能
|
||||||
|
- [ ] 我只是提出建议
|
||||||
|
|
||||||
|
## 环境信息 / Environment
|
||||||
|
- 操作系统 / OS:
|
||||||
|
- 浏览器 / Browser:
|
||||||
|
- 版本 / Version:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**注意事项 / Notes:**
|
||||||
|
- 提交前请先搜索现有的issue,避免重复提交
|
||||||
|
- 请尽量提供详细的信息,这有助于我们更好地理解和实现你的需求
|
||||||
|
- 如果可能,请提供mockup、截图或参考链接
|
||||||
8
.github/workflows/docker-build-push.yml
vendored
8
.github/workflows/docker-build-push.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- "v*.*.*"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
type=sha,format=short
|
type=sha,format=short
|
||||||
type=ref,event=branch,prefix=
|
type=ref,event=branch,prefix=
|
||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
|
|
||||||
# 构建并推送 Docker 镜像
|
# 构建并推送 Docker 镜像
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
@@ -55,10 +55,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true # ${{ github.event_name != 'pull_request' }}
|
push: true # ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
ENVIRONMENT=${{ github.event.inputs.environment || 'production' }}
|
ENVIRONMENT=${{ github.event.inputs.environment || 'production' }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
116
README-zh.md
116
README-zh.md
@@ -1,18 +1,66 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<h1>WR.DO</h1>
|
<h1>WR.DO</h1>
|
||||||
<p><a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
|
<p>
|
||||||
<p>生成短链接, 创建 DNS 记录, 管理临时邮箱</p>
|
<a href="https://wr.do/docs/developer">开发文档</a> · <a href="https://wr.do/feedback">Feedback</a> · <a href="/README.md">English</a> | 简体中文
|
||||||
<!-- <img src="https://wr.do/_static/images/light-preview.png"/> -->
|
</p>
|
||||||
|
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/oiov/wr.do?style=flat-square">
|
||||||
|
<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"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 功能
|
## 简介
|
||||||
|
|
||||||
- 🔗 **短链生成**:生成附有访问者统计信息的短链接 (支持密码保护, 支持调用 API)
|
WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱、子域名管理和开放API接口。支持自定义链接、密码保护、访问统计;提供无限制临时邮箱收发;管理多域名DNS记录;内置网站截图、元数据提取等实用API。完整的管理后台,支持用户权限控制和服务配置。
|
||||||
- 📮 **临时邮箱**:创建多个临时邮箱接收和发送邮件(支持调用 API)
|
|
||||||
- 🌐 **多租户支持**:无缝管理多个 DNS 记录
|
- 官网: [https://wr.do](https://wr.do)
|
||||||
- 📸 **截图 API**:访问截图 API、网站元数据抓取 API
|
- Demo: [https://699399.xyz](https://699399.xyz) (账号: `admin@admin.com`, 密码: `123456`)
|
||||||
- <20>😀 **权限管理**:方便审核的管理员面板
|
|
||||||
- 🔒 **安全可靠**:基于 Cloudflare 强大的 DNS API
|
## 功能列表
|
||||||
|
|
||||||
|
- 🔗 **短链服务**:
|
||||||
|
- 支持自定义短链
|
||||||
|
- 支持生成自定义二维码
|
||||||
|
- 支持密码保护链接
|
||||||
|
- 支持设置过期时间
|
||||||
|
- 支持访问统计(实时日志、地图等多维度数据分析)
|
||||||
|
- 支持调用 API 创建短链
|
||||||
|
|
||||||
|
- 📮 **临时邮箱服务**:
|
||||||
|
- 支持创建自定义前缀邮箱
|
||||||
|
- 支持过滤未读邮件列表
|
||||||
|
- 可创建无限数量邮箱
|
||||||
|
- 支持接收无限制邮件 (依赖 Cloudflare Email Worker)
|
||||||
|
- 支持发送邮件(依赖 Resend)
|
||||||
|
- 支持 Catch-All 配置
|
||||||
|
- 支持 Telegram 推送(多频道/群组)
|
||||||
|
- 支持调用 API 创建邮箱
|
||||||
|
- 支持调用 API 获取收件箱邮件
|
||||||
|
-
|
||||||
|
- 🌐 **子域名管理服务**:
|
||||||
|
- 支持管理多 Cloudflare 账户下的多个域名的 DNS 记录
|
||||||
|
- 支持创建多种 DNS 记录类型(CNAME、A、TXT 等)
|
||||||
|
- 支持开启申请模式(用户提交、管理员审批)
|
||||||
|
- 支持邮件通知管理员、用户域名申请状态
|
||||||
|
|
||||||
|
- 📡 **开放接口模块**:
|
||||||
|
- 获取网站元数据 API
|
||||||
|
- 获取网站截图 API
|
||||||
|
- 生成网站二维码 API
|
||||||
|
- 将网站转换为 Markdown、Text
|
||||||
|
- 支持所有类型 API 调用统计日志
|
||||||
|
- 支持生成用户 API Key,用于第三方调用开放接口
|
||||||
|
|
||||||
|
- 🔒 **管理员模块**:
|
||||||
|
- 多维度图表展示网站状态
|
||||||
|
- 域名服务配置(动态配置各项服务是否启用,包括短链、临时邮箱(收发邮件)、子域名管理)
|
||||||
|
- 用户列表管理(设置权限、分配使用额度、禁用用户等)
|
||||||
|
- 动态配置登录方式 (支持 Google, GitHub, 邮箱验证, 账户密码, LinuxDO)
|
||||||
|
- 短链管理(管理所有用户创建的短链)
|
||||||
|
- 邮箱管理(管理所有用户创建的临时邮箱)
|
||||||
|
- 子域名管理(管理所有用户创建的子域名)
|
||||||
|
|
||||||
## 截图预览
|
## 截图预览
|
||||||
|
|
||||||
@@ -33,30 +81,18 @@
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
查看开发者[快速开始](https://wr.do/docs/developer/quick-start)的详细文档。
|
查看开发者[快速开始](https://wr.do/docs/developer/quick-start)详细文档。
|
||||||
|
|
||||||
### 要求
|
|
||||||
|
|
||||||
- [Vercel](https://vercel.com) 账户用于部署应用
|
|
||||||
- 至少一个在 [Cloudflare](https://dash.cloudflare.com/) 托管的 **域名**
|
|
||||||
|
|
||||||
查看[开发文档](https://wr.do/docs/developer/installation)。
|
|
||||||
|
|
||||||
### Email worker
|
|
||||||
|
|
||||||
查看 [email worker](https://wr.do/docs/developer/cloudflare-email-worker) 文档用于邮件接收。
|
|
||||||
|
|
||||||
## 自部署教程
|
## 自部署教程
|
||||||
|
|
||||||
### 使用 Vercel 部署
|
### 使用 Vercel 部署
|
||||||
|
|
||||||
[](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)
|
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
|
||||||
|
|
||||||
记得填写必要的环境变量。
|
记得填写必要的环境变量。
|
||||||
|
|
||||||
### 使用 Docker Compose 部署
|
### 使用 Docker Compose 部署
|
||||||
|
|
||||||
|
|
||||||
在服务器中创建一个文件夹,进入该文件夹并新建`docker-compose.yml`文件,填写必要的环境变量,然后执行:
|
在服务器中创建一个文件夹,进入该文件夹并新建`docker-compose.yml`文件,填写必要的环境变量,然后执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -71,9 +107,6 @@ docker compose up -d
|
|||||||
git clone https://github.com/oiov/wr.do
|
git clone https://github.com/oiov/wr.do
|
||||||
cd wr.do
|
cd wr.do
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# 在 localhost:3000 上运行
|
|
||||||
pnpm dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 初始化数据库
|
#### 初始化数据库
|
||||||
@@ -83,16 +116,41 @@ pnpm postinstall
|
|||||||
pnpm db:push
|
pnpm db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 localhost:3000 上运行
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- 默认账号(管理员):`admin@admin.com`
|
||||||
|
- 默认密码:`123456`
|
||||||
|
|
||||||
|
> 登录后请及时修改密码
|
||||||
|
|
||||||
#### 管理员初始化
|
#### 管理员初始化
|
||||||
|
|
||||||
Follow https://localhost:3000/setup
|
> 此初始化引导在 v1.0.2 版本后, 不再是必要步骤
|
||||||
|
|
||||||
|
访问 https://localhost:3000/setup
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
查看 [开发者文档](https://wr.do/docs/developer).
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Next.js + React + TypeScript
|
||||||
|
- Tailwind CSS 用于样式设计
|
||||||
|
- Prisma ORM 作为数据库工具
|
||||||
|
- Cloudflare 作为主要的云基础设施
|
||||||
|
- Vercel 作为推荐的部署平台
|
||||||
|
- Resend 作为邮件服务
|
||||||
|
|
||||||
## 社区群组
|
## 社区群组
|
||||||
|
|
||||||
- Discord: https://discord.gg/AHPQYuZu3m
|
- Discord: https://discord.gg/AHPQYuZu3m
|
||||||
- 微信群:
|
- 微信群:
|
||||||
|
|
||||||

|
<img width="300" src="https://wr.do/s/group" />
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
112
README.md
112
README.md
@@ -1,19 +1,64 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<h1>WR.DO</h1>
|
<h1>WR.DO</h1>
|
||||||
<p><a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
|
<p><a href="https://wr.do/docs/developer">Docs</a> · <a href="https://wr.do/feedback">Feedback</a> · English | <a href="/README-zh.md">简体中文</a></p>
|
||||||
<p>Make Short Links, Manage DNS Records, Email Support.</p>
|
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/oiov/wr.do?style=flat-square">
|
||||||
<!-- <img src="https://wr.do/_static/images/light-preview.png"/> -->
|
<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"/>
|
||||||
</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.
|
||||||
|
|
||||||
|
- Official website: [https://wr.do](https://wr.do)
|
||||||
|
- Demo: [https://699399.xyz](https://699399.xyz) (Account: `admin@admin.com`, Password: `123456`)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🔗 **URL Shortening:** Generate short links with visitor analytic and password(support api)
|
- 🔗 **Short Link Service**:
|
||||||
- 📮 **Email Support:** Receive emails and send emails(support api)
|
- Custom short links
|
||||||
- 💬 **P2P Chat:** Start chat in seconds
|
- Generate custom QR codes
|
||||||
- 🌐 **Multi-Tenant Support:** Manage multiple DNS records seamlessly
|
- Password-protected links
|
||||||
- 📸 **Screenshot API:** Access to screenshot api、website meta-info scraping api.
|
- Expiration time control
|
||||||
- 😀 **Permission Management:** A convenient admin panel for auditing
|
- Access analytics (real-time logs, maps, and multi-dimensional data analysis)
|
||||||
- 🔒 **Secure & Reliable:** Built on Cloudflare's robust DNS API
|
- 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
|
||||||
|
|
||||||
|
- 📡 **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
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -37,28 +82,17 @@
|
|||||||
|
|
||||||
See step by step installation tutorial at [Quick Start for Developer](https://wr.do/docs/developer/quick-start).
|
See step by step installation tutorial at [Quick Start for Developer](https://wr.do/docs/developer/quick-start).
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- [Vercel](https://vercel.com) to deploy app
|
|
||||||
- A **domain** name hosted on [Cloudflare](https://dash.cloudflare.com/)
|
|
||||||
|
|
||||||
See more docs about [developer](https://wr.do/docs/developer/installation).
|
|
||||||
|
|
||||||
### Email worker
|
|
||||||
|
|
||||||
See docs about [email worker](https://wr.do/docs/developer/cloudflare-email-worker).
|
|
||||||
|
|
||||||
## Self-hosted
|
## Self-hosted
|
||||||
|
|
||||||
### Deploy with Vercel
|
### Deploy with Vercel
|
||||||
|
|
||||||
[](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)
|
[](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.
|
Remember to fill in the necessary environment variables.
|
||||||
|
|
||||||
### Deploy with Docker Compose
|
### Deploy with Docker Compose
|
||||||
|
|
||||||
Create a new folder and copy the `docker-compose.yml`、`.env` file to the folder.
|
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
|
```yml
|
||||||
- wrdo
|
- wrdo
|
||||||
@@ -82,11 +116,6 @@ pnpm install
|
|||||||
|
|
||||||
copy `.env.example` to `.env` and fill in the necessary environment variables.
|
copy `.env.example` to `.env` and fill in the necessary environment variables.
|
||||||
|
|
||||||
```bash
|
|
||||||
# run on localhost:3000
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Init database
|
#### Init database
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -94,10 +123,33 @@ pnpm postinstall
|
|||||||
pnpm db:push
|
pnpm db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# run on localhost:3000
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- Default admin account:`admin@admin.com`
|
||||||
|
- Default admin password:`123456`
|
||||||
|
|
||||||
#### Setup Admin Panel
|
#### Setup Admin Panel
|
||||||
|
|
||||||
|
> After v1.0.2, this setup guide is not needed anymore
|
||||||
|
|
||||||
Follow https://localhost:3000/setup
|
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
|
||||||
|
|
||||||
## Community Group
|
## Community Group
|
||||||
|
|
||||||
- Discord: https://discord.gg/AHPQYuZu3m
|
- Discord: https://discord.gg/AHPQYuZu3m
|
||||||
@@ -117,4 +169,6 @@ 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>
|
||||||
|
|
||||||
|
[](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
|
||||||
|
|||||||
39
actions/update-user-password.ts
Normal file
39
actions/update-user-password.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { hashPassword } from "@/lib/utils";
|
||||||
|
import { userPasswordSchema } from "@/lib/validations/user";
|
||||||
|
|
||||||
|
export type FormData = {
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateUserPassword(userId: string, data: FormData) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user || session?.user.id !== userId) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = userPasswordSchema.parse(data);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
password: hashPassword(password),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/settings");
|
||||||
|
return { status: "success" };
|
||||||
|
} catch (error) {
|
||||||
|
// console.log(error)
|
||||||
|
return { status: "error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,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,6 +15,7 @@ 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="container flex h-screen w-screen flex-col items-center justify-center">
|
||||||
<Link
|
<Link
|
||||||
@@ -25,44 +27,40 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<Icons.chevronLeft className="mr-2 size-4" />
|
<Icons.chevronLeft className="mr-2 size-4" />
|
||||||
Back {siteConfig.openSignup ? "Home" : ""}
|
{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" />
|
||||||
<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" }}>
|
<span style={{ fontFamily: "Bahamas Bold" }}>
|
||||||
{siteConfig.name}
|
{siteConfig.name}
|
||||||
</span>
|
</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>
|
||||||
|
|||||||
9
app/(marketing)/feedback/loading.tsx
Normal file
9
app/(marketing)/feedback/loading.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function DashboardLoading() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-full w-full rounded-lg" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/(marketing)/feedback/page.tsx
Normal file
11
app/(marketing)/feedback/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { constructMetadata } from "@/lib/utils";
|
||||||
|
import Comment from "@/components/shared/comment";
|
||||||
|
|
||||||
|
export const metadata = constructMetadata({
|
||||||
|
title: "Feedback",
|
||||||
|
description: "Help us do better",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
return <Comment />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NavMobile } from "@/components/layout/mobile-nav";
|
import { 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>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
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 default async function IndexPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroLanding />
|
<HeroLanding userId={user?.id} />
|
||||||
<LandingImages />
|
<LandingImages />
|
||||||
<PricingSection />
|
<PricingSection />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,6 +30,7 @@ export function RadialShapeChart({
|
|||||||
total: number;
|
total: number;
|
||||||
totalUser: number;
|
totalUser: number;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("Components");
|
||||||
const chartData = [
|
const chartData = [
|
||||||
{ browser: "safari", actived: total, fill: "var(--color-safari)" },
|
{ browser: "safari", actived: total, fill: "var(--color-safari)" },
|
||||||
];
|
];
|
||||||
@@ -94,7 +96,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>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { DashboardHeader } from "@/components/dashboard/header";
|
|
||||||
|
|
||||||
export default function DashboardRecordsLoading() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DashboardHeader heading="Domains Management" text="" />
|
|
||||||
<Skeleton className="h-32 w-full rounded-lg" />
|
|
||||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
"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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -54,8 +56,11 @@ export function LineChartMultiple({
|
|||||||
type1,
|
type1,
|
||||||
type2,
|
type2,
|
||||||
}: LineChartMultipleProps) {
|
}: LineChartMultipleProps) {
|
||||||
|
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,
|
||||||
@@ -67,20 +72,22 @@ 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>
|
<CardContent ref={wrapperRef}>
|
||||||
<ChartContainer config={chartConfig}>
|
<ChartContainer config={chartConfig}>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={processedData}
|
data={processedData}
|
||||||
|
width={wrapperWidth}
|
||||||
margin={{
|
margin={{
|
||||||
left: 12,
|
left: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 DNS 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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
700
app/(protected)/admin/system/app-configs.tsx
Normal file
700
app/(protected)/admin/system/app-configs.tsx
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
"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 [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);
|
||||||
|
}
|
||||||
|
// 计算登录方式数量
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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"
|
||||||
|
placeholder="Support HTML format, such as <div>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("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}
|
||||||
|
// defaultValue={configs.catch_all_emails}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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",
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
"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 { 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 { 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";
|
||||||
@@ -33,6 +37,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">;
|
||||||
@@ -68,7 +73,11 @@ function TableColumnSekleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DomainList({ user, action }: DomainListProps) {
|
export default function DomainList({ user, action }: DomainListProps) {
|
||||||
|
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);
|
||||||
@@ -123,12 +132,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" />
|
||||||
) : (
|
) : (
|
||||||
@@ -159,7 +188,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>
|
||||||
@@ -168,7 +197,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({
|
||||||
@@ -195,25 +224,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>
|
||||||
@@ -260,6 +289,9 @@ export default function DomainList({ user, action }: DomainListProps) {
|
|||||||
handleChangeStatus(value, "enable_email", domain)
|
handleChangeStatus(value, "enable_email", domain)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{domain.resend_api_key && (
|
||||||
|
<Icons.resend 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
|
||||||
@@ -268,6 +300,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
|
||||||
@@ -279,45 +316,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>
|
<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't have any domains yet. Start creating one.
|
You don't have any domains yet. Start creating one.
|
||||||
</EmptyPlaceholder.Description>
|
</EmptyPlaceholder.Description>
|
||||||
@@ -326,6 +381,7 @@ export default function DomainList({ user, action }: DomainListProps) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||||
<PaginationWrapper
|
<PaginationWrapper
|
||||||
|
layout={isMobile ? "right" : "split"}
|
||||||
total={data.total}
|
total={data.total}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
setCurrentPage={setCurrentPage}
|
setCurrentPage={setCurrentPage}
|
||||||
@@ -339,7 +395,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}
|
||||||
>
|
>
|
||||||
@@ -353,6 +409,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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
13
app/(protected)/admin/system/loading.tsx
Normal file
13
app/(protected)/admin/system/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { DashboardHeader } from "@/components/dashboard/header";
|
||||||
|
|
||||||
|
export default function SystemSettingsLoading() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader heading="System Settings" text="" />
|
||||||
|
<Skeleton className="h-48 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-56 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ 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 AppConfigs from "./app-configs";
|
||||||
import DomainList from "./domain-list";
|
import DomainList from "./domain-list";
|
||||||
|
import PlanList from "./plan-list";
|
||||||
|
|
||||||
export const metadata = constructMetadata({
|
export const metadata = constructMetadata({
|
||||||
title: "Domains - WR.DO",
|
title: "System Settings",
|
||||||
description: "List and manage domains.",
|
description: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
@@ -18,12 +20,8 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardHeader
|
<DashboardHeader heading="System Settings" text="" />
|
||||||
heading="Manage Domains"
|
<AppConfigs />
|
||||||
text="List and manage domains."
|
|
||||||
link="/docs/developer/cloudflare"
|
|
||||||
linkText="domains."
|
|
||||||
/>
|
|
||||||
<DomainList
|
<DomainList
|
||||||
user={{
|
user={{
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -35,6 +33,17 @@ export default async function DashboardPage() {
|
|||||||
}}
|
}}
|
||||||
action="/api/admin/domain"
|
action="/api/admin/domain"
|
||||||
/>
|
/>
|
||||||
|
<PlanList
|
||||||
|
user={{
|
||||||
|
id: user.id,
|
||||||
|
name: user.name || "",
|
||||||
|
apiKey: user.apiKey || "",
|
||||||
|
email: user.email || "",
|
||||||
|
role: user.role,
|
||||||
|
team: user.team,
|
||||||
|
}}
|
||||||
|
action="/api/admin/plan"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
269
app/(protected)/admin/system/plan-list.tsx
Normal file
269
app/(protected)/admin/system/plan-list.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import { PenLine, RefreshCwIcon } 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(10);
|
||||||
|
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 ? (
|
||||||
|
<RefreshCwIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex shrink-0 gap-1"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentEditPlan(null);
|
||||||
|
setShowForm(false);
|
||||||
|
setFormType("add");
|
||||||
|
setShowForm(!isShowForm);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.add className="size-4" />
|
||||||
|
<span className="hidden sm:inline">{t("Add Plan")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||||
|
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-8">
|
||||||
|
<TableHead className="col-span-1 flex items-center font-bold">
|
||||||
|
{t("Plan Name")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||||
|
{t("Short Limit")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||||
|
{t("Email Limit")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||||
|
{t("Send Limit")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||||
|
{t("Record Limit")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||||
|
{t("Active")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 flex items-center font-bold">
|
||||||
|
{t("Updated")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 flex items-center font-bold">
|
||||||
|
{t("Actions")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<TableColumnSekleton />
|
||||||
|
<TableColumnSekleton />
|
||||||
|
<TableColumnSekleton />
|
||||||
|
<TableColumnSekleton />
|
||||||
|
<TableColumnSekleton />
|
||||||
|
</>
|
||||||
|
) : data && data.list && data.list.length ? (
|
||||||
|
data.list.map((plan) => (
|
||||||
|
<div className="border-b" key={plan.id}>
|
||||||
|
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-8">
|
||||||
|
<TableCell className="col-span-1 flex items-center gap-1">
|
||||||
|
{plan.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||||
|
{nFormatter(plan.slNewLinks)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||||
|
{nFormatter(plan.emEmailAddresses)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||||
|
{nFormatter(plan.emSendEmails)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||||
|
{nFormatter(plan.rcNewRecords)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 flex items-center gap-1">
|
||||||
|
<Switch
|
||||||
|
disabled
|
||||||
|
defaultChecked={plan.isActive}
|
||||||
|
// onCheckedChange={(value) =>
|
||||||
|
// handleChangeStatus(value, "active", domain)
|
||||||
|
// }
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 flex items-center truncate">
|
||||||
|
<TimeAgoIntl date={plan.updatedAt as Date} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
|
||||||
|
size="sm"
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentEditPlan(plan);
|
||||||
|
setShowForm(false);
|
||||||
|
setFormType("edit");
|
||||||
|
setShowForm(!isShowForm);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="hidden text-nowrap sm:block">
|
||||||
|
{t("Edit")}
|
||||||
|
</p>
|
||||||
|
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyPlaceholder className="shadow-none">
|
||||||
|
<EmptyPlaceholder.Icon name="settings" />
|
||||||
|
<EmptyPlaceholder.Title>
|
||||||
|
{t("No Plans")}
|
||||||
|
</EmptyPlaceholder.Title>
|
||||||
|
<EmptyPlaceholder.Description>
|
||||||
|
You don't have any plans yet. Start creating one.
|
||||||
|
</EmptyPlaceholder.Description>
|
||||||
|
</EmptyPlaceholder>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||||
|
<PaginationWrapper
|
||||||
|
layout={isMobile ? "right" : "split"}
|
||||||
|
total={data.total}
|
||||||
|
currentPage={currentPage}
|
||||||
|
setCurrentPage={setCurrentPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
setPageSize={setPageSize}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* form */}
|
||||||
|
<Modal
|
||||||
|
className="md:max-w-2xl"
|
||||||
|
showModal={isShowForm}
|
||||||
|
setShowModal={setShowForm}
|
||||||
|
>
|
||||||
|
<PlanForm
|
||||||
|
user={{ id: user.id, name: user.name || "" }}
|
||||||
|
isShowForm={isShowForm}
|
||||||
|
setShowForm={setShowForm}
|
||||||
|
type={formType}
|
||||||
|
initData={currentEditPlan}
|
||||||
|
action={action}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,16 @@ import { DashboardHeader } from "@/components/dashboard/header";
|
|||||||
export default function DashboardUrlsLoading() {
|
export default function DashboardUrlsLoading() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardHeader heading="Short Urls" text="" />
|
<DashboardHeader
|
||||||
<Skeleton className="h-32 w-full rounded-lg" />
|
heading="Manage Short URLs"
|
||||||
|
text="List and manage short urls"
|
||||||
|
/>
|
||||||
|
<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" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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: "Short URLs",
|
||||||
description: "List and manage records.",
|
description: "List and manage records.",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,12 +19,11 @@ export default async function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
heading="Manage Short URLs"
|
heading="Manage Short URLs"
|
||||||
text="List and manage short urls."
|
text="List and manage short urls"
|
||||||
link="/docs/short-urls"
|
link="/docs/short-urls"
|
||||||
linkText="short urls."
|
linkText="short urls"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UserUrlsList
|
<UserUrlsList
|
||||||
user={{
|
user={{
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 || "" }} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
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, RefreshCwIcon } 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">;
|
||||||
@@ -73,6 +73,8 @@ function TableColumnSekleton({ className }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersList({ user }: UrlListProps) {
|
export default function UsersList({ user }: UrlListProps) {
|
||||||
|
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);
|
||||||
@@ -82,6 +84,8 @@ export default function UsersList({ user }: UrlListProps) {
|
|||||||
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}`,
|
||||||
@@ -103,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
|
||||||
@@ -120,6 +122,19 @@ export default function UsersList({ user }: UrlListProps) {
|
|||||||
<RefreshCwIcon className="size-4" />
|
<RefreshCwIcon 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>
|
||||||
@@ -178,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>
|
||||||
@@ -239,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} />
|
||||||
</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
|
||||||
@@ -261,17 +276,18 @@ 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>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<EmptyPlaceholder>
|
<EmptyPlaceholder className="shadow-none">
|
||||||
<EmptyPlaceholder.Icon name="users" />
|
<EmptyPlaceholder.Icon name="users" />
|
||||||
<EmptyPlaceholder.Title>No users</EmptyPlaceholder.Title>
|
<EmptyPlaceholder.Title>No users</EmptyPlaceholder.Title>
|
||||||
<EmptyPlaceholder.Description>
|
<EmptyPlaceholder.Description>
|
||||||
@@ -282,6 +298,7 @@ export default function UsersList({ user }: UrlListProps) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||||
<PaginationWrapper
|
<PaginationWrapper
|
||||||
|
layout={isMobile ? "right" : "split"}
|
||||||
total={data.total}
|
total={data.total}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
setCurrentPage={setCurrentPage}
|
setCurrentPage={setCurrentPage}
|
||||||
@@ -302,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,17 +170,25 @@ 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>
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={<Skeleton className="h-[200px] w-full rounded-lg" />}
|
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
|
||||||
>
|
>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={<Skeleton className="h-[200px] w-full rounded-lg" />}
|
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
|
||||||
>
|
>
|
||||||
<LiveLogSection />
|
<UserRecordsListSection
|
||||||
|
user={{
|
||||||
|
id: user.id,
|
||||||
|
name: user.name || "",
|
||||||
|
apiKey: user.apiKey || "",
|
||||||
|
email: user.email || "",
|
||||||
|
role: user.role,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
@@ -198,22 +208,6 @@ export default async function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ErrorBoundary
|
|
||||||
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
|
|
||||||
>
|
|
||||||
<Suspense
|
|
||||||
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
|
|
||||||
>
|
|
||||||
<UserRecordsListSection
|
|
||||||
user={{
|
|
||||||
id: user.id,
|
|
||||||
name: user.name || "",
|
|
||||||
apiKey: user.apiKey || "",
|
|
||||||
email: user.email || "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ export default function DashboardRecordsLoading() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
heading="Manage DNS 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" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 DNS 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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ 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, 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 { 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 { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -39,20 +41,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>
|
||||||
@@ -71,6 +71,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>
|
||||||
@@ -79,16 +82,20 @@ function TableColumnSekleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UserRecordsList({ user, action }: RecordListProps) {
|
export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
const [isShowForm, setShowForm] = useState(false);
|
const [isShowForm, setShowForm] = useState(false);
|
||||||
const [formType, setFormType] = useState<FormType>("add");
|
const [formType, setFormType] = useState<FormType>("add");
|
||||||
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(10);
|
||||||
|
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, {
|
||||||
@@ -105,7 +112,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,32 +145,32 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
|||||||
<>
|
<>
|
||||||
<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">
|
||||||
{action.includes("/admin") ? (
|
{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>
|
||||||
)}
|
)}
|
||||||
@@ -190,34 +197,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>
|
||||||
@@ -234,7 +244,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">
|
||||||
@@ -242,11 +252,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>
|
||||||
@@ -265,68 +279,167 @@ 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">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger className="truncate">
|
||||||
|
{record.user.name ?? record.user.email}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{record.user.name}</p>
|
||||||
|
<p>{record.user.email}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</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>
|
<EmptyPlaceholder className="shadow-none">
|
||||||
<EmptyPlaceholder.Icon name="globe" />
|
<EmptyPlaceholder.Icon name="globe" />
|
||||||
<EmptyPlaceholder.Title>No records</EmptyPlaceholder.Title>
|
<EmptyPlaceholder.Title>
|
||||||
|
{t("No Subdomains")}
|
||||||
|
</EmptyPlaceholder.Title>
|
||||||
<EmptyPlaceholder.Description>
|
<EmptyPlaceholder.Description>
|
||||||
You don't have any record yet. Start creating record.
|
You don't have any subdomain yet. Start creating
|
||||||
|
record.
|
||||||
</EmptyPlaceholder.Description>
|
</EmptyPlaceholder.Description>
|
||||||
</EmptyPlaceholder>
|
</EmptyPlaceholder>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||||
<PaginationWrapper
|
<PaginationWrapper
|
||||||
|
layout={isMobile ? "right" : "split"}
|
||||||
total={data.total}
|
total={data.total}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
setCurrentPage={setCurrentPage}
|
setCurrentPage={setCurrentPage}
|
||||||
|
|||||||
@@ -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"} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, removeUrlSuffix } 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])}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export default function DashboardRecordsLoading() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
heading="Scraping API 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">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { RefreshCwIcon } from "lucide-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";
|
||||||
@@ -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: "",
|
||||||
@@ -98,12 +101,12 @@ const LogsTable = ({ userId, target }) => {
|
|||||||
onChange={(e) => handleFilterChange("type", e.target.value)}
|
onChange={(e) => handleFilterChange("type", e.target.value)}
|
||||||
className="h-8 max-w-xs placeholder:text-xs"
|
className="h-8 max-w-xs placeholder:text-xs"
|
||||||
/>
|
/>
|
||||||
<Input
|
{/* <Input
|
||||||
placeholder="Filter by IP..."
|
placeholder="Filter by IP..."
|
||||||
value={filters.ip}
|
value={filters.ip}
|
||||||
onChange={(e) => handleFilterChange("ip", e.target.value)}
|
onChange={(e) => handleFilterChange("ip", e.target.value)}
|
||||||
className="h-8 max-w-xs placeholder:text-xs"
|
className="h-8 max-w-xs placeholder:text-xs"
|
||||||
/>
|
/> */}
|
||||||
{
|
{
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -139,16 +142,19 @@ const LogsTable = ({ userId, target }) => {
|
|||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-muted">
|
<TableHeader className="bg-muted">
|
||||||
<TableRow className="">
|
<TableRow className="grid grid-cols-5 items-center sm:grid-cols-6">
|
||||||
<TableHead className="hidden items-center justify-start px-2 sm:flex">
|
<TableHead className="hidden items-center justify-start px-2 sm:flex">
|
||||||
Date
|
{t("Date")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-2">Type</TableHead>
|
<TableHead className="flex items-center px-2">
|
||||||
<TableHead className="hidden items-center justify-start px-2 sm:flex">
|
{t("Type")}
|
||||||
IP
|
</TableHead>
|
||||||
|
<TableHead className="col-span-3 flex items-center px-2">
|
||||||
|
{t("Link")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="flex items-center px-2">
|
||||||
|
{t("User")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-2">Link</TableHead>
|
|
||||||
<TableHead className="px-2">User</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -161,9 +167,6 @@ const LogsTable = ({ userId, target }) => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-2 w-[80px]" />
|
<Skeleton className="h-2 w-[80px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden sm:inline-block">
|
|
||||||
<Skeleton className="h-2 w-[120px]" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Skeleton className="h-2 w-[200px]" />
|
<Skeleton className="h-2 w-[200px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -173,15 +176,15 @@ const LogsTable = ({ userId, target }) => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
: logs.map((log) => (
|
: logs.map((log) => (
|
||||||
<TableRow className="text-xs hover:bg-muted" key={log.id}>
|
<TableRow
|
||||||
|
className="grid grid-cols-5 items-center text-xs hover:bg-muted sm:grid-cols-6"
|
||||||
|
key={log.id}
|
||||||
|
>
|
||||||
<TableCell className="hidden truncate p-2 sm:inline-block">
|
<TableCell className="hidden truncate p-2 sm:inline-block">
|
||||||
{new Date(log.createdAt).toLocaleString()}
|
{new Date(log.createdAt).toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-2">{log.type}</TableCell>
|
<TableCell className="p-2">{log.type}</TableCell>
|
||||||
<TableCell className="hidden p-2 sm:inline-block">
|
<TableCell className="col-span-3 max-w-full truncate p-2">
|
||||||
{log.ip}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-md truncate p-2">
|
|
||||||
{log.link}
|
{log.link}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-md truncate p-2">
|
<TableCell className="max-w-md truncate p-2">
|
||||||
@@ -193,12 +196,13 @@ const LogsTable = ({ userId, target }) => {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-start justify-between gap-2 sm:items-center">
|
||||||
<p className="ml-auto text-nowrap text-sm">
|
<p className="ml-auto text-nowrap text-sm">
|
||||||
{nFormatter(data?.total || 0)} logs
|
{nFormatter(data?.total || 0)} logs
|
||||||
</p>
|
</p>
|
||||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||||
<PaginationWrapper
|
<PaginationWrapper
|
||||||
|
className="m-0"
|
||||||
total={data.total}
|
total={data.total}
|
||||||
currentPage={page}
|
currentPage={page}
|
||||||
setCurrentPage={setPage}
|
setCurrentPage={setPage}
|
||||||
|
|||||||
@@ -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" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 to 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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 to Meta 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"
|
||||||
|
|||||||
@@ -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 API 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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 to QR 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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 to 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"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 { 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 +23,14 @@ 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">
|
||||||
<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,
|
||||||
|
|||||||
@@ -2,18 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import {
|
import { differenceInMinutes, format } from "date-fns";
|
||||||
addHours,
|
|
||||||
addMinutes,
|
|
||||||
differenceInDays,
|
|
||||||
differenceInHours,
|
|
||||||
differenceInMinutes,
|
|
||||||
format,
|
|
||||||
startOfDay,
|
|
||||||
startOfHour,
|
|
||||||
startOfMinute,
|
|
||||||
} from "date-fns";
|
|
||||||
import { create } from "lodash";
|
|
||||||
|
|
||||||
import { DAILY_DIMENSION_ENUMS } from "@/lib/enums";
|
import { DAILY_DIMENSION_ENUMS } from "@/lib/enums";
|
||||||
import {
|
import {
|
||||||
@@ -102,129 +91,56 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
return `${Math.round(lat * 100) / 100},${Math.round(lng * 100) / 100}`;
|
return `${Math.round(lat * 100) / 100},${Math.round(lng * 100) / 100}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const processChartData = (locations: Location[]): ChartData[] => {
|
const processChartDataOptimized = (locations: Location[]): ChartData[] => {
|
||||||
// 过滤有效数据
|
|
||||||
const validLocations = locations.filter((loc) => loc.createdAt);
|
const validLocations = locations.filter((loc) => loc.createdAt);
|
||||||
if (validLocations.length === 0) return [];
|
if (validLocations.length === 0) return [];
|
||||||
|
|
||||||
// 获取时间范围
|
// 如果数据量很少,直接按原始时间点展示
|
||||||
|
if (validLocations.length <= 10) {
|
||||||
|
return validLocations.map((loc, index) => ({
|
||||||
|
time: format(new Date(loc.createdAt!), "HH:mm:ss"),
|
||||||
|
count: loc.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则使用智能分组
|
||||||
const dates = validLocations.map((loc) => new Date(loc.createdAt!));
|
const dates = validLocations.map((loc) => new Date(loc.createdAt!));
|
||||||
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
|
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
|
||||||
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
|
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
|
||||||
|
|
||||||
// 根据时间跨度选择分组策略
|
|
||||||
const totalMinutes = differenceInMinutes(maxDate, minDate);
|
const totalMinutes = differenceInMinutes(maxDate, minDate);
|
||||||
const totalHours = differenceInHours(maxDate, minDate);
|
|
||||||
const totalDays = differenceInDays(maxDate, minDate);
|
|
||||||
|
|
||||||
let groupByFn: (date: Date) => Date;
|
// 根据数据量和时间跨度动态调整分组
|
||||||
let formatFn: (date: Date) => string;
|
const targetGroups = Math.min(validLocations.length, 20); // 目标分组数量
|
||||||
let intervalFn: (date: Date, interval: number) => Date;
|
let groupMinutes: number;
|
||||||
let interval: number;
|
|
||||||
|
|
||||||
// 30分钟内:按1分钟分组
|
if (totalMinutes <= 60) {
|
||||||
if (totalMinutes <= 30) {
|
groupMinutes = Math.max(1, Math.ceil(totalMinutes / targetGroups));
|
||||||
groupByFn = startOfMinute;
|
|
||||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
|
||||||
intervalFn = addMinutes;
|
|
||||||
interval = 1;
|
|
||||||
} else if (totalMinutes <= 60) {
|
|
||||||
// 1小时内:按2分钟分组
|
|
||||||
groupByFn = (date) => {
|
|
||||||
const minutes = Math.floor(date.getMinutes() / 2) * 2;
|
|
||||||
const grouped = startOfMinute(date);
|
|
||||||
grouped.setMinutes(minutes);
|
|
||||||
return grouped;
|
|
||||||
};
|
|
||||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
|
||||||
intervalFn = addMinutes;
|
|
||||||
interval = 2;
|
|
||||||
} else if (totalHours <= 2) {
|
|
||||||
// 2小时内:按4分钟分组
|
|
||||||
groupByFn = (date) => {
|
|
||||||
const minutes = Math.floor(date.getMinutes() / 4) * 4;
|
|
||||||
const grouped = startOfMinute(date);
|
|
||||||
grouped.setMinutes(minutes);
|
|
||||||
return grouped;
|
|
||||||
};
|
|
||||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
|
||||||
intervalFn = addMinutes;
|
|
||||||
interval = 4;
|
|
||||||
} else if (totalHours <= 6) {
|
|
||||||
// 6小时内:按12分钟分组
|
|
||||||
groupByFn = (date) => {
|
|
||||||
const minutes = Math.floor(date.getMinutes() / 12) * 12;
|
|
||||||
const grouped = startOfMinute(date);
|
|
||||||
grouped.setMinutes(minutes);
|
|
||||||
return grouped;
|
|
||||||
};
|
|
||||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
|
||||||
intervalFn = addMinutes;
|
|
||||||
interval = 12;
|
|
||||||
} else if (totalHours <= 12) {
|
|
||||||
// 12小时内:按24分钟分组
|
|
||||||
groupByFn = (date) => {
|
|
||||||
const minutes = Math.floor(date.getMinutes() / 24) * 24;
|
|
||||||
const grouped = startOfMinute(date);
|
|
||||||
grouped.setMinutes(minutes);
|
|
||||||
return grouped;
|
|
||||||
};
|
|
||||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
|
||||||
intervalFn = addMinutes;
|
|
||||||
interval = 24;
|
|
||||||
} else if (totalHours <= 24) {
|
|
||||||
// 24小时内:按48分钟分组
|
|
||||||
groupByFn = (date) => {
|
|
||||||
const minutes = Math.floor(date.getMinutes() / 48) * 48;
|
|
||||||
const grouped = startOfMinute(date);
|
|
||||||
grouped.setMinutes(minutes);
|
|
||||||
return grouped;
|
|
||||||
};
|
|
||||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
|
||||||
intervalFn = addMinutes;
|
|
||||||
interval = 48;
|
|
||||||
} else if (totalDays <= 7) {
|
|
||||||
// 7天内:按天分组
|
|
||||||
groupByFn = startOfDay;
|
|
||||||
formatFn = (date) => format(date, "MM-dd");
|
|
||||||
intervalFn = addHours;
|
|
||||||
interval = 24;
|
|
||||||
} else {
|
} else {
|
||||||
// 更长时间:按天分组
|
groupMinutes = Math.max(5, Math.ceil(totalMinutes / targetGroups));
|
||||||
groupByFn = startOfDay;
|
|
||||||
formatFn = (date) => format(date, "MM-dd");
|
|
||||||
intervalFn = addHours;
|
|
||||||
interval = 24;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分组聚合数据
|
const groupByFn = (date: Date) => {
|
||||||
const groupedData = new Map<string, number>();
|
const minutes =
|
||||||
|
Math.floor(date.getMinutes() / groupMinutes) * groupMinutes;
|
||||||
|
const grouped = new Date(date);
|
||||||
|
grouped.setMinutes(minutes, 0, 0);
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedData = new Map<string, number>();
|
||||||
validLocations.forEach((loc) => {
|
validLocations.forEach((loc) => {
|
||||||
const date = new Date(loc.createdAt!);
|
const date = new Date(loc.createdAt!);
|
||||||
const groupedDate = groupByFn(date);
|
const groupedDate = groupByFn(date);
|
||||||
const key = groupedDate.getTime().toString();
|
const key = groupedDate.getTime().toString();
|
||||||
|
|
||||||
groupedData.set(key, (groupedData.get(key) || 0) + loc.count);
|
groupedData.set(key, (groupedData.get(key) || 0) + loc.count);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 填充时间间隔,确保连续性
|
return Array.from(groupedData.entries())
|
||||||
const result: ChartData[] = [];
|
.sort(([a], [b]) => parseInt(a) - parseInt(b))
|
||||||
const startGroup = groupByFn(minDate);
|
.map(([key, count]) => ({
|
||||||
const endGroup = groupByFn(maxDate);
|
time: format(new Date(parseInt(key)), "MM-dd HH:mm"),
|
||||||
|
count: count,
|
||||||
let current = startGroup;
|
}));
|
||||||
// 过滤掉count为0 的数据
|
|
||||||
while (current <= endGroup) {
|
|
||||||
const key = current.getTime().toString();
|
|
||||||
result.push({
|
|
||||||
time: formatFn(current),
|
|
||||||
count: groupedData.get(key) || 0,
|
|
||||||
});
|
|
||||||
current = intervalFn(current, interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendLocationData = (
|
const appendLocationData = (
|
||||||
@@ -276,7 +192,7 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
count: Math.max(0.1, loc.count / Math.max(totalCount, 1)),
|
count: Math.max(0.1, loc.count / Math.max(totalCount, 1)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const chartData = processChartData(updatedLocations);
|
const chartData = processChartDataOptimized(updatedLocations);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locations: normalizedLocations,
|
locations: normalizedLocations,
|
||||||
@@ -488,13 +404,13 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<RealtimeTimePicker
|
|
||||||
timeRange={timeRange}
|
|
||||||
setTimeRange={handleTimeRangeChange}
|
|
||||||
/>
|
|
||||||
<div className="sm:relative sm:p-4">
|
<div className="sm:relative sm:p-4">
|
||||||
|
<RealtimeTimePicker
|
||||||
|
timeRange={timeRange}
|
||||||
|
setTimeRange={handleTimeRangeChange}
|
||||||
|
/>
|
||||||
<RealtimeChart
|
<RealtimeChart
|
||||||
className="left-0 top-0 z-10 rounded-t-none text-left sm:absolute"
|
className="left-0 top-9 z-10 rounded-t-none text-left sm:absolute"
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
totalClicks={stats.totalClicks}
|
totalClicks={stats.totalClicks}
|
||||||
/>
|
/>
|
||||||
@@ -506,7 +422,7 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
setHandleTrafficEvent={(fn) => (handleTrafficEventRef.current = fn)}
|
setHandleTrafficEvent={(fn) => (handleTrafficEventRef.current = fn)}
|
||||||
/>
|
/>
|
||||||
<RealtimeLogs
|
<RealtimeLogs
|
||||||
className="-top-9 right-0 z-10 sm:absolute"
|
className="right-0 top-0 z-10 sm:absolute"
|
||||||
locations={locations}
|
locations={locations}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,7 +439,7 @@ export function RealtimeTimePicker({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Select onValueChange={setTimeRange} name="time range" value={timeRange}>
|
<Select onValueChange={setTimeRange} name="time range" value={timeRange}>
|
||||||
<SelectTrigger className="rounded-b-none border-b-0 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]">
|
||||||
<SelectValue placeholder="Select a time range" />
|
<SelectValue placeholder="Select a time range" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -36,7 +31,6 @@ export const RealtimeChart = ({
|
|||||||
return Math.ceil(dataLength / 6);
|
return Math.ceil(dataLength / 6);
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log("chartData", chartData);
|
|
||||||
const filteredChartData = chartData.filter((item, index) => {
|
const filteredChartData = chartData.filter((item, index) => {
|
||||||
return item.count !== 0 || index === chartData.length - 1;
|
return item.count !== 0 || index === chartData.length - 1;
|
||||||
});
|
});
|
||||||
@@ -46,11 +40,10 @@ 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>
|
||||||
{/* <ResponsiveContainer ></ResponsiveContainer> */}
|
|
||||||
<BarChart
|
<BarChart
|
||||||
width={300}
|
width={300}
|
||||||
height={200}
|
height={200}
|
||||||
@@ -68,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"]}
|
||||||
@@ -81,7 +76,7 @@ export const RealtimeChart = ({
|
|||||||
content={({ active, payload, label }) => {
|
content={({ active, payload, label }) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-primary-foreground py-2 text-primary backdrop-blur">
|
<div className="rounded-md border border-primary-foreground bg-primary py-2 text-primary-foreground backdrop-blur">
|
||||||
<p className="label px-2 text-base font-medium">{`${label}`}</p>
|
<p className="label px-2 text-base font-medium">{`${label}`}</p>
|
||||||
<p className="label px-2 text-sm">{`Visits: ${payload[0].value}`}</p>
|
<p className="label px-2 text-sm">{`Visits: ${payload[0].value}`}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +89,7 @@ export const RealtimeChart = ({
|
|||||||
dataKey="count"
|
dataKey="count"
|
||||||
fill="#2d9af9"
|
fill="#2d9af9"
|
||||||
radius={[1, 1, 0, 0]}
|
radius={[1, 1, 0, 0]}
|
||||||
maxBarSize={20}
|
maxBarSize={40}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { scaleSequentialSqrt } from "d3-scale";
|
|||||||
import { interpolateTurbo } from "d3-scale-chromatic";
|
import { interpolateTurbo } from "d3-scale-chromatic";
|
||||||
import { GlobeInstance } from "globe.gl";
|
import { GlobeInstance } from "globe.gl";
|
||||||
import { debounce } from "lodash-es";
|
import { debounce } from "lodash-es";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
import { Location } from "./index";
|
import { Location } from "./index";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export default function RealtimeGlobe({
|
|||||||
locations,
|
locations,
|
||||||
stats,
|
stats,
|
||||||
}: GlobeProps) {
|
}: GlobeProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const globeRef = useRef<HTMLDivElement>(null);
|
const globeRef = useRef<HTMLDivElement>(null);
|
||||||
const globeInstanceRef = useRef<any>(null);
|
const globeInstanceRef = useRef<any>(null);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
@@ -123,12 +125,12 @@ export default function RealtimeGlobe({
|
|||||||
globe = new Globe(container)
|
globe = new Globe(container)
|
||||||
.width(wrapperWidth)
|
.width(wrapperWidth)
|
||||||
.height(wrapperWidth > 728 ? wrapperWidth * 0.9 : wrapperWidth)
|
.height(wrapperWidth > 728 ? wrapperWidth * 0.9 : wrapperWidth)
|
||||||
.globeOffset([0, -130])
|
.globeOffset([0, -80])
|
||||||
.atmosphereColor("rgba(170, 170, 200, 0.8)")
|
.atmosphereColor("rgba(170, 170, 200, 0.7)")
|
||||||
.backgroundColor("rgba(0,0,0,0)")
|
.backgroundColor("rgba(0,0,0,0)")
|
||||||
.globeMaterial(
|
.globeMaterial(
|
||||||
new MeshPhongMaterial({
|
new MeshPhongMaterial({
|
||||||
color: "rgb(228, 228, 231)",
|
color: theme === "dark" ? "rgb(65, 65, 65)" : "rgb(228, 228, 231)",
|
||||||
transparent: false,
|
transparent: false,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
}) as any,
|
}) as any,
|
||||||
@@ -295,7 +297,10 @@ export default function RealtimeGlobe({
|
|||||||
}, [cleanup]);
|
}, [cleanup]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="relative max-h-screen overflow-hidden">
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className="relative -mt-8 max-h-screen overflow-hidden"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={globeRef}
|
ref={globeRef}
|
||||||
className="flex justify-center"
|
className="flex justify-center"
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -47,7 +47,7 @@ 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 }: { admin: boolean }) {
|
export default function LiveLog({ admin = false }: { admin?: boolean }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
const [isLive, setIsLive] = useState(false);
|
const [isLive, setIsLive] = useState(false);
|
||||||
@@ -55,6 +55,8 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
|||||||
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,
|
||||||
@@ -152,10 +154,10 @@ export default function LiveLog({ admin }: { 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>
|
||||||
|
|
||||||
@@ -163,11 +165,12 @@ export default function LiveLog({ admin }: { 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"
|
||||||
@@ -201,19 +204,30 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
|||||||
{error ? (
|
{error ? (
|
||||||
<div className="text-center text-red-500">{error.message}</div>
|
<div className="text-center text-red-500">{error.message}</div>
|
||||||
) : logs.length === 0 && !newLogs ? (
|
) : logs.length === 0 && !newLogs ? (
|
||||||
// <Skeleton className="h-8 w-full" />
|
|
||||||
<></>
|
<></>
|
||||||
) : (
|
) : (
|
||||||
<div className="scrollbar-hidden h-96 overflow-y-auto bg-primary-foreground">
|
<div className="scrollbar-hidden h-96 overflow-y-auto bg-primary-foreground">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-gray-100/50 text-sm dark:bg-primary-foreground">
|
<TableRow className="grid grid-cols-5 bg-gray-100/50 text-sm dark:bg-primary-foreground sm:grid-cols-9">
|
||||||
<TableHead className="h-8 w-1/6 px-1">Time</TableHead>
|
<TableHead className="col-span-2 flex h-8 items-center">
|
||||||
<TableHead className="h-8 w-1/12 px-1">Slug</TableHead>
|
{t("Time")}
|
||||||
<TableHead className="h-8 px-1">Target</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-8 w-1/12 px-1">IP</TableHead>
|
<TableHead className="col-span-1 flex h-8 items-center">
|
||||||
<TableHead className="h-8 w-1/6 px-1">Location</TableHead>
|
{t("Slug")}
|
||||||
<TableHead className="h-8 w-1/12 px-1">Clicks</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="col-span-3 hidden h-8 items-center sm:flex">
|
||||||
|
{t("Target")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden h-8 items-center sm:flex">
|
||||||
|
IP
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 flex h-8 items-center">
|
||||||
|
{t("Location")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 flex h-8 items-center">
|
||||||
|
{t("Clicks")}
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -235,23 +249,24 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
|||||||
ease: "linear",
|
ease: "linear",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
className="font-mono text-xs hover:bg-gray-200 dark:border-gray-800"
|
className="grid grid-cols-5 font-mono text-xs hover:bg-gray-200 dark:border-gray-800 sm:grid-cols-9"
|
||||||
>
|
>
|
||||||
<TableCell className="whitespace-nowrap px-1 py-1.5">
|
<TableCell className="col-span-2 truncate py-1.5">
|
||||||
{new Date(log.updatedAt).toLocaleString()}
|
{new Date(log.updatedAt).toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-midium px-1 py-1.5 text-green-700">
|
<TableCell className="font-midium col-span-1 truncate py-1.5 text-green-700">
|
||||||
{log.slug}
|
{log.slug}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-10 truncate px-1 py-1.5 hover:underline">
|
<TableCell className="col-span-3 hidden max-w-full truncate py-1.5 hover:underline sm:flex">
|
||||||
<a href={log.target} target="_blank" title={log.target}>
|
<a href={log.target} target="_blank" title={log.target}>
|
||||||
{log.target}
|
{log.target}
|
||||||
</a>
|
</a>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden truncate py-1.5 sm:flex">
|
||||||
<TableCell className="px-1 py-1.5">{log.ip}</TableCell>
|
{log.ip}
|
||||||
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className="max-w-6 truncate px-1 py-1.5"
|
className="col-span-1 truncate py-1.5"
|
||||||
title={getCountryName(log.country || "")}
|
title={getCountryName(log.country || "")}
|
||||||
>
|
>
|
||||||
{decodeURIComponent(
|
{decodeURIComponent(
|
||||||
@@ -260,7 +275,7 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
|||||||
: "-",
|
: "-",
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-1 py-1.5 text-green-700">
|
<TableCell className="col-span-1 py-1.5 text-green-700">
|
||||||
{log.click}
|
{log.click}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
@@ -272,7 +287,7 @@ export default function LiveLog({ admin }: { 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));
|
||||||
@@ -291,7 +306,7 @@ export default function LiveLog({ admin }: { admin: boolean }) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p>total logs</p>
|
<p>{t("total logs")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ export default function DashboardUrlsLoading() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
heading="Manage Short URLs"
|
heading="Manage Short URLs"
|
||||||
text="List and manage short urls."
|
text="List and manage short urls"
|
||||||
/>
|
/>
|
||||||
<Skeleton className="h-32 w-full rounded-lg" />
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
|
||||||
|
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-[102px] w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ 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 { getCountryName, getDeviceVendor } from "@/lib/contries";
|
getBotName,
|
||||||
|
getCountryName,
|
||||||
|
getDeviceVendor,
|
||||||
|
getEngineName,
|
||||||
|
getLanguageName,
|
||||||
|
getRegionName,
|
||||||
|
} 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, removeUrlSuffix } from "@/lib/utils";
|
||||||
|
import { useElementSize } from "@/hooks/use-element-size";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -34,7 +43,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
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: {
|
||||||
@@ -117,7 +128,15 @@ function generateStatsList(
|
|||||||
? getCountryName(rawValue as string) // 国家代码转为国家名称
|
? getCountryName(rawValue as string) // 国家代码转为国家名称
|
||||||
: dimension === "device"
|
: dimension === "device"
|
||||||
? getDeviceVendor(rawValue as string) // 设备型号转为厂商名称
|
? getDeviceVendor(rawValue as string) // 设备型号转为厂商名称
|
||||||
: rawValue; // 其他维度直接使用原始值
|
: dimension === "engine"
|
||||||
|
? getEngineName(rawValue as string) // 引擎名称
|
||||||
|
: dimension === "region"
|
||||||
|
? getRegionName(rawValue as string) // 区域名称
|
||||||
|
: dimension === "lang"
|
||||||
|
? getLanguageName(rawValue as string) // 语言名称
|
||||||
|
: dimension === "isBot"
|
||||||
|
? getBotName(rawValue as boolean) // 是否为机器人
|
||||||
|
: rawValue; // 其他维度直接使用原始值
|
||||||
|
|
||||||
const click = record.click || 0; // 确保 click 是数字,默认 0 如果未定义
|
const click = record.click || 0; // 确保 click 是数字,默认 0 如果未定义
|
||||||
|
|
||||||
@@ -154,20 +173,26 @@ export function DailyPVUVChart({
|
|||||||
setTimeRange: React.Dispatch<React.SetStateAction<string>>;
|
setTimeRange: React.Dispatch<React.SetStateAction<string>>;
|
||||||
user: Pick<User, "id" | "name" | "team">;
|
user: Pick<User, "id" | "name" | "team">;
|
||||||
}) {
|
}) {
|
||||||
|
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
|
||||||
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,
|
||||||
uv: new Set(entry.ips).size,
|
uv: new Set(entry.ips).size,
|
||||||
}));
|
}));
|
||||||
// .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
||||||
|
|
||||||
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)})` : "",
|
||||||
@@ -214,51 +239,59 @@ export function DailyPVUVChart({
|
|||||||
const deviceStats = generateStatsList(data, "device");
|
const deviceStats = generateStatsList(data, "device");
|
||||||
const browserStats = generateStatsList(data, "browser");
|
const browserStats = generateStatsList(data, "browser");
|
||||||
const countryStats = generateStatsList(data, "country");
|
const countryStats = generateStatsList(data, "country");
|
||||||
|
const osStats = generateStatsList(data, "os");
|
||||||
|
const cpuStats = generateStatsList(data, "cpu");
|
||||||
|
const engineStats = generateStatsList(data, "engine");
|
||||||
|
const languageStats = generateStatsList(data, "lang");
|
||||||
|
const regionStats = generateStatsList(data, "region");
|
||||||
|
const isBotStats = generateStatsList(data, "isBot");
|
||||||
|
|
||||||
|
const lastVisitorInfo = t.rich("last-visitor-info", {
|
||||||
|
location: latestFrom,
|
||||||
|
timeAgo: () => <TimeAgoIntl date={latestEntry.updatedAt} />,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-t-none border-t-0">
|
<Card>
|
||||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
|
<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 (
|
||||||
@@ -268,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()}
|
||||||
@@ -279,7 +312,7 @@ export function DailyPVUVChart({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2 sm:p-6">
|
<CardContent className="px-2 sm:p-6" ref={wrapperRef}>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig}
|
config={chartConfig}
|
||||||
className="aspect-auto h-[225px] w-full"
|
className="aspect-auto h-[225px] w-full"
|
||||||
@@ -351,9 +384,6 @@ export function DailyPVUVChart({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* <Bar dataKey="uv" fill={`var(--color-uv)`} stackId="a" />
|
|
||||||
<Bar dataKey="pv" fill={`var(--color-pv)`} stackId="a" /> */}
|
|
||||||
|
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="uv"
|
dataKey="uv"
|
||||||
@@ -371,31 +401,103 @@ export function DailyPVUVChart({
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
<VisSingleContainer data={{ areas: areaData }}>
|
<VisSingleContainer
|
||||||
<VisTopoJSONMap
|
data={{ areas: areaData }}
|
||||||
topojson={WorldMapTopoJSON}
|
width={wrapperWidth * 0.99}
|
||||||
// pointRadius={1.6}
|
>
|
||||||
// mapFitToPoints={true}
|
<VisTopoJSONMap topojson={WorldMapTopoJSON} />
|
||||||
/>
|
|
||||||
<VisTooltip triggers={triggers} />
|
<VisTooltip triggers={triggers} />
|
||||||
</VisSingleContainer>
|
</VisSingleContainer>
|
||||||
|
|
||||||
<div className="my-5 grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="my-5 grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
{refererStats.length > 0 && (
|
{/* Referrers、isBotStats */}
|
||||||
<StatsList data={refererStats} title="Referrers" />
|
<Tabs defaultValue="referrer">
|
||||||
)}
|
<TabsList>
|
||||||
{countryStats.length > 0 && (
|
<TabsTrigger value="referrer">{t("Referrers")}</TabsTrigger>
|
||||||
<StatsList data={countryStats} title="Countries" />
|
<TabsTrigger value="isBot">{t("Traffic Type")}</TabsTrigger>
|
||||||
)}
|
</TabsList>
|
||||||
{cityStats.length > 0 && (
|
<TabsContent className="h-[calc(100%-40px)]" value="referrer">
|
||||||
<StatsList data={cityStats} title="Cities" />
|
{refererStats.length > 0 && (
|
||||||
)}
|
<StatsList data={refererStats} title="Referrers" />
|
||||||
{browserStats.length > 0 && (
|
)}
|
||||||
<StatsList data={browserStats} title="Browsers" />
|
</TabsContent>
|
||||||
)}
|
<TabsContent className="h-[calc(100%-40px)]" value="isBot">
|
||||||
{deviceStats.length > 0 && (
|
{isBotStats.length > 0 && (
|
||||||
<StatsList data={deviceStats} title="Devices" />
|
<StatsList data={isBotStats} title="Is Bot" />
|
||||||
)}
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
{/* 国家、城市 */}
|
||||||
|
<Tabs defaultValue="country">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="country">{t("Country")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="city">{t("City")}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent className="h-[calc(100%-40px)]" value="country">
|
||||||
|
{countryStats.length > 0 && (
|
||||||
|
<StatsList data={countryStats} title="Countries" />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent className="h-[calc(100%-40px)]" value="city">
|
||||||
|
{cityStats.length > 0 && (
|
||||||
|
<StatsList data={cityStats} title="Cities" />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
{/* browserStats、engineStats */}
|
||||||
|
<Tabs defaultValue="browser">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="browser">{t("Browser")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="engine">{t("Engine")}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent className="h-[calc(100%-40px)]" value="browser">
|
||||||
|
{browserStats.length > 0 && (
|
||||||
|
<StatsList data={browserStats} title="Browsers" />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent className="h-[calc(100%-40px)]" value="engine">
|
||||||
|
{engineStats.length > 0 && (
|
||||||
|
<StatsList data={engineStats} title="Engines" />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Languages、regionStats */}
|
||||||
|
<Tabs className="h-full" defaultValue="language">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="language">{t("Language")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="region">{t("Region")}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent className="h-[calc(100%-40px)]" value="language">
|
||||||
|
{languageStats.length > 0 && (
|
||||||
|
<StatsList data={languageStats} title="Languages" />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent className="h-[calc(100%-40px)]" value="region">
|
||||||
|
{regionStats.length > 0 && (
|
||||||
|
<StatsList data={regionStats} title="Regions" />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
{/* deviceStats、osStats、cpuStats */}
|
||||||
|
<Tabs defaultValue="device">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="device">{t("Device")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="os">{t("OS")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="cpu">CPU</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent className="h-[calc(100%-40px)]" value="device">
|
||||||
|
{deviceStats.length > 0 && (
|
||||||
|
<StatsList data={deviceStats} title="Devices" />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent className="h-[calc(100%-40px)]" value="os">
|
||||||
|
{osStats.length > 0 && <StatsList data={osStats} title="OS" />}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent className="h-[calc(100%-40px)]" value="cpu">
|
||||||
|
{cpuStats.length > 0 && <StatsList data={cpuStats} title="CPU" />}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -405,14 +507,17 @@ 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="rounded-lg border p-4">
|
<div className="h-full rounded-lg border">
|
||||||
<h1 className="text-lg font-bold">{title}</h1>
|
<div className="flex items-center justify-between border-b px-5 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<span>{t("Name")}</span>
|
||||||
|
<span className="">{t("Visitors")}</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`scrollbar-hidden overflow-hidden overflow-y-auto transition-all duration-500 ease-in-out`}
|
className={`scrollbar-hidden overflow-hidden overflow-y-auto px-4 pb-4 pt-2 transition-all duration-500 ease-in-out`}
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "18rem", // 动态计算最大高度
|
maxHeight: "18rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{displayedData.map((ref) => (
|
{displayedData.map((ref) => (
|
||||||
@@ -454,7 +559,7 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.length > 8 && (
|
{data.length > 8 && (
|
||||||
<div className="mt-3 text-center">
|
<div className="mb-3 mt-1 text-center">
|
||||||
<Button
|
<Button
|
||||||
variant={"outline"}
|
variant={"outline"}
|
||||||
onClick={() => setShowAll(!showAll)}
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
|||||||
@@ -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,60 +28,70 @@ 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 p-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-40 w-full" />
|
<Skeleton className="h-[400px] w-full" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyPlaceholder>
|
<EmptyPlaceholder className="shadow-none">
|
||||||
<EmptyPlaceholder.Title>No Visits</EmptyPlaceholder.Title>
|
<EmptyPlaceholder.Title>{t("No Visits")}</EmptyPlaceholder.Title>
|
||||||
<EmptyPlaceholder.Description>
|
<EmptyPlaceholder.Description>
|
||||||
You don't have any visits yet in last {timeRange}.
|
{t("You don't have any visits yet in")}{" "}
|
||||||
<Select
|
{t(
|
||||||
onValueChange={(value: string) => {
|
DATE_DIMENSION_ENUMS.find((e) => e.value === timeRange)?.label ||
|
||||||
setTimeRange(value);
|
"",
|
||||||
}}
|
)}
|
||||||
name="time range"
|
.
|
||||||
defaultValue={timeRange}
|
{plan && (
|
||||||
>
|
<Select
|
||||||
<SelectTrigger className="mt-4 w-full shadow-inner">
|
onValueChange={(value: string) => {
|
||||||
<SelectValue placeholder="Select a time" />
|
setTimeRange(value);
|
||||||
</SelectTrigger>
|
}}
|
||||||
<SelectContent>
|
name="time range"
|
||||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
defaultValue={timeRange}
|
||||||
<div key={e.value}>
|
>
|
||||||
<SelectItem
|
<SelectTrigger className="mt-4 w-full shadow-inner">
|
||||||
disabled={
|
<SelectValue placeholder="Select a time" />
|
||||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
</SelectTrigger>
|
||||||
}
|
<SelectContent>
|
||||||
value={e.value}
|
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||||
>
|
<div key={e.value}>
|
||||||
<span className="flex items-center gap-1">
|
<SelectItem
|
||||||
{e.label}
|
disabled={e.key > plan.slAnalyticsRetention}
|
||||||
{e.key >
|
value={e.value}
|
||||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
>
|
||||||
<Icons.crown className="size-3" />
|
<span className="flex items-center gap-1">
|
||||||
)}
|
{t(e.label)}
|
||||||
</span>
|
{e.key > plan.slAnalyticsRetention && (
|
||||||
</SelectItem>
|
<Icons.crown className="size-3" />
|
||||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
)}
|
||||||
<SelectSeparator />
|
</span>
|
||||||
)}
|
</SelectItem>
|
||||||
</div>
|
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||||
))}
|
<SelectSeparator />
|
||||||
</SelectContent>
|
)}
|
||||||
</Select>
|
</div>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</EmptyPlaceholder.Description>
|
</EmptyPlaceholder.Description>
|
||||||
</EmptyPlaceholder>
|
</EmptyPlaceholder>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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: "Short URLs",
|
||||||
description: "List and manage records.",
|
description: "List and manage records.",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,10 +19,10 @@ export default async function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
heading="Manage Short URLs"
|
heading="Manage Short URLs"
|
||||||
text="List and manage short urls."
|
text="List and manage short urls"
|
||||||
link="/docs/short-urls"
|
link="/docs/short-urls"
|
||||||
linkText="short urls."
|
linkText="short urls"
|
||||||
/>
|
/>
|
||||||
<UserUrlsList
|
<UserUrlsList
|
||||||
user={{
|
user={{
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
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";
|
||||||
|
|
||||||
@@ -11,13 +13,23 @@ import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
|||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
expirationTime,
|
expirationTime,
|
||||||
|
extractHostname,
|
||||||
fetcher,
|
fetcher,
|
||||||
|
nFormatter,
|
||||||
removeUrlSuffix,
|
removeUrlSuffix,
|
||||||
timeAgo,
|
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
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 { 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";
|
||||||
import {
|
import {
|
||||||
@@ -35,15 +47,18 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { UrlStatus } from "@/components/dashboard/status-card";
|
||||||
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";
|
||||||
|
import BlurImage from "@/components/shared/blur-image";
|
||||||
import { CopyButton } from "@/components/shared/copy-button";
|
import { CopyButton } from "@/components/shared/copy-button";
|
||||||
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 { 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 Globe from "./globe";
|
||||||
import LiveLog from "./live-logs";
|
import LiveLog from "./live-logs";
|
||||||
@@ -86,6 +101,10 @@ function TableColumnSekleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UserUrlsList({ user, action }: UrlListProps) {
|
export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const t = useTranslations("List");
|
||||||
|
const [currentView, setCurrentView] = useState<string>("List");
|
||||||
const [isShowForm, setShowForm] = useState(false);
|
const [isShowForm, setShowForm] = useState(false);
|
||||||
const [formType, setFormType] = useState<FormType>("add");
|
const [formType, setFormType] = useState<FormType>("add");
|
||||||
const [currentEditUrl, setCurrentEditUrl] = useState<ShortUrlFormData | null>(
|
const [currentEditUrl, setCurrentEditUrl] = useState<ShortUrlFormData | null>(
|
||||||
@@ -101,6 +120,10 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
|||||||
target: "",
|
target: "",
|
||||||
userName: "",
|
userName: "",
|
||||||
});
|
});
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [currentListClickData, setCurrentListClickData] = useState<
|
||||||
|
Record<string, number>
|
||||||
|
>({});
|
||||||
|
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
const { data, isLoading } = useSWR<{
|
const { data, isLoading } = useSWR<{
|
||||||
@@ -114,6 +137,29 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentListIds = useMemo(() => {
|
||||||
|
return data?.list?.map((item) => item.id ?? "") ?? [];
|
||||||
|
}, [data?.list]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleGetUrlClicks();
|
||||||
|
}, [currentListIds]);
|
||||||
|
|
||||||
|
const handleGetUrlClicks = async () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
if (currentListIds.length > 0 && currentView !== "Realtime") {
|
||||||
|
const res = await fetch(action, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ids: currentListIds }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setCurrentListClickData(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
mutate(
|
mutate(
|
||||||
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
|
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
|
||||||
@@ -139,268 +185,516 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderList = () => (
|
const rendeEmpty = () => (
|
||||||
<div className="rounded-lg border p-4">
|
<EmptyPlaceholder className="col-span-full shadow-none">
|
||||||
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
<EmptyPlaceholder.Icon name="link" />
|
||||||
<div className="relative w-full">
|
<EmptyPlaceholder.Title>{t("No urls")}</EmptyPlaceholder.Title>
|
||||||
<Input
|
<EmptyPlaceholder.Description>
|
||||||
className="h-8 text-xs md:text-xs"
|
You don't have any url yet. Start creating url.
|
||||||
placeholder="Search by slug..."
|
</EmptyPlaceholder.Description>
|
||||||
value={searchParams.slug}
|
</EmptyPlaceholder>
|
||||||
onChange={(e) => {
|
);
|
||||||
setSearchParams({
|
|
||||||
...searchParams,
|
|
||||||
slug: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{searchParams.slug && (
|
|
||||||
<Button
|
|
||||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
|
||||||
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
|
|
||||||
variant={"ghost"}
|
|
||||||
>
|
|
||||||
<Icons.close className="size-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full">
|
const rendeSeachInputs = () => (
|
||||||
<Input
|
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
||||||
className="h-8 text-xs md:text-xs"
|
<div className="relative w-full">
|
||||||
placeholder="Search by target..."
|
<Input
|
||||||
value={searchParams.target}
|
className="h-8 text-xs md:text-xs"
|
||||||
onChange={(e) => {
|
placeholder={t("Search by slug") + "..."}
|
||||||
setSearchParams({
|
value={searchParams.slug}
|
||||||
...searchParams,
|
onChange={(e) => {
|
||||||
target: e.target.value,
|
setSearchParams({
|
||||||
});
|
...searchParams,
|
||||||
}}
|
slug: 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"
|
{searchParams.slug && (
|
||||||
onClick={() => setSearchParams({ ...searchParams, target: "" })}
|
<Button
|
||||||
variant={"ghost"}
|
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: "" })}
|
||||||
<Icons.close className="size-3" />
|
variant={"ghost"}
|
||||||
</Button>
|
>
|
||||||
)}
|
<Icons.close className="size-3" />
|
||||||
</div>
|
</Button>
|
||||||
|
|
||||||
{user.role === "ADMIN" && (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input
|
|
||||||
className="h-8 text-xs md:text-xs"
|
|
||||||
placeholder="Search by user name..."
|
|
||||||
value={searchParams.userName}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchParams({
|
|
||||||
...searchParams,
|
|
||||||
userName: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{searchParams.userName && (
|
|
||||||
<Button
|
|
||||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
|
||||||
onClick={() =>
|
|
||||||
setSearchParams({ ...searchParams, userName: "" })
|
|
||||||
}
|
|
||||||
variant={"ghost"}
|
|
||||||
>
|
|
||||||
<Icons.close className="size-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table>
|
<div className="relative w-full">
|
||||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
<Input
|
||||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
className="h-8 text-xs md:text-xs"
|
||||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
placeholder={t("Search by target") + "..."}
|
||||||
Slug
|
value={searchParams.target}
|
||||||
</TableHead>
|
onChange={(e) => {
|
||||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
setSearchParams({
|
||||||
Target
|
...searchParams,
|
||||||
</TableHead>
|
target: e.target.value,
|
||||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
});
|
||||||
User
|
}}
|
||||||
</TableHead>
|
/>
|
||||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
{searchParams.target && (
|
||||||
Enabled
|
<Button
|
||||||
</TableHead>
|
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
onClick={() => setSearchParams({ ...searchParams, target: "" })}
|
||||||
Expiration
|
variant={"ghost"}
|
||||||
</TableHead>
|
>
|
||||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
<Icons.close className="size-3" />
|
||||||
Updated
|
</Button>
|
||||||
</TableHead>
|
|
||||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
|
||||||
Created
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
|
||||||
Actions
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<TableColumnSekleton />
|
|
||||||
<TableColumnSekleton />
|
|
||||||
<TableColumnSekleton />
|
|
||||||
<TableColumnSekleton />
|
|
||||||
<TableColumnSekleton />
|
|
||||||
</>
|
|
||||||
) : data && data.list && data.list.length ? (
|
|
||||||
data.list.map((short) => (
|
|
||||||
<div className="border-b" key={short.id}>
|
|
||||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
|
||||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
|
||||||
<Link
|
|
||||||
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
|
||||||
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
|
||||||
target="_blank"
|
|
||||||
prefetch={false}
|
|
||||||
title={short.url}
|
|
||||||
>
|
|
||||||
{short.url}
|
|
||||||
</Link>
|
|
||||||
<CopyButton
|
|
||||||
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
|
||||||
className={cn(
|
|
||||||
"size-[25px]",
|
|
||||||
"duration-250 transition-all group-hover:opacity-100",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{short.password && (
|
|
||||||
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
|
|
||||||
<LinkInfoPreviewer
|
|
||||||
apiKey={user.apiKey ?? ""}
|
|
||||||
url={short.target}
|
|
||||||
formatUrl={removeUrlSuffix(short.target)}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={200}>
|
|
||||||
<TooltipTrigger className="truncate">
|
|
||||||
{short.userName ?? "Anonymous"}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{short.userName ?? "Anonymous"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="col-span-1 hidden sm:flex">
|
|
||||||
<Switch
|
|
||||||
defaultChecked={short.active === 1}
|
|
||||||
onCheckedChange={(value) =>
|
|
||||||
handleChangeStatu(value, short.id || "")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="col-span-1 hidden sm:flex">
|
|
||||||
{expirationTime(short.expiration, short.updatedAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
|
||||||
{timeAgo(short.updatedAt as Date)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
|
||||||
{timeAgo(short.createdAt as Date)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
|
||||||
<Button
|
|
||||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
|
||||||
size="sm"
|
|
||||||
variant={"outline"}
|
|
||||||
onClick={() => {
|
|
||||||
setCurrentEditUrl(short);
|
|
||||||
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>
|
|
||||||
<Button
|
|
||||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
|
||||||
size="sm"
|
|
||||||
variant={"outline"}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUrl(short);
|
|
||||||
setShowQrcode(!isShowQrcode);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icons.qrcode className="mx-0.5 size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUrl(short);
|
|
||||||
if (isShowStats && selectedUrl?.id !== short.id) {
|
|
||||||
} else {
|
|
||||||
setShowStats(!isShowStats);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icons.lineChart className="mx-0.5 size-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{isShowStats && selectedUrl?.id === short.id && (
|
|
||||||
<UserUrlMetaInfo
|
|
||||||
user={{
|
|
||||||
id: user.id,
|
|
||||||
name: user.name || "",
|
|
||||||
team: user.team,
|
|
||||||
}}
|
|
||||||
action="/api/url/meta"
|
|
||||||
urlId={short.id!}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<EmptyPlaceholder>
|
|
||||||
<EmptyPlaceholder.Icon name="link" />
|
|
||||||
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
|
|
||||||
<EmptyPlaceholder.Description>
|
|
||||||
You don't have any url yet. Start creating url.
|
|
||||||
</EmptyPlaceholder.Description>
|
|
||||||
</EmptyPlaceholder>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
|
||||||
<PaginationWrapper
|
|
||||||
total={data.total}
|
|
||||||
currentPage={currentPage}
|
|
||||||
setCurrentPage={setCurrentPage}
|
|
||||||
pageSize={pageSize}
|
|
||||||
setPageSize={setPageSize}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Table>
|
</div>
|
||||||
|
|
||||||
|
{user.role === "ADMIN" && (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs md:text-xs"
|
||||||
|
placeholder={t("Search by username") + "..."}
|
||||||
|
value={searchParams.userName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchParams({
|
||||||
|
...searchParams,
|
||||||
|
userName: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{searchParams.userName && (
|
||||||
|
<Button
|
||||||
|
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={() => setSearchParams({ ...searchParams, userName: "" })}
|
||||||
|
variant={"ghost"}
|
||||||
|
>
|
||||||
|
<Icons.close className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rendeClicks = (short: ShortUrlFormData) => (
|
||||||
|
<>
|
||||||
|
<Icons.mousePointerClick className="size-[14px]" />
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-4 w-6 rounded" />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs font-medium text-gray-700 dark:text-gray-50">
|
||||||
|
{(short.id && nFormatter(currentListClickData[short.id], 2)) || "-"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rendeStats = (short: ShortUrlFormData) =>
|
||||||
|
isShowStats &&
|
||||||
|
selectedUrl?.id === short.id && (
|
||||||
|
<UserUrlMetaInfo
|
||||||
|
user={{
|
||||||
|
id: user.id,
|
||||||
|
name: user.name || "",
|
||||||
|
team: user.team,
|
||||||
|
}}
|
||||||
|
action="/api/url/meta"
|
||||||
|
urlId={short.id!}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rendeList = () => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||||
|
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||||
|
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||||
|
{t("Slug")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||||
|
{t("Target")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||||
|
{t("User")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||||
|
{t("Enabled")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||||
|
{t("Expiration")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||||
|
{t("Clicks")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||||
|
{t("Updated")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||||
|
{t("Actions")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<TableColumnSekleton />
|
||||||
|
<TableColumnSekleton />
|
||||||
|
<TableColumnSekleton />
|
||||||
|
<TableColumnSekleton />
|
||||||
|
<TableColumnSekleton />
|
||||||
|
</>
|
||||||
|
) : data && data.list && data.list.length ? (
|
||||||
|
data.list.map((short) => (
|
||||||
|
<div className="border-b" key={short.id}>
|
||||||
|
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||||
|
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||||
|
<Link
|
||||||
|
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
||||||
|
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||||
|
target="_blank"
|
||||||
|
prefetch={false}
|
||||||
|
title={short.url}
|
||||||
|
>
|
||||||
|
{short.url}
|
||||||
|
</Link>
|
||||||
|
<CopyButton
|
||||||
|
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||||
|
className={cn(
|
||||||
|
"size-[25px]",
|
||||||
|
"duration-250 transition-all group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{short.password && (
|
||||||
|
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
|
||||||
|
<LinkInfoPreviewer
|
||||||
|
apiKey={user.apiKey ?? ""}
|
||||||
|
url={short.target}
|
||||||
|
formatUrl={removeUrlSuffix(short.target)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger className="truncate">
|
||||||
|
{short.userName ?? "Anonymous"}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{short.userName ?? "Anonymous"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden sm:flex">
|
||||||
|
<Switch
|
||||||
|
defaultChecked={short.active === 1}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
handleChangeStatu(value, short.id || "")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden sm:flex">
|
||||||
|
{expirationTime(short.expiration, short.updatedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border bg-gray-50 px-2 py-1 dark:bg-gray-600/50">
|
||||||
|
{rendeClicks(short)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||||
|
<TimeAgoIntl date={short.updatedAt as Date} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||||
|
<Button
|
||||||
|
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
|
||||||
|
size="sm"
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentEditUrl(short);
|
||||||
|
setShowForm(false);
|
||||||
|
setFormType("edit");
|
||||||
|
setShowForm(!isShowForm);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="hidden text-nowrap sm:block">{t("Edit")}</p>
|
||||||
|
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||||
|
size="sm"
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUrl(short);
|
||||||
|
setShowQrcode(!isShowQrcode);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.qrcode className="mx-0.5 size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUrl(short);
|
||||||
|
setCurrentView(short.id!);
|
||||||
|
if (isShowStats && selectedUrl?.id !== short.id) {
|
||||||
|
} else {
|
||||||
|
setShowStats(!isShowStats);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.lineChart className="mx-0.5 size-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/* {rendeStats(short)} */}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
rendeEmpty()
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||||
|
<PaginationWrapper
|
||||||
|
layout={isMobile ? "right" : "split"}
|
||||||
|
total={data.total}
|
||||||
|
currentPage={currentPage}
|
||||||
|
setCurrentPage={setCurrentPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
setPageSize={setPageSize}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rendeGrid = () => (
|
||||||
|
<>
|
||||||
|
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((v) => (
|
||||||
|
<Skeleton key={v} className="h-24 w-full" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : data && data.list && data.list.length ? (
|
||||||
|
data.list.map((short) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-24 rounded-lg border p-1 shadow-inner dark:bg-neutral-800",
|
||||||
|
)}
|
||||||
|
key={short.id}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col rounded-lg border border-dotted bg-white px-3 py-1.5 backdrop-blur-lg dark:bg-black">
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<BlurImage
|
||||||
|
src={`https://unavatar.io/${extractHostname(short.target)}?fallback=https://wr.do/logo.png`}
|
||||||
|
alt="logo"
|
||||||
|
width={30}
|
||||||
|
height={30}
|
||||||
|
className="rounded-md"
|
||||||
|
/>
|
||||||
|
<div className="ml-2 mr-auto flex flex-col justify-between truncate">
|
||||||
|
{/* url */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Link
|
||||||
|
className="overflow-hidden text-ellipsis whitespace-normal text-sm font-semibold text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-300"
|
||||||
|
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||||
|
target="_blank"
|
||||||
|
prefetch={false}
|
||||||
|
title={short.url}
|
||||||
|
>
|
||||||
|
{short.url}
|
||||||
|
</Link>
|
||||||
|
<CopyButton
|
||||||
|
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||||
|
className={cn(
|
||||||
|
"size-[25px]",
|
||||||
|
"duration-250 transition-all group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="duration-250 size-[26px] p-1.5 text-foreground transition-all hover:border hover:text-foreground dark:text-foreground"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUrl(short);
|
||||||
|
setShowQrcode(!isShowQrcode);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.qrcode className="size-4" />
|
||||||
|
</Button>
|
||||||
|
{short.password && (
|
||||||
|
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* target */}
|
||||||
|
<div className="flex items-center gap-1 overflow-hidden truncate text-sm text-muted-foreground">
|
||||||
|
<Icons.forwardArrow className="size-4 shrink-0 text-gray-400" />
|
||||||
|
<LinkInfoPreviewer
|
||||||
|
apiKey={user.apiKey ?? ""}
|
||||||
|
url={short.target}
|
||||||
|
formatUrl={removeUrlSuffix(short.target)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-2 flex items-center gap-1 rounded-md border bg-gray-50 px-2 py-1 dark:bg-gray-600/50">
|
||||||
|
{rendeClicks(short)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="size-[25px] p-1.5"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Icons.moreVertical className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Button
|
||||||
|
className="flex w-full items-center gap-2"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUrl(short);
|
||||||
|
setCurrentView(short.id!);
|
||||||
|
if (isShowStats && selectedUrl?.id !== short.id) {
|
||||||
|
} else {
|
||||||
|
setShowStats(!isShowStats);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.lineChart className="size-4" />
|
||||||
|
{t("Analytics")}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Button
|
||||||
|
className="flex w-full items-center gap-2"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentEditUrl(short);
|
||||||
|
setShowForm(false);
|
||||||
|
setFormType("edit");
|
||||||
|
setShowForm(!isShowForm);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PenLine className="size-4" />
|
||||||
|
{t("Edit URL")}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger className="truncate">
|
||||||
|
{short.userName ?? "Anonymous"}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{short.userName ?? "Anonymous"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<Separator
|
||||||
|
className="h-4/5"
|
||||||
|
orientation="vertical"
|
||||||
|
></Separator>
|
||||||
|
{short.expiration !== "-1" && (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
Expiration:{" "}
|
||||||
|
{expirationTime(short.expiration, short.updatedAt)}
|
||||||
|
</span>
|
||||||
|
<Separator
|
||||||
|
className="h-4/5"
|
||||||
|
orientation="vertical"
|
||||||
|
></Separator>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<TimeAgoIntl date={short.updatedAt as Date} />
|
||||||
|
<Switch
|
||||||
|
className="scale-[0.6]"
|
||||||
|
defaultChecked={short.active === 1}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
handleChangeStatu(value, short.id || "")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
rendeEmpty()
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||||
|
<PaginationWrapper
|
||||||
|
layout={isMobile ? "right" : "split"}
|
||||||
|
total={data.total}
|
||||||
|
currentPage={currentPage}
|
||||||
|
setCurrentPage={setCurrentPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
setPageSize={setPageSize}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue="Links">
|
<Tabs
|
||||||
|
className={cn("rounded-lg", pathname === "/dashboard" && "border p-6")}
|
||||||
|
value={currentView}
|
||||||
|
>
|
||||||
|
{/* Tabs */}
|
||||||
<div className="mb-4 flex items-center justify-between gap-2">
|
<div className="mb-4 flex items-center justify-between gap-2">
|
||||||
|
{pathname === "/dashboard" && (
|
||||||
|
<h2 className="mr-3 text-lg font-semibold">{t("Short URLs")}</h2>
|
||||||
|
)}
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="Links">Links</TabsTrigger>
|
<TabsTrigger onClick={() => setCurrentView("List")} value="List">
|
||||||
<TabsTrigger value="Realtime">Realtime</TabsTrigger>
|
<Icons.list className="size-4" />
|
||||||
|
{/* List */}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger onClick={() => setCurrentView("Grid")} value="Grid">
|
||||||
|
<Icons.layoutGrid className="size-4" />
|
||||||
|
{/* Grid */}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
onClick={() => setCurrentView("Realtime")}
|
||||||
|
value="Realtime"
|
||||||
|
>
|
||||||
|
<Icons.globe className="size-4 text-blue-500" />
|
||||||
|
{/* Realtime */}
|
||||||
|
</TabsTrigger>
|
||||||
|
{selectedUrl?.id && (
|
||||||
|
<TabsTrigger
|
||||||
|
className="flex items-center gap-1 text-muted-foreground"
|
||||||
|
value={selectedUrl.id}
|
||||||
|
onClick={() => setCurrentView(selectedUrl.id!)}
|
||||||
|
>
|
||||||
|
<Icons.lineChart className="size-4" />
|
||||||
|
{selectedUrl.url}
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
{/* <p>Total: {data?.total || 0}</p> */}
|
||||||
<div className="ml-auto flex items-center justify-end gap-3">
|
<div className="ml-auto flex items-center justify-end gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant={"outline"}
|
variant={"outline"}
|
||||||
@@ -413,34 +707,44 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
|||||||
<RefreshCwIcon className="size-4" />
|
<RefreshCwIcon 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="Links">
|
<TabsContent className="space-y-3" value="List">
|
||||||
{renderList()}
|
<UrlStatus action={action} />
|
||||||
<LiveLog admin={action.indexOf("admin") > -1} />
|
{rendeSeachInputs()}
|
||||||
<ApiReference
|
{rendeList()}
|
||||||
badge="POST /api/v1/short"
|
{rendLogs()}
|
||||||
target="creating short urls"
|
</TabsContent>
|
||||||
link="/docs/short-urls#api-reference"
|
<TabsContent className="space-y-3" value="Grid">
|
||||||
/>
|
<UrlStatus action={action} />
|
||||||
|
{rendeSeachInputs()}
|
||||||
|
{rendeGrid()}
|
||||||
|
{rendLogs()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="Realtime">
|
<TabsContent value="Realtime">
|
||||||
<Globe isAdmin={action.indexOf("admin") > -1} />
|
{action.indexOf("admin") > -1 ? <Globe isAdmin={true} /> : <Globe />}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
{selectedUrl?.id && (
|
||||||
|
<TabsContent value={selectedUrl.id}>
|
||||||
|
{rendeStats(selectedUrl)}
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* QR code editor */}
|
{/* QR code editor */}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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, removeUrlSuffix } 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,21 +198,20 @@ 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
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className=""
|
variant={"default"}
|
||||||
variant={"outline"}
|
|
||||||
size={"sm"}
|
size={"sm"}
|
||||||
onClick={handleSetAdmin}
|
onClick={handleSetAdmin}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
@@ -224,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>
|
||||||
@@ -257,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");
|
||||||
@@ -293,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
|
||||||
@@ -308,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>
|
||||||
|
|
||||||
@@ -319,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"
|
||||||
@@ -331,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>
|
||||||
|
|||||||
@@ -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 Short 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" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
70
app/api/admin/configs/route.ts
Normal file
70
app/api/admin/configs/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/admin/domain/duplicate/route.ts
Normal file
54
app/api/admin/domain/duplicate/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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,
|
||||||
|
resend_api_key: target_domain.resend_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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,10 +58,15 @@ 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,
|
||||||
|
resend_api_key: data.resend_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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +94,11 @@ 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,
|
||||||
|
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,
|
||||||
@@ -108,7 +118,12 @@ 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,
|
||||||
|
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,
|
||||||
|
|||||||
138
app/api/admin/plan/route.ts
Normal file
138
app/api/admin/plan/route.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
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,
|
||||||
|
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,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/api/auth/credentials/route.ts
Normal file
50
app/api/auth/credentials/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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 {
|
||||||
|
const passwordCorrect = verifyPassword(password, user.password || "");
|
||||||
|
if (passwordCorrect) {
|
||||||
|
return Response.json(user, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(null, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Auth Error]", error);
|
||||||
|
return Response.json(error.message || "Server error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/configs/route.ts
Normal file
35
app/api/configs/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const allowed_keys = [
|
||||||
|
"enable_user_registration",
|
||||||
|
"enable_subdomain_apply",
|
||||||
|
"system_notification",
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const keys = url.searchParams.getAll("key") || [];
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return Response.json("key is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!allowed_keys.includes(key)) {
|
||||||
|
return Response.json("Invalid key", { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs = await getMultipleConfigs(keys);
|
||||||
|
|
||||||
|
return Response.json(configs, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Error]", error);
|
||||||
|
return Response.json(error.message || "Server error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/api/domain/check-cf/route.ts
Normal file
24
app/api/domain/check-cf/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getZoneDetail } from "@/lib/cloudflare";
|
||||||
|
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;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const zone_id = url.searchParams.get("zone_id") || "";
|
||||||
|
const api_key = url.searchParams.get("api_key") || "";
|
||||||
|
const email = url.searchParams.get("email") || "";
|
||||||
|
|
||||||
|
const res = await getZoneDetail(zone_id, api_key, email);
|
||||||
|
|
||||||
|
if (res === 200) return Response.json(200, { status: 200 });
|
||||||
|
else return Response.json(400, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(500, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/api/domain/check-resend/route.ts
Normal file
37
app/api/domain/check-resend/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { Resend } from "resend";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const api_key = url.searchParams.get("api_key") || "";
|
||||||
|
const domain = url.searchParams.get("domain") || "";
|
||||||
|
|
||||||
|
if (!api_key || !domain) {
|
||||||
|
return Response.json(400, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resend = new Resend(api_key);
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from: `test@${domain}`,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Test Resend API Key",
|
||||||
|
html: "This is a test email sent using Resend API Key.",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
return Response.json(400, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(200, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(500, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { Resend } from "resend";
|
||||||
|
|
||||||
import { TeamPlanQuota } from "@/config/team";
|
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 { 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";
|
||||||
@@ -13,11 +14,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)
|
||||||
@@ -33,6 +36,18 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json("Invalid email address", { status: 403 });
|
return NextResponse.json("Invalid email address", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resend_key = await checkDomainIsConfiguratedResend(
|
||||||
|
from.split("@")[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resend_key) {
|
||||||
|
return NextResponse.json(
|
||||||
|
"This domain is not configured for sending emails",
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resend = new Resend(resend_key);
|
||||||
const { error } = await resend.emails.send({
|
const { error } = await resend.emails.send({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
@@ -41,8 +56,10 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log("Resend error:", error);
|
console.log("Resend error:", error); // ???如果删掉这句log,下面一行读取error的message会返回undefined
|
||||||
return NextResponse.json("Failed to send email", { status: 500 });
|
return NextResponse.json(`${error.message}`, {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveUserSendEmail(user.id, from, to, subject, html);
|
await saveUserSendEmail(user.id, from, to, subject, html);
|
||||||
|
|||||||
32
app/api/feature/route.ts
Normal file
32
app/api/feature/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
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_registration_suffix_limit",
|
||||||
|
"email_registration_suffix_limit_white_list",
|
||||||
|
]);
|
||||||
|
return Response.json({
|
||||||
|
google: configs.enable_google_oauth,
|
||||||
|
github: configs.enable_github_oauth,
|
||||||
|
linuxdo: configs.enable_liunxdo_oauth,
|
||||||
|
resend: configs.enable_resend_email_login,
|
||||||
|
credentials: configs.enable_email_password_login,
|
||||||
|
registration: configs.enable_user_registration,
|
||||||
|
enableSuffixLimit: configs.enable_email_registration_suffix_limit,
|
||||||
|
suffixWhiteList: configs.email_registration_suffix_limit_white_list,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[Error]", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { env } from "@/env.mjs";
|
|
||||||
import { generateApiKey } from "@/lib/dto/api-key";
|
import { generateApiKey } from "@/lib/dto/api-key";
|
||||||
import { checkUserStatus } from "@/lib/dto/user";
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ interface CurrentLocation {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const geo = geolocation(req);
|
const geo = geolocation(req);
|
||||||
|
|||||||
25
app/api/plan/names/route.ts
Normal file
25
app/api/plan/names/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getPlanNames } from "@/lib/dto/plan";
|
||||||
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Get plan names for frontend
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
|
||||||
|
const res = await getPlanNames();
|
||||||
|
if (!res) {
|
||||||
|
return Response.json("Plans not found", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(res, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Error]", error);
|
||||||
|
return Response.json(error.message || "Server error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/plan/route.ts
Normal file
30
app/api/plan/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getAllPlans, getPlanQuota } from "@/lib/dto/plan";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Get one plan by plan name for frontend
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const isAll = url.searchParams.get("all") || "0";
|
||||||
|
const team = url.searchParams.get("team") || "free";
|
||||||
|
|
||||||
|
if (isAll === "1") {
|
||||||
|
const res = await getAllPlans();
|
||||||
|
if (res) {
|
||||||
|
return Response.json(res, { status: 200 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await getPlanQuota(team);
|
||||||
|
if (res) {
|
||||||
|
return Response.json(res, { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json("Plan not found", { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Error]", error);
|
||||||
|
return Response.json(error.message || "Server error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TeamPlanQuota } from "@/config/team";
|
import { env } from "@/env.mjs";
|
||||||
|
import { siteConfig } from "@/config/site";
|
||||||
import { createDNSRecord } from "@/lib/cloudflare";
|
import { createDNSRecord } from "@/lib/cloudflare";
|
||||||
import {
|
import {
|
||||||
createUserRecord,
|
createUserRecord,
|
||||||
@@ -6,7 +7,10 @@ import {
|
|||||||
getUserRecordCount,
|
getUserRecordCount,
|
||||||
} from "@/lib/dto/cloudflare-dns-record";
|
} from "@/lib/dto/cloudflare-dns-record";
|
||||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||||
import { checkUserStatus } from "@/lib/dto/user";
|
import { getPlanQuota } from "@/lib/dto/plan";
|
||||||
|
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||||
|
import { checkUserStatus, getFirstAdminUser } from "@/lib/dto/user";
|
||||||
|
import { applyRecordEmailHtml, resend } from "@/lib/email";
|
||||||
import { reservedDomains } from "@/lib/enums";
|
import { reservedDomains } from "@/lib/enums";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { generateSecret } from "@/lib/utils";
|
import { generateSecret } from "@/lib/utils";
|
||||||
@@ -24,8 +28,10 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plan = await getPlanQuota(user.team);
|
||||||
|
|
||||||
const { total } = await getUserRecordCount(user.id);
|
const { total } = await getUserRecordCount(user.id);
|
||||||
if (total >= TeamPlanQuota[user.team].RC_NewRecords) {
|
if (total >= plan.rcNewRecords) {
|
||||||
return Response.json("Your records have reached the free limit.", {
|
return Response.json("Your records have reached the free limit.", {
|
||||||
status: 409,
|
status: 409,
|
||||||
});
|
});
|
||||||
@@ -37,7 +43,13 @@ export async function POST(req: Request) {
|
|||||||
id: generateSecret(16),
|
id: generateSecret(16),
|
||||||
};
|
};
|
||||||
|
|
||||||
let record_name = ["A", "CNAME"].includes(record.type)
|
if (reservedDomains.includes(record.name)) {
|
||||||
|
return Response.json("Domain name is reserved", {
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let record_name = ["A", "CNAME", "AAAA"].includes(record.type)
|
||||||
? record.name
|
? record.name
|
||||||
: `${record.name}.${record.zone_name}`;
|
: `${record.name}.${record.zone_name}`;
|
||||||
|
|
||||||
@@ -52,33 +64,76 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
if (!matchedZone) {
|
if (!matchedZone) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
`No matching zone found for domain: ${record_name}`,
|
`No matching zone found for domain: ${record.zone_name}`,
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
statusText: "Invalid domain",
|
statusText: "Invalid zone name",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reservedDomains.includes(record_name)) {
|
|
||||||
return Response.json("Domain name is reserved", {
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const user_record = await getUserRecordByTypeNameContent(
|
const user_record = await getUserRecordByTypeNameContent(
|
||||||
user.id,
|
user.id,
|
||||||
record.type,
|
record.type,
|
||||||
record_name,
|
record_name,
|
||||||
record.content,
|
record.content,
|
||||||
|
record.zone_name,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
if (user_record && user_record.length > 0) {
|
if (user_record && user_record.length > 0) {
|
||||||
return Response.json("Record already exists", {
|
return Response.json("Record already exists", {
|
||||||
status: 403,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configs = await getMultipleConfigs([
|
||||||
|
"enable_subdomain_apply",
|
||||||
|
"enable_subdomain_status_email_pusher",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// apply subdomain
|
||||||
|
if (configs.enable_subdomain_apply) {
|
||||||
|
const res = await createUserRecord(user.id, {
|
||||||
|
record_id: generateSecret(16),
|
||||||
|
zone_id: matchedZone.cf_zone_id,
|
||||||
|
zone_name: matchedZone.domain_name,
|
||||||
|
name: record.name,
|
||||||
|
type: record.type,
|
||||||
|
content: record.content,
|
||||||
|
proxied: record.proxied,
|
||||||
|
proxiable: false,
|
||||||
|
ttl: record.ttl,
|
||||||
|
comment: record.comment,
|
||||||
|
tags: "",
|
||||||
|
created_on: new Date().toISOString(),
|
||||||
|
modified_on: new Date().toISOString(),
|
||||||
|
active: 2, // pending
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== "success") {
|
||||||
|
return Response.json(res.status, {
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const admin_user = await getFirstAdminUser();
|
||||||
|
if (configs.enable_subdomain_status_email_pusher && admin_user) {
|
||||||
|
await resend.emails.send({
|
||||||
|
from: env.RESEND_FROM_EMAIL,
|
||||||
|
to: admin_user.email || "",
|
||||||
|
subject: "New record pending approval",
|
||||||
|
html: applyRecordEmailHtml({
|
||||||
|
appUrl: siteConfig.url,
|
||||||
|
appName: siteConfig.name,
|
||||||
|
zone_name: record.zone_name,
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.content,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json(res.data?.id);
|
||||||
|
}
|
||||||
|
|
||||||
const data = await createDNSRecord(
|
const data = await createDNSRecord(
|
||||||
matchedZone.cf_zone_id,
|
matchedZone.cf_zone_id,
|
||||||
matchedZone.cf_api_key,
|
matchedZone.cf_api_key,
|
||||||
@@ -88,7 +143,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
if (!data.success || !data.result?.id) {
|
if (!data.success || !data.result?.id) {
|
||||||
// console.log("[data]", data);
|
// console.log("[data]", data);
|
||||||
return Response.json(data.messages, {
|
return Response.json(data.errors[0].message, {
|
||||||
status: 501,
|
status: 501,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { TeamPlanQuota } from "@/config/team";
|
|
||||||
import { createDNSRecord } from "@/lib/cloudflare";
|
import { createDNSRecord } from "@/lib/cloudflare";
|
||||||
import {
|
import {
|
||||||
createUserRecord,
|
createUserRecord,
|
||||||
@@ -6,8 +5,8 @@ import {
|
|||||||
getUserRecordCount,
|
getUserRecordCount,
|
||||||
} from "@/lib/dto/cloudflare-dns-record";
|
} from "@/lib/dto/cloudflare-dns-record";
|
||||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||||
|
import { getPlanQuota } from "@/lib/dto/plan";
|
||||||
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
|
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
|
||||||
import { reservedDomains } from "@/lib/enums";
|
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
import { generateSecret } from "@/lib/utils";
|
import { generateSecret } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -39,8 +38,10 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plan = await getPlanQuota(user.team);
|
||||||
|
|
||||||
const { total } = await getUserRecordCount(target_user.id);
|
const { total } = await getUserRecordCount(target_user.id);
|
||||||
if (total >= TeamPlanQuota[target_user.team!].RC_NewRecords) {
|
if (total >= plan.rcNewRecords) {
|
||||||
return Response.json("Your records have reached the free limit.", {
|
return Response.json("Your records have reached the free limit.", {
|
||||||
status: 409,
|
status: 409,
|
||||||
});
|
});
|
||||||
@@ -51,7 +52,7 @@ export async function POST(req: Request) {
|
|||||||
id: generateSecret(16),
|
id: generateSecret(16),
|
||||||
};
|
};
|
||||||
|
|
||||||
let record_name = ["A", "CNAME"].includes(record.type)
|
let record_name = ["A", "CNAME", "AAAA"].includes(record.type)
|
||||||
? record.name
|
? record.name
|
||||||
: `${record.name}.${record.zone_name}`;
|
: `${record.name}.${record.zone_name}`;
|
||||||
|
|
||||||
@@ -74,17 +75,12 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reservedDomains.includes(record_name)) {
|
|
||||||
return Response.json("Domain name is reserved", {
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const user_record = await getUserRecordByTypeNameContent(
|
const user_record = await getUserRecordByTypeNameContent(
|
||||||
target_user.id,
|
target_user.id,
|
||||||
record.type,
|
record.type,
|
||||||
record_name,
|
record_name,
|
||||||
record.content,
|
record.content,
|
||||||
|
record.zone_name,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
if (user_record && user_record.length > 0) {
|
if (user_record && user_record.length > 0) {
|
||||||
@@ -102,7 +98,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
if (!data.success || !data.result?.id) {
|
if (!data.success || !data.result?.id) {
|
||||||
// console.log("[data]", data);
|
// console.log("[data]", data);
|
||||||
return Response.json(data.messages, {
|
return Response.json(data.errors[0].message, {
|
||||||
status: 501,
|
status: 501,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
100
app/api/record/admin/apply/route.ts
Normal file
100
app/api/record/admin/apply/route.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import { siteConfig } from "@/config/site";
|
||||||
|
import { createDNSRecord } from "@/lib/cloudflare";
|
||||||
|
import { updateUserRecordReview } from "@/lib/dto/cloudflare-dns-record";
|
||||||
|
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||||
|
import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||||
|
import { checkUserStatus, getUserById } from "@/lib/dto/user";
|
||||||
|
import { applyRecordToUserEmailHtml, resend } from "@/lib/email";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
|
||||||
|
const zones = await getDomainsByFeature("enable_dns", true);
|
||||||
|
if (!zones.length) {
|
||||||
|
return Response.json("Please add at least one domain", {
|
||||||
|
status: 400,
|
||||||
|
statusText: "Please add at least one domain",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { record: reviewRecord, userId, id } = await req.json();
|
||||||
|
const record = {
|
||||||
|
...reviewRecord,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let matchedZone;
|
||||||
|
|
||||||
|
for (const zone of zones) {
|
||||||
|
if (record.zone_name === zone.domain_name) {
|
||||||
|
matchedZone = zone;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await createDNSRecord(
|
||||||
|
matchedZone.cf_zone_id,
|
||||||
|
matchedZone.cf_api_key,
|
||||||
|
matchedZone.cf_email,
|
||||||
|
record,
|
||||||
|
);
|
||||||
|
|
||||||
|
// console.log("[data]", data);
|
||||||
|
|
||||||
|
if (!data.success || !data.result?.id) {
|
||||||
|
return Response.json(data.errors[0].message, {
|
||||||
|
status: 503,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const res = await updateUserRecordReview(userId, id, {
|
||||||
|
record_id: data.result.id,
|
||||||
|
zone_id: matchedZone.cf_zone_id,
|
||||||
|
zone_name: matchedZone.domain_name,
|
||||||
|
name: data.result.name,
|
||||||
|
type: data.result.type,
|
||||||
|
content: data.result.content,
|
||||||
|
proxied: data.result.proxied,
|
||||||
|
proxiable: data.result.proxiable,
|
||||||
|
ttl: data.result.ttl,
|
||||||
|
comment: data.result.comment ?? "",
|
||||||
|
tags: data.result.tags?.join("") ?? "",
|
||||||
|
created_on: data.result.created_on,
|
||||||
|
modified_on: data.result.modified_on,
|
||||||
|
active: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configs = await getMultipleConfigs([
|
||||||
|
"enable_subdomain_status_email_pusher",
|
||||||
|
]);
|
||||||
|
const userInfo = await getUserById(userId);
|
||||||
|
if (configs.enable_subdomain_status_email_pusher && userInfo) {
|
||||||
|
await resend.emails.send({
|
||||||
|
from: env.RESEND_FROM_EMAIL,
|
||||||
|
to: userInfo.email || "",
|
||||||
|
subject: "Your subdomain has been applied",
|
||||||
|
html: applyRecordToUserEmailHtml({
|
||||||
|
appUrl: siteConfig.url,
|
||||||
|
appName: siteConfig.name,
|
||||||
|
subdomain: data.result.name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== "success") {
|
||||||
|
return Response.json(res.status, {
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json(res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[错误]", error);
|
||||||
|
return Response.json(error, {
|
||||||
|
status: error?.status || 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/api/record/admin/reject/route.ts
Normal file
71
app/api/record/admin/reject/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { createDNSRecord } from "@/lib/cloudflare";
|
||||||
|
import { updateUserRecordReview } from "@/lib/dto/cloudflare-dns-record";
|
||||||
|
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||||
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
|
||||||
|
const zones = await getDomainsByFeature("enable_dns", true);
|
||||||
|
if (!zones.length) {
|
||||||
|
return Response.json("Please add at least one domain", {
|
||||||
|
status: 400,
|
||||||
|
statusText: "Please add at least one domain",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { record: reviewRecord, userId, recordId, id } = await req.json();
|
||||||
|
const record = {
|
||||||
|
...reviewRecord,
|
||||||
|
recordId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let matchedZone;
|
||||||
|
|
||||||
|
for (const zone of zones) {
|
||||||
|
if (record.zone_name === zone.domain_name) {
|
||||||
|
matchedZone = zone;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const data = await createDNSRecord(
|
||||||
|
// matchedZone.cf_zone_id,
|
||||||
|
// matchedZone.cf_api_key,
|
||||||
|
// matchedZone.cf_email,
|
||||||
|
// record,
|
||||||
|
// );
|
||||||
|
|
||||||
|
const res = await updateUserRecordReview(userId, id, {
|
||||||
|
record_id: recordId,
|
||||||
|
zone_id: matchedZone.cf_zone_id,
|
||||||
|
zone_name: matchedZone.domain_name,
|
||||||
|
name: record.name,
|
||||||
|
type: record.type,
|
||||||
|
content: record.content,
|
||||||
|
proxied: record.proxied,
|
||||||
|
proxiable: record.proxiable,
|
||||||
|
ttl: record.ttl,
|
||||||
|
comment: record.comment ?? "",
|
||||||
|
tags: "",
|
||||||
|
created_on: new Date().toISOString(),
|
||||||
|
modified_on: new Date().toISOString(),
|
||||||
|
active: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== "success") {
|
||||||
|
return Response.json(res.status, {
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[错误]", error);
|
||||||
|
return Response.json(error, {
|
||||||
|
status: error?.status || 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/api/record/admin/status/route.ts
Normal file
23
app/api/record/admin/status/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getUserRecordStatus } from "@/lib/dto/cloudflare-dns-record";
|
||||||
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
if (user.role !== "ADMIN") {
|
||||||
|
return Response.json("Unauthorized", {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await getUserRecordStatus(user.id, "ADMIN");
|
||||||
|
|
||||||
|
return Response.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(error?.statusText || error, {
|
||||||
|
status: error.status || 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let record_name = ["A", "CNAME"].includes(record.type)
|
let record_name = ["A", "CNAME", "AAAA"].includes(record.type)
|
||||||
? record.name
|
? record.name
|
||||||
: `${record.name}.${record.zone_name}`;
|
: `${record.name}.${record.zone_name}`;
|
||||||
|
|
||||||
|
|||||||
@@ -28,14 +28,22 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await deleteDNSRecord(
|
if (active !== 3) {
|
||||||
matchedZone.cf_zone_id!,
|
const res = await deleteDNSRecord(
|
||||||
matchedZone.cf_api_key!,
|
matchedZone.cf_zone_id!,
|
||||||
matchedZone.cf_email!,
|
matchedZone.cf_api_key!,
|
||||||
record_id,
|
matchedZone.cf_email!,
|
||||||
);
|
record_id,
|
||||||
|
);
|
||||||
|
|
||||||
if (res && res.result?.id) {
|
if (res && res.result?.id) {
|
||||||
|
await deleteUserRecord(user.id, record_id, zone_id, active);
|
||||||
|
return Response.json("success", {
|
||||||
|
status: 200,
|
||||||
|
statusText: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
await deleteUserRecord(user.id, record_id, zone_id, active);
|
await deleteUserRecord(user.id, record_id, zone_id, active);
|
||||||
return Response.json("success", {
|
return Response.json("success", {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
18
app/api/record/status/route.ts
Normal file
18
app/api/record/status/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { getUserRecordStatus } from "@/lib/dto/cloudflare-dns-record";
|
||||||
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
|
||||||
|
const status = await getUserRecordStatus(user.id, "USER");
|
||||||
|
|
||||||
|
return Response.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(error?.statusText || error, {
|
||||||
|
status: error.status || 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,14 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let record_name = ["A", "CNAME"].includes(record.type)
|
if (reservedDomains.includes(record.name)) {
|
||||||
|
return Response.json("Domain name is reserved", {
|
||||||
|
status: 403,
|
||||||
|
statusText: "Reserved domain",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let record_name = ["A", "CNAME", "AAAA"].includes(record.type)
|
||||||
? record.name
|
? record.name
|
||||||
: `${record.name}.${record.zone_name}`;
|
: `${record.name}.${record.zone_name}`;
|
||||||
|
|
||||||
@@ -53,13 +60,6 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reservedDomains.includes(record_name)) {
|
|
||||||
return Response.json("Domain name is reserved", {
|
|
||||||
status: 403,
|
|
||||||
statusText: "Reserved domain",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await updateDNSRecord(
|
const data = await updateDNSRecord(
|
||||||
matchedZone.cf_zone_id,
|
matchedZone.cf_zone_id,
|
||||||
matchedZone.cf_api_key,
|
matchedZone.cf_api_key,
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ export async function POST(req: NextRequest) {
|
|||||||
lang,
|
lang,
|
||||||
device,
|
device,
|
||||||
browser,
|
browser,
|
||||||
|
engine,
|
||||||
|
os,
|
||||||
|
cpu,
|
||||||
|
isBot,
|
||||||
password,
|
password,
|
||||||
} = await req.json();
|
} = await req.json();
|
||||||
|
|
||||||
if (!slug || !ip) return Response.json("Missing[0000]");
|
if (!slug || !ip) return Response.json("Missing[0000]");
|
||||||
|
|
||||||
const res = await getUrlBySuffix(slug);
|
const res = await getUrlBySuffix(slug);
|
||||||
if (!res) return Response.json("Disabled[0002]");
|
if (!res) return Response.json("Missing[0000]");
|
||||||
|
|
||||||
if (res.active !== 1) return Response.json("Disabled[0002]");
|
if (res.active !== 1) return Response.json("Disabled[0002]");
|
||||||
|
|
||||||
@@ -57,6 +61,10 @@ export async function POST(req: NextRequest) {
|
|||||||
lang,
|
lang,
|
||||||
device,
|
device,
|
||||||
browser,
|
browser,
|
||||||
|
engine,
|
||||||
|
os,
|
||||||
|
cpu,
|
||||||
|
isBot,
|
||||||
});
|
});
|
||||||
return Response.json(res.target);
|
return Response.json(res.target);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TeamPlanQuota } from "@/config/team";
|
|
||||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||||
|
import { getPlanQuota } from "@/lib/dto/plan";
|
||||||
import { createUserShortUrl } from "@/lib/dto/short-urls";
|
import { createUserShortUrl } from "@/lib/dto/short-urls";
|
||||||
import { checkUserStatus } from "@/lib/dto/user";
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
@@ -11,11 +11,12 @@ export async function POST(req: Request) {
|
|||||||
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: "userUrl",
|
model: "userUrl",
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
limit: TeamPlanQuota[user.team].SL_NewLinks,
|
limit: plan.slNewLinks,
|
||||||
rangeType: "month",
|
rangeType: "month",
|
||||||
});
|
});
|
||||||
if (limit) return Response.json(limit.statusText, { status: limit.status });
|
if (limit) return Response.json(limit.statusText, { status: limit.status });
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { getCurrentUser } from "@/lib/session";
|
|||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
const user = checkUserStatus(await getCurrentUser());
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const isAdmin = url.searchParams.get("admin");
|
const isAdmin = url.searchParams.get("admin");
|
||||||
|
|
||||||
if (isAdmin === "true") {
|
if (isAdmin === "true") {
|
||||||
if (user instanceof Response) return user;
|
|
||||||
if (user.role !== "ADMIN") {
|
if (user.role !== "ADMIN") {
|
||||||
return Response.json("Unauthorized", {
|
return Response.json("Unauthorized", {
|
||||||
status: 401,
|
status: 401,
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (item.latitude && item.longitude) {
|
if (item.latitude && item.longitude) {
|
||||||
const lat = Math.round(Number(item.latitude) * 100) / 100;
|
const lat = Math.round(Number(item.latitude) * 100) / 100;
|
||||||
const lng = Math.round(Number(item.longitude) * 100) / 100;
|
const lng = Math.round(Number(item.longitude) * 100) / 100;
|
||||||
const key = `${lat},${lng}`;
|
const key = `${lat},${lng},${item.createdAt},${item.userUrl.url},${item.userUrl.prefix}`;
|
||||||
|
|
||||||
if (locationMap.has(key)) {
|
if (locationMap.has(key)) {
|
||||||
const existing = locationMap.get(key)!;
|
const existing = locationMap.get(key)!;
|
||||||
@@ -148,10 +148,6 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
const uniqueLocations = aggregatedData.length;
|
const uniqueLocations = aggregatedData.length;
|
||||||
|
|
||||||
// console.log(
|
|
||||||
// `Fetched ${rawData.length} records, aggregated to ${uniqueLocations} locations, total clicks: ${totalClicks}`,
|
|
||||||
// );
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: aggregatedData,
|
data: aggregatedData,
|
||||||
total: uniqueLocations,
|
total: uniqueLocations,
|
||||||
@@ -180,9 +176,6 @@ export async function GET(request: NextRequest) {
|
|||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// finally {
|
|
||||||
// await prisma.$disconnect();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST endpoint remains the same
|
// POST endpoint remains the same
|
||||||
@@ -272,7 +265,4 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// finally {
|
|
||||||
// await prisma.$disconnect();
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getUserShortUrls } from "@/lib/dto/short-urls";
|
import { getUrlClicksByIds, getUserShortUrls } from "@/lib/dto/short-urls";
|
||||||
import { checkUserStatus } from "@/lib/dto/user";
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
@@ -34,11 +34,31 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
return Response.json(data);
|
return Response.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
// console.log(error);
|
||||||
|
|
||||||
return Response.json(error?.statusText || error, {
|
return Response.json(error?.statusText || error, {
|
||||||
status: error.status || 500,
|
status: error.status || 500,
|
||||||
statusText: error.statusText || "Server error",
|
statusText: error.statusText || "Server error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
if (user.role !== "ADMIN") {
|
||||||
|
return Response.json("Unauthorized", {
|
||||||
|
status: 401,
|
||||||
|
statusText: "Unauthorized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ids } = await req.json();
|
||||||
|
const data = await getUrlClicksByIds(ids, user.id, "ADMIN");
|
||||||
|
return Response.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(error?.statusText || error, {
|
||||||
|
status: error.status || 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
23
app/api/url/admin/status/route.ts
Normal file
23
app/api/url/admin/status/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getUrlStatusOptimized } from "@/lib/dto/short-urls";
|
||||||
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
if (user.role !== "ADMIN") {
|
||||||
|
return Response.json("Unauthorized", {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await getUrlStatusOptimized(user.id, "ADMIN");
|
||||||
|
|
||||||
|
return Response.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(error?.statusText || error, {
|
||||||
|
status: error.status || 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getUserShortUrls } from "@/lib/dto/short-urls";
|
import { getUrlClicksByIds, getUserShortUrls } from "@/lib/dto/short-urls";
|
||||||
import { checkUserStatus } from "@/lib/dto/user";
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
@@ -31,3 +31,18 @@ export async function GET(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
|
||||||
|
const { ids } = await req.json();
|
||||||
|
const data = await getUrlClicksByIds(ids, user.id, "USER");
|
||||||
|
return Response.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(error?.statusText || error, {
|
||||||
|
status: error.status || 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
18
app/api/url/status/route.ts
Normal file
18
app/api/url/status/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { getUrlStatusOptimized } from "@/lib/dto/short-urls";
|
||||||
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
|
||||||
|
const status = await getUrlStatusOptimized(user.id, "USER");
|
||||||
|
|
||||||
|
return Response.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(error?.statusText || error, {
|
||||||
|
status: error.status || 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/api/user/admin/add/route.ts
Normal file
47
app/api/user/admin/add/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { checkUserStatus } from "@/lib/dto/user";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
import { hashPassword } from "@/lib/utils";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const user = checkUserStatus(await getCurrentUser());
|
||||||
|
if (user instanceof Response) return user;
|
||||||
|
if (user.role !== "ADMIN") {
|
||||||
|
return Response.json("Unauthorized", {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password, name, team } = await req.json();
|
||||||
|
if (!email || !password) {
|
||||||
|
return Response.json("email and password is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const has_user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (has_user) {
|
||||||
|
return Response.json("User already exists", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password: hashPassword(password),
|
||||||
|
active: 1,
|
||||||
|
role: "USER",
|
||||||
|
team,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Response.json(newUser.id, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ statusText: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/api/user/admin/delete/route.ts
Normal file
31
app/api/user/admin/delete/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { checkUserStatus, deleteUserById } from "@/lib/dto/user";
|
||||||
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
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 res = await deleteUserById(id);
|
||||||
|
if (!res?.id) {
|
||||||
|
return Response.json("An error occurred", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json("success");
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ statusText: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { env } from "@/env.mjs";
|
|
||||||
import { checkUserStatus, getAllUsers } from "@/lib/dto/user";
|
import { checkUserStatus, getAllUsers } from "@/lib/dto/user";
|
||||||
import { getCurrentUser } from "@/lib/session";
|
import { getCurrentUser } from "@/lib/session";
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export async function POST(req: Request) {
|
|||||||
team: data.team,
|
team: data.team,
|
||||||
image: data.image,
|
image: data.image,
|
||||||
apiKey: data.apiKey,
|
apiKey: data.apiKey,
|
||||||
|
password: data.password,
|
||||||
});
|
});
|
||||||
if (!res?.id) {
|
if (!res?.id) {
|
||||||
return Response.json("An error occurred", {
|
return Response.json("An error occurred", {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user