Compare commits
95 Commits
v0.6.1
...
fix/docker
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4c9bad648 | ||
|
|
80796cdcca | ||
|
|
970dc5bbe8 | ||
|
|
6cade53ec5 | ||
|
|
34981f821d | ||
|
|
22f1686ff7 | ||
|
|
5d34f3707a | ||
|
|
d8ec5683d1 | ||
|
|
06a70b6680 | ||
|
|
938fcd4422 | ||
|
|
d86467674e | ||
|
|
a21ce6e8d6 | ||
|
|
0a4507bbd0 | ||
|
|
2f27c330a1 | ||
|
|
cff4579ff1 | ||
|
|
f2de129ba8 | ||
|
|
2a9a242f50 | ||
|
|
4e74053017 | ||
|
|
bb1fcd8c37 | ||
|
|
fb694cc749 | ||
|
|
778b18dd35 | ||
|
|
c80de8800f | ||
|
|
54c0ba67c8 | ||
|
|
2f19553dec | ||
|
|
d7c213c110 | ||
|
|
b832de9194 | ||
|
|
a781e84537 | ||
|
|
17881b5b0e | ||
|
|
40ece4e764 | ||
|
|
a983da20e5 | ||
|
|
43555a9985 | ||
|
|
adb0ce31c0 | ||
|
|
c619931386 | ||
|
|
026dfb2ffe | ||
|
|
5396a4e628 | ||
|
|
3e801fe85a | ||
|
|
ec3372a3c0 | ||
|
|
157c07c747 | ||
|
|
6505d1876a | ||
|
|
2a3ff9db9b | ||
|
|
434e326991 | ||
|
|
16b66f83da | ||
|
|
cc4c6c5e96 | ||
|
|
3301570213 | ||
|
|
c65176e607 | ||
|
|
55aa93d117 | ||
|
|
7c61b7fc44 | ||
|
|
bc7f86119c | ||
|
|
bc1490f0fd | ||
|
|
7bf2aa8b3c | ||
|
|
ba086b602f | ||
|
|
0d793ee31c | ||
|
|
7579be007f | ||
|
|
515e7d2719 | ||
|
|
c9cfdfc07a | ||
|
|
c589afd859 | ||
|
|
f10f8af0f6 | ||
|
|
cbeba449ef | ||
|
|
fa02ca000b | ||
|
|
af01d60d9b | ||
|
|
06f06a8a52 | ||
|
|
fc54d9e176 | ||
|
|
8fab48f849 | ||
|
|
69878126f6 | ||
|
|
0185520445 | ||
|
|
00cb224e84 | ||
|
|
c5a932b9f1 | ||
|
|
becc328811 | ||
|
|
c2ae4c78f7 | ||
|
|
40f2483332 | ||
|
|
a27eb84d61 | ||
|
|
01b80eaf9e | ||
|
|
1e713ea613 | ||
|
|
1eb7c71ff9 | ||
|
|
b9bf2733f9 | ||
|
|
1e48c209f7 | ||
|
|
fff455312e | ||
|
|
a5626ebefe | ||
|
|
400b1aac8d | ||
|
|
872baa7933 | ||
|
|
04b47b62ad | ||
|
|
8894d2daae | ||
|
|
3145ef884d | ||
|
|
7b1c21e972 | ||
|
|
5421285a29 | ||
|
|
59727b6be9 | ||
|
|
142cdf8b41 | ||
|
|
24ae1bc45e | ||
|
|
a1cd74e90f | ||
|
|
6e8b1ccefd | ||
|
|
4d9c20d90d | ||
|
|
91d3f06f38 | ||
|
|
a5f5312476 | ||
|
|
36254e048e | ||
|
|
72f76b8bca |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
# .dockerignore
|
||||
# node_modules
|
||||
# npm-debug.log
|
||||
# README.md
|
||||
# .env*
|
||||
# .next
|
||||
# .git
|
||||
13
.env.example
13
.env.example
@@ -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
65
.github/workflows/docker-build-push.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Build and Push Docker Image to GHCR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 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
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm config set registry https://registry.npmmirror.com
|
||||
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV IS_DOCKER=true
|
||||
|
||||
RUN pnpm add npm-run-all dotenv prisma@5.17.0 @prisma/client@5.17.0
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Check db
|
||||
COPY scripts/check-db.js /app/scripts/check-db.js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
# CMD ["node", "server.js"]
|
||||
CMD ["pnpm", "start-docker"]
|
||||
88
README-zh.md
88
README-zh.md
@@ -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
|
||||
## 功能列表
|
||||
|
||||

|
||||
- 🔗 **短链服务**:
|
||||
- 支持自定义短链
|
||||
- 支持生成自定义二维码
|
||||
- 支持密码保护链接
|
||||
- 支持设置过期时间
|
||||
- 支持访问统计(实时日志、地图等多维度数据分析)
|
||||
- 支持调用 API 创建短链
|
||||
|
||||

|
||||
- 📮 **临时邮箱服务**:
|
||||
- 支持创建自定义前缀邮箱
|
||||
- 支持过滤未读邮件列表
|
||||
- 可创建无限数量邮箱
|
||||
- 支持接收无限制邮件 (依赖 Cloudflare Email Worker)
|
||||
- 支持发送邮件(依赖 Resend)
|
||||
- 支持调用 API 创建邮箱
|
||||
- 支持调用 API 获取收件箱邮件
|
||||
-
|
||||
- 🌐 **子域名管理服务**:
|
||||
- 支持管理多 Cloudflare 账户下的多个域名的 DNS 记录
|
||||
- 支持创建多种 DNS 记录类型(CNAME、A、TXT 等)
|
||||
|
||||

|
||||
- 📡 **开放接口模块**:
|
||||
- 获取网站元数据 API
|
||||
- 获取网站截图 API
|
||||
- 生成网站二维码 API
|
||||
- 将网站转换为 Markdown、Text
|
||||
- 支持所有类型 API 调用统计日志
|
||||
- 支持生成用户 API Key,用于第三方调用开放接口
|
||||
|
||||

|
||||
- 🔒 **管理员模块**:
|
||||
- 多维度图表展示网站状态
|
||||
- 域名服务配置(动态配置各项服务是否启用,包括短链、临时邮箱(收发邮件)、子域名管理)
|
||||
- 用户列表管理(设置权限、分配使用额度、禁用用户等)
|
||||
- 短链管理(管理所有用户创建的短链)
|
||||
- 邮箱管理(管理所有用户创建的临时邮箱)
|
||||
- 子域名管理(管理所有用户创建的子域名)
|
||||
|
||||
## 截图预览
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://wr.do/_static/images/light-preview.png" /></td>
|
||||
<td><img src="https://wr.do/_static/images/example_02.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://wr.do/_static/images/example_01.png" /></td>
|
||||
<td><img src="https://wr.do/_static/images/realtime-globe.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://wr.do/_static/images/example_03.png" /></td>
|
||||
<td><img src="https://wr.do/_static/images/domains.png" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 快速开始
|
||||
|
||||
查看开发者[快速开始](https://wr.do/docs/developer/quick-start)的详细文档。
|
||||
查看开发者[快速开始](https://wr.do/docs/developer/quick-start)详细文档。
|
||||
|
||||
查看有关[快速开始](https://wr.do/docs/quick-start)的文档。
|
||||
## 自部署教程
|
||||
|
||||
## 自托管教程
|
||||
### 使用 Vercel 部署
|
||||
|
||||
### 要求
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
|
||||
|
||||
- [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
118
README.md
@@ -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
|
||||
|
||||

|
||||
<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
|
||||
|
||||
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).
|
||||
[](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
|
||||
- 微信群:
|
||||
|
||||

|
||||
<img width="300" src="https://wr.do/s/group" />
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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{" "}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 Domains"
|
||||
text="List and manage domains."
|
||||
link="/docs/domains"
|
||||
link="/docs/developer/cloudflare"
|
||||
linkText="domains."
|
||||
/>
|
||||
<DomainList
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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't have any record yet. Start creating record.
|
||||
You don't have any subdomain yet. Start creating
|
||||
record.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
|
||||
@@ -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}
|
||||
|
||||
459
app/(protected)/dashboard/urls/globe/index.tsx
Normal file
459
app/(protected)/dashboard/urls/globe/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
app/(protected)/dashboard/urls/globe/realtime-chart.tsx
Normal file
100
app/(protected)/dashboard/urls/globe/realtime-chart.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
314
app/(protected)/dashboard/urls/globe/realtime-globe.tsx
Normal file
314
app/(protected)/dashboard/urls/globe/realtime-globe.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { scaleSequentialSqrt } from "d3-scale";
|
||||
import { interpolateTurbo } from "d3-scale-chromatic";
|
||||
import { GlobeInstance } from "globe.gl";
|
||||
import { debounce } from "lodash-es";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Location } from "./index";
|
||||
|
||||
interface GlobeProps {
|
||||
time: {
|
||||
startAt: number;
|
||||
endAt: number;
|
||||
};
|
||||
filters: Record<string, any>;
|
||||
locations: Location[];
|
||||
stats: {
|
||||
totalClicks: number;
|
||||
uniqueLocations: number;
|
||||
rawRecords: number;
|
||||
lastFetch: string;
|
||||
};
|
||||
setHandleTrafficEvent: (
|
||||
fn: (lat: number, lng: number, city: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function RealtimeGlobe({
|
||||
time,
|
||||
filters,
|
||||
locations,
|
||||
stats,
|
||||
}: GlobeProps) {
|
||||
const { theme } = useTheme();
|
||||
const globeRef = useRef<HTMLDivElement>(null);
|
||||
const globeInstanceRef = useRef<any>(null);
|
||||
const mountedRef = useRef(true);
|
||||
let globe: GlobeInstance;
|
||||
const [countries, setCountries] = useState<any>({});
|
||||
const [currentLocation, setCurrentLocation] = useState<any>({});
|
||||
const [hexAltitude, setHexAltitude] = useState(0.001);
|
||||
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
const highest =
|
||||
locations.reduce((acc, curr) => Math.max(acc, curr.count), 0) || 1;
|
||||
const weightColor = scaleSequentialSqrt(interpolateTurbo).domain([
|
||||
0,
|
||||
highest * 15,
|
||||
]);
|
||||
|
||||
const loadGlobe = useCallback(async () => {
|
||||
try {
|
||||
const GlobeModule = await import("globe.gl");
|
||||
const Globe = GlobeModule.default;
|
||||
const { MeshPhongMaterial } = await import("three");
|
||||
return { Globe, MeshPhongMaterial };
|
||||
} catch (err) {
|
||||
// console.error("Failed to load Globe.gl:", err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getGlobeJSON = async () => {
|
||||
try {
|
||||
const response = await fetch("/countries.geojson");
|
||||
const data = await response.json();
|
||||
if (mountedRef.current) {
|
||||
setCountries(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching globe JSON:", error);
|
||||
if (mountedRef.current) {
|
||||
setCountries({ type: "FeatureCollection", features: [] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentLocation = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/location");
|
||||
const data = await response.json();
|
||||
if (mountedRef.current) {
|
||||
setCurrentLocation(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching current location:", error);
|
||||
if (mountedRef.current) {
|
||||
setCurrentLocation({ latitude: 0, longitude: 0 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initGlobe = useCallback(async () => {
|
||||
if (
|
||||
!globeRef.current ||
|
||||
!countries.features ||
|
||||
globeInstanceRef.current ||
|
||||
!mountedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const modules = await loadGlobe();
|
||||
if (!modules || !mountedRef.current) return;
|
||||
|
||||
const { Globe, MeshPhongMaterial } = modules;
|
||||
|
||||
const container = globeRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = "";
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
setTimeout(initGlobe, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
globe = new Globe(container)
|
||||
.width(wrapperWidth)
|
||||
.height(wrapperWidth > 728 ? wrapperWidth * 0.9 : wrapperWidth)
|
||||
.globeOffset([0, -80])
|
||||
.atmosphereColor("rgba(170, 170, 200, 0.7)")
|
||||
.backgroundColor("rgba(0,0,0,0)")
|
||||
.globeMaterial(
|
||||
new MeshPhongMaterial({
|
||||
color: theme === "dark" ? "rgb(65, 65, 65)" : "rgb(228, 228, 231)",
|
||||
transparent: false,
|
||||
opacity: 1,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
if (countries.features && countries.features.length > 0) {
|
||||
globe
|
||||
.hexPolygonsData(countries.features)
|
||||
.hexPolygonResolution(3)
|
||||
.hexPolygonMargin(0.2)
|
||||
.hexPolygonAltitude(() => hexAltitude)
|
||||
.hexPolygonColor(
|
||||
() => `rgba(45, 154, 249, ${Math.random() / 1.5 + 0.5})`,
|
||||
);
|
||||
}
|
||||
|
||||
globe
|
||||
.hexBinResolution(4)
|
||||
.hexBinPointsData(locations)
|
||||
.hexBinMerge(true)
|
||||
.hexBinPointWeight("count")
|
||||
.hexTopColor((d: any) => {
|
||||
const intensity = d.sumWeight || 0;
|
||||
return weightColor(intensity);
|
||||
})
|
||||
.hexSideColor((d: any) => {
|
||||
const intensity = d.sumWeight || 0;
|
||||
return weightColor(intensity * 0.8);
|
||||
})
|
||||
.hexAltitude((d: any) => {
|
||||
const intensity = d.sumWeight || 0;
|
||||
return Math.max(0.01, intensity * 0.8);
|
||||
});
|
||||
|
||||
globe.onGlobeReady(() => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const lat = currentLocation.latitude || 0;
|
||||
const lng = currentLocation.longitude || 0;
|
||||
|
||||
globe.pointOfView({
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
altitude: rect.width > 768 ? 2.5 : 3.5,
|
||||
});
|
||||
|
||||
if (globe.controls()) {
|
||||
globe.controls().autoRotate = true;
|
||||
globe.controls().autoRotateSpeed = 0.5;
|
||||
globe.controls().enableDamping = true;
|
||||
globe.controls().dampingFactor = 0.1;
|
||||
}
|
||||
|
||||
setIsLoaded(true);
|
||||
});
|
||||
|
||||
if (globe.controls()) {
|
||||
globe.controls().addEventListener(
|
||||
"end",
|
||||
debounce(() => {
|
||||
if (!mountedRef.current || !globeInstanceRef.current) return;
|
||||
|
||||
try {
|
||||
const distance = Math.round(globe.controls().getDistance());
|
||||
let nextAlt = 0.005;
|
||||
if (distance <= 300) nextAlt = 0.001;
|
||||
else if (distance >= 600) nextAlt = 0.02;
|
||||
|
||||
if (nextAlt !== hexAltitude) {
|
||||
setHexAltitude(nextAlt);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Error in controls event:", err);
|
||||
}
|
||||
}, 200),
|
||||
);
|
||||
}
|
||||
|
||||
globeInstanceRef.current = globe;
|
||||
} catch (err) {}
|
||||
}, [
|
||||
countries,
|
||||
locations,
|
||||
currentLocation,
|
||||
hexAltitude,
|
||||
loadGlobe,
|
||||
weightColor,
|
||||
]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (globeInstanceRef.current) {
|
||||
try {
|
||||
if (typeof globeInstanceRef.current._destructor === "function") {
|
||||
globeInstanceRef.current._destructor();
|
||||
}
|
||||
|
||||
if (globeRef.current) {
|
||||
globeRef.current.innerHTML = "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Error during cleanup:", err);
|
||||
}
|
||||
globeInstanceRef.current = null;
|
||||
}
|
||||
|
||||
setIsLoaded(false);
|
||||
}, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (globeInstanceRef.current) {
|
||||
// globeInstanceRef.current.width(wrapperWidth);
|
||||
// globeInstanceRef.current.height(
|
||||
// wrapperWidth > 728 ? wrapperWidth * 0.8 : wrapperWidth,
|
||||
// );
|
||||
// }
|
||||
// }, [globeInstanceRef.current, wrapperWidth, wrapperHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
globeInstanceRef.current &&
|
||||
mountedRef.current &&
|
||||
locations.length > 0
|
||||
) {
|
||||
try {
|
||||
globeInstanceRef.current.hexBinPointsData(locations);
|
||||
} catch (err) {
|
||||
console.warn("Error updating locations:", err);
|
||||
}
|
||||
}
|
||||
}, [locations]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeData = async () => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
try {
|
||||
await Promise.all([getCurrentLocation(), getGlobeJSON()]);
|
||||
} catch (error) {
|
||||
console.error("Error initializing data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
countries.features &&
|
||||
currentLocation &&
|
||||
!globeInstanceRef.current &&
|
||||
mountedRef.current
|
||||
) {
|
||||
const timer = setTimeout(initGlobe, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countries, currentLocation, initGlobe]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative -mt-8 max-h-screen overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={globeRef}
|
||||
className="flex justify-center"
|
||||
style={{
|
||||
maxWidth: `${wrapperWidth}px`, // 比较疑惑
|
||||
minHeight: "100px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
app/(protected)/dashboard/urls/globe/realtime-logs.tsx
Normal file
127
app/(protected)/dashboard/urls/globe/realtime-logs.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import ReactCountryFlag from "react-country-flag";
|
||||
|
||||
import { formatTime } from "@/lib/utils";
|
||||
|
||||
import { Location } from "./index";
|
||||
|
||||
const RealtimeLogs = ({
|
||||
className,
|
||||
locations,
|
||||
}: {
|
||||
className?: string;
|
||||
locations: Location[];
|
||||
}) => {
|
||||
const [displayedLocations, setDisplayedLocations] = useState<Location[]>([]);
|
||||
const [pendingLocations, setPendingLocations] = useState<Location[]>([]);
|
||||
|
||||
// 生成唯一标识用于去重
|
||||
const generateUniqueKey = (loc: Location): string => {
|
||||
return `${loc.userUrl?.url || ""}-${loc.userUrl?.target || ""}-${loc.userUrl?.prefix || ""}-${loc.country || ""}-${loc.city || ""}-${loc.browser || ""}-${loc.device || ""}-${loc.updatedAt?.toString() || ""}`;
|
||||
};
|
||||
|
||||
// 当外部 locations 更新时,更新预备列表
|
||||
useEffect(() => {
|
||||
const sortedLocations = [...locations].sort((a, b) => {
|
||||
const timeA = new Date(a.updatedAt?.toString() || "").getTime() || 0;
|
||||
const timeB = new Date(b.updatedAt?.toString() || "").getTime() || 0;
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
setPendingLocations((prev) => {
|
||||
// 去重:基于多个字段判断
|
||||
const newLocations = sortedLocations.filter(
|
||||
(loc) =>
|
||||
!prev.some((p) => generateUniqueKey(p) === generateUniqueKey(loc)) &&
|
||||
!displayedLocations.some(
|
||||
(d) => generateUniqueKey(d) === generateUniqueKey(loc),
|
||||
),
|
||||
);
|
||||
return [...prev, ...newLocations];
|
||||
});
|
||||
}, [locations]);
|
||||
|
||||
// 每 2 秒从预备列表插入一条数据到显示列表
|
||||
useEffect(() => {
|
||||
if (pendingLocations.length === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDisplayedLocations((prev) => {
|
||||
if (pendingLocations.length > 0) {
|
||||
const newLocation = pendingLocations[0];
|
||||
// 插入新数据到顶部,限制显示列表最多 8 条
|
||||
const newDisplayed = [newLocation, ...prev].slice(0, 8);
|
||||
// 从预备列表移除已插入的数据
|
||||
setPendingLocations((pending) => pending.slice(1));
|
||||
return newDisplayed;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pendingLocations]);
|
||||
|
||||
// 动画配置
|
||||
const itemVariants = {
|
||||
initial: { opacity: 0, scale: 0.1, x: "25%", y: "25%" }, // 从中心缩放
|
||||
animate: { opacity: 1, scale: 1, x: 0, y: 0 },
|
||||
// exit: { opacity: 0, transition: { duration: 0.3 } }, // 渐出
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto ${className}`}
|
||||
style={{ minHeight: "200px", maxHeight: "80vh" }}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{displayedLocations.length > 0 &&
|
||||
displayedLocations.map((loc) => (
|
||||
<motion.div
|
||||
key={generateUniqueKey(loc)}
|
||||
variants={itemVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mb-2 flex w-full items-center justify-start gap-3 rounded-lg border p-3 text-xs shadow-inner backdrop-blur-xl sm:w-60"
|
||||
>
|
||||
<ReactCountryFlag
|
||||
style={{ fontSize: "16px" }}
|
||||
countryCode={loc.country || "US"}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
className="text-sm font-semibold"
|
||||
href={`https://${loc.userUrl?.prefix}/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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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,6 +126,14 @@ function generateStatsList(
|
||||
? getCountryName(rawValue as string) // 国家代码转为国家名称
|
||||
: dimension === "device"
|
||||
? getDeviceVendor(rawValue as string) // 设备型号转为厂商名称
|
||||
: 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,12 +259,12 @@ export function DailyPVUVChart({
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e) => (
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
key={e.value}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -251,6 +275,10 @@ export function DailyPVUVChart({
|
||||
)}
|
||||
</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">
|
||||
{/* 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)}
|
||||
|
||||
@@ -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't have any visits yet in last {timeRange}.
|
||||
You don't have any visits yet in{" "}
|
||||
{DATE_DIMENSION_ENUMS.find(
|
||||
(e) => e.value === timeRange,
|
||||
)?.label.toLowerCase()}
|
||||
.
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setTimeRange(value);
|
||||
@@ -58,13 +63,12 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e) => (
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
className=""
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
key={e.value}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -75,6 +79,10 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,45 +182,17 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
{action.includes("/admin") ? (
|
||||
<CardDescription className="text-balance text-lg font-bold">
|
||||
<span>Total URLs:</span>{" "}
|
||||
<span className="font-bold">{data && data.total}</span>
|
||||
</CardDescription>
|
||||
) : (
|
||||
<CardTitle>Short URLs</CardTitle>
|
||||
)}
|
||||
<div className="ml-auto flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-[120px] shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add URL
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
const rendeEmpty = () => (
|
||||
<EmptyPlaceholder className="col-span-full shadow-none">
|
||||
<EmptyPlaceholder.Icon name="link" />
|
||||
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any url yet. Start creating url.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
);
|
||||
|
||||
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
|
||||
@@ -223,9 +232,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
{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: "" })
|
||||
}
|
||||
onClick={() => setSearchParams({ ...searchParams, target: "" })}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
@@ -249,9 +256,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
{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: "" })
|
||||
}
|
||||
onClick={() => setSearchParams({ ...searchParams, userName: "" })}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
@@ -260,7 +265,36 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
</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">
|
||||
@@ -280,10 +314,10 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
Expiration
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Updated
|
||||
Clicks
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Created
|
||||
Updated
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Actions
|
||||
@@ -355,10 +389,12 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
{expirationTime(short.expiration, short.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
{timeAgo(short.updatedAt as Date)}
|
||||
<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.createdAt as Date)}
|
||||
{timeAgo(short.updatedAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
<Button
|
||||
@@ -392,6 +428,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
setCurrentView(short.id!);
|
||||
if (isShowStats && selectedUrl?.id !== short.id) {
|
||||
} else {
|
||||
setShowStats(!isShowStats);
|
||||
@@ -402,31 +439,16 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
</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!}
|
||||
/>
|
||||
)}
|
||||
{/* {rendeStats(short)} */}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="link" />
|
||||
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any url yet. Start creating url.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
rendeEmpty()
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
@@ -435,8 +457,288 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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"}
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">Add URL</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
|
||||
57
app/(protected)/dashboard/urls/wrapper.tsx
Normal file
57
app/(protected)/dashboard/urls/wrapper.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import ApiReference from "@/components/shared/api-reference";
|
||||
|
||||
import Globe from "./globe";
|
||||
import LiveLog from "./live-logs";
|
||||
import UserUrlsList from "./url-list";
|
||||
|
||||
export function Wrapper({
|
||||
user,
|
||||
}: {
|
||||
user: Pick<User, "id" | "name" | "apiKey" | "role" | "team">;
|
||||
}) {
|
||||
const [tab, setTab] = useState("Links");
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChangeCapture={(e) => console.log(e)}
|
||||
defaultValue={tab}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="Links" onClick={() => setTab("Links")}>
|
||||
Links
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="Realtime" onClick={() => setTab("Realtime")}>
|
||||
Realtime
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)
|
||||
<TabsContent className="space-y-3" value="Links">
|
||||
<UserUrlsList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url"
|
||||
/>
|
||||
<LiveLog admin={false} />
|
||||
<ApiReference
|
||||
badge="POST /api/v1/short"
|
||||
target="creating short urls"
|
||||
link="/docs/short-urls#api-reference"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="Realtime">
|
||||
<Globe />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
24
app/api/domain/check-cf/route.ts
Normal file
24
app/api/domain/check-cf/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { getZoneDetail } from "@/lib/cloudflare";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const zone_id = url.searchParams.get("zone_id") || "";
|
||||
const api_key = url.searchParams.get("api_key") || "";
|
||||
const email = url.searchParams.get("email") || "";
|
||||
|
||||
const res = await getZoneDetail(zone_id, api_key, email);
|
||||
|
||||
if (res === 200) return Response.json(200, { status: 200 });
|
||||
else return Response.json(400, { status: 400 });
|
||||
} catch (error) {
|
||||
return Response.json(500, { status: 500 });
|
||||
}
|
||||
}
|
||||
37
app/api/domain/check-resend/route.ts
Normal file
37
app/api/domain/check-resend/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { Resend } from "resend";
|
||||
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const api_key = url.searchParams.get("api_key") || "";
|
||||
const domain = url.searchParams.get("domain") || "";
|
||||
|
||||
if (!api_key || !domain) {
|
||||
return Response.json(400, { status: 400 });
|
||||
}
|
||||
|
||||
const resend = new Resend(api_key);
|
||||
const { error } = await resend.emails.send({
|
||||
from: `test@${domain}`,
|
||||
to: user.email,
|
||||
subject: "Test Resend API Key",
|
||||
html: "This is a test email sent using Resend API Key.",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return Response.json(400, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json(200, { status: 200 });
|
||||
} catch (error) {
|
||||
return Response.json(500, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
14
app/api/feature/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
29
app/api/location/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
1
app/api/record/admin/apply/route.ts
Normal file
1
app/api/record/admin/apply/route.ts
Normal file
@@ -0,0 +1 @@
|
||||
export async function POST(req: Request) {}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
268
app/api/url/admin/locations/route.ts
Normal file
268
app/api/url/admin/locations/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
1
auth.ts
1
auth.ts
@@ -22,6 +22,7 @@ export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
} = NextAuth({
|
||||
trustHost: true, // TODO: Test with docker
|
||||
adapter: PrismaAdapter(prisma),
|
||||
session: { strategy: "jwt" },
|
||||
pages: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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" && (
|
||||
|
||||
@@ -233,7 +233,7 @@ export function RecordForm({
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
No domains configured
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
@@ -379,6 +379,7 @@ export function RecordForm({
|
||||
Optional. Time To Live.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
{["A", "CNAME"].includes(currentRecordType) && (
|
||||
<FormSectionColumns title="Proxy">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="proxy">
|
||||
@@ -394,6 +395,7 @@ export function RecordForm({
|
||||
Proxy status.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
@@ -224,7 +224,7 @@ export function UrlForm({
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
No domains configured
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,8 +62,43 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
});
|
||||
}
|
||||
|
||||
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" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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" }))}
|
||||
@@ -83,6 +121,8 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
)}{" "}
|
||||
Google
|
||||
</button>
|
||||
)}
|
||||
{loginMethod["github"] && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: "outline" }))}
|
||||
@@ -105,6 +145,8 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
)}{" "}
|
||||
Github
|
||||
</button>
|
||||
)}
|
||||
{loginMethod["linuxdo"] && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: "outline" }))}
|
||||
@@ -131,17 +173,11 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
)}{" "}
|
||||
LinuxDo
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative my-3">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{loginMethod["resend"] && rendeSeparator()}
|
||||
|
||||
{loginMethod["resend"] && (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1">
|
||||
@@ -176,10 +212,13 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
{isLoading && (
|
||||
<Icons.spinner className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{type === "register" ? "Sign Up with Email" : "Sign In with Email"}
|
||||
{type === "register"
|
||||
? "Sign Up with Email"
|
||||
: "Sign In with Email"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
© 2024{" "}
|
||||
<p className="mx-3 mt-auto flex items-center gap-1 pb-3 pt-6 font-mono text-xs text-muted-foreground/90">
|
||||
© 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">
|
||||
© 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> */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
components/shared/docs-lang.tsx
Normal file
29
components/shared/docs-lang.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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{" "}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</Avatar>
|
||||
)
|
||||
<AvatarImage
|
||||
alt="Picture"
|
||||
src={
|
||||
user.image ??
|
||||
`https://unavatar.io/${user.email}?ttl=1h&fallback=https://wr.do/_static/avatar.png`
|
||||
}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
107
content/docs/developer/cloudflare-email-worker-zh.mdx
Normal file
107
content/docs/developer/cloudflare-email-worker-zh.mdx
Normal 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>
|
||||
@@ -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
|
||||
|
||||
69
content/docs/developer/deploy-zh.mdx
Normal file
69
content/docs/developer/deploy-zh.mdx
Normal 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>
|
||||
|
||||
[](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 中触发打包镜像。
|
||||
64
content/docs/developer/deploy.mdx
Normal file
64
content/docs/developer/deploy.mdx
Normal 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)
|
||||
|
||||
[](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.
|
||||
47
content/docs/developer/email-zh.mdx
Normal file
47
content/docs/developer/email-zh.mdx
Normal 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>
|
||||
@@ -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`
|
||||
|
||||
*/}
|
||||
65
content/docs/developer/installation-zh.mdx
Normal file
65
content/docs/developer/installation-zh.mdx
Normal 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 部署:
|
||||
|
||||
[](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>
|
||||
@@ -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 |
|
||||
|
||||
245
content/docs/developer/quick-start-zh.mdx
Normal file
245
content/docs/developer/quick-start-zh.mdx
Normal 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时,参考以下配置:
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||

|
||||
|
||||
```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. 然后根据 **面板引导** 配置系统并添加第一个域名。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
<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
|
||||
```
|
||||
@@ -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
|
||||
@@ -130,11 +133,12 @@ Copy/paste in your `.env` file.
|
||||
|
||||
```js
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
54
docker-compose-localdb.yml
Normal file
54
docker-compose-localdb.yml
Normal 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
34
docker-compose.yml
Normal 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
15
env.mjs
@@ -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
102
hooks/use-element-size.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
327
lib/contries.ts
327
lib/contries.ts
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
lib/enums.ts
29
lib/enums.ts
@@ -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
147
lib/geo.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
47
lib/utils.ts
47
lib/utils.ts
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user