95 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
119 changed files with 31743 additions and 1115 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,10 +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=
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=
@@ -37,5 +42,7 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
# GitHub api token for getting gitHub stars count
GITHUB_TOKEN=
# Skip DB check and migration (only for docker), default is true. if false, will check and migrate database each time start docker compose.
SKIP_DB_CHECK=true
SKIP_DB_MIGRATION=true

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

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
```
## 本地开发
@@ -63,7 +105,7 @@ pnpm postinstall
pnpm db:push
```
#### 激活管理员面板
#### 管理员初始化
Follow https://localhost:3000/setup

118
README.md
View File

@@ -1,58 +1,109 @@
<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
```
@@ -68,21 +119,12 @@ pnpm db:push
Follow https://localhost:3000/setup
## Legitimacy review
- To avoid abuse, applications without website content will be rejected
- To avoid domain name conflicts, please check before applying
- Completed website construction or released open source project (ready to build website for open source project)
- Political sensitivity, violence, pornography, link jumping, VPN, reverse proxy services, and other illegal or sensitive content must not appear on the website
**Administrators will conduct domain name checks periodically to clean up domain names that violate the above rules, have no content, and are not open source related**
## Community Group
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
![](https://wr.do/s/group)
<img width="300" src="https://wr.do/s/group" />
## License

View File

@@ -44,10 +44,10 @@ 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">
{/* <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> */}
<p className="px-8 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our{" "}

View File

@@ -9,6 +9,7 @@ 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,
@@ -16,6 +17,7 @@ import {
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";
@@ -40,25 +42,25 @@ export interface DomainListProps {
function TableColumnSekleton() {
return (
<TableRow className="grid grid-cols-7 items-center">
<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 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">
<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>
@@ -67,16 +69,13 @@ function TableColumnSekleton() {
}
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 [isShowDomainInfo, setShowDomainInfo] = useState(false);
// const [selectedDomain, setSelectedDomain] = useState<DomainFormData | null>(
// null,
// );
const [searchParams, setSearchParams] = useState({
slug: "",
target: "",
@@ -88,13 +87,13 @@ export default function DomainList({ user, action }: DomainListProps) {
total: number;
list: DomainFormData[];
}>(
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
fetcher,
);
const handleRefresh = () => {
mutate(
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
undefined,
);
};
@@ -119,6 +118,7 @@ export default function DomainList({ user, action }: DomainListProps) {
const data = await res.json();
if (data) {
toast.success("Successed!");
handleRefresh();
}
} else {
toast.error("Activation failed!");
@@ -128,11 +128,15 @@ export default function DomainList({ user, action }: DomainListProps) {
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
<CardDescription className="text-balance text-lg font-bold">
<span>Total Domains:</span>{" "}
<span className="font-bold">{data && data.total}</span>
</CardDescription>
<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
@@ -147,7 +151,7 @@ export default function DomainList({ user, action }: DomainListProps) {
)}
</Button>
<Button
className="w-[120px] shrink-0 gap-1"
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditDomain(null);
@@ -156,55 +160,58 @@ export default function DomainList({ user, action }: DomainListProps) {
setShowForm(!isShowForm);
}}
>
Add Domain
<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="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by slug..."
value={searchParams.slug}
placeholder="Search by domain name..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
slug: e.target.value,
target: e.target.value,
});
}}
/>
{searchParams.slug && (
{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, slug: "" })}
onClick={() =>
setSearchParams({ ...searchParams, target: "" })
}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
</div> */}
</div>
<Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-7 items-center text-xs">
<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 flex items-center text-nowrap font-bold">
Shorten Service
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Shorten
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Email Service
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Email
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
DNS Service
<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 hidden items-center font-bold sm:flex">
<TableHead className="col-span-1 flex items-center font-bold">
Updated
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
@@ -224,7 +231,7 @@ export default function DomainList({ user, action }: DomainListProps) {
) : data && data.list && data.list.length ? (
data.list.map((domain) => (
<div className="border-b" key={domain.id}>
<TableRow className="grid grid-cols-7 items-center">
<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"
@@ -236,7 +243,7 @@ export default function DomainList({ user, action }: DomainListProps) {
{domain.domain_name}
</Link>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_short_link}
onCheckedChange={(value) =>
@@ -248,7 +255,7 @@ export default function DomainList({ user, action }: DomainListProps) {
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_email}
onCheckedChange={(value) =>
@@ -256,7 +263,7 @@ export default function DomainList({ user, action }: DomainListProps) {
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_dns}
onCheckedChange={(value) =>
@@ -273,7 +280,7 @@ export default function DomainList({ user, action }: DomainListProps) {
}
/>
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
<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">
@@ -310,7 +317,7 @@ export default function DomainList({ user, action }: DomainListProps) {
</div>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="globeLock" />
<EmptyPlaceholder.Title>No Domains</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
@@ -321,6 +328,7 @@ export default function DomainList({ user, action }: DomainListProps) {
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}

View File

@@ -4,12 +4,11 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import UserRecordsList from "../../dashboard/records/record-list";
import DomainList from "./domain-list";
export const metadata = constructMetadata({
title: "DNS Records - WR.DO",
description: "List and manage records.",
title: "Domains - WR.DO",
description: "List and manage domains.",
});
export default async function DashboardPage() {
@@ -22,7 +21,7 @@ export default async function DashboardPage() {
<DashboardHeader
heading="Manage&nbsp;&nbsp;Domains"
text="List and manage domains."
link="/docs/domains"
link="/docs/developer/cloudflare"
linkText="domains."
/>
<DomainList

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

@@ -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({
@@ -25,6 +24,7 @@ export default async function DashboardPage() {
link="/docs/short-urls"
linkText="short urls."
/>
<UserUrlsList
user={{
id: user.id,
@@ -35,7 +35,6 @@ export default async function DashboardPage() {
}}
action="/api/url/admin"
/>
<LiveLog admin={true} />
</>
);
}

View File

@@ -73,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);
@@ -271,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>
@@ -282,6 +283,7 @@ export default function UsersList({ user }: UrlListProps) {
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}

View File

@@ -173,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
@@ -198,22 +205,6 @@ export default async function DashboardPage() {
/>
</Suspense>
</ErrorBoundary>
<ErrorBoundary
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
>
<Suspense
fallback={<Skeleton className="h-[400px] w-full rounded-lg" />}
>
<UserRecordsListSection
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
}}
/>
</Suspense>
</ErrorBoundary>
</div>
</>
);

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 {
@@ -79,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();
@@ -134,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
@@ -180,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);
@@ -189,7 +195,8 @@ 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>
@@ -315,17 +322,19 @@ 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
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}

View File

@@ -98,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
@@ -139,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>
@@ -161,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>
@@ -173,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">
@@ -193,12 +189,13 @@ const LogsTable = ({ userId, target }) => {
</Table>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-start justify-between gap-2 sm:items-center">
<p className="ml-auto text-nowrap text-sm">
{nFormatter(data?.total || 0)} logs
</p>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
className="m-0"
total={data.total}
currentPage={page}
setCurrentPage={setPage}

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

@@ -10,9 +10,17 @@ import { WorldMapTopoJSON } from "@unovis/ts/maps";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { TeamPlanQuota } from "@/config/team";
import { getCountryName, getDeviceVendor } from "@/lib/contries";
import {
getBotName,
getCountryName,
getDeviceVendor,
getEngineName,
getLanguageName,
getRegionName,
} from "@/lib/contries";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { isLink, removeUrlSuffix, timeAgo } from "@/lib/utils";
import { useElementSize } from "@/hooks/use-element-size";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -30,9 +38,11 @@ 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 = {
@@ -116,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 如果未定义
@@ -153,6 +171,7 @@ export function DailyPVUVChart({
setTimeRange: React.Dispatch<React.SetStateAction<string>>;
user: Pick<User, "id" | "name" | "team">;
}) {
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("pv");
@@ -161,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);
@@ -213,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>
@@ -235,22 +259,26 @@ export function DailyPVUVChart({
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
key={e.value}
value={e.value}
>
<span className="flex items-center gap-1">
{e.label}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</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>
@@ -274,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"
@@ -346,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"
@@ -366,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>
@@ -402,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) => (
@@ -449,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

@@ -11,6 +11,7 @@ import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
@@ -36,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);
@@ -58,23 +63,26 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem
className=""
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
key={e.value}
value={e.value}
>
<span className="flex items-center gap-1">
{e.label}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</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>

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 "../../../../components/shared/api-reference";
import LiveLog from "./live-logs";
import UserUrlsList from "./url-list";
export const metadata = constructMetadata({
@@ -36,12 +34,6 @@ export default async function DashboardPage() {
}}
action="/api/url"
/>
<LiveLog admin={false} />
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</>
);
}

View File

@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { toast } from "sonner";
@@ -11,20 +12,24 @@ import { ShortUrlFormData } from "@/lib/dto/short-urls";
import {
cn,
expirationTime,
extractHostname,
fetcher,
nFormatter,
removeUrlSuffix,
timeAgo,
} from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
@@ -35,6 +40,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
@@ -43,16 +49,17 @@ import {
} from "@/components/ui/tooltip";
import { FormType } from "@/components/forms/record-form";
import { UrlForm } from "@/components/forms/url-form";
import ApiReference from "@/components/shared/api-reference";
import BlurImage from "@/components/shared/blur-image";
import { CopyButton } from "@/components/shared/copy-button";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import {
LinkInfoPreviewer,
LinkPreviewer,
} from "@/components/shared/link-previewer";
import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
import { PaginationWrapper } from "@/components/shared/pagination";
import QRCodeEditor from "@/components/shared/qr";
import Globe from "./globe";
import LiveLog from "./live-logs";
import UserUrlMetaInfo from "./meta";
export interface UrlListProps {
@@ -92,6 +99,9 @@ function TableColumnSekleton() {
}
export default function UserUrlsList({ user, action }: UrlListProps) {
const pathname = usePathname();
const { isMobile } = useMediaQuery();
const [currentView, setCurrentView] = useState<string>("List");
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditUrl, setCurrentEditUrl] = useState<ShortUrlFormData | null>(
@@ -107,6 +117,10 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
target: "",
userName: "",
});
const [isPending, startTransition] = useTransition();
const [currentListClickData, setCurrentListClickData] = useState<
Record<string, number>
>({});
const { mutate } = useSWRConfig();
const { data, isLoading } = useSWR<{
@@ -120,6 +134,29 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
},
);
const currentListIds = useMemo(() => {
return data?.list?.map((item) => item.id ?? "") ?? [];
}, [data?.list]);
useEffect(() => {
handleGetUrlClicks();
}, [currentListIds]);
const handleGetUrlClicks = async () => {
startTransition(async () => {
if (currentListIds.length > 0 && currentView !== "Realtime") {
const res = await fetch(action, {
method: "POST",
body: JSON.stringify({ ids: currentListIds }),
});
if (res.ok) {
const data = await res.json();
setCurrentListClickData(data);
}
}
});
};
const handleRefresh = () => {
mutate(
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
@@ -145,18 +182,516 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
}
};
const rendeEmpty = () => (
<EmptyPlaceholder className="col-span-full shadow-none">
<EmptyPlaceholder.Icon name="link" />
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any url yet. Start creating url.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
);
const rendeSeachInputs = () => (
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by slug..."
value={searchParams.slug}
onChange={(e) => {
setSearchParams({
...searchParams,
slug: e.target.value,
});
}}
/>
{searchParams.slug && (
<Button
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by target..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
target: e.target.value,
});
}}
/>
{searchParams.target && (
<Button
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
onClick={() => setSearchParams({ ...searchParams, target: "" })}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
{user.role === "ADMIN" && (
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by user name..."
value={searchParams.userName}
onChange={(e) => {
setSearchParams({
...searchParams,
userName: e.target.value,
});
}}
/>
{searchParams.userName && (
<Button
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
onClick={() => setSearchParams({ ...searchParams, userName: "" })}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
)}
</div>
);
const rendeClicks = (short: ShortUrlFormData) => (
<>
<Icons.mousePointerClick className="size-[14px]" />
{isPending ? (
<Skeleton className="h-4 w-6 rounded" />
) : (
<p className="text-xs font-medium text-gray-700 dark:text-gray-50">
{(short.id && nFormatter(currentListClickData[short.id], 2)) || "-"}
</p>
)}
</>
);
const rendeStats = (short: ShortUrlFormData) =>
isShowStats &&
selectedUrl?.id === short.id && (
<UserUrlMetaInfo
user={{
id: user.id,
name: user.name || "",
team: user.team,
}}
action="/api/url/meta"
urlId={short.id!}
/>
);
const rendeList = () => (
<Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Slug
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Target
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
User
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Enabled
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Expiration
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Clicks
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Updated
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<>
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
</>
) : data && data.list && data.list.length ? (
data.list.map((short) => (
<div className="border-b" key={short.id}>
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Link
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank"
prefetch={false}
title={short.url}
>
{short.url}
</Link>
<CopyButton
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
className={cn(
"size-[25px]",
"duration-250 transition-all group-hover:opacity-100",
)}
/>
{short.password && (
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
)}
</TableCell>
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
<LinkInfoPreviewer
apiKey={user.apiKey ?? ""}
url={short.target}
formatUrl={removeUrlSuffix(short.target)}
/>
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger className="truncate">
{short.userName ?? "Anonymous"}
</TooltipTrigger>
<TooltipContent>
{short.userName ?? "Anonymous"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Switch
defaultChecked={short.active === 1}
onCheckedChange={(value) =>
handleChangeStatu(value, short.id || "")
}
/>
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
{expirationTime(short.expiration, short.updatedAt)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
<div className="flex items-center gap-1 rounded-lg border bg-gray-50 px-2 py-1 dark:bg-gray-600/50">
{rendeClicks(short)}
</div>
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">Edit</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setSelectedUrl(short);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="mx-0.5 size-4" />
</Button>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant="outline"
onClick={() => {
setSelectedUrl(short);
setCurrentView(short.id!);
if (isShowStats && selectedUrl?.id !== short.id) {
} else {
setShowStats(!isShowStats);
}
}}
>
<Icons.lineChart className="mx-0.5 size-4" />
</Button>
</TableCell>
</TableRow>
{/* {rendeStats(short)} */}
</div>
))
) : (
rendeEmpty()
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
);
const rendeGrid = () => (
<>
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{isLoading ? (
<>
{[1, 2, 3, 4, 5, 6].map((v) => (
<Skeleton key={v} className="h-24 w-full" />
))}
</>
) : data && data.list && data.list.length ? (
data.list.map((short) => (
<div
className={cn(
"h-24 rounded-lg border p-1 shadow-inner dark:bg-neutral-800",
)}
key={short.id}
>
<div className="flex h-full flex-col rounded-lg border border-dotted bg-white px-3 py-1.5 backdrop-blur-lg dark:bg-black">
<div className="flex items-center justify-between gap-1">
<BlurImage
src={`https://unavatar.io/${extractHostname(short.target)}?fallback=https://wr.do/logo.png`}
alt="logo"
width={30}
height={30}
className="rounded-md"
/>
<div className="ml-2 mr-auto flex flex-col justify-between truncate">
{/* url */}
<div className="flex items-center">
<Link
className="overflow-hidden text-ellipsis whitespace-normal text-sm font-semibold text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-300"
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank"
prefetch={false}
title={short.url}
>
{short.url}
</Link>
<CopyButton
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
className={cn(
"size-[25px]",
"duration-250 transition-all group-hover:opacity-100",
)}
/>
<Button
className="duration-250 size-[26px] p-1.5 text-foreground transition-all hover:border hover:text-foreground dark:text-foreground"
size="sm"
variant="ghost"
onClick={() => {
setSelectedUrl(short);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="size-4" />
</Button>
{short.password && (
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
)}
</div>
{/* target */}
<div className="flex items-center gap-1 overflow-hidden truncate text-sm text-muted-foreground">
<Icons.forwardArrow className="size-4 shrink-0 text-gray-400" />
<LinkInfoPreviewer
apiKey={user.apiKey ?? ""}
url={short.target}
formatUrl={removeUrlSuffix(short.target)}
/>
</div>
</div>
<div className="ml-2 flex items-center gap-1 rounded-md border bg-gray-50 px-2 py-1 dark:bg-gray-600/50">
{rendeClicks(short)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="size-[25px] p-1.5"
size="sm"
variant="ghost"
>
<Icons.moreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => {
setSelectedUrl(short);
setCurrentView(short.id!);
if (isShowStats && selectedUrl?.id !== short.id) {
} else {
setShowStats(!isShowStats);
}
}}
>
<Icons.lineChart className="size-4" />
Analytics
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<PenLine className="size-4" />
Edit URL
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-auto flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger className="truncate">
{short.userName ?? "Anonymous"}
</TooltipTrigger>
<TooltipContent>
{short.userName ?? "Anonymous"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Separator
className="h-4/5"
orientation="vertical"
></Separator>
{short.expiration !== "-1" && (
<>
<span>
Expiration:{" "}
{expirationTime(short.expiration, short.updatedAt)}
</span>
<Separator
className="h-4/5"
orientation="vertical"
></Separator>
</>
)}
{timeAgo(short.updatedAt as Date)}
<Switch
className="scale-[0.6]"
defaultChecked={short.active === 1}
onCheckedChange={(value) =>
handleChangeStatu(value, short.id || "")
}
/>
</div>
</div>
</div>
))
) : (
rendeEmpty()
)}
</section>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</>
);
const rendLogs = () => (
<div className="mt-6 space-y-3">
{action.indexOf("admin") > -1 ? <LiveLog admin={true} /> : <LiveLog />}
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</div>
);
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
{action.includes("/admin") ? (
<CardDescription className="text-balance text-lg font-bold">
<span>Total URLs:</span>{" "}
<span className="font-bold">{data && data.total}</span>
</CardDescription>
) : (
<CardTitle>Short URLs</CardTitle>
<Tabs
className={cn("rounded-lg", pathname === "/dashboard" && "border p-6")}
value={currentView}
>
{/* Tabs */}
<div className="mb-4 flex items-center justify-between gap-2">
{pathname === "/dashboard" && (
<h2 className="mr-3 text-lg font-semibold">Short URLs</h2>
)}
<TabsList>
<TabsTrigger onClick={() => setCurrentView("List")} value="List">
<Icons.list className="size-4" />
{/* List */}
</TabsTrigger>
<TabsTrigger onClick={() => setCurrentView("Grid")} value="Grid">
<Icons.layoutGrid className="size-4" />
{/* Grid */}
</TabsTrigger>
<TabsTrigger
onClick={() => setCurrentView("Realtime")}
value="Realtime"
>
<Icons.globe className="size-4 text-blue-500" />
{/* Realtime */}
</TabsTrigger>
{selectedUrl?.id && (
<TabsTrigger
className="flex items-center gap-1 text-muted-foreground"
value={selectedUrl.id}
onClick={() => setCurrentView(selectedUrl.id!)}
>
<Icons.lineChart className="size-4" />
{selectedUrl.url}
</TabsTrigger>
)}
</TabsList>
{/* <p>Total: {data?.total || 0}</p> */}
<div className="ml-auto flex items-center justify-end gap-3">
<Button
variant={"outline"}
@@ -170,7 +705,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
)}
</Button>
<Button
className="w-[120px] shrink-0 gap-1"
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditUrl(null);
@@ -179,264 +714,31 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
setShowForm(!isShowForm);
}}
>
Add URL
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add URL</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 slug..."
value={searchParams.slug}
onChange={(e) => {
setSearchParams({
...searchParams,
slug: e.target.value,
});
}}
/>
{searchParams.slug && (
<Button
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
</div>
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by target..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
target: e.target.value,
});
}}
/>
{searchParams.target && (
<Button
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
onClick={() =>
setSearchParams({ ...searchParams, target: "" })
}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
{user.role === "ADMIN" && (
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by user name..."
value={searchParams.userName}
onChange={(e) => {
setSearchParams({
...searchParams,
userName: e.target.value,
});
}}
/>
{searchParams.userName && (
<Button
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
onClick={() =>
setSearchParams({ ...searchParams, userName: "" })
}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
)}
</div>
<Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Slug
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Target
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
User
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Enabled
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Expiration
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Updated
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Created
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<>
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
</>
) : data && data.list && data.list.length ? (
data.list.map((short) => (
<div className="border-b" key={short.id}>
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Link
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank"
prefetch={false}
title={short.url}
>
{short.url}
</Link>
<CopyButton
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
className={cn(
"size-[25px]",
"duration-250 transition-all group-hover:opacity-100",
)}
/>
{short.password && (
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
)}
</TableCell>
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
<LinkInfoPreviewer
apiKey={user.apiKey ?? ""}
url={short.target}
formatUrl={removeUrlSuffix(short.target)}
/>
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger className="truncate">
{short.userName ?? "Anonymous"}
</TooltipTrigger>
<TooltipContent>
{short.userName ?? "Anonymous"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Switch
defaultChecked={short.active === 1}
onCheckedChange={(value) =>
handleChangeStatu(value, short.id || "")
}
/>
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
{expirationTime(short.expiration, short.updatedAt)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.createdAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">Edit</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setSelectedUrl(short);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="mx-0.5 size-4" />
</Button>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant="outline"
onClick={() => {
setSelectedUrl(short);
if (isShowStats && selectedUrl?.id !== short.id) {
} else {
setShowStats(!isShowStats);
}
}}
>
<Icons.lineChart className="mx-0.5 size-4" />
</Button>
</TableCell>
</TableRow>
{isShowStats && selectedUrl?.id === short.id && (
<UserUrlMetaInfo
user={{
id: user.id,
name: user.name || "",
team: user.team,
}}
action="/api/url/meta"
urlId={short.id!}
/>
)}
</div>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="link" />
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any url yet. Start creating url.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</CardContent>
</Card>
<TabsContent className="space-y-3" value="List">
{rendeSeachInputs()}
{rendeList()}
{rendLogs()}
</TabsContent>
<TabsContent className="space-y-3" value="Grid">
{rendeSeachInputs()}
{rendeGrid()}
{rendLogs()}
</TabsContent>
<TabsContent value="Realtime">
{action.indexOf("admin") > -1 ? <Globe isAdmin={true} /> : <Globe />}
</TabsContent>
{selectedUrl?.id && (
<TabsContent value={selectedUrl.id}>
{rendeStats(selectedUrl)}
</TabsContent>
)}
</Tabs>
{/* QR code editor */}
<Modal

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

@@ -26,7 +26,6 @@ export default function StepGuide({
const [currentStep, setCurrentStep] = useState(1);
const [direction, setDirection] = useState(0);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const [isMobile, setIsMobile] = useState(false);
const steps = [
{
@@ -52,19 +51,6 @@ export default function StepGuide({
},
];
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, []);
const goToNextStep = () => {
if (currentStep < steps.length) {
setDirection(1);
@@ -130,7 +116,7 @@ export default function StepGuide({
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">
<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>
@@ -213,24 +199,23 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
);
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4">
<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-neutral-500">
<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-neutral-500">
<span className="text-sm font-semibold text-muted-foreground">
Set {email} as ADMIN:
</span>
{isAdmin ? (
ReadyBadge
) : (
<Button
className=""
variant={"outline"}
variant={"default"}
size={"sm"}
onClick={handleSetAdmin}
disabled={isPending}
@@ -306,7 +291,7 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
});
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4">
<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">
@@ -315,7 +300,7 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
<div className="w-full">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
className="flex-1 bg-neutral-50 shadow-inner dark:bg-neutral-600"
size={32}
placeholder="example.com"
onChange={(e) => setDomain(e.target.value)}

View File

@@ -4,7 +4,6 @@ import {
createDomain,
deleteDomain,
getAllDomains,
invalidateDomainConfigCache,
updateDomain,
} from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
@@ -19,13 +18,18 @@ export async function GET(req: NextRequest) {
return Response.json("Unauthorized", { status: 401 });
}
// TODO: Add pagination
const domains = await getAllDomains();
const url = new URL(req.url);
const page = url.searchParams.get("page");
const size = url.searchParams.get("size");
const target = url.searchParams.get("target") || "";
return Response.json(
{ list: domains, total: domains.length },
{ status: 200 },
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 });
@@ -55,14 +59,13 @@ export async function POST(req: NextRequest) {
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,
});
invalidateDomainConfigCache();
return Response.json(newDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
@@ -87,6 +90,7 @@ export async function PUT(req: NextRequest) {
cf_zone_id,
cf_api_key,
cf_email,
resend_api_key,
max_short_links,
max_email_forwards,
max_dns_records,
@@ -107,13 +111,12 @@ export async function PUT(req: NextRequest) {
cf_api_key,
cf_email,
cf_api_key_encrypted: false,
resend_api_key,
max_short_links,
max_email_forwards,
max_dns_records,
});
invalidateDomainConfigCache();
return Response.json(updatedDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
@@ -137,8 +140,6 @@ export async function DELETE(req: NextRequest) {
const deletedDomain = await deleteDomain(domain_name);
invalidateDomainConfigCache();
return Response.json(deletedDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);

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

View File

@@ -4,6 +4,8 @@ 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 {

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,3 +1,4 @@
import { siteConfig } from "@/config/site";
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
@@ -75,10 +76,38 @@ 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(
matchedZone.cf_zone_id,
matchedZone.cf_api_key,

View File

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

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) {

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,4 +1,4 @@
import { getUserShortUrls } from "@/lib/dto/short-urls";
import { getUrlClicksByIds, getUserShortUrls } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -34,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,4 +1,4 @@
import { getUserShortUrls } from "@/lib/dto/short-urls";
import { getUrlClicksByIds, getUserShortUrls } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -31,3 +31,18 @@ export async function GET(req: Request) {
});
}
}
export async function POST(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { ids } = await req.json();
const data = await getUrlClicksByIds(ids, user.id, "USER");
return Response.json(data);
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
}
}

View File

@@ -1,14 +1,11 @@
import { ipAddress } from "@vercel/functions";
import { getIpInfo } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
export async function GET(req: Request) {
try {
const data = getIpInfo(req);
const ip = ipAddress(req);
const data = await getIpInfo(req);
return Response.json({
ip,
ip: data.ip,
city: data.city,
region: data.region,
country: data.country,

View File

@@ -3,7 +3,8 @@ import TurndownService from "turndown";
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 = 600;
export const dynamic = "force-dynamic";
@@ -70,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

@@ -2,7 +2,8 @@ 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";
@@ -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

@@ -2,9 +2,10 @@ import { ImageResponse } from "@vercel/og";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo } from "@/lib/geo";
import { WRDO_QR_LOGO } from "@/lib/qr/constants";
import { QRCodeSVG } from "@/lib/qr/utils";
import { getIpInfo, getSearchParams } from "@/lib/utils";
import { getSearchParams } from "@/lib/utils";
import { getQRCodeQuerySchema } from "@/lib/validations/qr";
const CORS_HEADERS = {
@@ -41,9 +42,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: "qrcode",
referer: stats.referer,
city: stats.city,

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,7 +2,8 @@ import cheerio from "cheerio";
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 = 600;
export const dynamic = "force-dynamic";
@@ -63,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

@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "0.6.1",
"versionName": "0.6.5",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -47,7 +47,7 @@ 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 {
const { error } = await resend.emails.send({

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

@@ -7,6 +7,7 @@ import useSWR from "swr";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { cn, fetcher, nFormatter } from "@/lib/utils";
import { useElementSize } from "@/hooks/use-element-size";
import {
Card,
CardContent,
@@ -21,11 +22,11 @@ import {
ChartTooltipContent,
} from "@/components/ui/chart";
import CountUp from "../dashboard/count-up";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "../ui/select";
@@ -62,6 +63,7 @@ const chartConfig = {
} 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");
@@ -122,16 +124,19 @@ export function InteractiveBarChart() {
<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 value={e.value}>{e.label}</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
</div>
<div className="flex">
<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;
@@ -141,13 +146,13 @@ export function InteractiveBarChart() {
<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"
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-lg font-bold leading-none sm:text-3xl">
<span className="text-base font-bold leading-none sm:text-lg">
{nFormatter(data.total[key])}
</span>
<span
@@ -167,7 +172,7 @@ export function InteractiveBarChart() {
)}
</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"
@@ -175,6 +180,7 @@ export function InteractiveBarChart() {
<BarChart
accessibilityLayer
data={data.list}
width={wrapperWidth}
margin={{
left: 12,
right: 12,

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("");

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

@@ -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, 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";
@@ -422,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>
@@ -626,7 +622,7 @@ export default function EmailSidebar({
))
) : (
<Button className="w-full" variant="ghost">
No domain
No domains configured
</Button>
)}
</SelectContent>

View File

@@ -1,17 +1,21 @@
"use client";
import { Dispatch, SetStateAction, useState, useTransition } from "react";
import {
Dispatch,
SetStateAction,
useEffect,
useState,
useTransition,
} from "react";
import Link from "next/link";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { Sparkles } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
import { getZoneDetail } from "@/lib/cloudflare";
import { DomainFormData } from "@/lib/dto/domains";
import { EXPIRATION_ENUMS } from "@/lib/enums";
import { generateUrlSuffix } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { createDomainSchema } from "@/lib/validations/domain";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -25,13 +29,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Switch } from "../ui/switch";
export type FormData = DomainFormData;
@@ -57,9 +54,16 @@ export function DomainForm({
}: DomainFormProps) {
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const [isCheckingCf, startCheckCfTransition] = useTransition();
const [isCheckingResend, startCheckResendTransition] = useTransition();
const [currentRecordStatus, setCurrentRecordStatus] = useState(
initData?.enable_dns || false,
);
const [currentEmailStatus, setCurrentEmailStatus] = useState(
initData?.enable_email || false,
);
const [isCheckedCfConfig, setIsCheckedCfConfig] = useState(false);
const [isCheckedResendConfig, setIsCheckedResendConfig] = useState(false);
const {
handleSubmit,
@@ -79,6 +83,7 @@ export function DomainForm({
cf_api_key: initData?.cf_api_key || "",
cf_email: initData?.cf_email || "",
cf_api_key_encrypted: initData?.cf_api_key_encrypted || false,
resend_api_key: initData?.resend_api_key || "",
max_short_links: initData?.max_short_links || 0,
max_email_forwards: initData?.max_email_forwards || 0,
max_dns_records: initData?.max_dns_records || 0,
@@ -159,6 +164,84 @@ export function DomainForm({
}
};
const handleCfCheckAccess = async (event) => {
event?.stopPropagation();
if (!currentRecordStatus) return;
if (isCheckedCfConfig) {
setIsCheckedCfConfig(false);
}
startCheckCfTransition(async () => {
const values = getValues(["cf_zone_id", "cf_api_key", "cf_email"]);
const res = await fetch(
`/api/domain/check-cf?zone_id=${values[0]}&api_key=${values[1]}&email=${values[2]}`,
);
if (res.ok) {
const data = await res.json();
if (data === 200) {
setIsCheckedCfConfig(true);
return;
}
}
setIsCheckedCfConfig(false);
toast.error("Access Failed", {
description: "Please check your Cloudflare settings and try again.",
});
});
};
const handleResendCheckAccess = async (event) => {
event?.stopPropagation();
if (!currentEmailStatus) return;
if (isCheckedResendConfig) {
setIsCheckedResendConfig(false);
}
startCheckResendTransition(async () => {
const value = getValues(["resend_api_key", "domain_name"]);
const res = await fetch(
`/api/domain/check-resend?api_key=${value[0]}&domain=${value[1]}`,
);
if (res.ok) {
const data = await res.json();
if (data === 200) {
setIsCheckedResendConfig(true);
return;
}
}
setIsCheckedResendConfig(false);
toast.error("Access Failed", {
description: "Please check your Resend API key and try again.",
});
});
};
const ReadyBadge = (
active: boolean,
isChecked: boolean,
isChecking: boolean,
type: string,
) => (
<Badge
className={cn(
"ml-auto text-xs font-semibold",
!active && "text-muted-foreground",
)}
variant={active ? (isChecked ? "green" : "default") : "outline"}
onClick={(event) =>
type === "cf"
? handleCfCheckAccess(event)
: handleResendCheckAccess(event)
}
>
{isChecking && <Icons.spinner className="mr-1 size-3 animate-spin" />}
{isChecked && !isChecking && <Icons.check className="mr-1 size-3" />}
{isChecked ? "Ready" : "Access Check"}
</Badge>
);
return (
<div>
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
@@ -174,7 +257,7 @@ export function DomainForm({
<Label className="mt-2.5 text-nowrap" htmlFor="domain_name">
Domain Name:
</Label>
<div className="w-full sm:w-[60%]">
<div className="w-full sm:w-3/5">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
@@ -235,7 +318,10 @@ export function DomainForm({
id="email_service"
{...register("enable_email")}
defaultChecked={initData?.enable_email ?? false}
onCheckedChange={(value) => setValue("enable_email", value)}
onCheckedChange={(value) => {
setValue("enable_email", value);
setCurrentEmailStatus(value);
}}
/>
</div>
@@ -257,10 +343,16 @@ export function DomainForm({
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<CollapsibleTrigger className="flex w-full items-center justify-between">
<h2 className="absolute left-2 top-4 text-xs font-semibold text-neutral-400">
Cloudflare Configs(Optional)
<h2 className="absolute left-2 top-5 text-xs font-semibold text-neutral-400">
Cloudflare Configs (Optional)
</h2>
<Icons.chevronDown className="ml-auto size-4" />
{ReadyBadge(
currentRecordStatus,
isCheckedCfConfig,
isCheckingCf,
"cf",
)}
<Icons.chevronDown className="ml-2 size-4" />
</CollapsibleTrigger>
<CollapsibleContent>
{!currentRecordStatus && (
@@ -274,7 +366,7 @@ export function DomainForm({
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
Zone ID:
</Label>
<div className="w-full sm:w-[60%]">
<div className="w-full sm:w-3/5">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
@@ -308,7 +400,7 @@ export function DomainForm({
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
API Token:
</Label>
<div className="w-full sm:w-[60%]">
<div className="w-full sm:w-3/5">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
@@ -342,7 +434,7 @@ export function DomainForm({
<Label className="mt-2.5 text-nowrap" htmlFor="email">
Account Email:
</Label>
<div className="w-full sm:w-[60%]">
<div className="w-full sm:w-3/5">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
@@ -374,6 +466,63 @@ export function DomainForm({
</CollapsibleContent>
</Collapsible>
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<CollapsibleTrigger className="flex w-full items-center justify-between">
<h2 className="absolute left-2 top-5 text-xs font-semibold text-neutral-400">
Resend Configs (Optional)
</h2>
{ReadyBadge(
currentEmailStatus,
isCheckedResendConfig,
isCheckingResend,
"resend",
)}
<Icons.chevronDown className="ml-2 size-4" />
</CollapsibleTrigger>
<CollapsibleContent>
{!currentEmailStatus && (
<div className="mt-3 flex items-center gap-1 rounded bg-neutral-200 p-2 text-xs dark:bg-neutral-700">
<Icons.help className="size-3" /> Associate with "Email Service"
status
</div>
)}
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
API Key (send email service):
</Label>
<div className="w-full sm:w-3/5">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("resend_api_key")}
disabled={!currentEmailStatus}
/>
<div className="flex flex-col justify-between p-1">
{errors?.resend_api_key ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.resend_api_key.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
<Link
className="text-blue-500"
href="/docs/developer/email"
target="_blank"
>
How to get resend api key?
</Link>
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
</CollapsibleContent>
</Collapsible>
{/* Action buttons */}
<div className="mt-3 flex justify-end gap-3">
{type === "edit" && (

View File

@@ -233,7 +233,7 @@ export function RecordForm({
))
) : (
<Button className="w-full" variant="ghost">
No domain
No domains configured
</Button>
)}
</SelectContent>
@@ -379,21 +379,23 @@ export function RecordForm({
Optional. Time To Live.
</p>
</FormSectionColumns>
<FormSectionColumns title="Proxy">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="proxy">
Proxy
</Label>
<Switch
id="proxied"
{...register("proxied")}
onCheckedChange={(value) => setValue("proxied", value)}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Proxy status.
</p>
</FormSectionColumns>
{["A", "CNAME"].includes(currentRecordType) && (
<FormSectionColumns title="Proxy">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="proxy">
Proxy
</Label>
<Switch
id="proxied"
{...register("proxied")}
onCheckedChange={(value) => setValue("proxied", value)}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Proxy status.
</p>
</FormSectionColumns>
)}
</div>
{/* Action buttons */}

View File

@@ -224,7 +224,7 @@ export function UrlForm({
))
) : (
<Button className="w-full" variant="ghost">
No domain
No domains configured
</Button>
)}
</SelectContent>

View File

@@ -82,13 +82,14 @@ export function UserApiKeyForm({ user }: UserNameFormProps) {
<Button
type="submit"
variant={"blue"}
size={"sm"}
disabled={isPending}
className="shrink-0 px-4"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>Generate</p>
"Generate"
)}
</Button>
</div>

View File

@@ -6,16 +6,19 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
import * as z from "zod";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
import { cn, fetcher } from "@/lib/utils";
import { userAuthSchema } from "@/lib/validations/auth";
import { buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Icons } from "@/components/shared/icons";
import { Skeleton } from "../ui/skeleton";
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {
type?: string;
}
@@ -59,79 +62,14 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
});
}
return (
<div className={cn("grid gap-3", className)} {...props}>
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsGoogleLoading(true);
signIn("google");
}}
disabled={
!siteConfig.openSignup ||
isLoading ||
isGoogleLoading ||
isGithubLoading ||
isLinuxDoLoading
}
>
{isGoogleLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
) : (
<Icons.google className="mr-2 size-4" />
)}{" "}
Google
</button>
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsGithubLoading(true);
signIn("github");
}}
disabled={
!siteConfig.openSignup ||
isLoading ||
isGithubLoading ||
isGoogleLoading ||
isLinuxDoLoading
}
>
{isGithubLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
) : (
<Icons.github className="mr-2 size-4" />
)}{" "}
Github
</button>
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsLinuxDoLoading(true);
signIn("linuxdo");
}}
disabled={
!siteConfig.openSignup ||
isLoading ||
isGithubLoading ||
isGoogleLoading ||
isLinuxDoLoading
}
>
{isLinuxDoLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
) : (
<img
src="/_static/images/linuxdo.webp"
alt="linuxdo"
className="mr-2 size-4"
/>
)}{" "}
LinuxDo
</button>
const { data: loginMethod, isLoading: isLoadingMethod } = useSWR<
Record<string, boolean>
>("/api/feature", fetcher, {
revalidateOnFocus: false,
});
const rendeSeparator = () => {
return (
<div className="relative my-3">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
@@ -142,44 +80,145 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
</span>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
id="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading || isGoogleLoading}
{...register("email")}
);
};
if (isLoadingMethod || !loginMethod) {
return (
<div className={cn("grid gap-3", className)} {...props}>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
{rendeSeparator()}
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
return (
<div className={cn("grid gap-3", className)} {...props}>
{loginMethod["google"] && (
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsGoogleLoading(true);
signIn("google");
}}
disabled={
!siteConfig.openSignup ||
isLoading ||
isGoogleLoading ||
isGithubLoading ||
isLinuxDoLoading
}
>
{isGoogleLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
) : (
<Icons.google className="mr-2 size-4" />
)}{" "}
Google
</button>
)}
{loginMethod["github"] && (
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsGithubLoading(true);
signIn("github");
}}
disabled={
!siteConfig.openSignup ||
isLoading ||
isGithubLoading ||
isGoogleLoading ||
isLinuxDoLoading
}
>
{isGithubLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
) : (
<Icons.github className="mr-2 size-4" />
)}{" "}
Github
</button>
)}
{loginMethod["linuxdo"] && (
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsLinuxDoLoading(true);
signIn("linuxdo");
}}
disabled={
!siteConfig.openSignup ||
isLoading ||
isGithubLoading ||
isGoogleLoading ||
isLinuxDoLoading
}
>
{isLinuxDoLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
) : (
<img
src="/_static/images/linuxdo.webp"
alt="linuxdo"
className="mr-2 size-4"
/>
{errors?.email && (
<p className="px-1 text-xs text-red-600">
{errors.email.message}
</p>
)}
)}{" "}
LinuxDo
</button>
)}
{loginMethod["resend"] && rendeSeparator()}
{loginMethod["resend"] && (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
id="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading || isGoogleLoading}
{...register("email")}
/>
{errors?.email && (
<p className="px-1 text-xs text-red-600">
{errors.email.message}
</p>
)}
</div>
<button
className={cn(buttonVariants(), "mt-3")}
disabled={
!siteConfig.openSignup ||
isLoading ||
isGoogleLoading ||
isGithubLoading
}
>
{isLoading && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
{type === "register"
? "Sign Up with Email"
: "Sign In with Email"}
</button>
</div>
<button
className={cn(buttonVariants(), "mt-3")}
disabled={
!siteConfig.openSignup ||
isLoading ||
isGoogleLoading ||
isGithubLoading
}
>
{isLoading && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
{type === "register" ? "Sign Up with Email" : "Sign In with Email"}
</button>
</div>
</form>
</form>
)}
</div>
);
}

View File

@@ -77,7 +77,7 @@ export function UserNameForm({ user }: UserNameFormProps) {
type="submit"
variant={updated ? "blue" : "disable"}
disabled={isPending || !updated}
className="w-[67px] shrink-0 px-0 sm:w-[130px]"
className="h-9 w-[67px] shrink-0 px-0 sm:w-[130px]"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />

View File

@@ -110,7 +110,7 @@ export function UserRoleForm({ user }: UserNameFormProps) {
type="submit"
variant={updated ? "blue" : "disable"}
disabled={isPending || !updated}
className="w-[67px] shrink-0 px-0 sm:w-[130px]"
className="h-9 w-[67px] shrink-0 px-0 sm:w-[130px]"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />

View File

@@ -3,9 +3,10 @@
import { Fragment, useEffect, useState } from "react";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { NavItem, SidebarNavItem } from "@/types";
import { SidebarNavItem } from "@/types";
import { Menu, PanelLeftClose, PanelRightClose } from "lucide-react";
import { Link } from "next-view-transitions";
import { name, version } from "package.json";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
@@ -163,25 +164,17 @@ export function DashboardSidebar({ links }: DashboardSidebarProps) {
</nav>
{isSidebarExpanded && (
<p className="mx-3 mt-auto pb-3 pt-6 font-mono text-xs text-muted-foreground/70">
&copy; 2024{" "}
<p className="mx-3 mt-auto flex items-center gap-1 pb-3 pt-6 font-mono text-xs text-muted-foreground/90">
&copy; 2024
<Link
href={siteConfig.links.github}
target="_blank"
rel="noreferrer"
className="font-medium text-primary underline underline-offset-2"
className="font-medium underline-offset-2 hover:underline"
>
oiov
{name}
</Link>
.{/* <br /> Built with{" "} */}
{/* <Link
href="https://www.cloudflare.com?ref=wrdo"
target="_blank"
rel="noreferrer"
className="font-medium text-primary underline underline-offset-2"
>
Cloudflare
</Link> */}
v{version}
</p>
)}
</div>
@@ -275,6 +268,19 @@ export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
),
)}
<p className="mx-3 mt-auto flex items-center gap-1 pb-3 pt-6 font-mono text-xs text-muted-foreground/90">
&copy; 2024
<Link
href={siteConfig.links.github}
target="_blank"
rel="noreferrer"
className="font-medium underline-offset-2 hover:underline"
>
{name}
</Link>
v{version}
</p>
{/* <div className="mt-auto">
<UpgradeCard />
</div> */}

View File

@@ -39,7 +39,11 @@ export function UserAccountNav() {
<Drawer.Root open={open} onClose={closeDrawer}>
<Drawer.Trigger onClick={() => setOpen(true)}>
<UserAvatar
user={{ name: user.name || null, image: user.image || null }}
user={{
name: user.name || null,
image: user.image || null,
email: user.email || null,
}}
className="size-9 border"
/>
</Drawer.Trigger>
@@ -134,7 +138,11 @@ export function UserAccountNav() {
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger>
<UserAvatar
user={{ name: user.name || null, image: user.image || null }}
user={{
name: user.name || null,
image: user.image || null,
email: user.email || null,
}}
className="size-8 border border-gray-200 shadow-inner"
/>
</DropdownMenuTrigger>

View File

@@ -60,6 +60,7 @@ function DeleteAccountModal({
user={{
name: session?.user?.name || null,
image: session?.user?.image || null,
email: session?.user?.email || null,
}}
/>
<h3 className="text-lg font-semibold">Delete Account</h3>

View File

@@ -111,28 +111,7 @@ export default function EmailManagerInnovate() {
</div>
</div>
<div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
<svg
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 shrink-0 dark:text-gray-400"
>
<path
d="M15.25,9.75H4.75c-1.105,0-2-.895-2-2V3.75"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
></path>
<polyline
fill="none"
points="11 5.5 15.25 9.75 11 14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
></polyline>
</svg>
<Icons.forwardArrow className="h-4 w-4 shrink-0 text-gray-400" />
{viewMode === "inbox" ? "app@wr.do" : "example@gmail.com"}
</div>
</div>

View File

@@ -78,30 +78,7 @@ export default function UrlShotenerExp() {
</div>
</div>
<div className="flex items-center gap-1 text-sm font-semibold text-gray-400">
<svg
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 shrink-0 text-gray-400"
>
<g fill="currentColor">
<path
d="M15.25,9.75H4.75c-1.105,0-2-.895-2-2V3.75"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
></path>
<polyline
fill="none"
points="11 5.5 15.25 9.75 11 14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
></polyline>
</g>
</svg>
<Icons.forwardArrow className="h-4 w-4 shrink-0 text-gray-400" />
wr.do/dashboard
</div>
</div>

View File

@@ -0,0 +1,29 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Separator } from "../ui/separator";
export function DocsLang({ en, zh }: { en: string; zh: string }) {
const pathname = usePathname();
return (
<div className="flex items-center gap-1.5">
{pathname !== en ? (
<Link href={en} className="text-blue-500 hover:underline">
English
</Link>
) : (
<p className="text-muted-foreground">English</p>
)}
<div className="h-[20px] w-px shrink-0 border bg-border text-neutral-400"></div>
{pathname !== zh ? (
<Link href={zh} className="text-blue-500 hover:underline">
</Link>
) : (
<p className="text-muted-foreground"></p>
)}
</div>
);
}

View File

@@ -10,27 +10,19 @@ interface GitHubResponse {
}
async function getGitHubStars(owner: string, repo: string) {
const githubToken = process.env.GITHUB_TOKEN;
if (!githubToken) {
throw new Error("GitHub token is not configured");
}
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: {
Accept: "application/vnd.github.v3+json",
Authorization: `token ${githubToken}`,
"User-Agent": "NextJS-App",
const res = await fetch(
`https://wr.do/api/github?owner=${owner}&repo=${repo}`,
{
next: { revalidate: 3600 },
},
next: { revalidate: 3600 },
});
);
if (!res.ok) {
throw new Error("Failed to fetch GitHub stars");
}
const data: GitHubResponse = await res.json();
return data.stargazers_count;
const data = await res.json();
return data.stars;
}
interface Props {

View File

@@ -18,6 +18,7 @@ import {
Copy,
Crown,
Download,
EllipsisVertical,
File,
FileText,
Globe,
@@ -28,9 +29,10 @@ import {
Image,
Inbox,
Laptop,
LayoutGrid,
LayoutPanelLeft,
LineChart,
Link,
List,
ListChecks,
ListFilter,
Loader2,
@@ -62,6 +64,8 @@ import {
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import LogoIcon from "./logo";
export type Icon = LucideIcon;
@@ -84,9 +88,12 @@ export const Icons = {
calendar: Calendar,
crown: Crown,
lock: LockKeyhole,
list: List,
layoutGrid: LayoutGrid,
unLock: LockKeyholeOpen,
listFilter: ListFilter,
botMessageSquare: BotMessageSquare,
moreVertical: EllipsisVertical,
pwdKey: ({ ...props }: LucideProps) => (
<svg
height="18"
@@ -141,7 +148,74 @@ export const Icons = {
download: Download,
ellipsis: MoreVertical,
paintbrush: Paintbrush,
mousePointerClick: MousePointerClick,
mousePointerClick: ({ className, ...props }: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`group cursor-pointer transition-transform duration-75 hover:scale-110 ${className || ""}`}
{...props}
>
{/* 点击波纹线条 - 默认显示 */}
<g className="group-hover:hidden">
<path d="M14 4.1 12 6" />
<path d="m5.1 8-2.9-.8" />
<path d="m6 12-1.9 2" />
<path d="M7.2 2.2 8 5.1" />
</g>
{/* 点击波纹线条 - 动画版本 */}
<g className="opacity-0 group-hover:opacity-100">
<path
d="M14 4.1 12 6"
className="transition-all duration-500 group-hover:animate-ping"
style={{
animationDelay: "0.1s",
transformOrigin: "13px 5px",
}}
/>
<path
d="m5.1 8-2.9-.8"
className="transition-all duration-500 group-hover:animate-ping"
style={{
animationDelay: "0.2s",
transformOrigin: "4px 7.6px",
}}
/>
<path
d="m6 12-1.9 2"
className="transition-all duration-500 group-hover:animate-ping"
style={{
animationDelay: "0.3s",
transformOrigin: "5px 13px",
}}
/>
<path
d="M7.2 2.2 8 5.1"
className="transition-all duration-500 group-hover:animate-ping"
style={{
animationDelay: "0.4s",
transformOrigin: "7.6px 3.6px",
}}
/>
</g>
{/* 鼠标指针 - 带点击缩放效果 */}
<path
d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z"
className="transition-transform duration-200 group-hover:scale-90 group-hover:animate-pulse"
style={{
transformOrigin: "15px 15px",
}}
/>
</svg>
),
listChecks: ListChecks,
github: ({ ...props }: LucideProps) => (
<svg
@@ -182,7 +256,6 @@ export const Icons = {
heading1: Heading1,
qrcode: QrCode,
laptop: Laptop,
// lineChart: LineChart,
logo: LogoIcon,
media: Image,
messages: MessagesSquare,
@@ -219,16 +292,7 @@ export const Icons = {
users: Users,
warning: AlertTriangle,
globeLock: GlobeLock,
globe: Globe,
link: Link,
mail: Mail,
mailPlus: MailPlus,
mailOpen: MailOpen,
bug: Bug,
CirclePlay: CirclePlay,
unplug: Unplug,
send: Send,
lineChart: ({ ...props }: LucideProps) => (
globe: ({ className, ...props }: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
@@ -239,10 +303,66 @@ export const Icons = {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
"group inline-block cursor-pointer transition-transform duration-200 group-hover:scale-110",
className,
)}
style={{ animationDuration: "2s" }}
{...props}
>
<circle cx="12" cy="12" r="10" />
<path
className="group-hover:hidden"
d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"
/>
<path
d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"
className="transition-all duration-300 ease-in-out [stroke-dasharray:60] [stroke-dashoffset:-60] group-hover:[stroke-dashoffset:0]"
/>
<path className="group-hover:hidden" d="M2 12h20" />
<path
d="M2 12h20"
className="duration-800 transition-all ease-in-out [stroke-dasharray:20] [stroke-dashoffset:-20] group-hover:[stroke-dashoffset:0]"
style={{ transitionDelay: "0.2s" }}
/>
</svg>
),
link: Link,
mail: Mail,
mailPlus: MailPlus,
mailOpen: MailOpen,
bug: Bug,
CirclePlay: CirclePlay,
unplug: Unplug,
send: Send,
lineChart: ({ className, ...props }: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
"group inline-block cursor-pointer transition-transform duration-75 group-hover:scale-110",
className,
)}
{...props}
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="m19 9-5 5-4-4-3 3" stroke="#0065ea" />
<path
className="group-hover:hidden"
d="m19 9-5 5-4-4-3 3"
stroke="#0065ea"
/>
<path
d="m19 9-5 5-4-4-3 3"
stroke="#0065ea"
className="transition-all duration-1000 ease-in-out [stroke-dasharray:20] [stroke-dashoffset:-20] group-hover:[stroke-dashoffset:0]"
/>
</svg>
),
outLink: ({ ...props }: LucideProps) => (
@@ -330,4 +450,31 @@ export const Icons = {
</g>
</svg>
),
forwardArrow: ({ ...props }: LucideProps) => (
<svg
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
// className="h-4 w-4 shrink-0 text-gray-400"
{...props}
>
<g fill="currentColor">
<path
d="M15.25,9.75H4.75c-1.105,0-2-.895-2-2V3.75"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
></path>
<polyline
fill="none"
points="11 5.5 15.25 9.75 11 14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
></polyline>
</g>
</svg>
),
};

View File

@@ -237,7 +237,7 @@ export default function QRCodeEditor({
Url
</h3>
<Input
className="ml-auto w-[60%]"
className="ml-auto w-3/5"
type="text"
placeholder="https://example.com"
defaultValue={params.url}
@@ -338,7 +338,7 @@ export default function QRCodeEditor({
></div>
<input
id="color"
className="block w-full rounded-r-md border-2 border-l-0 pl-3 text-neutral-900 placeholder-gray-400 focus:outline-none focus:ring-black dark:text-neutral-300 sm:text-sm"
className="block w-full rounded-r-md border-2 border-l-0 pl-3 text-neutral-900 placeholder:text-gray-400 focus:outline-none focus:ring-black dark:text-neutral-300 sm:text-sm"
spellCheck="false"
defaultValue={params.fgColor}
name="color"
@@ -392,7 +392,7 @@ export default function QRCodeEditor({
></div>
<input
id="color"
className="block w-full rounded-r-md border-2 border-l-0 pl-3 text-neutral-900 placeholder-gray-400 focus:outline-none focus:ring-black dark:text-neutral-300 sm:text-sm"
className="block w-full rounded-r-md border-2 border-l-0 pl-3 text-neutral-900 placeholder:text-gray-400 focus:outline-none focus:ring-black dark:text-neutral-300 sm:text-sm"
spellCheck="false"
defaultValue={params.bgColor}
name="color"
@@ -426,7 +426,7 @@ export default function QRCodeEditor({
{/* Api Key Mask */}
{!user.apiKey && (
<div className="absolute left-0 top-0 flex size-full flex-col items-center justify-center gap-2 bg-neutral-100/20 px-4 backdrop-blur">
<div className="absolute left-0 top-0 z-20 flex size-full flex-col items-center justify-center gap-2 bg-neutral-100/20 px-4 backdrop-blur">
<p className="text-center text-sm">
Please create a <strong>api key</strong> before use this feature.{" "}
<br /> Learn more about{" "}

View File

@@ -1,24 +1,25 @@
import { User } from "@prisma/client"
import { AvatarProps } from "@radix-ui/react-avatar"
import { User } from "@prisma/client";
import { AvatarProps } from "@radix-ui/react-avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Icons } from "@/components/shared/icons"
import { generateGradientClasses } from "@/lib/enums";
import { cn } from "@/lib/utils";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
interface UserAvatarProps extends AvatarProps {
user: Pick<User, "image" | "name">
user: Pick<User, "image" | "name" | "email">;
}
export function UserAvatar({ user, ...props }: UserAvatarProps) {
return (
<Avatar {...props}>
{user.image ? (
<AvatarImage alt="Picture" src={user.image} referrerPolicy="no-referrer" />
) : (
<AvatarFallback>
<span className="sr-only">{user.name}</span>
<Icons.user className="size-4" />
</AvatarFallback>
)}
<AvatarImage
alt="Picture"
src={
user.image ??
`https://unavatar.io/${user.email}?ttl=1h&fallback=https://wr.do/_static/avatar.png`
}
referrerPolicy="no-referrer"
/>
</Avatar>
)
);
}

View File

@@ -16,7 +16,7 @@ export const sidebarLinks: SidebarNavItem[] = [
],
},
{
title: "SCRAPE",
title: "OPEN API",
items: [
{
href: "/dashboard/scrape",
@@ -85,11 +85,6 @@ export const sidebarLinks: SidebarNavItem[] = [
items: [
{ href: "/dashboard/settings", icon: "settings", title: "Settings" },
{ href: "/docs", icon: "bookOpen", title: "Documentation" },
{
href: siteConfig.links.oichat,
icon: "botMessageSquare",
title: "OiChat",
},
{
href: siteConfig.links.feedback,
icon: "messageQuoted",

View File

@@ -116,6 +116,11 @@ export const docsConfig: DocsConfig = {
href: "/docs/developer/quick-start",
icon: "page",
},
{
title: "Deploy Guide",
href: "/docs/developer/deploy",
icon: "page",
},
{
title: "Cloudflare",
href: "/docs/developer/cloudflare",

View File

@@ -1,9 +1,10 @@
import { SidebarNavItem, SiteConfig } from "types";
import { env } from "@/env.mjs";
const site_url = env.NEXT_PUBLIC_APP_URL;
const site_url = env.NEXT_PUBLIC_APP_URL || "http://localhost:3030";
const open_signup = env.NEXT_PUBLIC_OPEN_SIGNUP;
const email_r2_domain = env.NEXT_PUBLIC_EMAIL_R2_DOMAIN || "";
const enable_subdomain_apply = env.NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY || "0";
export const siteConfig: SiteConfig = {
name: "WR.DO",
@@ -21,6 +22,7 @@ export const siteConfig: SiteConfig = {
mailSupport: "support@wr.do",
openSignup: open_signup === "1" ? true : false,
emailR2Domain: email_r2_domain,
enableSubdomainApply: enable_subdomain_apply === "1" ? true : false,
};
export const footerLinks: SidebarNavItem[] = [

View File

@@ -0,0 +1,107 @@
---
title: Cloudflare Email Worker 配置
description: 配置 Cloudflare Email Worker 来激活接收邮件功能
---
<DocsLang en="/docs/developer/cloudflare-email-worker" zh="/docs/developer/cloudflare-email-worker-zh" />
在开始之前,你需要拥有一个 Cloudflare 账户,并且你的域名已经托管在 Cloudflare 上。
### Email Worker 与 R2 简介
#### Email Worker
Cloudflare Email Worker 是 Cloudflare 的电子邮件路由服务Email Routing与 Workers 平台结合提供的一项功能。它允许用户在 Cloudflare 的边缘网络中以编程方式处理接收到的电子邮件。
当邮件发送到在 Email Routing 中配置的自定义地址时,关联的 Worker 会被触发,接收邮件数据(例如发件人、收件人、头部、正文等)。
开发者可以编写 JavaScript 代码来定义自定义逻辑,比如将邮件转发到指定地址、过滤垃圾邮件,或与外部 API 集成。
#### Cloudflare R2
Cloudflare R2 是一款可扩展的、兼容 S3 的对象存储解决方案。它允许用户在边缘存储和读取文件(如电子邮件附件),并且没有出口流量费用。
在邮件 Worker 的上下文中R2 可用于存储邮件附件或其他数据,并可通过环境绑定在 Worker 脚本中访问。
### cf-email-forwarding-worker 概述
仓库:[oiov/cf-email-forwarding-worker](https://github.com/oiov/cf-email-forwarding-worker)
使用 Cloudflare Email Worker 和 R2 实现了一个高级的邮件转发解决方案。核心功能是将邮件数据通过 HTTP POST 请求发送到第三方 API 接口进行自定义处理。
此外,还利用 Cloudflare R2 来存储邮件附件,并使第三方应用可访问。
#### 主要特性
- **基于 API 的转发**:邮件以结构化数据的形式通过 POST 请求发送到可配置的第三方 API`APP_API_URL` 环境变量)。
- **附件存储**:邮件附件上传到 R2 存储桶,并将其 URL 包含在 API 请求中。
- **高度可定制**:第三方应用可根据需要处理邮件数据(如发件人、主题、正文、附件等)。
#### 配置说明
Worker 依赖 `wrangler.jsonc` 文件中定义的两个环境变量:
```json
"vars": {
"APP_API_URL": "https://wr.do/api/v1/email-catcher"
},
"r2_buckets": [
{
"binding": "R2_BUCKET",
"bucket_name": "wremail"
}
]
````
* `APP_API_URL`:接收邮件数据的第三方 API 地址。可以让第三方应用自定义处理邮件内容(如记录日志、进一步处理或转发)。
* `R2_BUCKET`R2 存储桶的绑定名,在 Worker 代码中可通过 `env.R2_BUCKET` 访问。
其中 `bucket_name`(如 `wremail`)指的是你在 Cloudflare 中预先创建的 R2 存储桶名称。
#### 工作原理
1. **接收邮件**当邮件发送到配置的地址时Worker 被触发。
2. **处理附件**:若邮件包含附件,则提取并上传到 R2 存储桶(如 wremail并生成可访问的 URL。
3. **转发邮件数据**:将邮件数据(发件人、收件人、主题、正文、附件链接等)封装为 JSON发送到 `APP_API_URL`。
4. **第三方处理**:第三方应用接收并根据自身逻辑处理这些数据。
#### 使用示例
* 用户向 [example@yourdomain.com](mailto:example@yourdomain.com) 发送邮件;
* Worker 将附件上传至刚才创建的存储桶;
* Worker 向 [https://wr.do/api/v1/email-catcher](https://wr.do/api/v1/email-catcher) 发送包含邮件信息的 POST 请求;
* 第三方应用接收数据,并进行记录、存入数据库或转发。
#### 前置条件
* 一个启用了 Email Routing 的 Cloudflare 账户;
* 已创建并绑定到 Worker 的 R2 存储桶(如 wremail
* 一个已准备好接收 POST 请求的第三方 API 接口。
### 部署 Email Worker 到 Cloudflare
```bash
git clone https://github.com/oiov/cf-email-forwarding-worker.git
cd cf-email-forwarding-worker
pnpm install
wranler login
wranler deploy
```
在部署前,记得在 `wrangler.jsonc` 中添加你的环境变量。
### 配置你的域名邮箱规则
访问:
```bash
https://dash.cloudflare.com/[account_id]/[zone_name]/email/routing/routes
```
编辑 `Catch-all address`,选择:
* `Action` -> `Send to a worker`
* `Destination` -> `wrdo-email-worker`(你部署的 Worker 名称)
然后保存并启用。
<Callout type="warning" twClass="mb-3">
每当你添加一个新域名时,都需要执行相同操作。
</Callout>

View File

@@ -3,6 +3,8 @@ title: Cloudflare Email Worker Configs
description: How to config the cloudflare api.
---
<DocsLang en="/docs/developer/cloudflare-email-worker" zh="/docs/developer/cloudflare-email-worker-zh" />
Before you start, you must have a Cloudflare account and a domain be hosted on Cloudflare.
### Introduction to Cloudflare Email Worker and R2

View File

@@ -0,0 +1,69 @@
---
title: 部署指南
description: 选择你的部署方式
---
<DocsLang en="/docs/developer/deploy" zh="/docs/developer/deploy-zh" />
## 使用 Vercel 部署(推荐)
<Callout type="warning" twClass="mt-4">
请在部署前先创建你的数据库实例。
</Callout>
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
## 使用 Docker Compose 部署
<Callout type="warning" twClass="mt-4">
请在部署前先创建你的数据库实例。
将 `.env` 文件中的 `SKIP_DB_CHECK` 和 `SKIP_DB_MIGRATION` 设置为 `false`
这样会在启动时进行数据库检查、初始化和迁移。
</Callout>
创建一个新文件夹,并将 [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) 文件复制到该文件夹中。
> 或者只创建一个 [docker-compose.yml](https://github.com/oiov/wr.do/blob/main/docker-compose.yml) 文件,将其中的 `${DATABASE_URL}` 等变量替换为你的数据库连接地址等信息。
```bash
- wrdo
| - docker-compose.yml
| - .env
````
在 `.env` 文件中填写环境变量,然后执行:
```bash
docker compose up -d
```
此命令会自动拉取最新的镜像并启动服务。(自动初始化数据库表,可以在容器日志中查看启动日志)
## 使用 Docker Compose本地数据库部署
创建一个新文件夹,并将 `docker-compose-localdb.yml` 和 `.env` 文件复制到该文件夹中。
```bash
- wrdo
| - docker-compose.yml
| - .env
```
在 `.env` 文件中填写环境变量,然后执行:
```bash
docker compose up -d
```
## 官方镜像
```bash
docker pull ghcr.io/oiov/wr.do/wrdo:main
```
在 [container/wr.do](https://github.com/oiov/wr.do/pkgs/container/wr.do%2Fwrdo) 可以找到官方镜像。
## 打包镜像
Fork 此仓库后,在 Actions 中触发打包镜像。

View File

@@ -0,0 +1,64 @@
---
title: Deploy Guide
description: Choose your deployment method
---
<DocsLang en="/docs/developer/deploy" zh="/docs/developer/deploy-zh" />
## Deploy with Vercel (Recommended)
[![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)
Remember to fill in the necessary environment variables.
## Deploy with Docker Compose
<Callout type="warning" twClass="mt-4">
Please create your database instance before deployment.
Set `SKIP_DB_CHECK` and `SKIP_DB_MIGRATION` to `false` in the `.env` file, this will start the database check and migration.
</Callout>
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.
> Or only create a [docker-compose.yml](https://github.com/oiov/wr.do/blob/main/docker-compose.yml) file, just replace `${DATABASE_URL}` with your database connection url and so on.
```bash
- wrdo
| - docker-compose.yml
| - .env
```
Fill in the environment variables in the `.env` file, then:
```bash
docker compose up -d
```
## Deploy with Docker Compose (Local DB)
Create a new folder and copy the `docker-compose-localdb.yml`、`.env` file to the folder.
```bash
- wrdo
| - docker-compose.yml
| - .env
```
Fill in the environment variables in the `.env` file, then:
```bash
docker compose up -d
```
## Official Image
```bash
docker pull ghcr.io/oiov/wr.do/wrdo:main
```
Find the official image here: [container/wr.do](https://github.com/oiov/wr.do/pkgs/container/wr.do%2Fwrdo)
## Build Image
Fork this repository and trigger the build image action in Actions.

View File

@@ -0,0 +1,47 @@
---
title: 邮件配置
description: 如何配置项目中的邮件服务
---
<DocsLang en="/docs/developer/email" zh="/docs/developer/email-zh" />
在 WR.DO 项目中,有两个功能依赖于 Resend
- 邮箱验证登录(魔法链接)
- 邮件发送功能(如果你需要接收邮件功能,请参考 [cloudflare-email-worker](/docs/developer/cloudflare-email-worker))。
`.env` 文件中配置的 `RESEND_API_KEY` 和 `RESEND_FROM_EMAIL` 用于登录功能,
而邮件发送功能所需的 Resend 密钥需要你在登录后台管理面板(`/admin/domains`)后,在域名配置中自行添加。
<Callout type="note">
这两个功能可以使用同一个密钥,因为它们本质上都是通过 Resend 发送邮件。
</Callout>
以下将演示如何配置登录所需的 Resend 密钥。
## 步骤
<Callout type="note">
邮件部分配置类似于 [resend](https://resend.com/) 的文档。
如果你想查阅官方文档,可以参考
[这里](https://authjs.dev/getting-started/installation#setup-environment)。
</Callout>
<Steps>
### 创建账号
如果你还没有 Resend 账号,请按照 [这里](https://resend.com/signup) 的注册流程操作。
### 创建 API 密钥
登录 Resend 后,它会提示你创建第一个 API 密钥。
将其复制并粘贴到你的 `.env` 文件中:
```js
RESEND_API_KEY = re_your_resend_api_key;
RESEND_FROM_EMAIL="you <support@your-domain.com>"
````
</Steps>

View File

@@ -3,11 +3,22 @@ title: Email
description: How to manage emails in this project.
---
<Callout type="success" twClass="mt-0">
The magic-link feature with Resend works with Auth.js v5! <br />
You can use it in your local environment and in your own production setup.
<DocsLang en="/docs/developer/email" zh="/docs/developer/email-zh" />
In the WR.DO project, there are two features that rely on Resend,
one is email login (magic link), and the other is email sending feature (if you need to receive email feature,
please refer to /docs/developer/cloudflare-email-worker).
The `RESEND_API_KEY` and `RESEND_SROM_SMAIL` configured in the `.env` file are used for login feature,
while the Resend key required for email sending feature needs to be added by
yourself in the domain configuration after logging into the admin panel (`/admin/domains`).
<Callout type="note">
Two features can use the same key, as both essentially use Resend to send emails.
</Callout>
The following will demonstrate how to configure the Resend key required for login.
## Steps
<Callout type="note">
@@ -31,13 +42,7 @@ Copy/paste in your `.env` file.
```js
RESEND_API_KEY = re_your_resend_api_key;
RESEND_FROM_EMAIL="you <support@your-domain.com>"
```
</Steps>
{/*
react-email
> [!WARNING]
> You need update `.react-email` folder before use `pnpm run email`. Check the link [here](https://github.com/resend/react-email/issues/868#issuecomment-1828411325) if you have the error : `renderToReadableStream not found`
*/}

View File

@@ -0,0 +1,65 @@
---
title: 开发手册
description: 如何快速开始 WR.DO
---
<DocsLang en="/docs/developer/installation" zh="/docs/developer/installation-zh" />
<Steps>
### 创建项目
首先使用 `create-next-app` 创建一个新的 Next.js 项目:
```bash
npx create-next-app wrdo --example "https://github.com/oiov/wr.do"
```
或者使用 Vercel 部署:
[![使用 Vercel 部署](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Foiov%2Fwr.do)
<Callout type="warning" twClass="mt-4">
这是一种创建代码仓库的好方法,但是部署可能会失败,
因为你需要在本地项目中添加环境变量。请按照文档进行设置。
</Callout>
### 安装依赖
进入文件夹并为项目安装依赖项:
```bash
cd wrdo
pnpm install
```
### 创建 `.env` 文件
将 `.env.example` 内容复制粘贴到 `.env` 文件中:
| 环境变量 | 值 | 描述 |
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------ |
| NEXTAUTH\_URL | `http://localhost:3000` | Next.js 应用的 URL。 |
| AUTH\_SECRET | `123465` | 用于加密令牌和邮件验证哈希的密钥。 |
| DATABASE\_URL | `postgres://username:password@host:port/database` | Postgres 数据库的路径。 |
| GOOGLE\_CLIENT\_ID | `123465` | Google OAuth 客户端的 ID。 |
| GOOGLE\_CLIENT\_SECRET | `123465` | Google OAuth 客户端的密钥。 |
| GITHUB\_ID | `123465` | GitHub OAuth 客户端的 ID。 |
| GITHUB\_SECRET | `123465` | GitHub OAuth 客户端的密钥。 |
| RESEND\_API\_KEY | `123465` | Resend 的 API 密钥。 |
| RESEND\_FROM\_EMAIL | `"you <support@your-domain.com>"` | 用于发送邮件的邮箱地址。 |
| NEXT\_PUBLIC\_OPEN\_SIGNUP | `1` | 开放注册。 |
| SCREENSHOTONE\_BASE\_URL | `https://api.example.com` | 待补充 |
| GITHUB\_TOKEN | `ghp_sscsfarwetqet` | [https://github.com/settings/tokens](https://github.com/settings/tokens) |
* 如何获取 `GOOGLE_CLIENT_ID`、`GITHUB_ID`,请参见 [认证](/docs/developer/authentification)。
* 如何获取 `RESEND_API_KEY`,请参见 [邮件](/docs/developer/email)。
* 如何启用邮件 worker请参见 [邮件 Worker](/docs/developer/cloudflare-email-worker)。
如需逐步安装说明,请参见 [快速开始](/docs/developer/quick-start)。
### 配置部分
在使用 `pnpm run dev` 之前,请确保检查配置部分并更新所有环境变量。
</Steps>

View File

@@ -3,6 +3,8 @@ title: Installation
description: How to install the project.
---
<DocsLang en="/docs/developer/installation" zh="/docs/developer/installation-zh" />
<Steps>
### Create project
@@ -36,8 +38,6 @@ pnpm install
Copy/paste the `.env.example` in the `.env` file:
{/* env表格 */}
| Environment Variable | Value | Description |
|----------------------|-------|-------------|
| NEXTAUTH_URL | `http://localhost:3000` | The URL of the Next.js application. |
@@ -48,6 +48,7 @@ Copy/paste the `.env.example` in the `.env` file:
| GITHUB_ID | `123465` | The ID of the GitHub OAuth client. |
| GITHUB_SECRET | `123465` | The secret of the GitHub OAuth client. |
| RESEND_API_KEY | `123465` | The API key for Resend. |
| RESEND_FROM_EMAIL | `"you <support@your-domain.com>"` | The email address to send emails from. |
| NEXT_PUBLIC_OPEN_SIGNUP | `1` | Open signup. |
| SCREENSHOTONE_BASE_URL | `https://api.example.com` | pending |
| GITHUB_TOKEN | `ghp_sscsfarwetqet` | https://github.com/settings/tokens |

View File

@@ -0,0 +1,245 @@
---
title: 快速开始
description: 手把手安装步骤详解
---
<DocsLang en="/docs/developer/quick-start" zh="/docs/developer/quick-start-zh" />
## 0. 安装
```bash
git clone https://github.com/oiov/wr.do
````
进入项目文件夹并安装依赖项:
```bash
cd wrdo
pnpm install
```
### 创建 `.env` 文件
将 `.env.example` 的内容复制粘贴到 `.env` 文件中。
## 1. 配置数据库
### 创建服务器数据库实例并获取连接 URL
在部署前,请确保你已准备好一个 Postgres 数据库实例。你可以选择以下方式之一:
* A. 使用 Vercel / Neon 等 Serverless Postgres 实例;
* B. 使用 Docker 等方式自建 Postgres 实例。
这两种方式的配置稍有不同,我们将在下一步中进行区分。
### 在 Vercel 中添加环境变量
在 Vercel 的部署环境变量中,添加 `DATABASE_URL` 以及其他环境变量,
并填写上一步中准备好的 Postgres 数据库连接 URL。
数据库连接 URL 的典型格式如下:
`postgres://username:password@host:port/database`
```js title=".env"
DATABASE_URL=
```
### 部署 Postgres
```bash
pnpm postinstall
pnpm db:push
```
#### 或者手动初始化
通过 [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations)
将 SQL 代码复制到数据库中以初始化数据库结构。
### 添加 AUTH_SECRET 环境变量
`AUTH_SECRET` 环境变量用于加密 token 和邮件验证哈希NextAuth.js
你可以通过 [https://generate-secret.vercel.app/32](https://generate-secret.vercel.app/32) 生成
`AUTH_URL` 是用于 NextAuth.js 的回调 URL如果使用docker部署需要设置为你的**站点域名**如果使用vercel部署可不填。
注意,`AUTH_URL` 与 `NEXT_PUBLIC_APP_URL` 需要保持一致
```js title=".env"
AUTH_SECRET=10000032bsfasfafk4lkkfsa
AUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000
```
## 2. 配置认证服务
服务器端数据库需要配合用户认证服务才能正常运行,
因此需要配置相应的认证服务。
提供以下认证方式:
* Google
* Github
* LinuxDo
* Resend 邮件验证
### Google 配置
在本部分中,你需要更新以下变量:
```js title=".env"
GOOGLE_CLIENT_ID = your_secret_client_id.apps.googleusercontent.com;
GOOGLE_CLIENT_SECRET = your_secret_client;
```
参见配置教程:[Authjs - Google OAuth](https://authjs.dev/getting-started/providers/google)
### Github 配置
在本部分中,你需要更新以下变量:
```js title=".env"
GITHUB_ID = your_secret_client_id;
GITHUB_SECRET = your_secret_client;
```
参见配置教程:[Authjs - Github OAuth](https://authjs.dev/getting-started/providers/github)
### LinuxDo 配置
```js title=".env"
LinuxDo_CLIENT_ID=
LinuxDo_CLIENT_SECRET=
```
参见配置教程:[Connect LinuxDo](https://connect.linux.do)
注意在配置Connect LinuxDo时参考以下配置:
![](/_static/docs/linuxdo-connect.png)
### Resend 邮件验证配置
<Callout type="note">
邮件部分与 [resend](https://resend.com/) 的文档类似。
如果你想了解详细配置,可以查阅官方文档:
[这里](https://authjs.dev/getting-started/installation#setup-environment)
</Callout>
<Steps>
#### 创建账号
如果你还没有 Resend 账号,请按照 [这里](https://resend.com/signup) 的注册流程操作。
#### 创建 API 密钥
登录 Resend 后,它会提示你创建第一个 API 密钥。
将其复制并粘贴到你的 `.env` 文件中:
```js
RESEND_API_KEY=re_your_resend_api_key;
RESEND_FROM_EMAIL="you <support@your-domain.com>"
```
其中 your-domain 与Resend绑定的域名一致与 `NEXT_PUBLIC_APP_URL` 一致)。
</Steps>
## 3. 邮件 Worker 配置(接收邮件)
详见:[Cloudflare Email Worker 配置教程](/docs/developer/cloudflare-email-worker)
完成上述步骤后,你需要为 r2 存储添加一个公共域名。
通过以下地址:
```bash
https://dash.cloudflare.com/[account_id]/r2/default/buckets/[bucket]/settings
```
![](/_static/docs/r2-domain.png)
```js title=".env"
NEXT_PUBLIC_EMAIL_R2_DOMAIN=https://email-attachment.wr.do
```
## 4. 添加业务配置
```js title=".env"
# 允许任何人注册
NEXT_PUBLIC_OPEN_SIGNUP=1
```
## 5. 添加 SCREENSHOTONE\_BASE\_URL 环境变量
这是 screenshotone API 的基础地址。
你可以通过部署 [jasonraimondi/url-to-png](https://github.com/jasonraimondi/url-to-png) 自建服务。
部署说明见:[这里](https://jasonraimondi.github.io/url-to-png/)
```js title=".env"
SCREENSHOTONE_BASE_URL=https://api.screenshotone.com
```
## 6. 添加 GITHUB\_TOKEN 环境变量
通过 [https://github.com/settings/tokens](https://github.com/settings/tokens) 获取你的 token
```js title=".env"
GITHUB_TOKEN=
```
## 7. 启动开发服务器
```bash
pnpm dev
```
通过浏览器访问:[http://localhost:3000](http://localhost:3000)
## 8. 设置系统
#### 创建第一个账号并将账号权限更改为 ADMIN
请按以下步骤操作:
* 1. 通过 [http://localhost:3000/login](http://localhost:3000/login) 注册登录你的第一个账号;
* 2. 通过 [http://localhost:3000/setup](http://localhost:3000/setup) 将账号权限更改为 ADMIN
* 3. 然后根据 **面板引导** 配置系统并添加第一个域名。
![](/_static/docs/setup-1.png)
![](/_static/docs/setup-2.png)
<Callout type="info">
将账号权限更改为 ADMIN 后,你可以刷新页面并访问 http://localhost:3000/admin。
<strong>你必须至少添加一个域名,才能使用短链接、邮件或子域名管理等功能。</strong>
</Callout>
## 9. 部署
详见:[部署指南](/docs/developer/deploy)
## Q & A
### Worker 错误 - 重定向过多
请访问:
```bash
https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
```
将 `SSL/TLS 加密模式` 更改为 `Full` 模式。
### 如何修改团队计划配额?
通过 team.ts 文件修改:
```bash
https://github.com/oiov/wr.do/tree/main/config/team.ts
```

View File

@@ -3,6 +3,8 @@ title: Quick Start for Developer
description: Step by step installation
---
<DocsLang en="/docs/developer/quick-start" zh="/docs/developer/quick-start-zh" />
## 0. Installation
```bash
@@ -62,6 +64,7 @@ You can generate one from https://generate-secret.vercel.app/32:
```js title=".env"
AUTH_SECRET=10000032bsfasfafk4lkkfsa
AUTH_URL=http://localhost:3000
```
## 2. Configure Authentication Service
@@ -129,12 +132,13 @@ After signin on Resend, he propurse you to create your first API key.
Copy/paste in your `.env` file.
```js
RESEND_API_KEY = re_your_resend_api_key;
RESEND_API_KEY=re_your_resend_api_key;
RESEND_FROM_EMAIL="you <support@your-domain.com>"
```
</Steps>
## 3. Email Worker Configs
## 3. Email Worker Configs (Recive email)
See detail in [Email Worker](/docs/developer/cloudflare-email-worker).
@@ -205,6 +209,10 @@ Follow the steps below:
<strong>You must add at least one domain to start using short links, email or subdomain management features.</strong>
</Callout>
## 9. Deploy
See [Deploy Guide](/docs/developer/deploy).
## Q & A
### Worker Error - Too many redirects

View File

@@ -3,25 +3,65 @@ title: Introduction
description: Welcome to the WR.DO documentation.
---
## 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
<img className="rounded-xl border mt-4 md:rounded-lg shadow-md" src="/_static/images/light-preview.png"/>
<img className="rounded-xl border mt-4 md:rounded-lg shadow-md" src="/_static/images/example_02.png"/>
<img className="rounded-xl border mt-4 md:rounded-lg shadow-md" src="/_static/images/example_01.png"/>
<img className="rounded-xl border mt-4 md:rounded-lg shadow-md" src="/_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>
## Quick Start

View File

@@ -0,0 +1,54 @@
services:
app:
image: ghcr.io/oiov/wr.do/wrdo:${TAG:-latest}
container_name: wrdo
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://postgres:postgres@postgres:5432/wrdo
AUTH_SECRET: ${AUTH_SECRET:-your-auth-secret}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
NEXT_PUBLIC_OPEN_SIGNUP: ${NEXT_PUBLIC_OPEN_SIGNUP}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GITHUB_ID: ${GITHUB_ID}
GITHUB_SECRET: ${GITHUB_SECRET}
LinuxDo_CLIENT_ID: ${LinuxDo_CLIENT_ID}
LinuxDo_CLIENT_SECRET: ${LinuxDo_CLIENT_SECRET}
RESEND_API_KEY: ${RESEND_API_KEY}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL}
NEXT_PUBLIC_EMAIL_R2_DOMAIN: ${NEXT_PUBLIC_EMAIL_R2_DOMAIN}
NEXT_PUBLIC_GOOGLE_ID: ${NEXT_PUBLIC_GOOGLE_ID}
SCREENSHOTONE_BASE_URL: ${SCREENSHOTONE_BASE_URL}
GITHUB_TOKEN: ${GITHUB_TOKEN}
SKIP_DB_CHECK: ${SKIP_DB_CHECK}
SKIP_DB_MIGRATION: ${SKIP_DB_MIGRATION}
depends_on:
- postgres
networks:
- wrdo-network
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=wrdo
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- wrdo-network
restart: unless-stopped
volumes:
postgres-data:
name: wrdo-postgres-data
networks:
wrdo-network:
driver: bridge

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
services:
app:
image: ghcr.io/oiov/wr.do/wrdo:${TAG:-latest}
container_name: wrdo
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
AUTH_SECRET: ${AUTH_SECRET:-your-auth-secret}
AUTH_URL: ${AUTH_URL}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
NEXT_PUBLIC_OPEN_SIGNUP: ${NEXT_PUBLIC_OPEN_SIGNUP}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GITHUB_ID: ${GITHUB_ID}
GITHUB_SECRET: ${GITHUB_SECRET}
LinuxDo_CLIENT_ID: ${LinuxDo_CLIENT_ID}
LinuxDo_CLIENT_SECRET: ${LinuxDo_CLIENT_SECRET}
RESEND_API_KEY: ${RESEND_API_KEY}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL}
NEXT_PUBLIC_EMAIL_R2_DOMAIN: ${NEXT_PUBLIC_EMAIL_R2_DOMAIN}
NEXT_PUBLIC_GOOGLE_ID: ${NEXT_PUBLIC_GOOGLE_ID}
SCREENSHOTONE_BASE_URL: ${SCREENSHOTONE_BASE_URL}
GITHUB_TOKEN: ${GITHUB_TOKEN}
SKIP_DB_CHECK: ${SKIP_DB_CHECK}
SKIP_DB_MIGRATION: ${SKIP_DB_MIGRATION}
networks:
- wrdo-network
restart: unless-stopped
networks:
wrdo-network:
driver: bridge

15
env.mjs
View File

@@ -3,25 +3,25 @@ import { z } from "zod";
export const env = createEnv({
server: {
// This is optional because it's only used in development.
// See https://next-auth.js.org/deployment.
NEXTAUTH_URL: z.string().url().optional(),
AUTH_SECRET: z.string().min(1),
AUTH_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
LinuxDo_CLIENT_ID: z.string().optional(),
LinuxDo_CLIENT_SECRET: z.string().optional(),
DATABASE_URL: z.string().min(1),
DATABASE_URL: z.string().optional(),
RESEND_API_KEY: z.string().optional(),
RESEND_FROM_EMAIL: z.string().optional(),
SCREENSHOTONE_BASE_URL: z.string().optional(),
GITHUB_TOKEN: z.string().optional(),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().optional(),
NEXT_PUBLIC_OPEN_SIGNUP: z.string().min(1).default("1"),
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().min(1),
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().optional(),
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY: z.string().min(1).default("0"),
},
runtimeEnv: {
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
@@ -32,9 +32,12 @@ export const env = createEnv({
GITHUB_SECRET: process.env.GITHUB_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
RESEND_API_KEY: process.env.RESEND_API_KEY,
RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_OPEN_SIGNUP: process.env.NEXT_PUBLIC_OPEN_SIGNUP,
NEXT_PUBLIC_EMAIL_R2_DOMAIN: process.env.NEXT_PUBLIC_EMAIL_R2_DOMAIN,
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY:
process.env.NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY,
SCREENSHOTONE_BASE_URL: process.env.SCREENSHOTONE_BASE_URL,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
LinuxDo_CLIENT_ID: process.env.LinuxDo_CLIENT_ID,

102
hooks/use-element-size.ts Normal file
View File

@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "lodash-es";
interface ElementSize {
width: number;
height: number;
}
interface UseElementSizeOptions {
box?: "content-box" | "border-box" | "device-pixel-content-box";
}
export function useElementSize<T extends HTMLDivElement>(
initialSize: ElementSize = { width: 0, height: 0 },
options: UseElementSizeOptions = {},
): { ref: React.RefObject<T>; width: number; height: number } {
const { box = "content-box" } = options;
const [size, setSize] = useState<ElementSize>(initialSize);
const ref = useRef<T>(null);
// 检查是否为 SVG 元素
const isSVG = useCallback(
() => ref.current?.namespaceURI?.includes("svg"),
[],
);
// 更新尺寸的防抖函数
const updateSize = useCallback(
debounce((newSize: ElementSize) => {
setSize((prev) =>
prev.width === newSize.width && prev.height === newSize.height
? prev
: newSize,
);
}, 100),
[],
);
// 初始化尺寸
useEffect(() => {
if (typeof window === "undefined") return;
const element = ref.current;
if (element) {
updateSize({
width:
"offsetWidth" in element ? element.offsetWidth : initialSize.width,
height:
"offsetHeight" in element ? element.offsetHeight : initialSize.height,
});
}
}, [initialSize, updateSize]);
// 监听尺寸变化
useEffect(() => {
if (typeof window === "undefined" || !window.ResizeObserver) return;
const element = ref.current;
if (!element) {
updateSize({ width: initialSize.width, height: initialSize.height });
return;
}
const observer = new window.ResizeObserver(([entry]) => {
const boxSize =
box === "border-box"
? entry.borderBoxSize
: box === "content-box"
? entry.contentBoxSize
: entry.devicePixelContentBoxSize;
if (isSVG()) {
const rect = entry.target.getBoundingClientRect();
updateSize({ width: rect.width, height: rect.height });
} else if (boxSize) {
const formatBoxSize = Array.isArray(boxSize) ? boxSize : [boxSize];
const width = formatBoxSize.reduce(
(acc, { inlineSize }) => acc + inlineSize,
0,
);
const height = formatBoxSize.reduce(
(acc, { blockSize }) => acc + blockSize,
0,
);
updateSize({ width, height });
} else {
updateSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
observer.observe(element);
return () => {
observer.unobserve(element);
observer.disconnect();
updateSize.cancel();
};
}, [box, initialSize, isSVG, updateSize]);
return { ref, width: size.width, height: size.height };
}

View File

@@ -223,3 +223,29 @@ export const getDNSRecordDetail = async (
throw error;
}
};
export const getZoneDetail = async (
zoneId: string,
apiKey: string,
email: string,
) => {
try {
const url = `${CLOUDFLARE_API_URL}/zones/${zoneId}`;
const headers = {
"X-Auth-Email": email,
"X-Auth-Key": apiKey,
};
const response = await fetch(url, {
method: "GET",
headers,
});
console.log(response.status);
return response.status;
} catch (error) {
throw error;
}
};

View File

@@ -263,3 +263,330 @@ export const getDeviceVendor = (model: string) => {
}
return model;
};
export function getRegionName(regionCode: string) {
const regionMap = {
// Vercel/Cloudflare 地区代码
hkg1: "Hong Kong",
sin1: "Singapore",
iad1: "Washington D.C. (US East)",
gru1: "São Paulo (Brazil)",
sfo1: "San Francisco (US West)",
cdg1: "Paris (France)",
cle1: "Cleveland (US Central)",
cpt1: "Cape Town (South Africa)",
// AWS 地区代码
"us-east-1": "N. Virginia (US East)",
"us-west-1": "N. California (US West)",
"us-west-2": "Oregon (US West)",
"eu-west-1": "Ireland (Europe)",
"eu-central-1": "Frankfurt (Europe)",
"ap-southeast-1": "Singapore (Asia Pacific)",
"ap-northeast-1": "Tokyo (Asia Pacific)",
"ap-south-1": "Mumbai (Asia Pacific)",
// Cloudflare 地区代码
lhr: "London (UK)",
fra: "Frankfurt (Germany)",
ams: "Amsterdam (Netherlands)",
nrt: "Tokyo (Japan)",
icn: "Seoul (South Korea)",
syd: "Sydney (Australia)",
yyz: "Toronto (Canada)",
mia: "Miami (US Southeast)",
lax: "Los Angeles (US West)",
ord: "Chicago (US Central)",
atl: "Atlanta (US Southeast)",
dfw: "Dallas (US Central)",
sea: "Seattle (US West)",
bos: "Boston (US Northeast)",
ewr: "Newark (US Northeast)",
jfk: "New York (US Northeast)",
// 其他常见地区代码
pdx1: "Portland (US West)",
bom1: "Mumbai (India)",
syd1: "Sydney (Australia)",
nrt1: "Tokyo (Japan)",
fra1: "Frankfurt (Germany)",
lon1: "London (UK)",
ams1: "Amsterdam (Netherlands)",
tor1: "Toronto (Canada)",
nyc1: "New York (US East)",
dub1: "Dublin (Ireland)",
blr1: "Bangalore (India)",
sgp1: "Singapore",
hnd1: "Tokyo Haneda (Japan)",
kix1: "Osaka (Japan)",
icn1: "Seoul (South Korea)",
bkk1: "Bangkok (Thailand)",
mnl1: "Manila (Philippines)",
jkt1: "Jakarta (Indonesia)",
mel1: "Melbourne (Australia)",
per1: "Perth (Australia)",
akl1: "Auckland (New Zealand)",
mad1: "Madrid (Spain)",
bcn1: "Barcelona (Spain)",
mxp1: "Milan (Italy)",
vie1: "Vienna (Austria)",
zrh1: "Zurich (Switzerland)",
sto1: "Stockholm (Sweden)",
hel1: "Helsinki (Finland)",
cph1: "Copenhagen (Denmark)",
osl1: "Oslo (Norway)",
waw1: "Warsaw (Poland)",
prg1: "Prague (Czech Republic)",
bud1: "Budapest (Hungary)",
buh1: "Bucharest (Romania)",
sof1: "Sofia (Bulgaria)",
ath1: "Athens (Greece)",
ist1: "Istanbul (Turkey)",
tlv1: "Tel Aviv (Israel)",
cai1: "Cairo (Egypt)",
jnb1: "Johannesburg (South Africa)",
lag1: "Lagos (Nigeria)",
nbo1: "Nairobi (Kenya)",
dxb1: "Dubai (UAE)",
bah1: "Bahrain",
khi1: "Karachi (Pakistan)",
del1: "Delhi (India)",
ccj1: "Kolkata (India)",
maa1: "Chennai (India)",
hyd1: "Hyderabad (India)",
pnq1: "Pune (India)",
};
return regionMap[regionCode.toLowerCase()] || regionCode.toUpperCase();
}
export function getLanguageName(langCode: string) {
// 统一转换为小写处理大小写不一致
const normalizedCode = langCode.toLowerCase();
const languageMap = {
// 英语系列
en: "English",
"en-us": "English (United States)",
"en-gb": "English (United Kingdom)",
"en-ca": "English (Canada)",
"en-au": "English (Australia)",
"en-nz": "English (New Zealand)",
"en-ie": "English (Ireland)",
"en-za": "English (South Africa)",
"en-in": "English (India)",
// 中文系列
zh: "Chinese",
"zh-cn": "Chinese (Simplified)",
"zh-tw": "Chinese (Traditional)",
"zh-hk": "Chinese (Hong Kong)",
"zh-sg": "Chinese (Singapore)",
// 法语系列
fr: "French",
"fr-fr": "French (France)",
"fr-ca": "French (Canada)",
"fr-be": "French (Belgium)",
"fr-ch": "French (Switzerland)",
// 德语系列
de: "German",
"de-de": "German (Germany)",
"de-at": "German (Austria)",
"de-ch": "German (Switzerland)",
// 西班牙语系列
es: "Spanish",
"es-es": "Spanish (Spain)",
"es-mx": "Spanish (Mexico)",
"es-ar": "Spanish (Argentina)",
"es-co": "Spanish (Colombia)",
"es-cl": "Spanish (Chile)",
// 葡萄牙语系列
pt: "Portuguese",
"pt-pt": "Portuguese (Portugal)",
"pt-br": "Portuguese (Brazil)",
// 意大利语
it: "Italian",
"it-it": "Italian (Italy)",
// 俄语
ru: "Russian",
"ru-ru": "Russian (Russia)",
// 日语
ja: "Japanese",
"ja-jp": "Japanese (Japan)",
// 韩语
ko: "Korean",
"ko-kr": "Korean (South Korea)",
// 阿拉伯语
ar: "Arabic",
"ar-sa": "Arabic (Saudi Arabia)",
"ar-ae": "Arabic (UAE)",
"ar-eg": "Arabic (Egypt)",
// 荷兰语
nl: "Dutch",
"nl-nl": "Dutch (Netherlands)",
"nl-be": "Dutch (Belgium)",
// 北欧语言
sv: "Swedish",
"sv-se": "Swedish (Sweden)",
da: "Danish",
"da-dk": "Danish (Denmark)",
no: "Norwegian",
"no-no": "Norwegian (Norway)",
"nb-no": "Norwegian Bokmål",
"nn-no": "Norwegian Nynorsk",
fi: "Finnish",
"fi-fi": "Finnish (Finland)",
// 其他常见语言
hi: "Hindi",
"hi-in": "Hindi (India)",
th: "Thai",
"th-th": "Thai (Thailand)",
vi: "Vietnamese",
"vi-vn": "Vietnamese (Vietnam)",
tr: "Turkish",
"tr-tr": "Turkish (Turkey)",
pl: "Polish",
"pl-pl": "Polish (Poland)",
cs: "Czech",
"cs-cz": "Czech (Czech Republic)",
sk: "Slovak",
"sk-sk": "Slovak (Slovakia)",
hu: "Hungarian",
"hu-hu": "Hungarian (Hungary)",
ro: "Romanian",
"ro-ro": "Romanian (Romania)",
bg: "Bulgarian",
"bg-bg": "Bulgarian (Bulgaria)",
hr: "Croatian",
"hr-hr": "Croatian (Croatia)",
sr: "Serbian",
"sr-rs": "Serbian (Serbia)",
sl: "Slovenian",
"sl-si": "Slovenian (Slovenia)",
et: "Estonian",
"et-ee": "Estonian (Estonia)",
lv: "Latvian",
"lv-lv": "Latvian (Latvia)",
lt: "Lithuanian",
"lt-lt": "Lithuanian (Lithuania)",
el: "Greek",
"el-gr": "Greek (Greece)",
he: "Hebrew",
"he-il": "Hebrew (Israel)",
fa: "Persian",
"fa-ir": "Persian (Iran)",
ur: "Urdu",
"ur-pk": "Urdu (Pakistan)",
bn: "Bengali",
"bn-bd": "Bengali (Bangladesh)",
ta: "Tamil",
"ta-in": "Tamil (India)",
te: "Telugu",
"te-in": "Telugu (India)",
ml: "Malayalam",
"ml-in": "Malayalam (India)",
kn: "Kannada",
"kn-in": "Kannada (India)",
gu: "Gujarati",
"gu-in": "Gujarati (India)",
pa: "Punjabi",
"pa-in": "Punjabi (India)",
mr: "Marathi",
"mr-in": "Marathi (India)",
ne: "Nepali",
"ne-np": "Nepali (Nepal)",
si: "Sinhala",
"si-lk": "Sinhala (Sri Lanka)",
my: "Myanmar",
"my-mm": "Myanmar (Myanmar)",
km: "Khmer",
"km-kh": "Khmer (Cambodia)",
lo: "Lao",
"lo-la": "Lao (Laos)",
ka: "Georgian",
"ka-ge": "Georgian (Georgia)",
hy: "Armenian",
"hy-am": "Armenian (Armenia)",
az: "Azerbaijani",
"az-az": "Azerbaijani (Azerbaijan)",
kk: "Kazakh",
"kk-kz": "Kazakh (Kazakhstan)",
ky: "Kyrgyz",
"ky-kg": "Kyrgyz (Kyrgyzstan)",
uz: "Uzbek",
"uz-uz": "Uzbek (Uzbekistan)",
tg: "Tajik",
"tg-tj": "Tajik (Tajikistan)",
mn: "Mongolian",
"mn-mn": "Mongolian (Mongolia)",
bo: "Tibetan",
"bo-cn": "Tibetan (China)",
ug: "Uyghur",
"ug-cn": "Uyghur (China)",
id: "Indonesian",
"id-id": "Indonesian (Indonesia)",
ms: "Malay",
"ms-my": "Malay (Malaysia)",
tl: "Filipino",
"tl-ph": "Filipino (Philippines)",
sw: "Swahili",
"sw-ke": "Swahili (Kenya)",
am: "Amharic",
"am-et": "Amharic (Ethiopia)",
ha: "Hausa",
"ha-ng": "Hausa (Nigeria)",
yo: "Yoruba",
"yo-ng": "Yoruba (Nigeria)",
ig: "Igbo",
"ig-ng": "Igbo (Nigeria)",
zu: "Zulu",
"zu-za": "Zulu (South Africa)",
xh: "Xhosa",
"xh-za": "Xhosa (South Africa)",
af: "Afrikaans",
"af-za": "Afrikaans (South Africa)",
};
// 如果找到精确匹配,返回对应值
if (languageMap[normalizedCode]) {
return languageMap[normalizedCode];
}
// 如果没有精确匹配,尝试匹配语言部分(如 en-xx -> English
const langPart = normalizedCode.split("-")[0];
if (languageMap[langPart]) {
return languageMap[langPart];
}
// 如果都没有匹配,返回原始值(大写)
return langCode.toUpperCase();
}
export function getEngineName(engine: string) {
const engineMap = {
Blink: "Chrome Engine",
WebKit: "Safari Engine",
Gecko: "Firefox Engine",
Trident: "IE Engine",
EdgeHTML: "Edge Engine",
Presto: "Opera Engine",
};
return engineMap[engine] || `${engine} Engine`;
}
export function getBotName(bot: boolean) {
return bot === true ? "Bot" : "Human";
}

View File

@@ -1,4 +1,4 @@
import { getAllDomains, invalidateDomainConfigCache } from "@/lib/dto/domains";
import { getAllDomains } from "@/lib/dto/domains";
export async function getDomainConfig() {
return await getAllDomains();
@@ -7,7 +7,7 @@ export async function getDomainConfig() {
export async function getCloudflareCredentials(domain_name: string) {
try {
const domains = await getAllDomains();
const domain = domains.find((d) => d.domain_name === domain_name);
const domain = domains.list.find((d) => d.domain_name === domain_name);
if (!domain || !domain.cf_api_key || !domain.cf_email) {
throw new Error(
`No Cloudflare credentials found for domain: ${domain_name}`,
@@ -32,5 +32,3 @@ export async function getCloudflareCredentials(domain_name: string) {
function decrypt(encryptedKey: string) {
return encryptedKey; // Replace with actual decryption logic
}
export { invalidateDomainConfigCache };

View File

@@ -1,6 +1,5 @@
"use server";
import { auth } from "@/auth";
import { UserRole } from "@prisma/client";
import { prisma } from "@/lib/db";
@@ -25,7 +24,7 @@ export type UserRecordFormData = {
tags: string;
created_on?: string;
modified_on?: string;
active: number;
active: number; // 0: inactive, 1: active, 2: pending
};
export async function createUserRecord(
@@ -89,9 +88,15 @@ export async function getUserRecords(
role === "USER"
? {
userId,
// active,
active: {
not: 2,
},
}
: {};
: {
active: {
not: 2,
},
};
const [total, list] = await prisma.$transaction([
prisma.userRecord.count({
where: option,

View File

@@ -5,7 +5,7 @@ import { prisma } from "../db";
// In-memory cache
let domainConfigCache: Domain[] | null = null;
let lastCacheUpdate = 0;
const CACHE_DURATION = 60 * 1000; // Cache for 1 minute in memory
const CACHE_DURATION = 60 * 1000;
export const FeatureMap = {
short: "enable_short_link",
@@ -22,6 +22,7 @@ export interface DomainConfig {
cf_api_key: string | null;
cf_email: string | null;
cf_api_key_encrypted: boolean;
resend_api_key: string | null;
max_short_links: number | null;
max_email_forwards: number | null;
max_dns_records: number | null;
@@ -34,20 +35,33 @@ export interface DomainFormData extends DomainConfig {
updatedAt: Date;
}
export async function getAllDomains() {
export async function getAllDomains(page = 1, size = 10, target: string = "") {
try {
const now = Date.now();
if (domainConfigCache && now - lastCacheUpdate < CACHE_DURATION) {
return domainConfigCache;
let option: any;
if (target) {
option = {
domain_name: {
contains: target,
},
};
}
const domains = await prisma.domain.findMany({
// where: { active: true },
});
const [total, list] = await prisma.$transaction([
prisma.domain.count({
where: option,
}),
prisma.domain.findMany({
where: option,
skip: (page - 1) * size,
take: size,
orderBy: {
updatedAt: "desc",
},
}),
]);
domainConfigCache = domains;
lastCacheUpdate = now;
return domains;
return { list, total };
} catch (error) {
throw new Error(`Failed to fetch domain config: ${error.message}`);
}
@@ -58,11 +72,6 @@ export async function getDomainsByFeature(
admin: boolean = false,
) {
try {
const now = Date.now();
if (domainConfigCache && now - lastCacheUpdate < CACHE_DURATION) {
return domainConfigCache;
}
const domains = await prisma.domain.findMany({
where: { [feature]: true },
select: {
@@ -95,6 +104,20 @@ export async function getDomainsByFeatureClient(feature: string) {
}
}
export async function checkDomainIsConfiguratedResend(domain_name: string) {
try {
const domain = await prisma.domain.findUnique({
where: { domain_name },
select: {
resend_api_key: true,
},
});
return Boolean(domain?.resend_api_key);
} catch (error) {
throw new Error(`Failed to fetch domain config: ${error.message}`);
}
}
export async function createDomain(data: DomainConfig) {
try {
const createdDomain = await prisma.domain.create({ data });
@@ -129,8 +152,3 @@ export async function deleteDomain(domain_name: string) {
throw new Error(`Failed to delete domain`);
}
}
export function invalidateDomainConfigCache() {
domainConfigCache = null;
lastCacheUpdate = 0;
}

View File

@@ -124,6 +124,36 @@ export async function getUserShortUrlCount(
}
}
export async function getUrlClicksByIds(
ids: string[],
userId: string,
role: UserRole,
): Promise<Record<string, number>> {
if (ids.length === 0) return {};
try {
const clicksData = await prisma.urlMeta.groupBy({
by: ["urlId"],
where: {
urlId: { in: ids },
userUrl: role === "USER" ? { userId } : undefined,
},
_sum: { click: true },
});
const clicksMap: Record<string, number> = {};
ids.forEach((id) => (clicksMap[id] = 0)); // 初始化
clicksData.forEach((item) => {
clicksMap[item.urlId] = item._sum.click || 0;
});
return clicksMap;
} catch (error) {
console.error("Error fetching clicks:", error);
return Object.fromEntries(ids.map((id) => [id, 0]));
}
}
export async function createUserShortUrl(data: ShortUrlFormData) {
try {
const res = await prisma.userUrl.create({
@@ -306,6 +336,9 @@ export async function getUrlMetaLiveLog(userId?: string) {
createdAt: true,
city: true,
country: true,
os: true,
cpu: true,
engine: true,
userUrl: {
select: {
url: true,

View File

@@ -2,7 +2,7 @@ import { Resend } from "resend";
import { env } from "@/env.mjs";
export const resend = new Resend(env.RESEND_API_KEY);
export const resend = new Resend(env.RESEND_API_KEY || "re_key");
export function getVerificationEmailHtml({
url,

View File

@@ -312,3 +312,32 @@ export const DATE_DIMENSION_ENUMS = [
{ value: "365d", label: "Last 1 Year", key: 365 },
{ value: "All", label: "All the time", key: 1000 },
] as const;
export const DAILY_DIMENSION_ENUMS = [
{ value: "5min", label: "Last 5 Minutes", key: 5 },
{ value: "10min", label: "Last 10 Minutes", key: 10 },
{ value: "30min", label: "Last 30 Minutes", key: 30 },
{ value: "1h", label: "Last 1 Hour", key: 60 },
{ value: "6h", label: "Last 6 Hours", key: 360 },
{ value: "12h", label: "Last 12 Hours", key: 720 },
{ value: "24h", label: "Last 24 Hours", key: 1440 },
] as const;
export 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];
};

147
lib/geo.ts Normal file
View File

@@ -0,0 +1,147 @@
import { userAgent } from "next/server";
import { Geo, geolocation, ipAddress } from "@vercel/functions";
import { NextAuthRequest } from "next-auth/lib";
import UAParser from "ua-parser-js";
interface GeoLocation extends Geo {
ip?: string;
}
const isVercel = process.env.VERCEL;
export async function getGeolocation(
req: NextAuthRequest,
ip: string,
): Promise<GeoLocation | null> {
// console.log("[Runtime Env]", isVercel ? "Vercel" : "Other");
if (isVercel) {
return geolocation(req);
} else {
return await getClientGeolocationWithIpApi(ip);
}
}
export function getUserAgent(req: NextAuthRequest) {
if (isVercel) {
return userAgent(req);
} else {
const headers = req.headers;
const userAgent = headers.get("user-agent") || "";
const parser = new UAParser(userAgent);
return {
browser: parser.getBrowser(),
device: parser.getDevice(),
os: parser.getOS(),
engine: parser.getEngine(),
cpu: parser.getCPU(),
isBot: false,
};
}
}
export async function getClientGeolocation(ip): Promise<GeoLocation | null> {
// const new_headers = new Headers();
// new_headers.set("X-Forwarded-For", ip);
// new_headers.set("User-Agent", req.headers.get("user-agent") || "");
const response = await fetch(`https://ip.wr.do/api?ip=${ip}`);
if (!response.ok) return null;
return await response.json();
}
export async function getClientGeolocationWithIpApi(ip: string) {
const response = await fetch(`http://ip-api.com/json/${ip}`);
if (!response.ok) return null;
const res = await response.json();
// {
// "query": "154.64.226.29",
// "status": "success",
// "continent": "North America",
// "continentCode": "NA",
// "country": "United States",
// "countryCode": "US",
// "region": "CA",
// "regionName": "California",
// "city": "Los Angeles",
// "district": "",
// "zip": "90009",
// "lat": 34.0549,
// "lon": -118.243,
// "timezone": "America/Los_Angeles",
// "offset": -25200,
// "currency": "USD",
// "isp": "Cogent Communications",
// "org": "NetLab Global",
// "as": "AS51847 Nearoute Limited",
// "asname": "NEAROUTE",
// "mobile": true,
// "proxy": true,
// "hosting": false
// }
return {
ip: res.query,
country: res.countryCode,
countryName: res.country,
region: res.region,
regionName: res.regionName,
city: res.city,
latitude: res.lat.toString(),
longitude: res.lon.toString(),
timezone: res.timezone,
};
}
export function extractRealIP(headers: Headers): string {
const ipHeaders = [
"X-Forwarded-For",
"X-Real-IP",
"CF-Connecting-IP",
"X-Client-IP",
"X-Cluster-Client-IP",
];
for (const header of ipHeaders) {
const value = headers.get(header);
if (value) {
const ip = value.split(",")[0].trim();
if (isValidIP(ip)) {
return ip;
}
}
}
return "::1";
}
function isValidIP(ip: string): boolean {
// IPv4 正则
const ipv4Regex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// IPv6 正则(简化版)
const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
}
export async function getIpInfo(req) {
const headers = req.headers;
const ip = isVercel ? ipAddress(req) : extractRealIP(headers);
const ua = getUserAgent(req);
const geo = await getGeolocation(req, ip || "::1");
const userLanguage =
req.headers.get("accept-language")?.split(",")[0] || "en-US";
return {
referer: headers.get("referer") || "(None)",
ip: isVercel ? ip : geo?.ip,
city: geo?.city || "",
region: geo?.region || "",
country: geo?.country || "",
latitude: geo?.latitude || "",
longitude: geo?.longitude || "",
flag: geo?.flag,
lang: userLanguage,
device: ua.device.model || "Unknown",
browser: ua.browser.name || "Unknown",
};
}

View File

@@ -1,12 +1,9 @@
import crypto from "crypto";
import { Metadata } from "next";
import { geolocation } from "@vercel/functions";
import { clsx, type ClassValue } from "clsx";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import UAParser from "ua-parser-js";
import { env } from "@/env.mjs";
import { siteConfig } from "@/config/site";
import { TIME_RANGES } from "./enums";
@@ -88,8 +85,16 @@ export function formatDate(input: string | number): string {
});
}
export function absoluteUrl(path: string) {
return `${env.NEXT_PUBLIC_APP_URL}${path}`;
export function formatTime(input: string | number): string {
const date = new Date(input);
const locale = navigator.language || "en-US";
return date.toLocaleTimeString(locale, {
// second: "numeric",
minute: "numeric",
hour: "numeric",
});
}
// Utils from precedent.dev
@@ -240,31 +245,13 @@ export function removeUrlSuffix(url: string): string {
return url.startsWith("http") ? url.split("//")[1] : url;
}
export function getIpInfo(req: Request) {
const geo = geolocation(req);
const ua = req.headers.get("user-agent") || "";
const parser = new UAParser();
parser.setUA(ua);
const browser = parser.getBrowser();
const device = parser.getDevice();
const referer = req.headers.get("referer") || "(None)";
const ip = req.headers.get("X-Forwarded-For") || "127.0.0.1";
const userLanguage =
req.headers.get("accept-language")?.split(",")[0] || "en-US";
return {
referer,
ip,
city: geo?.city || "",
region: geo?.region || "",
country: geo?.country || "",
latitude: geo?.latitude || "",
longitude: geo?.longitude || "",
flag: geo?.flag,
lang: userLanguage,
device: device.model || "Unknown",
browser: browser.name || "Unknown",
};
export function extractHostname(url: string): string {
try {
const urlObject = new URL(url);
return urlObject.hostname;
} catch (error) {
return "";
}
}
export function toCamelCase(str: string) {

View File

@@ -10,6 +10,7 @@ export const createDomainSchema = z.object({
cf_api_key: z.string().optional(),
cf_email: z.string().optional(),
cf_api_key_encrypted: z.boolean().default(false),
resend_api_key: z.string().optional(),
max_short_links: z.number().optional(),
max_email_forwards: z.number().optional(),
max_dns_records: z.number().optional(),

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