144 Commits

Author SHA1 Message Date
oiov
f4c9bad648 test env 2025-06-03 15:58:20 +08:00
oiov
80796cdcca env 2025-06-03 15:51:39 +08:00
oiov
970dc5bbe8 test env 2025-06-03 15:38:58 +08:00
oiov
6cade53ec5 test env 2025-06-03 15:31:20 +08:00
oiov
34981f821d test env 2025-06-03 15:25:18 +08:00
oiov
22f1686ff7 test env 2025-06-03 15:17:54 +08:00
oiov
5d34f3707a add logs 2025-06-03 15:07:12 +08:00
oiov
d8ec5683d1 chore 2025-06-03 15:06:44 +08:00
oiov
06a70b6680 chore 2025-06-03 14:47:18 +08:00
oiov
938fcd4422 test env 2025-06-03 14:44:46 +08:00
oiov
d86467674e add NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY env 2025-06-03 14:39:01 +08:00
oiov
a21ce6e8d6 chore no domain config discription 2025-06-02 19:39:24 +08:00
oiov
0a4507bbd0 bump version to v0.6.5 2025-06-01 14:09:20 +08:00
oiov
2f27c330a1 adjust stats layout 2025-06-01 14:08:19 +08:00
oiov
cff4579ff1 enhance list pagenation layout 2025-06-01 13:49:29 +08:00
oiov
f2de129ba8 docs(dev): add docker image url 2025-05-31 15:50:41 +08:00
oiov
2a9a242f50 add app version on sidebar 2025-05-31 13:14:57 +08:00
oiov
4e74053017 chore docs codes 2025-05-31 11:50:09 +08:00
oiov
bb1fcd8c37 bump version to 0.6.4 2025-05-30 20:41:13 +08:00
oiov
fb694cc749 Merge pull request #24 from oiov/resend
- Resend api key Config
- Login method optional
2025-05-30 20:38:41 +08:00
oiov
778b18dd35 docs: add chinese docs for developer 2025-05-30 20:21:48 +08:00
oiov
c80de8800f feats(domain): resend api key config 2025-05-30 20:20:57 +08:00
oiov
54c0ba67c8 chore login page codes 2025-05-30 11:31:15 +08:00
oiov
2f19553dec chore codes 2025-05-30 11:15:03 +08:00
oiov
d7c213c110 test ClientGeolocation 2025-05-30 11:01:36 +08:00
oiov
b832de9194 fix: add serverActions allowedOrigins 2025-05-30 10:42:58 +08:00
oiov
a781e84537 chore bar chart tooltip colors 2025-05-29 14:48:33 +08:00
oiov
17881b5b0e enhance empty discription 2025-05-29 14:42:09 +08:00
oiov
40ece4e764 fix(chart): duplicate keys of xaxis 2025-05-29 14:33:09 +08:00
oiov
a983da20e5 styles(icons): add animate draw svg 2025-05-29 11:00:25 +08:00
oiov
43555a9985 fix(grid): empty layout center 2025-05-28 23:02:25 +08:00
oiov
adb0ce31c0 refact: short url stats position 2025-05-28 22:51:33 +08:00
oiov
c619931386 Enhance url display method(grids) 2025-05-28 18:20:22 +08:00
oiov
026dfb2ffe Enhance chart display 2025-05-28 16:08:24 +08:00
oiov
5396a4e628 styles: adjust form button height 2025-05-27 16:59:09 +08:00
oiov
3e801fe85a bump version to v0.6.3 2025-05-27 16:27:25 +08:00
oiov
ec3372a3c0 styles: random avatar 2025-05-27 16:18:43 +08:00
oiov
157c07c747 feats(record): support check cf configs access 2025-05-27 15:54:33 +08:00
oiov
6505d1876a chore: dashboard cpm position 2025-05-27 15:04:03 +08:00
oiov
2a3ff9db9b docs: chore readme file 2025-05-27 12:27:13 +08:00
oiov
434e326991 docs: add deploy guide 2025-05-27 11:40:09 +08:00
oiov
16b66f83da feats: deploy with docker(#19)
deploy with docker
2025-05-27 11:23:26 +08:00
oiov
cc4c6c5e96 fix(docker): prisma client package 2025-05-27 11:11:00 +08:00
oiov
3301570213 fix(docker): install prisma on runner 2025-05-27 11:01:52 +08:00
oiov
c65176e607 fix(docker): check db do not run 2025-05-27 10:50:07 +08:00
oiov
55aa93d117 feats(docker): add check db script 2025-05-27 10:06:49 +08:00
oiov
7c61b7fc44 docs(deploy): add deploy methods 2025-05-26 21:56:47 +08:00
oiov
bc7f86119c fixup type error 2025-05-26 21:39:01 +08:00
oiov
bc1490f0fd fixup 2025-05-26 21:25:54 +08:00
oiov
7bf2aa8b3c test 2025-05-26 20:43:56 +08:00
oiov
ba086b602f test 2025-05-26 20:19:04 +08:00
oiov
0d793ee31c test 2025-05-26 19:59:43 +08:00
oiov
7579be007f test 2025-05-26 19:53:02 +08:00
oiov
515e7d2719 fixup error 2025-05-26 18:05:23 +08:00
oiov
c9cfdfc07a upd 2025-05-26 17:59:28 +08:00
oiov
c589afd859 fixup url error 2025-05-26 17:50:39 +08:00
oiov
f10f8af0f6 chore 2025-05-26 17:42:37 +08:00
oiov
cbeba449ef chore: get geo and ua info without vercel 2025-05-26 17:40:01 +08:00
oiov
fa02ca000b test 2025-05-26 16:41:50 +08:00
oiov
af01d60d9b fixup 2025-05-26 16:36:36 +08:00
oiov
06f06a8a52 test 2025-05-26 16:16:48 +08:00
oiov
fc54d9e176 fixup 2025-05-26 16:06:30 +08:00
oiov
8fab48f849 test 2025-05-26 15:57:34 +08:00
oiov
69878126f6 test geo 2025-05-26 14:40:26 +08:00
oiov
0185520445 upd 2025-05-25 22:34:12 +08:00
oiov
00cb224e84 test 2025-05-25 22:20:18 +08:00
oiov
c5a932b9f1 prisma/prisma/discussions/19341 2025-05-25 22:09:44 +08:00
oiov
becc328811 emmm 2025-05-25 21:45:37 +08:00
oiov
c2ae4c78f7 fixup 2025-05-25 21:32:04 +08:00
oiov
40f2483332 chore 2025-05-25 21:15:39 +08:00
oiov
a27eb84d61 upd 2025-05-25 21:02:14 +08:00
oiov
01b80eaf9e test 2025-05-25 20:59:10 +08:00
oiov
1e713ea613 test 2025-05-25 17:16:24 +08:00
oiov
1eb7c71ff9 test 2025-05-25 17:01:06 +08:00
oiov
b9bf2733f9 change workflow 2025-05-25 15:27:34 +08:00
oiov
1e48c209f7 chore 2025-05-25 15:20:13 +08:00
oiov
fff455312e chore 2025-05-25 15:15:45 +08:00
oiov
a5626ebefe chore 2025-05-25 15:07:33 +08:00
oiov
400b1aac8d test docker build 2025-05-25 15:02:16 +08:00
oiov
872baa7933 chore 2025-05-25 12:07:52 +08:00
oiov
04b47b62ad fix(docker): change github star api 2025-05-25 11:56:17 +08:00
oiov
8894d2daae fix(docker): build error 2025-05-25 11:17:38 +08:00
oiov
3145ef884d feats: deploy with docker 2025-05-25 10:57:26 +08:00
oiov
7b1c21e972 Merge pull request #21 from oiov/realtime
Realtime
2025-05-24 22:41:41 +08:00
oiov
5421285a29 bump version 0.6.2 2025-05-24 22:37:52 +08:00
oiov
59727b6be9 style: adjust url list layout 2025-05-24 22:36:31 +08:00
oiov
142cdf8b41 style(realtime): globe size 2025-05-24 21:46:55 +08:00
oiov
24ae1bc45e fix(realtime): fix charts display 2025-05-24 21:28:43 +08:00
oiov
a1cd74e90f feats: realtime globe and visits charts 2025-05-24 17:28:25 +08:00
oiov
6e8b1ccefd update domain page title 2025-05-21 19:41:17 +08:00
oiov
4d9c20d90d adjust button styles 2025-05-21 19:39:51 +08:00
oiov
91d3f06f38 add pagenation for domain list 2025-05-21 19:15:10 +08:00
oiov
a5f5312476 chore setup style for dark model 2025-05-21 18:51:25 +08:00
oiov
36254e048e adjust mobile style for domain list 2025-05-21 18:50:30 +08:00
oiov
72f76b8bca hide proxy on TXT 2025-05-21 18:40:44 +08:00
oiov
11d8f0d1d5 Merge pull request #20 from oiov/config-domains
Config domains and setup guide
2025-05-21 16:39:37 +08:00
oiov
2369885fda feat: support setup guide for admin 2025-05-21 16:16:56 +08:00
oiov
8a05fa0907 feat: support multi domain configuration and management features (database) 2025-05-20 22:46:13 +08:00
oiov
28c0cf7da7 chore url list swich color 2025-05-20 10:40:46 +08:00
oiov
399a8e70e7 release version 0.5.1 2025-05-19 21:46:07 +08:00
oiov
e0163351aa change link preview cpn 2025-05-19 21:43:20 +08:00
oiov
87c24da5a0 feat: support multi domain configuration for DNS records 2025-05-19 21:40:24 +08:00
oiov
7d83ff8678 chore styles and sql file link 2025-05-19 16:15:37 +08:00
oiov
08270f7edc fixup link preview error 2025-05-19 15:32:57 +08:00
oiov
b37d5164ff upd install docs and adjust landing page 2025-05-19 15:23:11 +08:00
oiov
617af0c7b9 add wechat qrcode 2025-05-18 12:13:22 +08:00
oiov
ddae8ff662 docs: add wechat group qrcode 2025-05-17 21:24:38 +08:00
oiov
deee7786a0 fixup email content rende error 2025-05-16 17:18:24 +08:00
oiov
3c8097b1b9 adjust list styles and add pro badge 2025-05-13 15:59:48 +08:00
oiov
35201961a2 add yaxis for line charts 2025-05-13 10:30:49 +08:00
oiov
34113f6f2f chore styles 2025-05-13 10:18:40 +08:00
oiov
068151aeb2 chore 2025-05-10 11:38:09 +08:00
oiov
c8628fc197 better seo 2025-05-10 11:35:12 +08:00
oiov
b755bc5f7e auto generate sitemap 2025-05-10 11:10:00 +08:00
oiov
bd8c4eb01c fixup time range error 2025-05-09 16:06:55 +08:00
oiov
d8b0ef455d test geo api 2025-05-09 15:37:24 +08:00
oiov
7de86d2855 chore api methods 2025-05-09 10:54:43 +08:00
oiov
e4cb1c475a add api cpn for scraping api 2025-05-09 10:47:38 +08:00
oiov
4f12de115d fixup pagenation error 2025-05-09 10:13:15 +08:00
oiov
46730b9c3f fixup email list pages 2025-05-08 22:39:54 +08:00
oiov
d0d310956f fixup pagenation calculate 2025-05-08 16:54:15 +08:00
oiov
49fece0983 update loading status display 2025-05-07 10:00:09 +08:00
oiov
150bb2af7c fixup error qrcode 2025-05-06 19:44:41 +08:00
oiov
92f8b6aa48 add page size input 2025-05-06 19:36:14 +08:00
oiov
a538b1c5a8 styled edit form 2025-05-06 18:59:38 +08:00
oiov
c99f3c31a6 add download methods for qrcode 2025-05-06 16:46:40 +08:00
oiov
31819aa72e add qr code info in pricing 2025-05-06 15:22:13 +08:00
oiov
61f3b9abcd chore sent list style and login page notice 2025-05-06 15:09:13 +08:00
oiov
be8b26eddf add debounce for qr input 2025-05-05 22:16:51 +08:00
oiov
1924d2b9d5 chore qr editor style 2025-05-05 21:48:44 +08:00
oiov
d6ddb234e1 chore 2025-05-05 21:16:29 +08:00
oiov
4be7e1c22c docs: remove other title 2025-05-05 21:03:12 +08:00
oiov
c7f8c1d5a9 chore create apikey guide 2025-05-05 20:58:34 +08:00
oiov
d852b7f52d fixup dark theme for qr editor 2025-05-05 20:06:46 +08:00
oiov
1fdd2373ee fixup 2025-05-05 20:01:41 +08:00
oiov
4ad5563bdc refact qr code generator 2025-05-05 18:32:08 +08:00
oiov
82f8ec33f5 docs: add svg icon for error link 2025-05-04 21:34:21 +08:00
oiov
01ca3ad732 add time range param for apis stats 2025-05-04 21:00:30 +08:00
oiov
fab90a0bd8 chore AnalyticsRetention config 2025-05-04 20:42:34 +08:00
oiov
4469e408e9 optimize the data display of count-up component 2025-04-30 15:36:31 +08:00
oiov
2a067c6e65 add growth rate feature and send email list 2025-04-29 18:37:27 +08:00
oiov
b0a0e4a200 chore lint code 2025-04-29 15:22:09 +08:00
oiov
0471ff772c tiny image resouces 2025-04-29 14:50:33 +08:00
oiov
b63dba0b30 bump nextjs version to 14.2.28 2025-04-28 19:41:05 +08:00
222 changed files with 38357 additions and 1970 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
# .dockerignore
# node_modules
# npm-debug.log
# README.md
# .env*
# .next
# .git

View File

@@ -4,9 +4,10 @@
NEXT_PUBLIC_APP_URL=http://localhost:3000
# -----------------------------------------------------------------------------
# Authentication (NextAuth.js)
# Authentication (NextAuth.js 5.0.x)
# -----------------------------------------------------------------------------
AUTH_SECRET=
AUTH_SECRET=abc123
AUTH_URL=http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
@@ -24,19 +25,14 @@ DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=requ
# Email api (https://resend.com) for login and send email
# -----------------------------------------------------------------------------
RESEND_API_KEY=
# -----------------------------------------------------------------------------
# Cloudflare
# -----------------------------------------------------------------------------
CLOUDFLARE_ZONE_ID=
CLOUDFLARE_API_KEY=
CLOUDFLARE_EMAIL=
# Cloudflare zone name, example: wr.do
CLOUDFLARE_ZONE_NAME=
RESEND_FROM_EMAIL="wrdo <support@wr.do>"
# Open Signup
NEXT_PUBLIC_OPEN_SIGNUP=1
# Enable subdomain apply, default is false(0). If set to 1, will enable subdomain apply
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY=1
# Google Analytics
NEXT_PUBLIC_GOOGLE_ID=
@@ -46,7 +42,7 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
# GitHub api token for getting gitHub stars count
GITHUB_TOKEN=
# Short domains, split by ","
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
# Skip DB check and migration (only for docker), default is true. if false, will check and migrate database each time start docker compose.
SKIP_DB_CHECK=true
SKIP_DB_MIGRATION=true

View File

@@ -12,7 +12,10 @@
"react/jsx-key": "off",
"tailwindcss/no-custom-classname": "off",
"tailwindcss/classnames-order": "off",
"react/no-unescaped-entities": "off"
"tailwindcss/enforces-shorthand": "off",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "off",
"@typescript-eslint/no-unused-vars": "off"
},
"settings": {
"tailwindcss": {

65
.github/workflows/docker-build-push.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Build and Push Docker Image to GHCR
on:
push:
branches:
- main
- fix/docker
tags:
- "v*.*.*"
pull_request:
branches:
- main
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/wrdo
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
# 检出代码
- name: Checkout repository
uses: actions/checkout@v4
# 设置 Docker Buildx支持多平台构建
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 登录到 GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 提取 Docker 镜像元数据(标签、版本等)
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=short
type=ref,event=branch,prefix=
type=ref,event=tag
# 构建并推送 Docker 镜像
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true # ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
ENVIRONMENT=${{ github.event.inputs.environment || 'production' }}
cache-from: type=gha
cache-to: type=gha,mode=max

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
enable-pre-post-scripts=true

60
Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache openssl
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN npm install -g pnpm
COPY . .
RUN pnpm config set registry https://registry.npmmirror.com
RUN pnpm i --frozen-lockfile
FROM base AS builder
WORKDIR /app
RUN apk add --no-cache openssl
RUN npm install -g pnpm
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm run build
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache openssl
RUN npm install -g pnpm
ENV NODE_ENV=production
ENV IS_DOCKER=true
RUN pnpm add npm-run-all dotenv prisma@5.17.0 @prisma/client@5.17.0
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Check db
COPY scripts/check-db.js /app/scripts/check-db.js
EXPOSE 3000
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
# CMD ["node", "server.js"]
CMD ["pnpm", "start-docker"]

View File

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

124
README.md
View File

@@ -1,74 +1,130 @@
<div align="center">
<h1>WR.DO</h1>
<p><a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
<p>Make Short Links, Manage DNS Records, Email Support.</p>
<!-- <img src="https://wr.do/_static/images/light-preview.png"/> -->
<p><a href="https://wr.do/docs/developer">Docs</a> · <a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
<p>Make Short Links, Manage DNS Records, Receive Emails.</p>
</div>
## Introduction
WR.DO is a all-in-one web utility platform featuring short links with analytics, temporary email service, subdomain management, open APIs for screenshots and metadata extraction, plus comprehensive admin dashboard.
## Features
- 🔗 **URL Shortening:** Generate short links with visitor analytic and password(support api)
- 📮 **Email Support:** Receive emails and send emails(support api)
- 💬 **P2P Chat:** Start chat in seconds
- 🌐 **Multi-Tenant Support:** Manage multiple DNS records seamlessly
- 📸 **Screenshot API:** Access to screenshot api、website meta-info scraping api.
- 😀 **Permission Management:** A convenient admin panel for auditing
- 🔒 **Secure & Reliable:** Built on Cloudflare's robust DNS API
- 🔗 **Short Link Service**:
- Custom short links
- Generate custom QR codes
- Password-protected links
- Expiration time control
- Access analytics (real-time logs, maps, and multi-dimensional data analysis)
- API integration for link creation
- 📮 **Email Service**:
- Create custom prefix emails
- Filter unread email lists
- Unlimited mailbox creation
- Receive unlimited emails (powered by Cloudflare Email Worker)
- Send emails (powered by Resend)
- API endpoints for mailbox creation
- API endpoints for inbox retrieval
- 🌐 **Subdomain Management Service**:
- Manage DNS records across multiple Cloudflare accounts and domains
- Create various DNS record types (CNAME, A, TXT, etc.)
- 📡 **Open API Module**:
- Website metadata extraction API
- Website screenshot capture API
- Website QR code generation API
- Convert websites to Markdown/Text format
- Comprehensive API call logging and statistics
- User API key generation for third-party integrations
- 🔒 **Administrator Module**:
- Multi-dimensional dashboard with website analytics
- Dynamic service configuration (toggle short links, email, subdomain management)
- User management (permissions, quotas, account control)
- Centralized short link administration
- Centralized email management
- Centralized subdomain administration
## Screenshots
![screenshot](https://wr.do/_static/images/light-preview.png)
<table>
<tr>
<td><img src="https://wr.do/_static/images/light-preview.png" /></td>
<td><img src="https://wr.do/_static/images/example_02.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_01.png" /></td>
<td><img src="https://wr.do/_static/images/realtime-globe.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_03.png" /></td>
<td><img src="https://wr.do/_static/images/domains.png" /></td>
</tr>
</table>
![screenshot](https://wr.do/_static/images/example_02.png)
![screenshot](https://wr.do/_static/images/example_01.png)
![screenshot](https://wr.do/_static/images/example_03.png)
## Quick Start
See usage docs about [guide](https://wr.do/docs/quick-start) for quick start.
## Self-hosted Tutorial
See step by step installation tutorial at [Quick Start for Developer](https://wr.do/docs/developer/quick-start).
### Requirements
## Self-hosted
- [Vercel](https://vercel.com) to deploy app
- A **domain** name hosted on [Cloudflare](https://dash.cloudflare.com/)
### Deploy with Vercel
See more docs about [developer](https://wr.do/docs/developer/installation).
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
### Email worker
Remember to fill in the necessary environment variables.
See docs about [email worker](https://wr.do/docs/developer/cloudflare-email-worker).
### Deploy with Docker Compose
Create a new folder and copy the [`docker-compose.yml`](https://github.com/oiov/wr.do/blob/main/docker-compose.yml)、[`.env`](https://github.com/oiov/wr.do/blob/main/.env.example) file to the folder.
```yml
- wrdo
| - docker-compose.yml
| - .env
```
Fill in the environment variables in the `.env` file, then:
```bash
docker compose up -d
```
## Local development
copy `.env.example` to `.env` and fill in the necessary environment variables.
```bash
git clone https://github.com/oiov/wr.do
cd wr.do
pnpm install
```
copy `.env.example` to `.env` and fill in the necessary environment variables.
```bash
# run on localhost:3000
pnpm dev
```
## Legitimacy review
#### Init database
- To avoid abuse, applications without website content will be rejected
- To avoid domain name conflicts, please check before applying
- Completed website construction or released open source project (ready to build website for open source project)
- Political sensitivity, violence, pornography, link jumping, VPN, reverse proxy services, and other illegal or sensitive content must not appear on the website
```bash
pnpm postinstall
pnpm db:push
```
**Administrators will conduct domain name checks periodically to clean up domain names that violate the above rules, have no content, and are not open source related**
#### Setup Admin Panel
Follow https://localhost:3000/setup
## Community Group
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
<img width="300" src="https://wr.do/s/group" />
## License

View File

@@ -44,6 +44,11 @@ export default function LoginPage() {
<Suspense>
<UserAuthForm />
</Suspense>
{/* <p className="mt-4 break-all rounded-md border border-dashed bg-neutral-50 p-2 text-left text-sm text-gray-600 dark:border-neutral-600 dark:bg-neutral-800 dark:text-zinc-400">
📢 To keep our free resources accessible to all, we're allowing only
200 new account sign-ups each day.
</p> */}
<p className="px-8 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our{" "}
<Link

View File

@@ -0,0 +1,365 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
import { DomainFormData } from "@/lib/dto/domains";
import { fetcher, timeAgo } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DomainForm } from "@/components/forms/domain-form";
import { FormType } from "@/components/forms/record-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import { PaginationWrapper } from "@/components/shared/pagination";
export interface DomainListProps {
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
action: string;
}
function TableColumnSekleton() {
return (
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-7">
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-32" />
</TableCell>
</TableRow>
);
}
export default function DomainList({ user, action }: DomainListProps) {
const { isMobile } = useMediaQuery();
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditDomain, setCurrentEditDomain] =
useState<DomainFormData | 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: DomainFormData[];
}>(
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
fetcher,
);
const handleRefresh = () => {
mutate(
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
undefined,
);
};
const handleChangeStatus = async (
checked: boolean,
target: string,
domain: DomainFormData,
) => {
const res = await fetch(action, {
method: "PUT",
body: JSON.stringify({
id: domain.id,
enable_short_link:
target === "enable_short_link" ? checked : domain.enable_short_link,
enable_email: target === "enable_email" ? checked : domain.enable_email,
enable_dns: target === "enable_dns" ? checked : domain.enable_dns,
active: target === "active" ? checked : domain.active,
}),
});
if (res.ok) {
const data = await res.json();
if (data) {
toast.success("Successed!");
handleRefresh();
}
} else {
toast.error("Activation failed!");
}
};
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">Total Domains:</span>
{isLoading ? (
<Skeleton className="h-6 w-16" />
) : (
<span>{data && data.total}</span>
)}
</div>
<div className="ml-auto flex items-center justify-end gap-3">
<Button
variant={"outline"}
onClick={() => handleRefresh()}
disabled={isLoading}
>
{isLoading ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
</Button>
<Button
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditDomain(null);
setShowForm(false);
setFormType("add");
setShowForm(!isShowForm);
}}
>
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add Domain</span>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by domain name..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
target: e.target.value,
});
}}
/>
{searchParams.target && (
<Button
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
onClick={() =>
setSearchParams({ ...searchParams, target: "" })
}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
</div>
<Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-7">
<TableHead className="col-span-1 flex items-center font-bold">
Domain
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Shorten
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Email
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Subdomain
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Active
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
Updated
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<>
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
</>
) : data && data.list && data.list.length ? (
data.list.map((domain) => (
<div className="border-b" key={domain.id}>
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-7">
<TableCell className="col-span-1 flex items-center gap-1">
<Link
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={`https://${domain.domain_name}`}
target="_blank"
prefetch={false}
title={domain.domain_name}
>
{domain.domain_name}
</Link>
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_short_link}
onCheckedChange={(value) =>
handleChangeStatus(
value,
"enable_short_link",
domain,
)
}
/>
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_email}
onCheckedChange={(value) =>
handleChangeStatus(value, "enable_email", domain)
}
/>
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_dns}
onCheckedChange={(value) =>
handleChangeStatus(value, "enable_dns", domain)
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Switch
disabled
defaultChecked={domain.active}
onCheckedChange={(value) =>
handleChangeStatus(value, "active", domain)
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center truncate">
{timeAgo(domain.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"
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
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant="ghost"
>
<Icons.cloudflare className="mx-0.5 size-4" />
</Button>
)}
</TableCell>
</TableRow>
{/* {isShowDomainInfo && selectedDomain?.id === domain.id && (
<DomainInfo domain={domain} />
)} */}
</div>
))
) : (
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="globeLock" />
<EmptyPlaceholder.Title>No Domains</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any domains yet. Start creating one.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</CardContent>
</Card>
{/* form */}
<Modal
className="max-h-[90vh] overflow-y-auto md:max-w-2xl"
showModal={isShowForm}
setShowModal={setShowForm}
>
<DomainForm
user={{ id: user.id, name: user.name || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type={formType}
initData={currentEditDomain}
action={action}
onRefresh={handleRefresh}
/>
</Modal>
</>
);
}
export function DomainInfo({ domain }: { domain: DomainFormData }) {
return <>{domain.domain_name}</>;
}

View File

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

View File

@@ -0,0 +1,40 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import DomainList from "./domain-list";
export const metadata = constructMetadata({
title: "Domains - WR.DO",
description: "List and manage domains.",
});
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
return (
<>
<DashboardHeader
heading="Manage&nbsp;&nbsp;Domains"
text="List and manage domains."
link="/docs/developer/cloudflare"
linkText="domains."
/>
<DomainList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
role: user.role,
team: user.team,
}}
action="/api/admin/domain"
/>
</>
);
}

View File

@@ -3,6 +3,7 @@
import { ScrapeMeta } from "@prisma/client";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { useElementSize } from "@/hooks/use-element-size";
import {
Card,
CardContent,
@@ -54,6 +55,7 @@ export function LineChartMultiple({
type1,
type2,
}: LineChartMultipleProps) {
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const processedData = processChartData(chartData, type1, type2);
const chartConfig = {
@@ -75,12 +77,13 @@ export function LineChartMultiple({
{type2 && ` and ${type2}`}.
</CardDescription>
</CardHeader>
<CardContent>
<CardContent ref={wrapperRef}>
<ChartContainer config={chartConfig}>
<AreaChart
className="mt-6"
accessibilityLayer
data={processedData}
width={wrapperWidth}
margin={{
left: 12,
right: 12,

View File

@@ -119,17 +119,18 @@ async function InteractiveBarChartSection() {
// 请求统计图表组件
async function RequestStatsSection() {
const screenshot_stats = await getScrapeStatsByType("screenshot");
const meta_stats = await getScrapeStatsByType("meta-info");
const md_stats = await getScrapeStatsByType("markdown");
const text_stats = await getScrapeStatsByType("text");
const qr_stats = await getScrapeStatsByType("qrcode");
const screenshot_stats = await getScrapeStatsByType("screenshot", "30d");
const meta_stats = await getScrapeStatsByType("meta-info", "30d");
const md_stats = await getScrapeStatsByType("markdown", "30d");
const text_stats = await getScrapeStatsByType("text", "30d");
const qr_stats = await getScrapeStatsByType("qrcode", "30d");
const hasStats =
screenshot_stats.length > 0 ||
meta_stats.length > 0 ||
md_stats.length > 0 ||
text_stats.length > 0;
text_stats.length > 0 ||
qr_stats.length > 0;
return hasStats ? (
<>
@@ -155,8 +156,8 @@ async function RadialShapeChartSection() {
// 二维码/截图折线图组件
async function QrScreenshotChartSection() {
const screenshot_stats = await getScrapeStatsByType("screenshot");
const qr_stats = await getScrapeStatsByType("qrcode");
const screenshot_stats = await getScrapeStatsByType("screenshot", "90d");
const qr_stats = await getScrapeStatsByType("qrcode", "90d");
return (
<LineChartMultiple
@@ -169,8 +170,8 @@ async function QrScreenshotChartSection() {
// 截图/元信息折线图组件
async function ScreenshotMetaChartSection() {
const screenshot_stats = await getScrapeStatsByType("screenshot");
const meta_stats = await getScrapeStatsByType("meta-info");
const screenshot_stats = await getScrapeStatsByType("screenshot", "90d");
const meta_stats = await getScrapeStatsByType("meta-info", "90d");
return (
<LineChartMultiple
@@ -183,8 +184,8 @@ async function ScreenshotMetaChartSection() {
// Markdown/文本折线图组件
async function MarkdownTextChartSection() {
const md_stats = await getScrapeStatsByType("markdown");
const text_stats = await getScrapeStatsByType("text");
const md_stats = await getScrapeStatsByType("markdown", "90d");
const text_stats = await getScrapeStatsByType("text", "90d");
return (
<LineChartMultiple
@@ -276,10 +277,10 @@ export default async function AdminPage() {
</Suspense>
</ErrorBoundary>
<ErrorBoundary
fallback={<Skeleton className="h-[342px] w-full rounded-lg" />}
fallback={<Skeleton className="min-h-[342px] w-full rounded-lg" />}
>
<Suspense
fallback={<Skeleton className="h-[342px] w-full rounded-lg" />}
fallback={<Skeleton className="min-h-[342px] w-full rounded-lg" />}
>
<RequestStatsSection />
</Suspense>

View File

@@ -5,7 +5,8 @@ export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="DNS Records" text="" />
<Skeleton className="h-full w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -22,10 +22,15 @@ export default async function DashboardPage() {
heading="Manage&nbsp;&nbsp;DNS&nbsp;&nbsp;Records"
text="List and manage records."
link="/docs/dns-records"
linkText="DNS Records."
linkText="DNS records."
/>
<UserRecordsList
user={{ id: user.id, name: user.name || "", apiKey: user.apiKey || "" }}
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
}}
action="/api/record/admin"
/>
</>

View File

@@ -5,7 +5,8 @@ export default function DashboardUrlsLoading() {
return (
<>
<DashboardHeader heading="Short Urls" text="" />
<Skeleton className="h-full w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -4,7 +4,6 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import LiveLog from "../../dashboard/urls/live-logs";
import UserUrlsList from "../../dashboard/urls/url-list";
export const metadata = constructMetadata({
@@ -23,18 +22,19 @@ export default async function DashboardPage() {
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
link="/docs/short-urls"
linkText="Short urls."
linkText="short urls."
/>
<UserUrlsList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
team: user.team,
}}
action="/api/url/admin"
/>
<LiveLog admin={true} />
</>
);
}

View File

@@ -8,7 +8,8 @@ export default function OrdersLoading() {
heading="User Management"
text="List and manage all users."
/>
<Skeleton className="h-full w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -16,7 +16,9 @@ import {
CardHeader,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
@@ -31,7 +33,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import StatusDot from "@/components/dashboard/status-dot";
import { UserForm } from "@/components/forms/user-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
@@ -72,6 +73,7 @@ function TableColumnSekleton({ className }: { className?: string }) {
}
export default function UsersList({ user }: UrlListProps) {
const { isMobile } = useMediaQuery();
const [isShowForm, setShowForm] = useState(false);
const [currentEditUser, setcurrentEditUser] = useState<User | null>(null);
const [currentPage, setCurrentPage] = useState(1);
@@ -82,7 +84,7 @@ export default function UsersList({ user }: UrlListProps) {
});
const { mutate } = useSWRConfig();
const { data, error, 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}`,
fetcher,
{
@@ -122,16 +124,6 @@ export default function UsersList({ user }: UrlListProps) {
</div>
</CardHeader>
<CardContent>
{isShowForm && (
<UserForm
user={{ id: user.id, name: user.name || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type="edit"
initData={currentEditUser}
onRefresh={handleRefresh}
/>
)}
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="relative w-full">
<Input
@@ -257,7 +249,7 @@ export default function UsersList({ user }: UrlListProps) {
</Badge>
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
<StatusDot status={user.active} />
<Switch defaultChecked={user.active === 1} />
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
{timeAgo(user.createdAt || "")}
@@ -280,7 +272,7 @@ export default function UsersList({ user }: UrlListProps) {
</TableRow>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="users" />
<EmptyPlaceholder.Title>No users</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
@@ -291,14 +283,32 @@ export default function UsersList({ user }: UrlListProps) {
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={Math.ceil(data.total / pageSize)}
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</CardContent>
</Card>
<Modal
className="md:max-w-2xl"
showModal={isShowForm}
setShowModal={setShowForm}
>
<UserForm
user={{ id: user.id, name: user.name || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type="edit"
initData={currentEditUser}
onRefresh={handleRefresh}
/>
</Modal>
</>
);
}

View File

@@ -94,7 +94,13 @@ async function LiveLogSection() {
async function UserUrlsListSection({
user,
}: {
user: { id: string; name: string; apiKey: string; role: UserRole };
user: {
id: string;
name: string;
apiKey: string;
role: UserRole;
team: string;
};
}) {
return (
<UserUrlsList
@@ -103,6 +109,7 @@ async function UserUrlsListSection({
name: user.name,
apiKey: user.apiKey,
role: user.role,
team: user.team,
}}
action="/api/url"
/>
@@ -112,7 +119,7 @@ async function UserUrlsListSection({
async function UserRecordsListSection({
user,
}: {
user: { id: string; name: string; apiKey: string };
user: { id: string; name: string; apiKey: string; email: string };
}) {
return (
<UserRecordsList
@@ -120,6 +127,7 @@ async function UserRecordsListSection({
id: user.id,
name: user.name,
apiKey: user.apiKey,
email: user.email,
}}
action="/api/record"
/>
@@ -165,12 +173,19 @@ export default async function DashboardPage() {
</ErrorBoundary>
</div>
<ErrorBoundary
fallback={<Skeleton className="h-[200px] w-full rounded-lg" />}
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
>
<Suspense
fallback={<Skeleton className="h-[200px] w-full rounded-lg" />}
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
>
<LiveLogSection />
<UserRecordsListSection
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
}}
/>
</Suspense>
</ErrorBoundary>
<ErrorBoundary
@@ -185,21 +200,7 @@ export default async function DashboardPage() {
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
}}
/>
</Suspense>
</ErrorBoundary>
<ErrorBoundary
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
>
<Suspense
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
>
<UserRecordsListSection
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
team: user.team,
}}
/>
</Suspense>

View File

@@ -4,8 +4,12 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="DNS Records" text="" />
<Skeleton className="h-full w-full rounded-lg" />
<DashboardHeader
heading="Manage&nbsp;&nbsp;DNS&nbsp;&nbsp;Records"
text="List and manage records."
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -22,10 +22,15 @@ export default async function DashboardPage() {
heading="Manage&nbsp;&nbsp;DNS&nbsp;&nbsp;Records"
text="List and manage records."
link="/docs/dns-records"
linkText="DNS Records."
linkText="DNS records."
/>
<UserRecordsList
user={{ id: user.id, name: user.name || "", apiKey: user.apiKey || "" }}
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
}}
action="/api/record"
/>
</>

View File

@@ -10,6 +10,7 @@ import useSWR, { useSWRConfig } from "swr";
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
import { TTL_ENUMS } from "@/lib/enums";
import { fetcher, timeAgo } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -19,6 +20,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
@@ -38,11 +40,14 @@ import {
import { FormType, RecordForm } from "@/components/forms/record-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import { LinkPreviewer } from "@/components/shared/link-previewer";
import {
LinkInfoPreviewer,
LinkPreviewer,
} from "@/components/shared/link-previewer";
import { PaginationWrapper } from "@/components/shared/pagination";
export interface RecordListProps {
user: Pick<User, "id" | "name" | "apiKey">;
user: Pick<User, "id" | "name" | "apiKey" | "email">;
action: string;
}
@@ -75,12 +80,15 @@ function TableColumnSekleton() {
}
export default function UserRecordsList({ user, action }: RecordListProps) {
const { isMobile } = useMediaQuery();
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditRecord, setCurrentEditRecord] =
useState<UserRecordFormData | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [tab, setTab] = useState("app");
const isAdmin = action.includes("/admin");
const { mutate } = useSWRConfig();
@@ -130,18 +138,20 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
}
};
const rendeApplyList = () => {};
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
{action.includes("/admin") ? (
{isAdmin ? (
<CardDescription className="text-balance text-lg font-bold">
<span>Total Records:</span>{" "}
<span>Total Subdomains:</span>{" "}
<span className="font-bold">{data && data.total}</span>
</CardDescription>
) : (
<div className="grid gap-2">
<CardTitle>DNS Records</CardTitle>
<CardTitle>Subdomains</CardTitle>
<CardDescription className="hidden text-balance sm:block">
Please read the{" "}
<Link
@@ -176,7 +186,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
)}
</Button>
<Button
className="w-[120px] shrink-0 gap-1"
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditRecord(null);
@@ -185,22 +195,12 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
setShowForm(!isShowForm);
}}
>
Add record
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add Record</span>
</Button>
</div>
</CardHeader>
<CardContent>
{isShowForm && (
<RecordForm
user={{ id: user.id, name: user.name || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type={formType}
initData={currentEditRecord}
action={action}
onRefresh={handleRefresh}
/>
)}
<Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-8">
@@ -248,14 +248,10 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
</Badge>
</TableCell>
<TableCell className="col-span-1">
<LinkPreviewer
<LinkInfoPreviewer
apiKey={user.apiKey ?? ""}
url={"https://" + record.name}
formatUrl={
"https://" + record.name.endsWith(".wr.do")
? record.name.slice(0, -6)
: record.name
}
formatUrl={record.name}
/>
</TableCell>
<TableCell className="col-span-2 hidden truncate text-nowrap sm:inline-block">
@@ -317,12 +313,6 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
if (!isShowForm) {
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
}}
>
<p>Edit</p>
@@ -332,25 +322,45 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
</TableRow>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="globeLock" />
<EmptyPlaceholder.Title>No records</EmptyPlaceholder.Title>
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="globe" />
<EmptyPlaceholder.Title>No Subdomain</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any record yet. Start creating record.
You don&apos;t have any subdomain yet. Start creating
record.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={Math.ceil(data.total / pageSize)}
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</CardContent>
</Card>
<Modal
className="md:max-w-2xl"
showModal={isShowForm}
setShowModal={setShowForm}
>
<RecordForm
user={{ id: user.id, name: user.name || "", email: user.email || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type={formType}
initData={currentEditRecord}
action={action}
onRefresh={handleRefresh}
/>
</Modal>
</>
);
}

View File

@@ -3,9 +3,9 @@
import * as React from "react";
import Link from "next/link";
import { ScrapeMeta } from "@prisma/client";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { isLink, removeUrlSuffix, timeAgo } from "@/lib/utils";
import { isLink, nFormatter, removeUrlSuffix, timeAgo } from "@/lib/utils";
import {
Card,
CardContent,
@@ -18,6 +18,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import CountUp from "@/components/dashboard/count-up";
const chartConfig = {
request: {
@@ -105,10 +106,10 @@ export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
const latestFrom = latestEntry.type;
return (
<Card className="">
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-2 sm:py-3">
<CardTitle>Total Requests of APIs</CardTitle>
<div className="flex flex-1 flex-col justify-center gap-1 px-5 py-4">
<CardTitle>Total Requests of APIs in Last 30 Days</CardTitle>
<CardDescription>
Last request from <strong>{latestFrom}</strong> api about{" "}
{latestDate}.
@@ -128,7 +129,7 @@ export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none">
{dataTotal[key as keyof typeof dataTotal].toLocaleString()}
{nFormatter(dataTotal[key])}
</span>
</button>
);
@@ -191,6 +192,7 @@ export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
});
}}
/>
<YAxis width={20} axisLine={false} tickLine={false} />
<ChartTooltip
content={
<ChartTooltipContent

View File

@@ -4,8 +4,22 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Scraping API" text="" />
<Skeleton className="h-full w-full rounded-lg" />
<DashboardHeader
heading="Scraping&nbsp;&nbsp;API&nbsp;&nbsp;Overview"
text="Quickly extract valuable structured website data. It's free and unlimited to use!"
/>
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</div>
</>
);
}

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { RefreshCwIcon } from "lucide-react";
import useSWR, { useSWRConfig } from "swr";
import { nFormatter } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
@@ -46,6 +47,8 @@ const getLogsUrl = (
const LogsTable = ({ userId, target }) => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filters, setFilters] = useState({
type: "",
ip: "",
@@ -95,12 +98,12 @@ const LogsTable = ({ userId, target }) => {
onChange={(e) => handleFilterChange("type", e.target.value)}
className="h-8 max-w-xs placeholder:text-xs"
/>
<Input
{/* <Input
placeholder="Filter by IP..."
value={filters.ip}
onChange={(e) => handleFilterChange("ip", e.target.value)}
className="h-8 max-w-xs placeholder:text-xs"
/>
/> */}
{
<>
<Input
@@ -136,16 +139,15 @@ const LogsTable = ({ userId, target }) => {
<div className="rounded-md border">
<Table>
<TableHeader className="bg-muted">
<TableRow className="">
<TableRow className="grid grid-cols-5 items-center sm:grid-cols-6">
<TableHead className="hidden items-center justify-start px-2 sm:flex">
Date
</TableHead>
<TableHead className="px-2">Type</TableHead>
<TableHead className="hidden items-center justify-start px-2 sm:flex">
IP
<TableHead className="flex items-center px-2">Type</TableHead>
<TableHead className="col-span-3 flex items-center px-2">
Link
</TableHead>
<TableHead className="px-2">Link</TableHead>
<TableHead className="px-2">User</TableHead>
<TableHead className="flex items-center px-2">User</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -158,9 +160,6 @@ const LogsTable = ({ userId, target }) => {
<TableCell>
<Skeleton className="h-2 w-[80px]" />
</TableCell>
<TableCell className="hidden sm:inline-block">
<Skeleton className="h-2 w-[120px]" />
</TableCell>
<TableCell>
<Skeleton className="h-2 w-[200px]" />
</TableCell>
@@ -170,15 +169,15 @@ const LogsTable = ({ userId, target }) => {
</TableRow>
))
: logs.map((log) => (
<TableRow className="text-xs hover:bg-muted" key={log.id}>
<TableRow
className="grid grid-cols-5 items-center text-xs hover:bg-muted sm:grid-cols-6"
key={log.id}
>
<TableCell className="hidden truncate p-2 sm:inline-block">
{new Date(log.createdAt).toLocaleString()}
</TableCell>
<TableCell className="p-2">{log.type}</TableCell>
<TableCell className="hidden p-2 sm:inline-block">
{log.ip}
</TableCell>
<TableCell className="max-w-md truncate p-2">
<TableCell className="col-span-3 max-w-full truncate p-2">
{log.link}
</TableCell>
<TableCell className="max-w-md truncate p-2">
@@ -190,13 +189,19 @@ const LogsTable = ({ userId, target }) => {
</Table>
</div>
<div className="flex items-center justify-between gap-2">
<p className="ml-auto text-nowrap text-sm">{data?.total || 0} logs</p>
{data && Math.ceil(data.total / 20) > 1 && (
<div className="flex items-start justify-between gap-2 sm:items-center">
<p className="ml-auto text-nowrap text-sm">
{nFormatter(data?.total || 0)} logs
</p>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={Math.ceil(data.total / 20)}
className="m-0"
total={data.total}
currentPage={page}
setCurrentPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
layout="right"
/>
)}
</div>

View File

@@ -5,7 +5,8 @@ export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Scraping API" text="" />
<Skeleton className="h-full w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "@/components/shared/api-reference";
import { MarkdownScraping, TextScraping } from "../scrapes";
@@ -21,10 +22,20 @@ export default async function DashboardPage() {
<>
<DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;Markdown"
text="Quickly extract website content and convert it to Markdown format. It's free and unlimited to use!"
text="Quickly extract website content and convert it to Markdown format."
link="/docs/open-api/markdown"
linkText="Markdown API."
/>
<ApiReference
badge="GET /api/v1/scraping/markdown"
target="extracting url as markdown"
link="/docs/open-api/markdown"
/>
<ApiReference
badge="GET /api/v1/scraping/text"
target="extracting url as text"
link="/docs/open-api/text"
/>
<MarkdownScraping user={{ id: user.id, apiKey: user.apiKey }} />
<TextScraping user={{ id: user.id, apiKey: user.apiKey }} />
</>

View File

@@ -5,7 +5,8 @@ export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Scraping API" text="" />
<Skeleton className="h-full w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "@/components/shared/api-reference";
import DashboardScrapeCharts from "../charts";
import { MetaScraping } from "../scrapes";
@@ -21,10 +22,15 @@ export default async function DashboardPage() {
<>
<DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;Meta&nbsp;&nbsp;Info"
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/meta-info"
linkText="Meta Info API."
/>
<ApiReference
badge="GET /api/v1/scraping/meta"
target="extracting url as meta info"
link="/docs/open-api/meta-info"
/>
<MetaScraping user={{ id: user.id, apiKey: user.apiKey }} />
</>
);

View File

@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { ScrapeInfoCard } from "@/components/dashboard/dashboard-info-card";
import { StaticInfoCard } from "@/components/dashboard/dashboard-info-card";
import { DashboardHeader } from "@/components/dashboard/header";
import DashboardScrapeCharts from "./charts";
@@ -26,22 +26,19 @@ export default async function DashboardPage() {
linkText="Open API."
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Screenshot"
desc="Take a screenshot of the webpage."
link="/dashboard/scrape/screenshot"
icon="camera"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Meta Info"
desc="Extract website metadata."
link="/dashboard/scrape/meta-info"
icon="globe"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to QR Code"
desc="Generate QR Code from URL."
link="/dashboard/scrape/qrcode"
@@ -49,15 +46,13 @@ export default async function DashboardPage() {
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Markdown"
desc="Convert website content to Markdown format."
link="/dashboard/scrape/markdown"
icon="heading1"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Text"
desc="Extract website text."
link="/dashboard/scrape/markdown"

View File

@@ -5,7 +5,8 @@ export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Scraping API" text="" />
<Skeleton className="h-full w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -3,8 +3,10 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "@/components/shared/api-reference";
import QRCodeEditor from "@/components/shared/qr";
import { QrCodeScraping } from "../scrapes";
import { CodeLight, QrCodeScraping } from "../scrapes";
export const metadata = constructMetadata({
title: "Url to QR Code API - WR.DO",
@@ -24,7 +26,16 @@ export default async function DashboardPage() {
link="/docs/open-api/qrcode"
linkText="QR Code API."
/>
<QrCodeScraping user={{ id: user.id, apiKey: user.apiKey }} />
<ApiReference
badge="GET /api/v1/scraping/qrcode"
target="extracting url as QR code"
link="/docs/open-api/qrcode"
/>
<CodeLight content={`https://wr.do/api/v1/scraping/qrcode`} />
<QRCodeEditor
user={{ id: user.id, apiKey: user.apiKey || "", team: user.team }}
url="https://github.com/oiov"
/>
</>
);
}

View File

@@ -460,8 +460,8 @@ export function QrCodeScraping({
const [isShoting, setIsShoting] = useState(false);
const [currentScreenshotLink, setCurrentScreenshotLink] =
useState("vmail.dev");
const [screenshotInfo, setScreenshotInfo] = useState({
tmp_url: "",
const [qrInfo, setQrInfo] = useState({
// tmp_url: "",
payload: "",
});
@@ -473,10 +473,7 @@ export function QrCodeScraping({
if (!res.ok || res.status !== 200) {
toast.error(res.statusText);
} else {
// const blob = await res.blob();
// const imageUrl = URL.createObjectURL(blob);
setScreenshotInfo({
tmp_url: await res.text(),
setQrInfo({
payload: `${window.location.origin}${payload}`,
});
toast.success("Success!");
@@ -539,14 +536,14 @@ export function QrCodeScraping({
<JsonView
className="max-w-2xl overflow-auto p-2"
style={theme === "dark" ? vscodeTheme : githubLightTheme}
value={screenshotInfo}
value={qrInfo}
displayObjectSize={false}
displayDataTypes={false}
// shortenTextAfterLength={50}
/>
{screenshotInfo.tmp_url && (
{qrInfo.payload && (
<BlurImage
src={screenshotInfo.tmp_url}
src={qrInfo.payload}
alt="ligth preview landing"
className="my-4 flex rounded-md border object-contain object-center shadow-md"
width={150}

View File

@@ -5,7 +5,8 @@ export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Scraping API" text="" />
<Skeleton className="h-full w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "@/components/shared/api-reference";
import DashboardScrapeCharts from "../charts";
import { ScreenshotScraping } from "../scrapes";
@@ -22,10 +23,15 @@ export default async function DashboardPage() {
<>
<DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;Screenshot"
text="Quickly extract website screenshots. It's free and unlimited to use!"
text="Quickly extract website screenshots."
link="/docs/open-api/screenshot"
linkText="Screenshot API."
/>
<ApiReference
badge="GET /api/v1/scraping/screenshot"
target="extracting url as screenshot"
link="/docs/open-api/screenshot"
/>
<ScreenshotScraping user={{ id: user.id, apiKey: user.apiKey }} />
</>
);

View File

@@ -0,0 +1,459 @@
"use client";
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { differenceInMinutes, format } from "date-fns";
import { DAILY_DIMENSION_ENUMS } from "@/lib/enums";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RealtimeChart } from "./realtime-chart";
import RealtimeLogs from "./realtime-logs";
const RealtimeGlobe = dynamic(() => import("./realtime-globe"), { ssr: false });
export interface Location {
latitude: number;
longitude: number;
count: number;
city?: string;
country?: string;
lastUpdate?: Date;
createdAt?: Date;
updatedAt?: Date;
device?: string;
browser?: string;
userUrl?: {
url: string;
target: string;
prefix: string;
};
}
interface DatabaseLocation {
latitude: number;
longitude: number;
count: number;
city: string;
country: string;
lastUpdate: Date;
updatedAt: Date;
createdAt: Date;
device?: string;
browser?: string;
userUrl?: {
url: string;
target: string;
prefix: string;
};
}
interface ChartData {
time: string;
count: number;
}
function date2unix(date: Date): number {
return Math.floor(date.getTime() / 1000);
}
export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
const mountedRef = useRef(true);
const locationDataRef = useRef<Map<string, Location>>(new Map());
const lastUpdateRef = useRef<string>();
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [timeRange, setTimeRange] = useState<string>("30min");
const [time, setTime] = useState(() => {
const now = new Date();
return {
startAt: date2unix(new Date(now.getTime() - 30 * 60 * 1000)),
endAt: date2unix(now),
};
});
const [filters, setFilters] = useState<Record<string, any>>({});
const [locations, setLocations] = useState<Location[]>([]);
const [chartData, setChartData] = useState<ChartData[]>([]);
const [stats, setStats] = useState({
totalClicks: 0,
uniqueLocations: 0,
rawRecords: 0,
lastFetch: new Date().toISOString(),
});
const createLocationKey = (lat: number, lng: number) => {
return `${Math.round(lat * 100) / 100},${Math.round(lng * 100) / 100}`;
};
const processChartDataOptimized = (locations: Location[]): ChartData[] => {
const validLocations = locations.filter((loc) => loc.createdAt);
if (validLocations.length === 0) return [];
// 如果数据量很少,直接按原始时间点展示
if (validLocations.length <= 10) {
return validLocations.map((loc, index) => ({
time: format(new Date(loc.createdAt!), "HH:mm:ss"),
count: loc.count,
}));
}
// 否则使用智能分组
const dates = validLocations.map((loc) => new Date(loc.createdAt!));
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
const totalMinutes = differenceInMinutes(maxDate, minDate);
// 根据数据量和时间跨度动态调整分组
const targetGroups = Math.min(validLocations.length, 20); // 目标分组数量
let groupMinutes: number;
if (totalMinutes <= 60) {
groupMinutes = Math.max(1, Math.ceil(totalMinutes / targetGroups));
} else {
groupMinutes = Math.max(5, Math.ceil(totalMinutes / targetGroups));
}
const groupByFn = (date: Date) => {
const minutes =
Math.floor(date.getMinutes() / groupMinutes) * groupMinutes;
const grouped = new Date(date);
grouped.setMinutes(minutes, 0, 0);
return grouped;
};
const groupedData = new Map<string, number>();
validLocations.forEach((loc) => {
const date = new Date(loc.createdAt!);
const groupedDate = groupByFn(date);
const key = groupedDate.getTime().toString();
groupedData.set(key, (groupedData.get(key) || 0) + loc.count);
});
return Array.from(groupedData.entries())
.sort(([a], [b]) => parseInt(a) - parseInt(b))
.map(([key, count]) => ({
time: format(new Date(parseInt(key)), "MM-dd HH:mm"),
count: count,
}));
};
const appendLocationData = (
newData: DatabaseLocation[],
isInitialLoad = false,
) => {
const locationMap = isInitialLoad
? new Map()
: new Map(locationDataRef.current);
let totalNewClicks = 0;
newData.forEach((item) => {
const lat = Math.round(item.latitude * 100) / 100;
const lng = Math.round(item.longitude * 100) / 100;
const key = createLocationKey(lat, lng);
const clickCount = item.count || 1;
if (locationMap.has(key)) {
const existing = locationMap.get(key)!;
existing.count += clickCount;
existing.lastUpdate = new Date(item.lastUpdate);
} else {
locationMap.set(key, {
lat,
lng,
count: clickCount,
city: item.city,
country: item.country,
lastUpdate: new Date(item.lastUpdate),
device: item.device,
browser: item.browser,
userUrl: item.userUrl,
updatedAt: item.updatedAt,
createdAt: item.createdAt,
});
}
totalNewClicks += clickCount;
});
locationDataRef.current = locationMap;
const updatedLocations = Array.from(locationMap.values());
const totalCount = updatedLocations.reduce(
(sum, loc) => sum + loc.count,
0,
);
const normalizedLocations = updatedLocations.map((loc) => ({
...loc,
count: Math.max(0.1, loc.count / Math.max(totalCount, 1)),
}));
const chartData = processChartDataOptimized(updatedLocations);
return {
locations: normalizedLocations,
chartData,
totalNewClicks,
totalCount,
};
};
const getLiveLocations = async (isInitialLoad = true) => {
try {
const params = new URLSearchParams({
startAt: time.startAt.toString(),
endAt: time.endAt.toString(),
isAdmin: isAdmin ? "true" : "false",
...filters,
});
const response = await fetch(`/api/url/admin/locations?${params}`);
const result = await response.json();
if (result.error) {
// console.error("API Error:", result.error);
return;
}
const rawData: DatabaseLocation[] = result.data || [];
const {
locations: processedLocations,
chartData,
totalNewClicks,
totalCount,
} = appendLocationData(rawData, isInitialLoad);
setStats({
totalClicks: result.totalClicks || totalCount,
uniqueLocations: processedLocations.length,
rawRecords: result.rawRecords || rawData.length,
lastFetch: result.timestamp,
});
if (mountedRef.current) {
setLocations(processedLocations);
setChartData(chartData);
lastUpdateRef.current = result.timestamp;
}
if (!isInitialLoad) {
rawData.forEach((item, index) => {
setTimeout(() => {
if (mountedRef.current) {
createTrafficEvent(
item.latitude,
item.longitude,
item.city || "Unknown",
);
}
}, index * 100);
});
}
} catch (error) {
console.error("Error fetching live locations:", error);
if (mountedRef.current) {
setLocations([]);
setChartData([]);
}
}
};
const getRealtimeUpdates = async () => {
try {
const response = await fetch("/api/url/admin/locations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lastUpdate: lastUpdateRef.current,
startAt: time.startAt,
endAt: time.endAt,
isAdmin,
...filters,
}),
});
const result = await response.json();
if (result.error || !result.data || result.data.length === 0) {
return;
}
const {
locations: processedLocations,
chartData,
totalNewClicks,
} = appendLocationData(result.data, false);
setStats((prev) => ({
totalClicks: prev.totalClicks + totalNewClicks,
uniqueLocations: processedLocations.length,
rawRecords: prev.rawRecords + result.data.length,
lastFetch: result.timestamp,
}));
if (mountedRef.current) {
setLocations(processedLocations);
setChartData(chartData);
lastUpdateRef.current = result.timestamp;
result.data.forEach((item: DatabaseLocation, index: number) => {
setTimeout(() => {
if (mountedRef.current) {
createTrafficEvent(
item.latitude,
item.longitude,
item.city || "Unknown",
);
}
}, index * 100);
});
}
} catch (error) {
console.error("Error fetching realtime updates:", error);
}
};
const resetLocationData = () => {
locationDataRef.current.clear();
setLocations([]);
setChartData([]);
setStats({
totalClicks: 0,
uniqueLocations: 0,
rawRecords: 0,
lastFetch: new Date().toISOString(),
});
};
useEffect(() => {
if (!mountedRef.current) return;
realtimeIntervalRef.current = setInterval(() => {
if (mountedRef.current) {
getRealtimeUpdates();
}
}, 5000);
return () => {
if (realtimeIntervalRef.current) {
clearInterval(realtimeIntervalRef.current);
}
};
}, []);
useEffect(() => {
if (mountedRef.current) {
resetLocationData();
getLiveLocations(true);
}
}, [time, filters]);
useEffect(() => {
const restoreTimeRange = () => {
setTimeRange("30min");
const now = new Date();
setTime({
startAt: date2unix(new Date(now.getTime() - 30 * 60 * 1000)),
endAt: date2unix(now),
});
};
(window as any).restoreTimeRange = restoreTimeRange;
const interval = setInterval(
() => {
if (mountedRef.current) {
restoreTimeRange();
}
},
5 * 60 * 1000,
);
return () => {
clearInterval(interval);
delete (window as any).restoreTimeRange;
};
}, []);
const handleTrafficEventRef = useRef<
(lat: number, lng: number, city: string) => void
>(() => {});
const createTrafficEvent = (lat: number, lng: number, city: string) => {
if (handleTrafficEventRef.current) {
handleTrafficEventRef.current(lat, lng, city);
}
};
const handleTimeRangeChange = (value: string) => {
setTimeRange(value);
const now = new Date();
const selectedRange = DAILY_DIMENSION_ENUMS.find((e) => e.value === value);
if (!selectedRange) return;
const minutes = selectedRange.key;
const startAt = date2unix(new Date(now.getTime() - minutes * 60 * 1000));
const endAt = date2unix(now);
setTime({ startAt, endAt });
};
return (
<div className="relative w-full">
<div className="sm:relative sm:p-4">
<RealtimeTimePicker
timeRange={timeRange}
setTimeRange={handleTimeRangeChange}
/>
<RealtimeChart
className="left-0 top-9 z-10 rounded-t-none text-left sm:absolute"
chartData={chartData}
totalClicks={stats.totalClicks}
/>
<RealtimeGlobe
time={time}
filters={filters}
locations={locations}
stats={stats}
setHandleTrafficEvent={(fn) => (handleTrafficEventRef.current = fn)}
/>
<RealtimeLogs
className="right-0 top-0 z-10 sm:absolute"
locations={locations}
/>
</div>
</div>
);
}
export function RealtimeTimePicker({
timeRange,
setTimeRange,
}: {
timeRange: string;
setTimeRange: (value: string) => void;
}) {
return (
<Select onValueChange={setTimeRange} name="time range" value={timeRange}>
<SelectTrigger className="left-0 top-0 z-10 h-9 rounded-b-none border-b-0 bg-transparent text-left backdrop-blur-2xl sm:absolute sm:w-[326px]">
<SelectValue placeholder="Select a time range" />
</SelectTrigger>
<SelectContent>
{DAILY_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem value={e.value}>
<span className="flex items-center gap-1">{e.label}</span>
</SelectItem>
{i % 2 === 0 && i !== DAILY_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { cn } from "@/lib/utils";
import StatusDot from "@/components/dashboard/status-dot";
import { Icons } from "@/components/shared/icons";
interface ChartData {
time: string;
count: number;
}
interface RealtimeChartProps {
className?: string;
chartData: ChartData[];
totalClicks: number;
}
export const RealtimeChart = ({
className,
chartData,
totalClicks,
}: RealtimeChartProps) => {
const getTickInterval = (dataLength: number) => {
if (dataLength <= 6) return 0;
if (dataLength <= 12) return 1;
if (dataLength <= 24) return Math.ceil(dataLength / 8);
return Math.ceil(dataLength / 6);
};
const filteredChartData = chartData.filter((item, index) => {
return item.count !== 0 || index === chartData.length - 1;
});
const tickInterval = getTickInterval(filteredChartData.length);
return (
<div className={cn(`rounded-lg border p-3 backdrop-blur-2xl`, className)}>
<div className="mb-1 flex items-center text-base font-semibold">
<StatusDot status={1} />
<h3 className="ml-2">Realtime Visits</h3>
<Icons.mousePointerClick className="ml-auto size-4 text-muted-foreground" />
</div>
<p className="mb-2 text-lg font-semibold">{totalClicks}</p>
<BarChart
width={300}
height={200}
data={filteredChartData}
margin={{ top: 10, right: 0, left: -20, bottom: 0 }}
barCategoryGap={1}
>
<XAxis
dataKey="time"
tick={{ fontSize: 12 }}
interval={tickInterval}
tickCount={Math.min(filteredChartData.length, 10)}
axisLine={false}
tickLine={false}
type="category"
scale="point"
padding={{ left: 14, right: 20 }}
tickFormatter={(value) => value.split(" ")[1]}
/>
<YAxis
domain={[0, "dataMax"]}
tickCount={5}
tick={{ fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="rounded-md border border-primary-foreground bg-primary py-2 text-primary-foreground backdrop-blur">
<p className="label px-2 text-base font-medium">{`${label}`}</p>
<p className="label px-2 text-sm">{`Visits: ${payload[0].value}`}</p>
</div>
);
}
return null;
}}
/>
<Bar
dataKey="count"
fill="#2d9af9"
radius={[1, 1, 0, 0]}
maxBarSize={40}
/>
</BarChart>
</div>
);
};

View File

@@ -0,0 +1,314 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useElementSize } from "@mantine/hooks";
import { scaleSequentialSqrt } from "d3-scale";
import { interpolateTurbo } from "d3-scale-chromatic";
import { GlobeInstance } from "globe.gl";
import { debounce } from "lodash-es";
import { useTheme } from "next-themes";
import { Location } from "./index";
interface GlobeProps {
time: {
startAt: number;
endAt: number;
};
filters: Record<string, any>;
locations: Location[];
stats: {
totalClicks: number;
uniqueLocations: number;
rawRecords: number;
lastFetch: string;
};
setHandleTrafficEvent: (
fn: (lat: number, lng: number, city: string) => void,
) => void;
}
export default function RealtimeGlobe({
time,
filters,
locations,
stats,
}: GlobeProps) {
const { theme } = useTheme();
const globeRef = useRef<HTMLDivElement>(null);
const globeInstanceRef = useRef<any>(null);
const mountedRef = useRef(true);
let globe: GlobeInstance;
const [countries, setCountries] = useState<any>({});
const [currentLocation, setCurrentLocation] = useState<any>({});
const [hexAltitude, setHexAltitude] = useState(0.001);
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const [isLoaded, setIsLoaded] = useState(false);
const highest =
locations.reduce((acc, curr) => Math.max(acc, curr.count), 0) || 1;
const weightColor = scaleSequentialSqrt(interpolateTurbo).domain([
0,
highest * 15,
]);
const loadGlobe = useCallback(async () => {
try {
const GlobeModule = await import("globe.gl");
const Globe = GlobeModule.default;
const { MeshPhongMaterial } = await import("three");
return { Globe, MeshPhongMaterial };
} catch (err) {
// console.error("Failed to load Globe.gl:", err);
return null;
}
}, []);
const getGlobeJSON = async () => {
try {
const response = await fetch("/countries.geojson");
const data = await response.json();
if (mountedRef.current) {
setCountries(data);
}
} catch (error) {
console.error("Error fetching globe JSON:", error);
if (mountedRef.current) {
setCountries({ type: "FeatureCollection", features: [] });
}
}
};
const getCurrentLocation = async () => {
try {
const response = await fetch("/api/location");
const data = await response.json();
if (mountedRef.current) {
setCurrentLocation(data);
}
} catch (error) {
console.error("Error fetching current location:", error);
if (mountedRef.current) {
setCurrentLocation({ latitude: 0, longitude: 0 });
}
}
};
const initGlobe = useCallback(async () => {
if (
!globeRef.current ||
!countries.features ||
globeInstanceRef.current ||
!mountedRef.current
) {
return;
}
try {
const modules = await loadGlobe();
if (!modules || !mountedRef.current) return;
const { Globe, MeshPhongMaterial } = modules;
const container = globeRef.current;
if (!container) return;
container.innerHTML = "";
const rect = container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
setTimeout(initGlobe, 200);
return;
}
globe = new Globe(container)
.width(wrapperWidth)
.height(wrapperWidth > 728 ? wrapperWidth * 0.9 : wrapperWidth)
.globeOffset([0, -80])
.atmosphereColor("rgba(170, 170, 200, 0.7)")
.backgroundColor("rgba(0,0,0,0)")
.globeMaterial(
new MeshPhongMaterial({
color: theme === "dark" ? "rgb(65, 65, 65)" : "rgb(228, 228, 231)",
transparent: false,
opacity: 1,
}) as any,
);
if (countries.features && countries.features.length > 0) {
globe
.hexPolygonsData(countries.features)
.hexPolygonResolution(3)
.hexPolygonMargin(0.2)
.hexPolygonAltitude(() => hexAltitude)
.hexPolygonColor(
() => `rgba(45, 154, 249, ${Math.random() / 1.5 + 0.5})`,
);
}
globe
.hexBinResolution(4)
.hexBinPointsData(locations)
.hexBinMerge(true)
.hexBinPointWeight("count")
.hexTopColor((d: any) => {
const intensity = d.sumWeight || 0;
return weightColor(intensity);
})
.hexSideColor((d: any) => {
const intensity = d.sumWeight || 0;
return weightColor(intensity * 0.8);
})
.hexAltitude((d: any) => {
const intensity = d.sumWeight || 0;
return Math.max(0.01, intensity * 0.8);
});
globe.onGlobeReady(() => {
if (!mountedRef.current) return;
const lat = currentLocation.latitude || 0;
const lng = currentLocation.longitude || 0;
globe.pointOfView({
lat: lat,
lng: lng,
altitude: rect.width > 768 ? 2.5 : 3.5,
});
if (globe.controls()) {
globe.controls().autoRotate = true;
globe.controls().autoRotateSpeed = 0.5;
globe.controls().enableDamping = true;
globe.controls().dampingFactor = 0.1;
}
setIsLoaded(true);
});
if (globe.controls()) {
globe.controls().addEventListener(
"end",
debounce(() => {
if (!mountedRef.current || !globeInstanceRef.current) return;
try {
const distance = Math.round(globe.controls().getDistance());
let nextAlt = 0.005;
if (distance <= 300) nextAlt = 0.001;
else if (distance >= 600) nextAlt = 0.02;
if (nextAlt !== hexAltitude) {
setHexAltitude(nextAlt);
}
} catch (err) {
console.warn("Error in controls event:", err);
}
}, 200),
);
}
globeInstanceRef.current = globe;
} catch (err) {}
}, [
countries,
locations,
currentLocation,
hexAltitude,
loadGlobe,
weightColor,
]);
const cleanup = useCallback(() => {
if (globeInstanceRef.current) {
try {
if (typeof globeInstanceRef.current._destructor === "function") {
globeInstanceRef.current._destructor();
}
if (globeRef.current) {
globeRef.current.innerHTML = "";
}
} catch (err) {
console.warn("Error during cleanup:", err);
}
globeInstanceRef.current = null;
}
setIsLoaded(false);
}, []);
// useEffect(() => {
// if (globeInstanceRef.current) {
// globeInstanceRef.current.width(wrapperWidth);
// globeInstanceRef.current.height(
// wrapperWidth > 728 ? wrapperWidth * 0.8 : wrapperWidth,
// );
// }
// }, [globeInstanceRef.current, wrapperWidth, wrapperHeight]);
useEffect(() => {
if (
globeInstanceRef.current &&
mountedRef.current &&
locations.length > 0
) {
try {
globeInstanceRef.current.hexBinPointsData(locations);
} catch (err) {
console.warn("Error updating locations:", err);
}
}
}, [locations]);
useEffect(() => {
const initializeData = async () => {
if (!mountedRef.current) return;
try {
await Promise.all([getCurrentLocation(), getGlobeJSON()]);
} catch (error) {
console.error("Error initializing data:", error);
}
};
initializeData();
}, []);
useEffect(() => {
if (
countries.features &&
currentLocation &&
!globeInstanceRef.current &&
mountedRef.current
) {
const timer = setTimeout(initGlobe, 100);
return () => clearTimeout(timer);
}
}, [countries, currentLocation, initGlobe]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
cleanup();
};
}, [cleanup]);
return (
<div
ref={wrapperRef}
className="relative -mt-8 max-h-screen overflow-hidden"
>
<div
ref={globeRef}
className="flex justify-center"
style={{
maxWidth: `${wrapperWidth}px`, // 比较疑惑
minHeight: "100px",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import ReactCountryFlag from "react-country-flag";
import { formatTime } from "@/lib/utils";
import { Location } from "./index";
const RealtimeLogs = ({
className,
locations,
}: {
className?: string;
locations: Location[];
}) => {
const [displayedLocations, setDisplayedLocations] = useState<Location[]>([]);
const [pendingLocations, setPendingLocations] = useState<Location[]>([]);
// 生成唯一标识用于去重
const generateUniqueKey = (loc: Location): string => {
return `${loc.userUrl?.url || ""}-${loc.userUrl?.target || ""}-${loc.userUrl?.prefix || ""}-${loc.country || ""}-${loc.city || ""}-${loc.browser || ""}-${loc.device || ""}-${loc.updatedAt?.toString() || ""}`;
};
// 当外部 locations 更新时,更新预备列表
useEffect(() => {
const sortedLocations = [...locations].sort((a, b) => {
const timeA = new Date(a.updatedAt?.toString() || "").getTime() || 0;
const timeB = new Date(b.updatedAt?.toString() || "").getTime() || 0;
return timeA - timeB;
});
setPendingLocations((prev) => {
// 去重:基于多个字段判断
const newLocations = sortedLocations.filter(
(loc) =>
!prev.some((p) => generateUniqueKey(p) === generateUniqueKey(loc)) &&
!displayedLocations.some(
(d) => generateUniqueKey(d) === generateUniqueKey(loc),
),
);
return [...prev, ...newLocations];
});
}, [locations]);
// 每 2 秒从预备列表插入一条数据到显示列表
useEffect(() => {
if (pendingLocations.length === 0) return;
const interval = setInterval(() => {
setDisplayedLocations((prev) => {
if (pendingLocations.length > 0) {
const newLocation = pendingLocations[0];
// 插入新数据到顶部,限制显示列表最多 8 条
const newDisplayed = [newLocation, ...prev].slice(0, 8);
// 从预备列表移除已插入的数据
setPendingLocations((pending) => pending.slice(1));
return newDisplayed;
}
return prev;
});
}, 1500);
return () => clearInterval(interval);
}, [pendingLocations]);
// 动画配置
const itemVariants = {
initial: { opacity: 0, scale: 0.1, x: "25%", y: "25%" }, // 从中心缩放
animate: { opacity: 1, scale: 1, x: 0, y: 0 },
// exit: { opacity: 0, transition: { duration: 0.3 } }, // 渐出
};
return (
<div
className={`flex-1 overflow-y-auto ${className}`}
style={{ minHeight: "200px", maxHeight: "80vh" }}
>
<AnimatePresence initial={false}>
{displayedLocations.length > 0 &&
displayedLocations.map((loc) => (
<motion.div
key={generateUniqueKey(loc)}
variants={itemVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className="mb-2 flex w-full items-center justify-start gap-3 rounded-lg border p-3 text-xs shadow-inner backdrop-blur-xl sm:w-60"
>
<ReactCountryFlag
style={{ fontSize: "16px" }}
countryCode={loc.country || "US"}
/>
<div>
<div className="flex items-center gap-1">
<Link
className="text-sm font-semibold"
href={`https://${loc.userUrl?.prefix}/s/${loc.userUrl?.url}`}
target="_blank"
>
{loc.userUrl?.url}
</Link>
<span className="font-semibold">·</span>
<span className="text-muted-foreground">
{formatTime(loc.updatedAt?.toString() || "")}
</span>
</div>
{loc.browser && loc.browser !== "Unknown" && (
<div className="mt-1 line-clamp-1 break-words font-medium text-muted-foreground">
{loc.browser}
{loc.device &&
loc.device !== "Unknown" &&
`${", "}${loc.device}`}
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
);
};
export default RealtimeLogs;

View File

@@ -24,7 +24,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
@@ -47,7 +46,7 @@ export interface LogEntry {
isNew?: boolean; // New property to track newly added logs
}
export default function LiveLog({ admin }: { admin: boolean }) {
export default function LiveLog({ admin = false }: { admin?: boolean }) {
const { theme } = useTheme();
const { mutate } = useSWRConfig();
const [isLive, setIsLive] = useState(false);
@@ -201,19 +200,30 @@ export default function LiveLog({ admin }: { admin: boolean }) {
{error ? (
<div className="text-center text-red-500">{error.message}</div>
) : logs.length === 0 && !newLogs ? (
// <Skeleton className="h-8 w-full" />
<></>
) : (
<div className="scrollbar-hidden h-96 overflow-y-auto bg-primary-foreground">
<Table>
<TableHeader>
<TableRow className="bg-gray-100/50 text-sm dark:bg-primary-foreground">
<TableHead className="h-8 w-1/6 px-1">Time</TableHead>
<TableHead className="h-8 w-1/12 px-1">Slug</TableHead>
<TableHead className="h-8 px-1">Target</TableHead>
<TableHead className="h-8 w-1/12 px-1">IP</TableHead>
<TableHead className="h-8 w-1/6 px-1">Location</TableHead>
<TableHead className="h-8 w-1/12 px-1">Clicks</TableHead>
<TableRow className="grid grid-cols-5 bg-gray-100/50 text-sm dark:bg-primary-foreground sm:grid-cols-9">
<TableHead className="col-span-2 flex h-8 items-center">
Time
</TableHead>
<TableHead className="col-span-1 flex h-8 items-center">
Slug
</TableHead>
<TableHead className="col-span-3 hidden h-8 items-center sm:flex">
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">
Location
</TableHead>
<TableHead className="col-span-1 flex h-8 items-center">
Clicks
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -235,23 +245,24 @@ export default function LiveLog({ admin }: { admin: boolean }) {
ease: "linear",
},
}}
className="font-mono text-xs hover:bg-gray-200 dark:border-gray-800"
className="grid grid-cols-5 font-mono text-xs hover:bg-gray-200 dark:border-gray-800 sm:grid-cols-9"
>
<TableCell className="whitespace-nowrap px-1 py-1.5">
<TableCell className="col-span-2 truncate py-1.5">
{new Date(log.updatedAt).toLocaleString()}
</TableCell>
<TableCell className="font-midium px-1 py-1.5 text-green-700">
<TableCell className="font-midium col-span-1 truncate py-1.5 text-green-700">
{log.slug}
</TableCell>
<TableCell className="max-w-10 truncate px-1 py-1.5 hover:underline">
<TableCell className="col-span-3 hidden max-w-full truncate py-1.5 hover:underline sm:flex">
<a href={log.target} target="_blank" title={log.target}>
{log.target}
</a>
</TableCell>
<TableCell className="px-1 py-1.5">{log.ip}</TableCell>
<TableCell className="col-span-1 hidden truncate py-1.5 sm:flex">
{log.ip}
</TableCell>
<TableCell
className="max-w-6 truncate px-1 py-1.5"
className="col-span-1 truncate py-1.5"
title={getCountryName(log.country || "")}
>
{decodeURIComponent(
@@ -260,7 +271,7 @@ export default function LiveLog({ admin }: { admin: boolean }) {
: "-",
)}
</TableCell>
<TableCell className="px-1 py-1.5 text-green-700">
<TableCell className="col-span-1 py-1.5 text-green-700">
{log.click}
</TableCell>
</motion.tr>

View File

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

View File

@@ -3,15 +3,24 @@
import * as React from "react";
import { useState } from "react";
import Link from "next/link";
import { UrlMeta } from "@prisma/client";
import { UrlMeta, User } from "@prisma/client";
import { VisSingleContainer, VisTooltip, VisTopoJSONMap } from "@unovis/react";
import { TopoJSONMap } from "@unovis/ts";
import { WorldMapTopoJSON } from "@unovis/ts/maps";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { getCountryName, getDeviceVendor } from "@/lib/contries";
import { TeamPlanQuota } from "@/config/team";
import {
getBotName,
getCountryName,
getDeviceVendor,
getEngineName,
getLanguageName,
getRegionName,
} from "@/lib/contries";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { isLink, removeUrlSuffix, timeAgo } from "@/lib/utils";
import { useElementSize } from "@/hooks/use-element-size";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -29,9 +38,12 @@ import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Icons } from "@/components/shared/icons";
const chartConfig = {
pv: {
@@ -39,7 +51,7 @@ const chartConfig = {
color: "hsl(var(--chart-2))",
},
uv: {
label: "Visitors",
label: "Visits",
color: "hsl(var(--chart-1))",
},
};
@@ -114,7 +126,15 @@ function generateStatsList(
? getCountryName(rawValue as string) // 国家代码转为国家名称
: dimension === "device"
? getDeviceVendor(rawValue as string) // 设备型号转为厂商名称
: rawValue; // 其他维度直接使用原始值
: dimension === "engine"
? getEngineName(rawValue as string) // 引擎名称
: dimension === "region"
? getRegionName(rawValue as string) // 区域名称
: dimension === "lang"
? getLanguageName(rawValue as string) // 语言名称
: dimension === "isBot"
? getBotName(rawValue as boolean) // 是否为机器人
: rawValue; // 其他维度直接使用原始值
const click = record.click || 0; // 确保 click 是数字,默认 0 如果未定义
@@ -144,11 +164,14 @@ export function DailyPVUVChart({
data,
timeRange,
setTimeRange,
user,
}: {
data: UrlMeta[];
timeRange: string;
setTimeRange: React.Dispatch<React.SetStateAction<string>>;
user: Pick<User, "id" | "name" | "team">;
}) {
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("pv");
@@ -157,7 +180,6 @@ export function DailyPVUVChart({
pv: entry.clicks,
uv: new Set(entry.ips).size,
}));
// .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const dataTotal = calculateUVAndPV(data);
@@ -209,9 +231,15 @@ export function DailyPVUVChart({
const deviceStats = generateStatsList(data, "device");
const browserStats = generateStatsList(data, "browser");
const countryStats = generateStatsList(data, "country");
const osStats = generateStatsList(data, "os");
const cpuStats = generateStatsList(data, "cpu");
const engineStats = generateStatsList(data, "engine");
const languageStats = generateStatsList(data, "lang");
const regionStats = generateStatsList(data, "region");
const isBotStats = generateStatsList(data, "isBot");
return (
<Card className="rounded-t-none border-t-0">
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-2 sm:py-3">
<CardTitle>Link Analytics</CardTitle>
@@ -231,10 +259,26 @@ export function DailyPVUVChart({
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
value={e.value}
>
<span className="flex items-center gap-1">
{e.label}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
@@ -247,7 +291,7 @@ export function DailyPVUVChart({
className="relative z-30 flex flex-1 flex-col items-center justify-center gap-1 border-t px-6 py-2 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-3"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
<span className="text-sm font-semibold text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none">
@@ -258,7 +302,7 @@ export function DailyPVUVChart({
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<CardContent className="px-2 sm:p-6" ref={wrapperRef}>
<ChartContainer
config={chartConfig}
className="aspect-auto h-[225px] w-full"
@@ -314,6 +358,7 @@ export function DailyPVUVChart({
});
}}
/>
<YAxis width={20} axisLine={false} tickLine={false} />
<ChartTooltip
content={
<ChartTooltipContent
@@ -329,9 +374,6 @@ export function DailyPVUVChart({
/>
}
/>
{/* <Bar dataKey="uv" fill={`var(--color-uv)`} stackId="a" />
<Bar dataKey="pv" fill={`var(--color-pv)`} stackId="a" /> */}
<Area
type="monotone"
dataKey="uv"
@@ -349,31 +391,103 @@ export function DailyPVUVChart({
</AreaChart>
</ChartContainer>
<VisSingleContainer data={{ areas: areaData }}>
<VisTopoJSONMap
topojson={WorldMapTopoJSON}
// pointRadius={1.6}
// mapFitToPoints={true}
/>
<VisSingleContainer
data={{ areas: areaData }}
width={wrapperWidth * 0.99}
>
<VisTopoJSONMap topojson={WorldMapTopoJSON} />
<VisTooltip triggers={triggers} />
</VisSingleContainer>
<div className="my-5 grid grid-cols-1 gap-6 sm:grid-cols-2">
{refererStats.length > 0 && (
<StatsList data={refererStats} title="Referrers" />
)}
{countryStats.length > 0 && (
<StatsList data={countryStats} title="Countries" />
)}
{cityStats.length > 0 && (
<StatsList data={cityStats} title="Cities" />
)}
{browserStats.length > 0 && (
<StatsList data={browserStats} title="Browsers" />
)}
{deviceStats.length > 0 && (
<StatsList data={deviceStats} title="Devices" />
)}
{/* Referrers、isBotStats */}
<Tabs defaultValue="referrer">
<TabsList>
<TabsTrigger value="referrer">Referrers</TabsTrigger>
<TabsTrigger value="isBot">Traffic Type</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="referrer">
{refererStats.length > 0 && (
<StatsList data={refererStats} title="Referrers" />
)}
</TabsContent>
<TabsContent className="h-[calc(100%-40px)]" value="isBot">
{isBotStats.length > 0 && (
<StatsList data={isBotStats} title="Is Bot" />
)}
</TabsContent>
</Tabs>
{/* 国家、城市 */}
<Tabs defaultValue="country">
<TabsList>
<TabsTrigger value="country">Country</TabsTrigger>
<TabsTrigger value="city">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">Browser</TabsTrigger>
<TabsTrigger value="engine">Browser 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">Language</TabsTrigger>
<TabsTrigger value="region">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">Device</TabsTrigger>
<TabsTrigger value="os">OS</TabsTrigger>
<TabsTrigger value="cpu">CPU</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="device">
{deviceStats.length > 0 && (
<StatsList data={deviceStats} title="Devices" />
)}
</TabsContent>
<TabsContent className="h-[calc(100%-40px)]" value="os">
{osStats.length > 0 && <StatsList data={osStats} title="OS" />}
</TabsContent>
<TabsContent className="h-[calc(100%-40px)]" value="cpu">
{cpuStats.length > 0 && <StatsList data={cpuStats} title="CPU" />}
</TabsContent>
</Tabs>
</div>
</CardContent>
</Card>
@@ -385,12 +499,15 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
const displayedData = showAll ? data.slice(0, 50) : data.slice(0, 8);
return (
<div className="rounded-lg border p-4">
<h1 className="text-lg font-bold">{title}</h1>
<div className="h-full rounded-lg border">
<div className="flex items-center justify-between border-b px-5 py-2 text-xs font-medium text-muted-foreground">
<span></span>
<span className=""></span>
</div>
<div
className={`scrollbar-hidden overflow-hidden overflow-y-auto transition-all duration-500 ease-in-out`}
className={`scrollbar-hidden overflow-hidden overflow-y-auto px-4 pb-4 pt-2 transition-all duration-500 ease-in-out`}
style={{
maxHeight: "18rem", // 动态计算最大高度
maxHeight: "18rem",
}}
>
{displayedData.map((ref) => (
@@ -432,7 +549,7 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
</div>
{data.length > 8 && (
<div className="mt-3 text-center">
<div className="mb-3 mt-1 text-center">
<Button
variant={"outline"}
onClick={() => setShowAll(!showAll)}

View File

@@ -4,22 +4,25 @@ import { useState } from "react";
import { UrlMeta, User } from "@prisma/client";
import useSWR from "swr";
import { TeamPlanQuota } from "@/config/team";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { fetcher } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import { DailyPVUVChart } from "./meta-chart";
export interface UrlMetaProps {
user: Pick<User, "id" | "name">;
user: Pick<User, "id" | "name" | "team">;
action: string;
urlId: string;
}
@@ -34,17 +37,21 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
if (isLoading)
return (
<div className="space-y-2 p-2">
<Skeleton className="h-40 w-full" />
<div className="space-y-2">
<Skeleton className="h-[400px] w-full" />
</div>
);
if (!data || data.length === 0) {
return (
<EmptyPlaceholder>
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Title>No Visits</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any visits yet in last {timeRange}.
You don&apos;t have any visits yet in{" "}
{DATE_DIMENSION_ENUMS.find(
(e) => e.value === timeRange,
)?.label.toLowerCase()}
.
<Select
onValueChange={(value: string) => {
setTimeRange(value);
@@ -56,10 +63,26 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
value={e.value}
>
<span className="flex items-center gap-1">
{e.label}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
@@ -74,6 +97,7 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
data={data}
timeRange={timeRange}
setTimeRange={setTimeRange}
user={user}
/>
</div>
);

View File

@@ -4,8 +4,6 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "./api-reference";
import LiveLog from "./live-logs";
import UserUrlsList from "./url-list";
export const metadata = constructMetadata({
@@ -32,11 +30,10 @@ export default async function DashboardPage() {
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
team: user.team,
}}
action="/api/url"
/>
<LiveLog admin={false} />
<ApiReference />
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { User } from "@prisma/client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import ApiReference from "@/components/shared/api-reference";
import Globe from "./globe";
import LiveLog from "./live-logs";
import UserUrlsList from "./url-list";
export function Wrapper({
user,
}: {
user: Pick<User, "id" | "name" | "apiKey" | "role" | "team">;
}) {
const [tab, setTab] = useState("Links");
return (
<Tabs
value={tab}
onChangeCapture={(e) => console.log(e)}
defaultValue={tab}
>
<TabsList>
<TabsTrigger value="Links" onClick={() => setTab("Links")}>
Links
</TabsTrigger>
<TabsTrigger value="Realtime" onClick={() => setTab("Realtime")}>
Realtime
</TabsTrigger>
</TabsList>
)
<TabsContent className="space-y-3" value="Links">
<UserUrlsList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
team: user.team,
}}
action="/api/url"
/>
<LiveLog admin={false} />
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</TabsContent>
<TabsContent value="Realtime">
<Globe />
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,343 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
import { cn, removeUrlSuffix } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { FormSectionColumns } from "@/components/dashboard/form-section-columns";
import { Icons } from "@/components/shared/icons";
export default function StepGuide({
user,
}: {
user: { id: string; email: string };
}) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [direction, setDirection] = useState(0);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const steps = [
{
id: 1,
title: "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} />,
},
{
id: 2,
title: "Add the first domain",
description:
"Check out your reimagined site and click to Migrate & Download.",
component: () => <AddDomain onNextStep={goToNextStep} />,
},
{
id: 3,
title: "Congrats on completing setup 🎉",
description:
"Navigate to your GitHub dashboard where you'll manage your repository and project files.",
component: () => <Congrats />,
},
];
const goToNextStep = () => {
if (currentStep < steps.length) {
setDirection(1);
setCurrentStep(currentStep + 1);
if (!completedSteps.includes(currentStep)) {
setCompletedSteps([...completedSteps, currentStep]);
}
} else if (currentStep === steps.length) {
router.push("/admin");
}
};
const goToPreviousStep = () => {
if (currentStep > 1) {
setDirection(-1);
setCurrentStep(currentStep - 1);
}
};
const currentStepData =
steps.find((step) => step.id === currentStep) || steps[0];
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 100 : -100,
opacity: 0,
}),
};
return (
<Modal className="md:max-w-2xl">
<div className="w-full px-4 py-2 md:px-8 md:py-4">
<div className="mb-6 mt-3 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold">Admin Setup Guide</h2>
<div className="flex items-center gap-2 rounded-full bg-muted/50 px-3 py-1.5 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
{currentStep}
</span>
<span className="text-muted-foreground">of</span>
<span>{steps.length}</span>
</div>
</div>
{/* Content area */}
<div className="relative w-full rounded-lg">
<AnimatePresence custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.4, ease: "easeInOut" }}
className="flex flex-col justify-center gap-6"
>
<div className="flex h-full w-full flex-col">
<div className="mb-2 flex items-center gap-1 rounded-lg bg-neutral-100 p-2 dark:bg-neutral-800">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{currentStep}
</span>
<motion.h3
className="text-base font-semibold"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.title}
</motion.h3>
</div>
<motion.div
className="h-full"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.component()}
</motion.div>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
<motion.div
className="mt-auto flex justify-between px-4 pb-4 pt-3 md:px-8 md:pb-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<button
onClick={goToPreviousStep}
disabled={currentStep === 1}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 transition-colors",
currentStep === 1
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
)}
>
<ChevronLeft className="h-4 w-4" />
Previous
</button>
<button
onClick={goToNextStep}
// disabled={currentStep === steps.length}
className={cn(
"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90",
)}
>
{currentStep === steps.length ? "🚀 Start" : "Next"}
<ChevronRight className="h-4 w-4" />
</button>
</motion.div>
</Modal>
);
}
function SetAdminRole({ id, email }: { id: string; email: string }) {
const [isPending, startTransition] = useTransition();
const [isAdmin, setIsAdmin] = useState(false);
const handleSetAdmin = async () => {
startTransition(async () => {
const res = await fetch("/api/setup");
if (res.ok) {
setIsAdmin(true);
}
});
};
const ReadyBadge = (
<Badge className="text-xs font-semibold" variant="green">
<Icons.check className="mr-1 size-3" />
Ready
</Badge>
);
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground">
Allow Sign Up:
</span>
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground">
Set {email} as ADMIN:
</span>
{isAdmin ? (
ReadyBadge
) : (
<Button
variant={"default"}
size={"sm"}
onClick={handleSetAdmin}
disabled={isPending}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Active Now
</Button>
)}
</div>
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
<p className="flex items-start gap-1">
📢 Only by becoming an administrator can one access the admin panel
and add domain names.
</p>
<p className="my-1">
📢 Administrators can set all user permissions, allocate quotas, view
and edit all resources (short links, subdomains, email), etc.
</p>
<p>
📢 Via{" "}
<a
className="text-blue-500"
target="_blank"
href="/docs/developer/quick-start"
>
quick start
</a>{" "}
docs to get more information.
</p>
</div>
</div>
);
}
function AddDomain({ onNextStep }: { onNextStep: () => void }) {
const [isPending, startTransition] = useTransition();
const [domain, setDomain] = useState("");
const handleCreateDomain = async () => {
if (!domain) {
toast.warning("Domain name cannot be empty");
return;
}
startTransition(async () => {
const res = await fetch("/api/admin/domain", {
method: "POST",
body: JSON.stringify({
data: {
domain_name: removeUrlSuffix(domain),
enable_short_link: true,
enable_email: true,
enable_dns: true,
cf_zone_id: "",
cf_api_key: "",
cf_email: "",
cf_api_key_encrypted: false,
max_short_links: 0,
max_email_forwards: 0,
max_dns_records: 0,
active: true,
},
}),
});
if (res.ok) {
onNextStep();
} else {
toast.error("Created Failed!", {
description: await res.text(),
});
}
});
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<FormSectionColumns title="Domain Name">
<div className="flex w-full flex-col items-start justify-between gap-2">
<Label className="sr-only" htmlFor="domain_name">
Domain Name
</Label>
<div className="w-full">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner dark:bg-neutral-600"
size={32}
placeholder="example.com"
onChange={(e) => setDomain(e.target.value)}
/>
</div>
<p className="text-xs text-muted-foreground">
Please enter a valid domain name (must be hosted on Cloudflare).
</p>
</div>
<div className="mt-2 flex w-full items-center justify-end gap-3">
<Button
className="text-xs text-muted-foreground"
variant={"ghost"}
size={"sm"}
onClick={onNextStep}
>
Or add later
</Button>
<Button
className="flex items-center gap-1"
size={"sm"}
variant={"blue"}
disabled={isPending}
onClick={handleCreateDomain}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Submit
</Button>
</div>
</FormSectionColumns>
</div>
);
}
function Congrats() {
return <></>;
}

View File

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

View File

@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { getAllUsersCount } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import SetupGuide from "./guide";
export const metadata = constructMetadata({
title: "Setup Guide",
description: "Setup Guide",
});
export default async function SetupPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
if (user.role === "ADMIN") redirect("/admin");
const count = await getAllUsersCount();
if (count === 1 && user.role === "USER") {
return <SetupGuide user={{ id: user.id, email: user.email! }} />;
}
return redirect("/admin");
}

View File

@@ -0,0 +1,148 @@
import { NextRequest } from "next/server";
import {
createDomain,
deleteDomain,
getAllDomains,
updateDomain,
} from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
// Get domains list
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const url = new URL(req.url);
const page = url.searchParams.get("page");
const size = url.searchParams.get("size");
const target = url.searchParams.get("target") || "";
const data = await getAllDomains(
Number(page || "1"),
Number(size || "10"),
target,
);
return Response.json(data, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
// Create domain
export async function POST(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const { data } = await req.json();
if (!data || !data.domain_name) {
return Response.json("domain_name is required", { status: 400 });
}
const newDomain = await createDomain({
domain_name: data.domain_name,
enable_short_link: !!data.enable_short_link,
enable_email: !!data.enable_email,
enable_dns: !!data.enable_dns,
cf_zone_id: data.cf_zone_id,
cf_api_key: data.cf_api_key,
cf_email: data.cf_email,
cf_api_key_encrypted: false,
resend_api_key: data.resend_api_key,
max_short_links: data.max_short_links,
max_email_forwards: data.max_email_forwards,
max_dns_records: data.max_dns_records,
active: true,
});
return Response.json(newDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
// Update domain
export async function PUT(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const {
domain_name,
enable_short_link,
enable_email,
enable_dns,
cf_zone_id,
cf_api_key,
cf_email,
resend_api_key,
max_short_links,
max_email_forwards,
max_dns_records,
active,
id,
} = await req.json();
if (!id) {
return Response.json("domain id is required", { status: 400 });
}
const updatedDomain = await updateDomain(id, {
domain_name,
enable_short_link: !!enable_short_link,
enable_email: !!enable_email,
enable_dns: !!enable_dns,
active: !!active,
cf_zone_id,
cf_api_key,
cf_email,
cf_api_key_encrypted: false,
resend_api_key,
max_short_links,
max_email_forwards,
max_dns_records,
});
return Response.json(updatedDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
// Delete domain
export async function DELETE(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const { domain_name } = await req.json();
if (!domain_name) {
return Response.json("domain_name is required", { status: 400 });
}
const deletedDomain = await deleteDomain(domain_name);
return Response.json(deletedDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}

View File

@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { checkUserStatus } from "@/lib/dto/user";
import { TIME_RANGES } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { getStartDate } from "@/lib/utils";
@@ -20,6 +21,14 @@ export async function GET(req: Request) {
const range = url.searchParams.get("range") || "7d";
const startDate = getStartDate(range);
if (!startDate) {
return Response.json({ statusText: "Invalid range" }, { status: 400 });
}
// Calculate previous period start and end dates
const rangeDuration = TIME_RANGES[range];
const prevStartDate = new Date(startDate.getTime() - rangeDuration);
const prevEndDate = startDate;
const users = await prisma.user.findMany({
where: {
@@ -86,12 +95,95 @@ export async function GET(req: Request) {
createdAt: true,
},
});
const sends = await prisma.userSendEmail.findMany({
where: {
createdAt: {
gte: startDate,
},
},
orderBy: {
createdAt: "desc",
},
select: {
createdAt: true,
},
});
// Fetch previous period data
const prevUsers = await prisma.user.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
const prevRecords = await prisma.userRecord.findMany({
where: {
created_on: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
created_on: true,
},
});
const prevUrls = await prisma.userUrl.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
const prevEmails = await prisma.userEmail.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
const prevInbox = await prisma.forwardEmail.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
const prevSends = await prisma.userSendEmail.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
// Process current period data
const userCountByDate: { [date: string]: number } = {};
const recordCountByDate: { [date: string]: number } = {};
const urlCountByDate: { [date: string]: number } = {};
const emailCountByDate: { [date: string]: number } = {};
const inboxCountByDate: { [date: string]: number } = {};
const sendCountByDate: { [date: string]: number } = {};
users.forEach((user) => {
const date = user.createdAt!.toISOString().split("T")[0];
@@ -113,6 +205,10 @@ export async function GET(req: Request) {
const date = email.createdAt.toISOString().split("T")[0];
inboxCountByDate[date] = (inboxCountByDate[date] || 0) + 1;
});
sends.forEach((send) => {
const date = send.createdAt.toISOString().split("T")[0];
sendCountByDate[date] = (sendCountByDate[date] || 0) + 1;
});
const allDates = Array.from(
new Set([
@@ -121,6 +217,7 @@ export async function GET(req: Request) {
...Object.keys(urlCountByDate),
...Object.keys(emailCountByDate),
...Object.keys(inboxCountByDate),
...Object.keys(sendCountByDate),
]),
);
const combinedData = allDates.map((date) => ({
@@ -130,6 +227,7 @@ export async function GET(req: Request) {
users: userCountByDate[date] || 0,
emails: emailCountByDate[date] || 0,
inbox: inboxCountByDate[date] || 0,
sends: sendCountByDate[date] || 0,
}));
const total = {
@@ -138,9 +236,64 @@ export async function GET(req: Request) {
users: combinedData.reduce((acc, curr) => acc + curr.users, 0),
emails: combinedData.reduce((acc, curr) => acc + curr.emails, 0),
inbox: combinedData.reduce((acc, curr) => acc + curr.inbox, 0),
sends: combinedData.reduce((acc, curr) => acc + curr.sends, 0),
};
return Response.json({ list: combinedData.reverse(), total });
// Calculate totals for previous period
const prevTotal = {
records: prevRecords.length,
urls: prevUrls.length,
users: prevUsers.length,
emails: prevEmails.length,
inbox: prevInbox.length,
sends: prevSends.length,
};
// Calculate growth rates
const growthRates = {
records:
prevTotal.records === 0
? total.records > 0
? 100
: 0
: ((total.records - prevTotal.records) / prevTotal.records) * 100,
urls:
prevTotal.urls === 0
? total.urls > 0
? 100
: 0
: ((total.urls - prevTotal.urls) / prevTotal.urls) * 100,
users:
prevTotal.users === 0
? total.users > 0
? 100
: 0
: ((total.users - prevTotal.users) / prevTotal.users) * 100,
emails:
prevTotal.emails === 0
? total.emails > 0
? 100
: 0
: ((total.emails - prevTotal.emails) / prevTotal.emails) * 100,
inbox:
prevTotal.inbox === 0
? total.inbox > 0
? 100
: 0
: ((total.inbox - prevTotal.inbox) / prevTotal.inbox) * 100,
sends:
prevTotal.sends === 0
? total.sends > 0
? 100
: 0
: ((total.sends - prevTotal.sends) / prevTotal.sends) * 100,
};
return Response.json({
list: combinedData.reverse(),
total,
growthRates,
});
} catch (error) {
return Response.json({ statusText: "Server error" }, { status: 500 });
}

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

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

34
app/api/domain/route.ts Normal file
View File

@@ -0,0 +1,34 @@
import { NextRequest } from "next/server";
import { FeatureMap, getDomainsByFeatureClient } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export const dynamic = "force-dynamic";
// Get domains by feature for frontend
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 feature = url.searchParams.get("feature") || "";
if (!Object.keys(FeatureMap).includes(feature)) {
return Response.json(
"Invalid feature parameter. Use 'short', 'email', or 'record'.",
{
status: 400,
},
);
}
const domainList = await getDomainsByFeatureClient(FeatureMap[feature]);
return Response.json(domainList, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getUserSendEmailList } from "@/lib/dto/email";
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 { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const size = parseInt(searchParams.get("size") || "10", 10);
const search = searchParams.get("search") || "";
const all = searchParams.get("all") || "false";
const data = await getUserSendEmailList(
user.id,
user.role === "ADMIN" && all === "true",
page,
size,
search,
);
return NextResponse.json(data);
} catch (error) {
return NextResponse.json("Internal server error", { status: 500 });
}
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { TeamPlanQuota } from "@/config/team";
import { checkDomainIsConfiguratedResend } from "@/lib/dto/domains";
import { getUserSendEmailCount, saveUserSendEmail } from "@/lib/dto/email";
import { checkUserStatus } from "@/lib/dto/user";
import { resend } from "@/lib/email";
@@ -33,6 +34,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json("Invalid email address", { status: 403 });
}
if (!(await checkDomainIsConfiguratedResend(from.split("@")[1]))) {
return NextResponse.json(
"This domain is not configured for sending emails",
{ status: 400 },
);
}
const { error } = await resend.emails.send({
from,
to,

14
app/api/feature/route.ts Normal file
View File

@@ -0,0 +1,14 @@
// import { env } from "@/env.mjs";
export async function GET(req: Request) {
try {
return Response.json({
google: true,
github: true,
linuxdo: true,
resend: true,
});
} catch (error) {
console.log("[Error]", error);
}
}

View File

@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { generateApiKey } from "@/lib/dto/api-key";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";

29
app/api/location/route.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { geolocation } from "@vercel/functions";
interface CurrentLocation {
latitude: number;
longitude: number;
}
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
const geo = geolocation(req);
const location: CurrentLocation = {
latitude: Number(geo.latitude || "0"),
longitude: Number(geo.longitude || "0"),
};
return NextResponse.json(location, { status: 200 });
} catch (error) {
console.error("Error fetching location:", error);
// Fallback to default coordinates
return NextResponse.json(
{ latitude: 40.7128, longitude: -74.006 },
{ status: 200 },
);
}
}

View File

@@ -1,4 +1,4 @@
import { env } from "@/env.mjs";
import { siteConfig } from "@/config/site";
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
@@ -6,6 +6,7 @@ import {
getUserRecordByTypeNameContent,
getUserRecordCount,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
@@ -16,31 +17,16 @@ export async function POST(req: Request) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const {
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_ZONE_NAME,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
} = env;
if (
!CLOUDFLARE_ZONE_ID ||
!CLOUDFLARE_ZONE_NAME ||
!CLOUDFLARE_API_KEY ||
!CLOUDFLARE_EMAIL
) {
return Response.json("API key、zone iD and email are required", {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json("Please add at least one domain", {
status: 400,
statusText: "API key、zone iD and email are required",
statusText: "Please add at least one domain",
});
}
// Check quota: 若是管理员则不检查,否则检查
const { total } = await getUserRecordCount(user.id);
if (
user.role !== "ADMIN" &&
total >= TeamPlanQuota[user.team].RC_NewRecords
) {
if (total >= TeamPlanQuota[user.team].RC_NewRecords) {
return Response.json("Your records have reached the free limit.", {
status: 409,
});
@@ -50,13 +36,30 @@ export async function POST(req: Request) {
const record = {
...records[0],
id: generateSecret(16),
// type: "CNAME",
proxied: false,
};
const record_name = record.name.endsWith(".wr.do")
let record_name = ["A", "CNAME"].includes(record.type)
? record.name
: record.name + ".wr.do";
: `${record.name}.${record.zone_name}`;
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
}
if (!matchedZone) {
return Response.json(
`No matching zone found for domain: ${record_name}`,
{
status: 400,
statusText: "Invalid domain",
},
);
}
if (reservedDomains.includes(record_name)) {
return Response.json("Domain name is reserved", {
@@ -73,27 +76,55 @@ export async function POST(req: Request) {
);
if (user_record && user_record.length > 0) {
return Response.json("Record already exists", {
status: 403,
status: 400,
});
}
// apply subdomain
if (siteConfig.enableSubdomainApply) {
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,
});
}
// send email to admin
return Response.json(res.data?.id);
}
const data = await createDNSRecord(
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
record,
);
if (!data.success || !data.result?.id) {
console.log("[data]", data);
// console.log("[data]", data);
return Response.json(data.messages, {
status: 501,
});
} else {
const res = await createUserRecord(user.id, {
record_id: data.result.id,
zone_id: CLOUDFLARE_ZONE_ID,
zone_name: CLOUDFLARE_ZONE_NAME,
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,

View File

@@ -0,0 +1,139 @@
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
createUserRecord,
getUserRecordByTypeNameContent,
getUserRecordCount,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { generateSecret } 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,
statusText: "Admin access required",
});
}
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 { records, email } = await req.json();
const target_user = await getUserByEmail(email);
if (!target_user) {
return Response.json("User not found", {
status: 404,
statusText: "User not found",
});
}
const { total } = await getUserRecordCount(target_user.id);
if (total >= TeamPlanQuota[target_user.team!].RC_NewRecords) {
return Response.json("Your records have reached the free limit.", {
status: 409,
});
}
const record = {
...records[0],
id: generateSecret(16),
};
let record_name = ["A", "CNAME"].includes(record.type)
? record.name
: `${record.name}.${record.zone_name}`;
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
}
if (!matchedZone) {
return Response.json(
`No matching zone found for domain: ${record_name}`,
{
status: 400,
statusText: "Invalid domain",
},
);
}
if (reservedDomains.includes(record_name)) {
return Response.json("Domain name is reserved", {
status: 403,
});
}
const user_record = await getUserRecordByTypeNameContent(
target_user.id,
record.type,
record_name,
record.content,
1,
);
if (user_record && user_record.length > 0) {
return Response.json("Record already exists", {
status: 403,
});
}
const data = await createDNSRecord(
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
record,
);
if (!data.success || !data.result?.id) {
// console.log("[data]", data);
return Response.json(data.messages, {
status: 501,
});
} else {
const res = await createUserRecord(target_user.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,
});
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,
});
}
}

View File

@@ -0,0 +1 @@
export async function POST(req: Request) {}

View File

@@ -1,6 +1,6 @@
import { env } from "@/env.mjs";
import { deleteDNSRecord } from "@/lib/cloudflare";
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -11,44 +11,52 @@ export async function POST(req: Request) {
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Admin access required",
});
}
const { record_id, zone_id, userId, active } = await req.json();
if (!record_id || !userId) {
return Response.json("RecordId and userId are required", {
if (!record_id || !userId || !zone_id) {
return Response.json("record_id, userId, and zone_id are required", {
status: 400,
statusText: "Invalid request body",
});
}
const { CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
if (!CLOUDFLARE_ZONE_ID || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key、zone iD and email are required", {
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",
});
}
// Delete cf dns record first.
const res = await deleteDNSRecord(
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
statusText: "Invalid zone_id",
});
}
// force delete
await deleteUserRecord(userId, record_id, zone_id, active);
await deleteDNSRecord(
matchedZone.cf_zone_id!,
matchedZone.cf_api_key!,
matchedZone.cf_email!,
record_id,
);
if (res && res.result?.id) {
// Then delete user record.
await deleteUserRecord(userId, record_id, zone_id, active);
return Response.json("success", {
status: 200,
});
}
return Response.json("Not Implemented", {
status: 501,
return Response.json("success", {
status: 200,
statusText: "success",
});
} catch (error) {
console.error(error);
return Response.json(error?.statusText || error, {
status: error.status || 500,
console.error("[Error]", error);
return Response.json(error.message || "Server error", {
status: error?.status || 500,
statusText: error?.statusText || "Server error",
});
}
}

View File

@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";

View File

@@ -1,6 +1,6 @@
import { env } from "@/env.mjs";
import { updateDNSRecord } from "@/lib/cloudflare";
import { updateUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -11,70 +11,99 @@ export async function POST(req: Request) {
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Admin access required",
});
}
const {
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_ZONE_NAME,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
} = env;
if (
!CLOUDFLARE_ZONE_ID ||
!CLOUDFLARE_ZONE_NAME ||
!CLOUDFLARE_API_KEY ||
!CLOUDFLARE_EMAIL
) {
return Response.json("API key、zone iD and email are required", {
status: 400,
});
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json(
"API key, zone configuration, and email are required",
{ status: 401, statusText: "Missing required configuration" },
);
}
const { record, recordId, userId } = await req.json();
if (!recordId || !userId) {
return Response.json("RecordId and userId are required", {
if (!record || !recordId || !userId) {
return Response.json("record, recordId, and userId are required", {
status: 400,
statusText: "Invalid request body",
});
}
const data = await updateDNSRecord(
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
recordId,
record,
);
if (!data.success || !data.result?.id) {
return Response.json(data.errors, {
status: 501,
});
} else {
const res = await updateUserRecord(userId, {
record_id: data.result.id,
zone_id: CLOUDFLARE_ZONE_ID,
zone_name: CLOUDFLARE_ZONE_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("") ?? "",
modified_on: data.result.modified_on,
active: 1,
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
});
let record_name = ["A", "CNAME"].includes(record.type)
? record.name
: `${record.name}.${record.zone_name}`;
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
return Response.json(res.data);
}
if (!matchedZone) {
return Response.json(
`No matching zone found for domain: ${record_name}`,
{
status: 400,
statusText: "Invalid domain",
},
);
}
const data = await updateDNSRecord(
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
recordId,
{ ...record, name: record_name },
);
if (!data.success || !data.result?.id) {
return Response.json(
data.errors?.[0]?.message || "Failed to update DNS record",
{
status: 501,
statusText: "Cloudflare API error",
},
);
}
const res = await updateUserRecord(userId, {
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("") ?? "",
modified_on: data.result.modified_on,
active: 1,
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
statusText: "Failed to update user record",
});
}
return Response.json(res.data, {
status: 200,
statusText: "success",
});
} catch (error) {
return Response.json(error?.statusText || error, {
console.error("[Error]", error);
return Response.json(error.message || "Server error", {
status: error?.status || 500,
statusText: error?.statusText || "Server error",
});
}
}

View File

@@ -1,6 +1,7 @@
import { env } from "@/env.mjs";
import { deleteDNSRecord } from "@/lib/cloudflare";
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -10,36 +11,45 @@ export async function POST(req: Request) {
if (user instanceof Response) return user;
const { record_id, zone_id, active } = await req.json();
const { CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
if (!CLOUDFLARE_ZONE_ID || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key、zone iD and email are required", {
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 matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
statusText: "Invalid zone_id",
});
}
// Delete cf dns record first.
const res = await deleteDNSRecord(
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id!,
matchedZone.cf_api_key!,
matchedZone.cf_email!,
record_id,
);
if (res && res.result?.id) {
// Then delete user record.
await deleteUserRecord(user.id, record_id, zone_id, active);
return Response.json("success", {
status: 200,
statusText: "success",
});
}
return Response.json({
status: 501,
statusText: "Not Implemented",
statusText: "Failed to delete DNS record",
});
} catch (error) {
console.error(error);
return Response.json(error?.statusText || error, {
console.error("[Error]", error);
return Response.json(error.message || "Server error", {
status: error.status || 500,
statusText: error.statusText || "Server error",
});

View File

@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";

View File

@@ -1,111 +1,155 @@
import { env } from "@/env.mjs";
import { updateDNSRecord } from "@/lib/cloudflare";
import {
updateUserRecord,
updateUserRecordState,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
// update record
// Update DNS record
export async function POST(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const {
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_ZONE_NAME,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
} = env;
if (
!CLOUDFLARE_ZONE_ID ||
!CLOUDFLARE_ZONE_NAME ||
!CLOUDFLARE_API_KEY ||
!CLOUDFLARE_EMAIL
) {
return Response.json("API key andzone id are required.", { status: 401 });
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json(
"API key, zone configuration, and email are required",
{ status: 401, statusText: "Missing required configuration" },
);
}
const { record, recordId } = await req.json();
if (!record || !recordId) {
return Response.json("Record and recordId are required", {
status: 400,
statusText: "Invalid request body",
});
}
const record_name = record.name.endsWith(".wr.do")
let record_name = ["A", "CNAME"].includes(record.type)
? record.name
: record.name + ".wr.do";
: `${record.name}.${record.zone_name}`;
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
}
if (!matchedZone) {
return Response.json(
`No matching zone found for domain: ${record_name}`,
{
status: 400,
statusText: "Invalid domain",
},
);
}
if (reservedDomains.includes(record_name)) {
return Response.json("Domain name is reserved", {
status: 403,
statusText: "Reserved domain",
});
}
const data = await updateDNSRecord(
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
recordId,
record,
{ ...record, name: record_name },
);
console.log("updateDNSRecord", data);
if (!data.success || !data.result?.id) {
return Response.json(data.errors, {
status: 501,
});
} else {
const res = await updateUserRecord(user.id, {
record_id: data.result.id,
zone_id: CLOUDFLARE_ZONE_ID,
zone_name: CLOUDFLARE_ZONE_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("") ?? "",
modified_on: data.result.modified_on,
active: 1,
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
});
}
return Response.json(res.data);
return Response.json(
data.errors?.[0]?.message || "Failed to update DNS record",
{ status: 501, statusText: "Cloudflare API error" },
);
}
const res = await updateUserRecord(user.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("") ?? "",
modified_on: data.result.modified_on,
active: 1,
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
statusText: "Failed to update user record",
});
}
return Response.json(res.data);
} catch (error) {
console.log(error);
return Response.json(error?.statusText || error, {
console.error("[Error]", error);
return Response.json(error.message || "Server error", {
status: error?.status || 500,
statusText: error?.statusText || "Server error",
});
}
}
// update record state
// Update record state
export async function PUT(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
if (!CLOUDFLARE_ZONE_ID || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key and zone id are required.", {
status: 401,
});
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json(
"API key, zone configuration, and email are required",
{ status: 401, statusText: "Missing required configuration" },
);
}
const { zone_id, record_id, target, active } = await req.json();
if (!zone_id || !record_id || !target) {
return Response.json("zone_id, record_id, and target are required", {
status: 400,
statusText: "Invalid request body",
});
}
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
statusText: "Invalid zone_id",
});
}
let isTargetAccessible = false;
try {
const target_res = await fetch(`https://${target}`);
const target_res = await fetch(`https://${target}`, {
method: "HEAD",
signal: AbortSignal.timeout(10000),
});
isTargetAccessible = target_res.status === 200;
} catch (fetchError) {
isTargetAccessible = false;
// console.log(`Failed to access target: ${fetchError}`);
// console.log(
// `[Fetch Error] Failed to access target ${target}: ${fetchError}`,
// );
}
const res = await updateUserRecordState(
@@ -116,13 +160,24 @@ export async function PUT(req: Request) {
);
if (!res) {
return Response.json("An error occurred.", { status: 502 });
return Response.json("Failed to update record state", {
status: 502,
statusText: "Database error",
});
}
return Response.json(
isTargetAccessible ? "Target is accessible!" : "Target is unaccessible!",
{ status: 200 },
);
} catch (error) {
console.error(error);
return Response.json(`An error occurred. ${error}`, { status: 500 });
console.error("[Error]", error);
return Response.json(
`An error occurred: ${error.message || "Unknown error"}`,
{
status: 500,
statusText: "Server error",
},
);
}
}

View File

@@ -16,6 +16,10 @@ export async function POST(req: NextRequest) {
lang,
device,
browser,
engine,
os,
cpu,
isBot,
password,
} = await req.json();
@@ -57,6 +61,10 @@ export async function POST(req: NextRequest) {
lang,
device,
browser,
engine,
os,
cpu,
isBot,
});
return Response.json(res.target);
} catch (error) {

31
app/api/setup/route.ts Normal file
View File

@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import {
checkUserStatus,
getAllUsersCount,
setFirstUserAsAdmin,
} 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 count = await getAllUsersCount();
if (count === 1 && user.role === "USER") {
const res = await setFirstUserAsAdmin(user.id);
if (res) {
return Response.json({ admin: res.role === "ADMIN" }, { status: 201 });
}
return Response.json({ admin: false }, { status: 400 });
}
return redirect("/admin");
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
}
}

View File

@@ -1,4 +1,5 @@
import { TeamPlanQuota } from "@/config/team";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserShortUrl } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -23,6 +24,18 @@ export async function POST(req: Request) {
const { target, url, prefix, visible, active, expiration, password } =
createUrlSchema.parse(data);
const zones = await getDomainsByFeature("enable_short_link");
if (
!zones.length ||
!zones.map((zone) => zone.domain_name).includes(prefix)
) {
return Response.json("Invalid domain", {
status: 400,
statusText: "Invalid domain",
});
}
const res = await createUserShortUrl({
userId: user.id,
userName: user.name || "Anonymous",

View File

@@ -5,12 +5,12 @@ import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const url = new URL(req.url);
const isAdmin = url.searchParams.get("admin");
if (isAdmin === "true") {
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,

View File

@@ -0,0 +1,268 @@
import { NextRequest, NextResponse } from "next/server";
import { create } from "lodash";
import { prisma } from "@/lib/db";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const searchParams = request.nextUrl.searchParams;
const isAdmin = searchParams.get("isAdmin");
if (isAdmin === "true") {
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Unauthorized",
});
}
}
const startAtParam = searchParams.get("startAt");
const endAtParam = searchParams.get("endAt");
const country = searchParams.get("country");
let startDate: Date;
let endDate: Date;
if (startAtParam && endAtParam) {
startDate = new Date(parseInt(startAtParam) * 1000);
endDate = new Date(parseInt(endAtParam) * 1000);
} else {
endDate = new Date();
startDate = new Date(Date.now() - 30 * 60 * 1000); // 30分钟前
}
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new Error("Invalid startAt or endAt parameters");
}
const whereClause: any = {
...(isAdmin === "true" ? {} : { userUrl: { userId: user.id } }),
createdAt: {
gte: startDate,
lte: endDate,
},
latitude: {
not: null,
},
longitude: {
not: null,
},
};
if (country && country !== "") {
whereClause.country = country;
}
const rawData = await prisma.urlMeta.findMany({
where: whereClause,
select: {
latitude: true,
longitude: true,
click: true,
city: true,
country: true,
device: true,
browser: true,
createdAt: true,
updatedAt: true,
userUrl: {
select: {
url: true,
target: true,
prefix: true,
},
},
},
orderBy: { updatedAt: "desc" },
take: 2000,
});
// console.log("Raw data fetched:", rawData.length, "records");
const locationMap = new Map<
string,
{
latitude: number;
longitude: number;
count: number;
city: string;
country: string;
lastUpdate: Date;
updatedAt: Date;
createdAt: Date;
device: string;
browser: string;
userUrl: {
url: string;
target: string;
prefix: string;
};
}
>();
rawData.forEach((item) => {
if (item.latitude && item.longitude) {
const lat = Math.round(Number(item.latitude) * 100) / 100;
const lng = Math.round(Number(item.longitude) * 100) / 100;
const key = `${lat},${lng},${item.createdAt},${item.userUrl.url},${item.userUrl.prefix}`;
if (locationMap.has(key)) {
const existing = locationMap.get(key)!;
existing.count += item.click || 1;
if (item.updatedAt > existing.lastUpdate) {
existing.lastUpdate = item.updatedAt;
existing.city = item.city || existing.city;
existing.country = item.country || existing.country;
}
} else {
locationMap.set(key, {
latitude: lat,
longitude: lng,
count: item.click || 1,
city: item.city || "",
country: item.country || "",
lastUpdate: item.updatedAt,
updatedAt: item.updatedAt,
createdAt: item.createdAt,
device: item.device || "",
browser: item.browser || "",
userUrl: item.userUrl,
});
}
}
});
const aggregatedData = Array.from(locationMap.values()).sort(
(a, b) => b.count - a.count,
);
// .slice(0, 500);
const totalClicks = aggregatedData.reduce(
(sum, item) => sum + item.count,
0,
);
const uniqueLocations = aggregatedData.length;
return NextResponse.json({
data: aggregatedData,
total: uniqueLocations,
totalClicks,
rawRecords: rawData.length,
timeRange: {
startAt: startDate.toISOString(),
endAt: endDate.toISOString(),
},
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error("Error fetching location data:", error);
return NextResponse.json(
{
data: [],
total: 0,
totalClicks: 0,
rawRecords: 0,
error:
error instanceof Error
? error.message
: "Failed to fetch location data",
timestamp: new Date().toISOString(),
},
{ status: 500 },
);
}
}
// POST endpoint remains the same
export async function POST(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const body = await request.json();
const { lastUpdate, isAdmin } = body;
if (isAdmin) {
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Unauthorized",
});
}
}
const sinceDate = lastUpdate
? new Date(lastUpdate)
: new Date(Date.now() - 5000);
// console.log("lastUpdate", lastUpdate, sinceDate);
if (isNaN(sinceDate.getTime())) {
throw new Error("Invalid lastUpdate parameter");
}
const whereClause: any = {
...(isAdmin ? {} : { userUrl: { userId: user.id } }),
createdAt: {
gt: sinceDate,
},
latitude: {
not: null,
},
longitude: {
not: null,
},
};
const newData = await prisma.urlMeta.findMany({
where: whereClause,
select: {
latitude: true,
longitude: true,
click: true,
city: true,
country: true,
device: true,
browser: true,
createdAt: true,
updatedAt: true,
userUrl: {
select: {
url: true,
target: true,
prefix: true,
},
},
},
orderBy: { updatedAt: "desc" },
take: 1000,
});
// console.log("Realtime updates fetched:", newData.length, "records");
return NextResponse.json({
data: newData,
count: newData.length,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error("Error fetching realtime updates:", error);
return NextResponse.json(
{
data: [],
count: 0,
error:
error instanceof Error
? error.message
: "Failed to fetch realtime updates",
timestamp: new Date().toISOString(),
},
{ status: 500 },
);
}
}

View File

@@ -1,8 +1,9 @@
import { env } from "@/env.mjs";
import { getUserShortUrls } from "@/lib/dto/short-urls";
import { getUrlClicksByIds, getUserShortUrls } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
@@ -33,11 +34,31 @@ export async function GET(req: Request) {
return Response.json(data);
} catch (error) {
console.log(error);
// console.log(error);
return Response.json(error?.statusText || error, {
status: error.status || 500,
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,
});
}
}

View File

@@ -1,5 +1,4 @@
import { env } from "@/env.mjs";
import { getUserShortUrls } from "@/lib/dto/short-urls";
import { getUrlClicksByIds, getUserShortUrls } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -32,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,
});
}
}

View File

@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { siteConfig } from "@/config/site";
import { TeamPlanQuota } from "@/config/team";
import { checkApiKey } from "@/lib/dto/api-key";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserEmail, deleteUserEmailByAddress } from "@/lib/dto/email";
import { reservedAddressSuffix } from "@/lib/enums";
import { restrictByTimeRange } from "@/lib/team";
@@ -53,7 +54,12 @@ export async function POST(req: NextRequest) {
status: 400,
});
}
if (!siteConfig.emailDomains.includes(suffix)) {
const zones = await getDomainsByFeature("enable_email", true);
if (
!zones.length ||
!zones.map((zone) => zone.domain_name).includes(suffix)
) {
return NextResponse.json("Invalid email suffix address", { status: 400 });
}

22
app/api/v1/geo/route.ts Normal file
View File

@@ -0,0 +1,22 @@
import { getIpInfo } from "@/lib/geo";
export async function GET(req: Request) {
try {
const data = await getIpInfo(req);
return Response.json({
ip: data.ip,
city: data.city,
region: data.region,
country: data.country,
latitude: data.latitude,
longitude: data.longitude,
flag: data.flag,
lang: data.lang,
device: data.device,
browser: data.browser,
});
} catch (error) {
return Response.json({ statusText: "Server error" }, { status: 500 });
}
}

View File

@@ -3,9 +3,11 @@ import TurndownService from "turndown";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink, removeUrlSuffix } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { isLink } from "@/lib/utils";
export const revalidate = 600;
export const dynamic = "force-dynamic";
const turndownService = new TurndownService();
@@ -69,9 +71,9 @@ export async function GET(req: Request) {
const markdown = turndownService.turndown(mainContent || "");
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "markdown",
referer: stats.referer,
city: stats.city,

View File

@@ -1,11 +1,12 @@
import { geolocation } from "@vercel/functions";
import cheerio from "cheerio";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink, removeUrlSuffix } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { isLink, removeUrlSuffix } from "@/lib/utils";
export const revalidate = 600;
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
@@ -47,9 +48,9 @@ export async function GET(req: Request) {
const res = await fetch(link);
if (!res.ok) {
return Response.json(
{ statusText: "Failed to fetch url" },
{ statusText: `Failed to fetch url. ${res.statusText}` },
{
status: 405,
status: res.status || 405,
},
);
}
@@ -84,9 +85,9 @@ export async function GET(req: Request) {
$("meta[name='author']").attr("content") ||
$("meta[property='author']").attr("content");
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "meta-info",
referer: stats.referer,
city: stats.city,

View File

@@ -1,36 +1,26 @@
import QRCode from "qrcode";
import { ImageResponse } from "@vercel/og";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { WRDO_QR_LOGO } from "@/lib/qr/constants";
import { QRCodeSVG } from "@/lib/qr/utils";
import { getSearchParams } from "@/lib/utils";
import { getQRCodeQuerySchema } from "@/lib/validations/qr";
export const revalidate = 60;
// export const runtime = "edge";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const link = url.searchParams.get("url");
const width = parseInt(url.searchParams.get("width") || "200");
const margin = parseInt(url.searchParams.get("margin") || "4");
const dark = url.searchParams.get("dark") || "#000000";
const light = url.searchParams.get("light") || "#ffffff";
const type = url.searchParams.get("type") || "png"; // png | jpeg | webp | string
// Check if the url is valid
if (!link || !isLink(link)) {
return Response.json(
{ statusText: "Url is required" },
{
status: 400,
},
);
}
const paramsParsed = getQRCodeQuerySchema.parse(getSearchParams(req.url));
const { key, logo, url, size, level, fgColor, bgColor, margin, hideLogo } =
paramsParsed;
// Get the API key from the request
const custom_apiKey = url.searchParams.get("key");
if (!custom_apiKey) {
if (!key) {
return Response.json(
{
statusText:
@@ -41,7 +31,7 @@ export async function GET(req: Request) {
}
// Check if the API key is valid
const user_apiKey = await checkApiKey(custom_apiKey);
const user_apiKey = await checkApiKey(key);
if (!user_apiKey?.id) {
return Response.json(
{
@@ -52,30 +42,9 @@ export async function GET(req: Request) {
);
}
let qrResult: any;
if (type === "string") {
qrResult = QRCode.toString(link);
} else {
qrResult = await QRCode.toDataURL(link, {
width,
margin,
color: {
dark,
light,
},
errorCorrectionLevel: "H", // Optional: L, M, Q, H
type:
type === "png"
? "image/png"
: type === "jepg"
? "image/jpeg"
: "image/webp",
});
}
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "qrcode",
referer: stats.referer,
city: stats.city,
@@ -88,12 +57,42 @@ export async function GET(req: Request) {
browser: stats.browser,
click: 1,
userId: user_apiKey.id,
apiKey: custom_apiKey,
link,
apiKey: key,
link: url,
});
return new Response(qrResult);
return new ImageResponse(
QRCodeSVG({
value: url,
size,
level,
fgColor,
bgColor,
margin,
...(!hideLogo && {
imageSettings: {
src: logo || WRDO_QR_LOGO,
height: size / 4,
width: size / 4,
excavate: true,
},
}),
isOGContext: true,
}),
{
width: size,
height: size,
headers: CORS_HEADERS,
},
);
} catch (error) {
return Response.json({ statusText: "Server error" }, { status: 500 });
}
}
export function OPTIONS() {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}

View File

@@ -1,7 +1,8 @@
import { env } from "@/env.mjs";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { isLink } from "@/lib/utils";
export const revalidate = 60;
@@ -69,9 +70,9 @@ export async function GET(req: Request) {
);
}
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "screenshot",
referer: stats.referer,
city: stats.city,

View File

@@ -2,9 +2,11 @@ import cheerio from "cheerio";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink, removeUrlSuffix } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { isLink } from "@/lib/utils";
export const revalidate = 600;
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
@@ -62,9 +64,9 @@ export async function GET(req: Request) {
$("style").remove();
const text = $("body").text().trim();
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "text",
referer: stats.referer,
city: stats.city,

View File

@@ -1,5 +1,6 @@
import { TeamPlanQuota } from "@/config/team";
import { checkApiKey } from "@/lib/dto/api-key";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserShortUrl } from "@/lib/dto/short-urls";
import { restrictByTimeRange } from "@/lib/team";
import { createUrlSchema } from "@/lib/validations/url";
@@ -41,6 +42,17 @@ export async function POST(req: Request) {
});
}
const zones = await getDomainsByFeature("enable_short_link");
if (
!zones.length ||
!zones.map((zone) => zone.domain_name).includes(prefix)
) {
return Response.json("Invalid domain", {
status: 409,
statusText: "Invalid domain",
});
}
const res = await createUserShortUrl({
userId: user.id,
userName: user.name || "Anonymous",

View File

@@ -32,8 +32,8 @@ export default async function Dashboard({ children }: ProtectedLayoutProps) {
<DashboardSidebar links={filteredLinks} />
<div className="flex flex-1 flex-col">
<header className="sticky top-0 z-50 flex h-14 border-b bg-background px-4 lg:h-[60px] xl:px-8">
<MaxWidthWrapper className="flex max-w-7xl items-center gap-x-3 px-0">
<header className="sticky top-0 z-50 flex h-14 border-b bg-background px-4 lg:h-[60px]">
<MaxWidthWrapper className="flex max-w-full items-center gap-x-3 px-0">
<MobileSheetSidebar links={filteredLinks} />
<div className="w-full flex-1">

View File

@@ -1,9 +1,9 @@
{
"name": "WR.DO",
"short_name": "WR.DO",
"description": "A DNS record distribution system",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "1.0.0",
"versionName": "0.6.5",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -47,11 +47,10 @@ export default {
}),
Resend({
apiKey: env.RESEND_API_KEY,
from: "wrdo <support@wr.do>",
from: env.RESEND_FROM_EMAIL || "wrdo <support@wr.do>",
async sendVerificationRequest({ identifier: email, url, provider }) {
try {
// 使用 Resend 发送自定义验证邮件
const { data, error } = await resend.emails.send({
const { error } = await resend.emails.send({
from: provider.from || "no-reply@wr.do",
to: [email],
subject: "Verify your email address",

View File

@@ -22,6 +22,7 @@ export const {
handlers: { GET, POST },
auth,
} = NextAuth({
trustHost: true, // TODO: Test with docker
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
pages: {

View File

@@ -2,11 +2,12 @@
import * as React from "react";
import { useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import useSWR from "swr";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { fetcher } from "@/lib/utils";
import { cn, fetcher, nFormatter } from "@/lib/utils";
import { useElementSize } from "@/hooks/use-element-size";
import {
Card,
CardContent,
@@ -25,6 +26,7 @@ import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "../ui/select";
@@ -54,9 +56,14 @@ const chartConfig = {
label: "Inbox",
color: "hsl(var(--chart-1))",
},
sends: {
label: "Sends",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export function InteractiveBarChart() {
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const [timeRange, setTimeRange] = useState<string>("7d");
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("users");
@@ -69,6 +76,7 @@ export function InteractiveBarChart() {
users: number;
emails: number;
inbox: number;
sends: number;
date: string;
},
];
@@ -78,6 +86,15 @@ export function InteractiveBarChart() {
users: number;
emails: number;
inbox: number;
sends: number;
};
growthRates: {
records: number;
urls: number;
users: number;
emails: number;
inbox: number;
sends: number;
};
}>(`api/admin?range=${timeRange}`, fetcher, {
revalidateOnFocus: false,
@@ -91,53 +108,71 @@ export function InteractiveBarChart() {
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle>Data Increase</CardTitle>
<CardDescription>
Showing data increase in
<Select
onValueChange={(value: string) => {
setTimeRange(value);
}}
name="time range"
defaultValue={timeRange}
>
<SelectTrigger className="mt-1 w-40 shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardDescription>
<div className="flex w-full flex-1 justify-between gap-2 px-6 py-5 sm:flex-col sm:py-6">
<div className="flex flex-col justify-center gap-1">
<CardTitle>Data Increase</CardTitle>
<CardDescription>Showing data increase in:</CardDescription>
</div>
<Select
onValueChange={(value: string) => {
setTimeRange(value);
}}
name="time range"
defaultValue={timeRange}
>
<SelectTrigger className="w-44 shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem value={e.value}>{e.label}</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
</div>
<div className="flex">
{["users", "records", "urls", "emails", "inbox"].map((key) => {
const chart = key as keyof typeof chartConfig;
return (
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-l border-t p-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:p-6"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none sm:text-3xl">
{data.total[key as keyof typeof data.total].toLocaleString()}
</span>
</button>
);
})}
<div className="grid grid-cols-3 sm:grid-cols-6">
{["users", "records", "urls", "emails", "inbox", "sends"].map(
(key) => {
const chart = key as keyof typeof chartConfig;
const growthRate =
data.growthRates[key as keyof typeof data.growthRates];
return (
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-col justify-center gap-1 border-l border-t p-3 text-left transition-colors hover:bg-muted/30 data-[active=true]:bg-muted/50 sm:border-t-0 sm:p-4"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-base font-bold leading-none sm:text-lg">
{nFormatter(data.total[key])}
</span>
<span
className={cn(
"rounded px-1.5 py-1 text-xs font-semibold leading-none",
growthRate > 0 && "bg-green-200 text-green-700",
growthRate < 0 && "bg-red-200 text-red-700",
growthRate === 0 && "bg-neutral-100 text-neutral-700",
)}
>
{growthRate >= 0 ? "+" : ""}
{growthRate.toFixed(1)}%
</span>
</button>
);
},
)}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<CardContent ref={wrapperRef} className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[200px] w-full"
@@ -145,6 +180,7 @@ export function InteractiveBarChart() {
<BarChart
accessibilityLayer
data={data.list}
width={wrapperWidth}
margin={{
left: 12,
right: 12,
@@ -165,6 +201,7 @@ export function InteractiveBarChart() {
});
}}
/>
<YAxis width={20} axisLine={false} tickLine={false} />
<ChartTooltip
content={
<ChartTooltipContent

View File

@@ -18,6 +18,7 @@ import Peer from "peerjs";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
import { generateGradientClasses } from "@/lib/enums";
import { cn } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
@@ -47,25 +48,6 @@ const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const generateGradientClasses = (seed: string) => {
const gradients = [
"bg-gradient-to-br from-red-400 to-pink-500",
"bg-gradient-to-br from-blue-400 to-indigo-500",
"bg-gradient-to-br from-green-400 to-teal-500",
"bg-gradient-to-br from-yellow-400 to-orange-500",
"bg-gradient-to-br from-purple-400 to-pink-500",
"bg-gradient-to-br from-cyan-400 to-blue-500",
"bg-gradient-to-br from-pink-400 to-red-500",
"bg-gradient-to-br from-teal-400 to-green-500",
"bg-gradient-to-br from-orange-400 to-yellow-500",
"bg-gradient-to-br from-indigo-400 to-blue-500",
];
const hash = seed
.split("")
.reduce((acc, char) => acc + char.charCodeAt(0), 0);
return gradients[hash % gradients.length];
};
export default function ChatRoom() {
const { isMobile, isSm } = useMediaQuery();
const [peerId, setPeerId] = useState("");
@@ -673,7 +655,7 @@ export default function ChatRoom() {
className={`${
isMobile
? "fixed inset-0 z-50 w-full"
: "w-[300px] flex-shrink-0 border-r dark:border-neutral-600"
: "w-[300px] shrink-0 border-r dark:border-neutral-600"
} flex animate-fade-in-left flex-col bg-white p-4 transition-all duration-300 dark:bg-neutral-800`}
>
<div className="mb-3 flex items-center justify-between">
@@ -816,7 +798,7 @@ export default function ChatRoom() {
placeholder="You are the room owner"
readOnly
disabled
className="flex-1 rounded border bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder-neutral-400"
className="flex-1 rounded border bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
) : (
<Input
@@ -826,7 +808,7 @@ export default function ChatRoom() {
placeholder="Enter a room id"
readOnly={isConnected}
disabled={isConnected}
className="flex-1 rounded border bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder-neutral-400"
className="flex-1 rounded border bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-200 dark:placeholder:text-neutral-400"
/>
)}
<Button
@@ -860,7 +842,7 @@ export default function ChatRoom() {
<>
{!msg.isSelf && (
<div
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white ${generateGradientClasses(
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white ${generateGradientClasses(
msg.username,
)}`}
>
@@ -905,7 +887,7 @@ export default function ChatRoom() {
</div>
{msg.isSelf && (
<div
className={`mt-1 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white ${avatarClasses}`}
className={`mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white ${avatarClasses}`}
>
{username?.slice(0, 1).toUpperCase()}
</div>
@@ -926,7 +908,7 @@ export default function ChatRoom() {
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={`Hi ${username || "Loading..."}, send a message to start...`}
className="min-h-20 flex-1 rounded-md rounded-t-none border border-t-0 bg-neutral-50 p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 dark:border-neutral-600 dark:bg-neutral-600 dark:text-neutral-200 dark:placeholder-neutral-400"
className="min-h-20 flex-1 rounded-md rounded-t-none border border-t-0 bg-neutral-50 p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 dark:border-neutral-600 dark:bg-neutral-600 dark:text-neutral-200 dark:placeholder:text-neutral-400"
onKeyPress={(e) =>
e.key === "Enter" && !e.shiftKey && sendMessage()
}

View File

@@ -8,6 +8,9 @@ import BlurImage from "@/components/shared/blur-image";
import { Callout } from "@/components/shared/callout";
import { CopyButton } from "@/components/shared/copy-button";
import { DocsLang } from "../shared/docs-lang";
import { Separator } from "../ui/separator";
const components = {
h1: ({ className, ...props }) => (
<h1
@@ -166,6 +169,7 @@ const components = {
),
Callout,
Card: MdxCard,
DocsLang,
Step: ({ className, ...props }: React.ComponentProps<"h3">) => (
<h3
className={cn(

View File

@@ -2,6 +2,15 @@
import CountUp from "react-countup";
import { nFormatter } from "@/lib/utils";
export default function CountUpFn({ count }: { count: number }) {
return <CountUp end={count} duration={3} />;
return (
<CountUp
end={count}
duration={2}
redraw={false}
formattingFn={(value) => nFormatter(value)}
/>
);
}

View File

@@ -145,14 +145,12 @@ export function HeroCard({
);
}
export async function ScrapeInfoCard({
userId,
export async function StaticInfoCard({
title,
desc,
link,
icon = "users",
}: {
userId: string;
title: string;
desc?: string;
link: string;

View File

@@ -1,16 +1,33 @@
import React from "react";
import { cn } from "@/lib/utils";
interface SectionColumnsType {
title: string;
children: React.ReactNode;
required?: boolean;
className?: string;
}
export function FormSectionColumns({ title, children }: SectionColumnsType) {
export function FormSectionColumns({
title,
children,
required,
className,
}: SectionColumnsType) {
return (
<div className="grid grid-cols-1 items-center gap-x-12 gap-y-2 py-2">
<h2 className="col-span-4 text-base font-semibold leading-none">
{title}
</h2>
<div
className={cn(
"grid w-full grid-cols-1 items-center gap-x-12 gap-y-2 py-2",
className,
)}
>
<div className="col-span-4 flex items-start gap-0.5 text-sm leading-none">
<h2 className="font-semibold">{title}</h2>
{required && (
<span className="text-neutral-500 dark:text-neutral-300">*</span>
)}
</div>
<div className="col-span-6">{children}</div>
</div>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { ForwardEmail } from "@prisma/client";
import {
File,
@@ -13,13 +13,7 @@ import {
} from "lucide-react";
import { siteConfig } from "@/config/site";
import {
cn,
downloadFile,
downloadFileFromUrl,
formatDate,
formatFileSize,
} from "@/lib/utils";
import { cn, downloadFile, formatDate, formatFileSize } from "@/lib/utils";
import { Icons } from "@/components/shared/icons";
import { BlurImg } from "../shared/blur-image";
@@ -31,6 +25,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import EmailViewer from "./EmailViewer";
interface EmailDetailProps {
email: ForwardEmail | undefined;
@@ -176,7 +171,7 @@ export default function EmailDetail({
email.fromName?.[0].toUpperCase() ||
"U"}
</div>
<div className="max-w-[80%] flex-grow text-neutral-600 dark:text-neutral-300">
<div className="max-w-[80%] grow text-neutral-600 dark:text-neutral-300">
<p className="text-sm">
<strong>{email.subject}</strong>
</p>
@@ -209,7 +204,7 @@ export default function EmailDetail({
)}
</div>
<Button
className="ml-auto size-8 flex-grow-0 px-1 py-1"
className="ml-auto size-8 grow-0 px-1 py-1"
size={"sm"}
onClick={onClose}
variant={"outline"}
@@ -218,16 +213,17 @@ export default function EmailDetail({
</Button>
</div>
<div className="scrollbar-hidden flex h-full flex-col justify-between overflow-y-auto px-2 py-3">
<div
<div className="scrollbar-hidden flex h-full flex-col justify-between overflow-y-auto">
{/* <div
className=""
dangerouslySetInnerHTML={{
__html: processContent(email.html || email.text || ""),
}}
/>
/> */}
<EmailViewer email={processContent(email.html || email.text || "")} />
{attachments.length > 0 && (
<div className="mt-auto border-t border-dashed py-3">
<div className="mt-auto border-t border-dashed px-2 py-3">
<h3 className="mb-2 text-sm font-semibold text-neutral-700 dark:text-neutral-400">
Attachments ({attachments.length})
</h3>
@@ -270,7 +266,7 @@ export default function EmailDetail({
</div>
<Button
onClick={() => handleDownload(attachment)}
className="absolute right-0 top-0 hidden transform animate-fade-in px-2 group-hover:block"
className="absolute right-0 top-0 hidden animate-fade-in px-2 group-hover:block"
size="sm"
variant="ghost"
>

View File

@@ -220,7 +220,7 @@ export default function EmailList({
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<DropdownMenuItem asChild disabled>
<Button variant="ghost" size="sm" className="w-full">
<span className="text-xs">Delete selected</span>
</Button>
@@ -308,10 +308,12 @@ export default function EmailList({
)}
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
className="mx-2 my-1 justify-center"
total={Math.ceil(data.total / pageSize)}
className="mx-2 my-1"
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</div>

View File

@@ -14,15 +14,11 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import { siteConfig } from "@/config/site";
import { TeamPlanQuota } from "@/config/team";
import { UserEmailList } from "@/lib/dto/email";
import { reservedAddressSuffix } from "@/lib/enums";
import { cn, fetcher, timeAgo } from "@/lib/utils";
import { cn, fetcher, nFormatter, timeAgo } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import ApiReference from "@/app/emails/api-reference";
import CountUp from "../dashboard/count-up";
import { CopyButton } from "../shared/copy-button";
import { EmptyPlaceholder } from "../shared/empty-placeholder";
import { Icons } from "../shared/icons";
@@ -47,6 +43,7 @@ import {
TooltipTrigger,
} from "../ui/tooltip";
import { SendEmailModal } from "./SendEmailModal";
import SendsEmailList from "./SendsEmailList";
interface EmailSidebarProps {
user: User;
@@ -76,16 +73,16 @@ export default function EmailSidebar({
const [isEdit, setIsEdit] = useState(false);
const [showEmailModal, setShowEmailModal] = useState(false);
const [isPending, startTransition] = useTransition();
const [domainSuffix, setDomainSuffix] = useState<string | null>(
siteConfig.shortDomains[0],
);
const [domainSuffix, setDomainSuffix] = useState<string | null>("wr.do");
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [emailToDelete, setEmailToDelete] = useState<string | null>(null);
const [deleteInput, setDeleteInput] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [onlyUnread, setOnlyUnread] = useState(false);
const pageSize = 10;
const [showSendsModal, setShowSendsModal] = useState(false);
const [pageSize, setPageSize] = useState(10);
const { data, isLoading, error, mutate } = useSWR<{
list: UserEmailList[];
@@ -106,6 +103,13 @@ export default function EmailSidebar({
},
);
const { data: emailDomains, isLoading: isLoadingDomains } = useSWR<
{ domain_name: string }[]
>("/api/domain?feature=email", fetcher, {
revalidateOnFocus: false,
dedupingInterval: 10000,
});
if (!selectedEmailAddress && data && data.list.length > 0) {
onSelectEmail(data.list[0].emailAddress);
}
@@ -252,7 +256,7 @@ export default function EmailSidebar({
}
/>
</Button>
<div className="relative w-full flex-grow">
<div className="relative w-full grow">
<Input
type="text"
value={searchQuery}
@@ -306,7 +310,7 @@ export default function EmailSidebar({
</p>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
<CountUp count={data ? data.total : 0} />
{nFormatter(data ? data.total : 0)}
</p>
</div>
@@ -319,7 +323,7 @@ export default function EmailSidebar({
</p>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
<CountUp count={data ? data.totalInboxCount : 0} />
{nFormatter(data ? data.totalInboxCount : 0)}
</p>
</div>
@@ -342,7 +346,7 @@ export default function EmailSidebar({
</p>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
<CountUp count={data ? data.totalUnreadCount : 0} />
{nFormatter(data ? data.totalUnreadCount : 0)}
</p>
<TooltipProvider>
<Tooltip>
@@ -355,7 +359,16 @@ export default function EmailSidebar({
</div>
{/* Sent Emails */}
<div className="flex cursor-pointer flex-col items-center gap-1 rounded-md bg-neutral-100 px-1 pb-1 pt-2 transition-colors hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-gray-700">
<div
onClick={() => setShowSendsModal(!showSendsModal)}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-md bg-neutral-100 px-1 pb-1 pt-2 transition-colors hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-gray-700",
{
"bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-gray-700":
showSendsModal,
},
)}
>
<div className="flex items-center gap-1">
<Icons.send className="size-3" />
<p className="line-clamp-1 text-start font-medium">
@@ -363,7 +376,7 @@ export default function EmailSidebar({
</p>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
<CountUp count={sendEmails || 0} />
{nFormatter(sendEmails || 0)}
</p>
</div>
</div>
@@ -405,7 +418,7 @@ export default function EmailSidebar({
<>
{!isCollapsed ? (
<div className="flex h-full items-center justify-center">
<EmptyPlaceholder>
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="mailPlus" />
<EmptyPlaceholder.Title>No emails</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
@@ -510,13 +523,13 @@ export default function EmailSidebar({
</div>
{!isCollapsed && (
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-gray-500">
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 text-nowrap">
{email.unreadCount > 0 && (
<Badge variant="default">{email.unreadCount}</Badge>
)}
{email.count} recived
</div>
<span>
<span className="line-clamp-1 hover:line-clamp-none">
{isAdminModel
? `Created by ${email.user || email.email.slice(0, 5)} at`
: ""}{" "}
@@ -531,13 +544,25 @@ export default function EmailSidebar({
{/* Pagination */}
{!isCollapsed && data && totalPages > 1 && (
<PaginationWrapper
className="m-0 scale-75 justify-center"
total={totalPages}
className="m-0 scale-75"
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
layout="center"
/>
)}
{showSendsModal && (
<Modal
className="md:max-w-2xl"
showModal={showSendsModal}
setShowModal={setShowSendsModal}
>
<SendsEmailList isAdminModel={isAdminModel} />
</Modal>
)}
{/* 创建\编辑邮箱的 Modal */}
{showEmailModal && (
<Modal showModal={showEmailModal} setShowModal={setShowEmailModal}>
@@ -571,25 +596,38 @@ export default function EmailSidebar({
isEdit ? selectedEmailAddress?.split("@")[0] || "" : ""
}
/>
<Select
onValueChange={(value: string) => {
setDomainSuffix(value);
}}
name="suffix"
defaultValue={domainSuffix || siteConfig.emailDomains[0]}
disabled={isEdit}
>
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{siteConfig.emailDomains.map((v) => (
<SelectItem key={v} value={v}>
@{v}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoadingDomains ? (
<Skeleton className="h-9 w-1/3 rounded-none border-x-0 shadow-inner" />
) : (
<Select
onValueChange={(value: string) => {
setDomainSuffix(value);
}}
name="suffix"
defaultValue={domainSuffix || "wr.do"}
disabled={isEdit}
>
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{emailDomains && emailDomains.length > 0 ? (
emailDomains.map((v) => (
<SelectItem
key={v.domain_name}
value={v.domain_name}
>
@{v.domain_name}
</SelectItem>
))
) : (
<Button className="w-full" variant="ghost">
No domains configured
</Button>
)}
</SelectContent>
</Select>
)}
<Button
className="rounded-l-none"
type="button"

View File

@@ -0,0 +1,70 @@
import { useCallback, useEffect, useRef } from "react";
const EmailViewer = ({ email }: { email: string }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const updateIframe = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return () => {};
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc) {
console.warn("Cannot access iframe document");
return () => {};
}
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<style>
* {
box-sizing: border-box;
}
</style>
</head>
<body>${email}</body>
</html>
`);
doc.close();
const adjustHeight = () => {
if (iframe.contentDocument?.body) {
const height = iframe.contentDocument.body.scrollHeight;
iframe.style.height = `${height + 20}px`; // Add padding
}
};
iframe.addEventListener("load", adjustHeight);
// Handle dynamic content (e.g., images)
const observer = new MutationObserver(adjustHeight);
observer.observe(doc.body, { childList: true, subtree: true });
return () => {
iframe.removeEventListener("load", adjustHeight);
observer.disconnect();
};
}, [email]);
useEffect(() => {
const cleanup = updateIframe();
return cleanup;
}, [updateIframe]);
return (
<iframe
ref={iframeRef}
title="Email Content"
sandbox="allow-same-origin allow-popups"
style={{
width: "100%",
border: "none",
display: "block",
minHeight: "100px",
}}
/>
);
};
export default EmailViewer;

View File

@@ -0,0 +1,133 @@
"use client";
import { useCallback, useState } from "react";
import { UserSendEmail } from "@prisma/client";
import useSWR from "swr";
import { cn, fetcher, formatDate, htmlToText } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { PaginationWrapper } from "../shared/pagination";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
export default function SendsEmailList({
isAdminModel,
}: {
isAdminModel: boolean;
}) {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [searchQuery, setSearchQuery] = useState("");
const { data, isLoading, error } = useSWR<{
list: UserSendEmail[];
total: number;
}>(
`/api/email/send/list?page=${currentPage}&size=${pageSize}&search=${encodeURIComponent(searchQuery)}&all=${isAdminModel}`,
fetcher,
{ dedupingInterval: 5000 },
);
const totalPages = data ? Math.ceil(data.total / pageSize) : 1;
const debouncedSearch = useCallback((value: string) => {
setSearchQuery(value);
setCurrentPage(1); // Reset to first page on search
}, []);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
debouncedSearch(e.target.value);
};
return (
<Card className="mx-auto w-full max-w-4xl border-none">
<CardHeader>
<CardTitle>Sent Emails</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-2 flex items-center justify-between gap-4">
<Input
placeholder="Search by send to email..."
value={searchQuery}
onChange={handleSearch}
className="w-full bg-neutral-50"
/>
</div>
{isLoading ? (
<div className="space-y-1.5">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
) : error ? (
<div className="text-center text-red-500">
Failed to load emails. Please try again.
</div>
) : !data || data.list.length === 0 ? (
<div className="text-center text-muted-foreground">
No emails found.
</div>
) : (
<div className="scrollbar-hidden max-h-[50vh] overflow-y-auto">
<div className="space-y-1.5">
{data.list.map((email) => (
<Collapsible
className="w-full rounded-lg border bg-white transition-all duration-200 hover:bg-gray-50"
key={email.id}
>
<CollapsibleTrigger className="w-full">
<div className="grids flex items-center justify-between rounded-t-lg bg-neutral-300/70 px-2 py-1.5">
<span className="truncate text-xs font-semibold text-neutral-600 dark:text-neutral-200">
{email.from}
</span>
<span className="text-xs text-neutral-800 dark:text-neutral-300">
{formatDate(email.createdAt as any)}
</span>
</div>
<div className="grid w-full grid-cols-1 gap-3 p-2 sm:grid-cols-2">
<div className="text-start">
<div className="truncate text-xs font-semibold text-neutral-600 dark:text-neutral-200">
<strong>Send To:</strong> {email.to}
</div>
<p className="line-clamp-1 truncate text-xs font-semibold text-neutral-600 dark:text-neutral-400">
<strong>Subject:</strong>{" "}
{email.subject || "No subject"}
</p>
</div>
<p className="line-clamp-2 break-all text-start text-xs text-neutral-500">
{htmlToText(email.html || "")}
</p>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="animate-fade-in break-all border-t border-dashed p-2 text-sm text-neutral-500">
{htmlToText(email.html || "")}
</div>
</CollapsibleContent>
</Collapsible>
))}
</div>
{data && totalPages > 1 && (
<PaginationWrapper
className="m-0 mt-6"
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
layout="split"
/>
)}
</div>
)}
</CardContent>
</Card>
);
}

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