Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5421285a29 | ||
|
|
59727b6be9 | ||
|
|
142cdf8b41 | ||
|
|
24ae1bc45e | ||
|
|
a1cd74e90f | ||
|
|
6e8b1ccefd | ||
|
|
4d9c20d90d | ||
|
|
91d3f06f38 | ||
|
|
a5f5312476 | ||
|
|
36254e048e | ||
|
|
72f76b8bca | ||
|
|
11d8f0d1d5 | ||
|
|
2369885fda | ||
|
|
8a05fa0907 | ||
|
|
28c0cf7da7 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
# .dockerignore
|
||||
# node_modules
|
||||
# npm-debug.log
|
||||
# README.md
|
||||
# .env*
|
||||
# .next
|
||||
# .git
|
||||
20
.env.example
20
.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=
|
||||
@@ -25,14 +26,6 @@ DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=requ
|
||||
# -----------------------------------------------------------------------------
|
||||
RESEND_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Cloudflare
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=wr.do,uv.do
|
||||
CLOUDFLARE_ZONE=[{"zone_id":"abc123", "zone_name": "wr.do"},{"zone_id":"abc456", "zone_name": "uv.do"}]
|
||||
CLOUDFLARE_API_KEY=
|
||||
CLOUDFLARE_EMAIL=
|
||||
|
||||
# Open Signup
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
|
||||
@@ -45,7 +38,6 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
|
||||
# GitHub api token for getting gitHub stars count
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# Short domains, split by ","
|
||||
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
|
||||
|
||||
|
||||
# Skip DB check and migration (only for docker), default is true. if false, will check and migrate database each time start docker compose.
|
||||
SKIP_DB_CHECK=true
|
||||
SKIP_DB_MIGRATION=true
|
||||
64
.github/workflows/docker-build-push.yml
vendored
Normal file
64
.github/workflows/docker-build-push.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Build and Push Docker Image to GHCR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
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"]
|
||||
61
README-zh.md
61
README-zh.md
@@ -14,24 +14,27 @@
|
||||
- <20>😀 **权限管理**:方便审核的管理员面板
|
||||
- 🔒 **安全可靠**:基于 Cloudflare 强大的 DNS API
|
||||
|
||||
## 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>
|
||||
|
||||
## 快速开始
|
||||
|
||||
查看开发者[快速开始](https://wr.do/docs/developer/quick-start)的详细文档。
|
||||
|
||||
查看有关[快速开始](https://wr.do/docs/quick-start)的文档。
|
||||
|
||||
## 自托管教程
|
||||
|
||||
### 要求
|
||||
|
||||
- [Vercel](https://vercel.com) 账户用于部署应用
|
||||
@@ -43,6 +46,23 @@
|
||||
|
||||
查看 [email worker](https://wr.do/docs/developer/cloudflare-email-worker) 文档用于邮件接收。
|
||||
|
||||
## 自部署教程
|
||||
|
||||
### 使用 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)
|
||||
|
||||
记得填写必要的环境变量。
|
||||
|
||||
### 使用 Docker Compose 部署
|
||||
|
||||
|
||||
在服务器中创建一个文件夹,进入该文件夹并新建`docker-compose.yml`文件,填写必要的环境变量,然后执行:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
将 `.env.example` 复制为 `.env` 并填写必要的环境变量。
|
||||
@@ -56,11 +76,22 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
#### 初始化数据库
|
||||
|
||||
```bash
|
||||
pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
#### 管理员初始化
|
||||
|
||||
Follow https://localhost:3000/setup
|
||||
|
||||
## 社区群组
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||

|
||||
|
||||
## 许可证
|
||||
|
||||
76
README.md
76
README.md
@@ -17,20 +17,24 @@
|
||||
|
||||
## 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
|
||||
@@ -44,34 +48,62 @@ See more docs about [developer](https://wr.do/docs/developer/installation).
|
||||
|
||||
See docs about [email worker](https://wr.do/docs/developer/cloudflare-email-worker).
|
||||
|
||||
## Local development
|
||||
## Self-hosted
|
||||
|
||||
copy `.env.example` to `.env` and fill in the necessary environment variables.
|
||||
### Deploy with Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
|
||||
|
||||
Remember to fill in the necessary environment variables.
|
||||
|
||||
### Deploy with Docker Compose
|
||||
|
||||
Create a new folder and copy the `docker-compose.yml`、`.env` file to the folder.
|
||||
|
||||
```yml
|
||||
- wrdo
|
||||
| - docker-compose.yml
|
||||
| - .env
|
||||
```
|
||||
|
||||
Fill in the environment variables in the `.env` file, then:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oiov/wr.do
|
||||
cd wr.do
|
||||
pnpm install
|
||||
```
|
||||
|
||||
copy `.env.example` to `.env` and fill in the necessary environment variables.
|
||||
|
||||
```bash
|
||||
# run on localhost:3000
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Legitimacy review
|
||||
#### Init database
|
||||
|
||||
- To avoid abuse, applications without website content will be rejected
|
||||
- To avoid domain name conflicts, please check before applying
|
||||
- Completed website construction or released open source project (ready to build website for open source project)
|
||||
- Political sensitivity, violence, pornography, link jumping, VPN, reverse proxy services, and other illegal or sensitive content must not appear on the website
|
||||
```bash
|
||||
pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
**Administrators will conduct domain name checks periodically to clean up domain names that violate the above rules, have no content, and are not open source related**
|
||||
#### Setup Admin Panel
|
||||
|
||||
Follow https://localhost:3000/setup
|
||||
|
||||
## Community Group
|
||||
|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||

|
||||
- Discord: https://discord.gg/AHPQYuZu3m
|
||||
- 微信群:
|
||||
|
||||
<img width="300" src="https://wr.do/s/group" />
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function LoginPage() {
|
||||
>
|
||||
<>
|
||||
<Icons.chevronLeft className="mr-2 size-4" />
|
||||
Back
|
||||
Back {siteConfig.openSignup ? "Home" : ""}
|
||||
</>
|
||||
</Link>
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
|
||||
362
app/(protected)/admin/domains/domain-list.tsx
Normal file
362
app/(protected)/admin/domains/domain-list.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { DomainFormData } from "@/lib/dto/domains";
|
||||
import { fetcher, timeAgo } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DomainForm } from "@/components/forms/domain-form";
|
||||
import { FormType } from "@/components/forms/record-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
|
||||
export interface DomainListProps {
|
||||
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
|
||||
action: string;
|
||||
}
|
||||
|
||||
function TableColumnSekleton() {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-7">
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DomainList({ user, action }: DomainListProps) {
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditDomain, setCurrentEditDomain] =
|
||||
useState<DomainFormData | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
slug: "",
|
||||
target: "",
|
||||
userName: "",
|
||||
});
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR<{
|
||||
total: number;
|
||||
list: DomainFormData[];
|
||||
}>(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
mutate(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeStatus = async (
|
||||
checked: boolean,
|
||||
target: string,
|
||||
domain: DomainFormData,
|
||||
) => {
|
||||
const res = await fetch(action, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
id: domain.id,
|
||||
enable_short_link:
|
||||
target === "enable_short_link" ? checked : domain.enable_short_link,
|
||||
enable_email: target === "enable_email" ? checked : domain.enable_email,
|
||||
enable_dns: target === "enable_dns" ? checked : domain.enable_dns,
|
||||
active: target === "active" ? checked : domain.active,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data) {
|
||||
toast.success("Successed!");
|
||||
handleRefresh();
|
||||
}
|
||||
} else {
|
||||
toast.error("Activation failed!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-lg font-bold">
|
||||
<span className="text-nowrap">Total Domains:</span>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-6 w-16" />
|
||||
) : (
|
||||
<span>{data && data.total}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditDomain(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">Add Domain</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by domain name..."
|
||||
value={searchParams.target}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
target: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.target && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() =>
|
||||
setSearchParams({ ...searchParams, target: "" })
|
||||
}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-7">
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Domain
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
Shorten
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
Email
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
|
||||
Subdomain
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
Active
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Updated
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((domain) => (
|
||||
<div className="border-b" key={domain.id}>
|
||||
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-7">
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Link
|
||||
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
||||
href={`https://${domain.domain_name}`}
|
||||
target="_blank"
|
||||
prefetch={false}
|
||||
title={domain.domain_name}
|
||||
>
|
||||
{domain.domain_name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_short_link}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(
|
||||
value,
|
||||
"enable_short_link",
|
||||
domain,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_email}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "enable_email", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_dns}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "enable_dns", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
disabled
|
||||
defaultChecked={domain.active}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "active", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center truncate">
|
||||
{timeAgo(domain.updatedAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditDomain(domain);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden sm:block">Edit</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
{domain.cf_zone_id &&
|
||||
domain.cf_api_key &&
|
||||
domain.cf_email && (
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icons.cloudflare className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/* {isShowDomainInfo && selectedDomain?.id === domain.id && (
|
||||
<DomainInfo domain={domain} />
|
||||
)} */}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="globeLock" />
|
||||
<EmptyPlaceholder.Title>No Domains</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any domains yet. Start creating one.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* form */}
|
||||
<Modal
|
||||
className="max-h-[90vh] overflow-y-auto md:max-w-2xl"
|
||||
showModal={isShowForm}
|
||||
setShowModal={setShowForm}
|
||||
>
|
||||
<DomainForm
|
||||
user={{ id: user.id, name: user.name || "" }}
|
||||
isShowForm={isShowForm}
|
||||
setShowForm={setShowForm}
|
||||
type={formType}
|
||||
initData={currentEditDomain}
|
||||
action={action}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DomainInfo({ domain }: { domain: DomainFormData }) {
|
||||
return <>{domain.domain_name}</>;
|
||||
}
|
||||
12
app/(protected)/admin/domains/loading.tsx
Normal file
12
app/(protected)/admin/domains/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardRecordsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Domains Management" text="" />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
app/(protected)/admin/domains/page.tsx
Normal file
40
app/(protected)/admin/domains/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import DomainList from "./domain-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Domains - WR.DO",
|
||||
description: "List and manage domains.",
|
||||
});
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage Domains"
|
||||
text="List and manage domains."
|
||||
link="/docs/developer/cloudflare"
|
||||
linkText="domains."
|
||||
/>
|
||||
<DomainList
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
apiKey: user.apiKey || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/admin/domain"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import StatusDot from "@/components/dashboard/status-dot";
|
||||
import { UserForm } from "@/components/forms/user-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
@@ -140,12 +140,12 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
{action.includes("/admin") ? (
|
||||
<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 +180,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 +189,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>
|
||||
@@ -316,7 +317,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="globeLock" />
|
||||
<EmptyPlaceholder.Icon name="globe" />
|
||||
<EmptyPlaceholder.Title>No records</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any record yet. Start creating record.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { ScrapeInfoCard } from "@/components/dashboard/dashboard-info-card";
|
||||
import { StaticInfoCard } from "@/components/dashboard/dashboard-info-card";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import DashboardScrapeCharts from "./charts";
|
||||
@@ -26,22 +26,19 @@ export default async function DashboardPage() {
|
||||
linkText="Open API."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Screenshot"
|
||||
desc="Take a screenshot of the webpage."
|
||||
link="/dashboard/scrape/screenshot"
|
||||
icon="camera"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Meta Info"
|
||||
desc="Extract website metadata."
|
||||
link="/dashboard/scrape/meta-info"
|
||||
icon="globe"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to QR Code"
|
||||
desc="Generate QR Code from URL."
|
||||
link="/dashboard/scrape/qrcode"
|
||||
@@ -49,15 +46,13 @@ export default async function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Markdown"
|
||||
desc="Convert website content to Markdown format."
|
||||
link="/dashboard/scrape/markdown"
|
||||
icon="heading1"
|
||||
/>
|
||||
<ScrapeInfoCard
|
||||
userId={user.id}
|
||||
<StaticInfoCard
|
||||
title="Url to Text"
|
||||
desc="Extract website text."
|
||||
link="/dashboard/scrape/markdown"
|
||||
|
||||
543
app/(protected)/dashboard/urls/globe/index.tsx
Normal file
543
app/(protected)/dashboard/urls/globe/index.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
addHours,
|
||||
addMinutes,
|
||||
differenceInDays,
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
format,
|
||||
startOfDay,
|
||||
startOfHour,
|
||||
startOfMinute,
|
||||
} from "date-fns";
|
||||
import { create } from "lodash";
|
||||
|
||||
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 processChartData = (locations: Location[]): ChartData[] => {
|
||||
// 过滤有效数据
|
||||
const validLocations = locations.filter((loc) => loc.createdAt);
|
||||
if (validLocations.length === 0) return [];
|
||||
|
||||
// 获取时间范围
|
||||
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 totalHours = differenceInHours(maxDate, minDate);
|
||||
const totalDays = differenceInDays(maxDate, minDate);
|
||||
|
||||
let groupByFn: (date: Date) => Date;
|
||||
let formatFn: (date: Date) => string;
|
||||
let intervalFn: (date: Date, interval: number) => Date;
|
||||
let interval: number;
|
||||
|
||||
// 30分钟内:按1分钟分组
|
||||
if (totalMinutes <= 30) {
|
||||
groupByFn = startOfMinute;
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 1;
|
||||
} else if (totalMinutes <= 60) {
|
||||
// 1小时内:按2分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 2) * 2;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 2;
|
||||
} else if (totalHours <= 2) {
|
||||
// 2小时内:按4分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 4) * 4;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 4;
|
||||
} else if (totalHours <= 6) {
|
||||
// 6小时内:按12分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 12) * 12;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 12;
|
||||
} else if (totalHours <= 12) {
|
||||
// 12小时内:按24分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 24) * 24;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 24;
|
||||
} else if (totalHours <= 24) {
|
||||
// 24小时内:按48分钟分组
|
||||
groupByFn = (date) => {
|
||||
const minutes = Math.floor(date.getMinutes() / 48) * 48;
|
||||
const grouped = startOfMinute(date);
|
||||
grouped.setMinutes(minutes);
|
||||
return grouped;
|
||||
};
|
||||
formatFn = (date) => format(date, "MM-dd HH:mm");
|
||||
intervalFn = addMinutes;
|
||||
interval = 48;
|
||||
} else if (totalDays <= 7) {
|
||||
// 7天内:按天分组
|
||||
groupByFn = startOfDay;
|
||||
formatFn = (date) => format(date, "MM-dd");
|
||||
intervalFn = addHours;
|
||||
interval = 24;
|
||||
} else {
|
||||
// 更长时间:按天分组
|
||||
groupByFn = startOfDay;
|
||||
formatFn = (date) => format(date, "MM-dd");
|
||||
intervalFn = addHours;
|
||||
interval = 24;
|
||||
}
|
||||
|
||||
// 分组聚合数据
|
||||
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);
|
||||
});
|
||||
|
||||
// 填充时间间隔,确保连续性
|
||||
const result: ChartData[] = [];
|
||||
const startGroup = groupByFn(minDate);
|
||||
const endGroup = groupByFn(maxDate);
|
||||
|
||||
let current = startGroup;
|
||||
// 过滤掉count为0 的数据
|
||||
while (current <= endGroup) {
|
||||
const key = current.getTime().toString();
|
||||
result.push({
|
||||
time: formatFn(current),
|
||||
count: groupedData.get(key) || 0,
|
||||
});
|
||||
current = intervalFn(current, interval);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const appendLocationData = (
|
||||
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 = processChartData(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">
|
||||
<RealtimeTimePicker
|
||||
timeRange={timeRange}
|
||||
setTimeRange={handleTimeRangeChange}
|
||||
/>
|
||||
<div className="sm:relative sm:p-4">
|
||||
<RealtimeChart
|
||||
className="left-0 top-0 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="-top-9 right-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="rounded-b-none border-b-0 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>
|
||||
);
|
||||
}
|
||||
102
app/(protected)/dashboard/urls/globe/realtime-chart.tsx
Normal file
102
app/(protected)/dashboard/urls/globe/realtime-chart.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"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);
|
||||
};
|
||||
|
||||
// console.log("chartData", chartData);
|
||||
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>
|
||||
{/* <ResponsiveContainer ></ResponsiveContainer> */}
|
||||
<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 bg-primary-foreground py-2 text-primary 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={20}
|
||||
/>
|
||||
</BarChart>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
309
app/(protected)/dashboard/urls/globe/realtime-globe.tsx
Normal file
309
app/(protected)/dashboard/urls/globe/realtime-globe.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
"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 { 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 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, -130])
|
||||
.atmosphereColor("rgba(170, 170, 200, 0.8)")
|
||||
.backgroundColor("rgba(0,0,0,0)")
|
||||
.globeMaterial(
|
||||
new MeshPhongMaterial({
|
||||
color: "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 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;
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -235,22 +236,26 @@ export function DailyPVUVChart({
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e) => (
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
key={e.value}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -58,23 +59,26 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
|
||||
<SelectValue placeholder="Select a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_DIMENSION_ENUMS.map((e) => (
|
||||
<SelectItem
|
||||
className=""
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
key={e.value}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{DATE_DIMENSION_ENUMS.map((e, i) => (
|
||||
<div key={e.value}>
|
||||
<SelectItem
|
||||
disabled={
|
||||
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
|
||||
}
|
||||
value={e.value}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{e.label}
|
||||
{e.key >
|
||||
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
|
||||
<Icons.crown className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,6 @@ import {
|
||||
timeAgo,
|
||||
} from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -35,6 +28,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -43,16 +37,16 @@ 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 { 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 {
|
||||
@@ -145,18 +139,268 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderList = () => (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by slug..."
|
||||
value={searchParams.slug}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
slug: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.slug && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by target..."
|
||||
value={searchParams.target}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
target: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.target && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setSearchParams({ ...searchParams, target: "" })}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user.role === "ADMIN" && (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by user name..."
|
||||
value={searchParams.userName}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
userName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.userName && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() =>
|
||||
setSearchParams({ ...searchParams, userName: "" })
|
||||
}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Slug
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Target
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
User
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Enabled
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Expiration
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Updated
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Created
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((short) => (
|
||||
<div className="border-b" key={short.id}>
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
<Link
|
||||
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
||||
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
target="_blank"
|
||||
prefetch={false}
|
||||
title={short.url}
|
||||
>
|
||||
{short.url}
|
||||
</Link>
|
||||
<CopyButton
|
||||
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
className={cn(
|
||||
"size-[25px]",
|
||||
"duration-250 transition-all group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
{short.password && (
|
||||
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
|
||||
<LinkInfoPreviewer
|
||||
apiKey={user.apiKey ?? ""}
|
||||
url={short.target}
|
||||
formatUrl={removeUrlSuffix(short.target)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger className="truncate">
|
||||
{short.userName ?? "Anonymous"}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{short.userName ?? "Anonymous"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Switch
|
||||
defaultChecked={short.active === 1}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatu(value, short.id || "")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
{expirationTime(short.expiration, short.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
{timeAgo(short.updatedAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
{timeAgo(short.createdAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(short);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden sm:block">Edit</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
setShowQrcode(!isShowQrcode);
|
||||
}}
|
||||
>
|
||||
<Icons.qrcode className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
if (isShowStats && selectedUrl?.id !== short.id) {
|
||||
} else {
|
||||
setShowStats(!isShowStats);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icons.lineChart className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isShowStats && selectedUrl?.id === short.id && (
|
||||
<UserUrlMetaInfo
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url/meta"
|
||||
urlId={short.id!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="link" />
|
||||
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any url yet. Start creating url.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
{action.includes("/admin") ? (
|
||||
<CardDescription className="text-balance text-lg font-bold">
|
||||
<span>Total URLs:</span>{" "}
|
||||
<span className="font-bold">{data && data.total}</span>
|
||||
</CardDescription>
|
||||
) : (
|
||||
<CardTitle>Short URLs</CardTitle>
|
||||
)}
|
||||
<Tabs defaultValue="Links">
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="Links">Links</TabsTrigger>
|
||||
<TabsTrigger value="Realtime">Realtime</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="ml-auto flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
@@ -170,7 +414,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-[120px] shrink-0 gap-1"
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(null);
|
||||
@@ -179,265 +423,25 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add url
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">Add URL</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by slug..."
|
||||
value={searchParams.slug}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
slug: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.slug && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by target..."
|
||||
value={searchParams.target}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
target: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.target && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() =>
|
||||
setSearchParams({ ...searchParams, target: "" })
|
||||
}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user.role === "ADMIN" && (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by user name..."
|
||||
value={searchParams.userName}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
userName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.userName && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() =>
|
||||
setSearchParams({ ...searchParams, userName: "" })
|
||||
}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Slug
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Target
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
User
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Enabled
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Expiration
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Updated
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Created
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((short) => (
|
||||
<div className="border-b" key={short.id}>
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
<Link
|
||||
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
||||
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
target="_blank"
|
||||
prefetch={false}
|
||||
title={short.url}
|
||||
>
|
||||
{short.url}
|
||||
</Link>
|
||||
<CopyButton
|
||||
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
||||
className={cn(
|
||||
"size-[25px]",
|
||||
"duration-250 transition-all group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
{short.password && (
|
||||
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
|
||||
<LinkInfoPreviewer
|
||||
apiKey={user.apiKey ?? ""}
|
||||
url={short.target}
|
||||
formatUrl={removeUrlSuffix(short.target)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger className="truncate">
|
||||
{short.userName ?? "Anonymous"}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{short.userName ?? "Anonymous"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Switch
|
||||
className="data-[state=checked]:bg-blue-500"
|
||||
defaultChecked={short.active === 1}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatu(value, short.id || "")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
{expirationTime(short.expiration, short.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
{timeAgo(short.updatedAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
{timeAgo(short.createdAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(short);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden sm:block">Edit</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
setShowQrcode(!isShowQrcode);
|
||||
}}
|
||||
>
|
||||
<Icons.qrcode className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedUrl(short);
|
||||
if (isShowStats && selectedUrl?.id !== short.id) {
|
||||
} else {
|
||||
setShowStats(!isShowStats);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icons.lineChart className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isShowStats && selectedUrl?.id === short.id && (
|
||||
<UserUrlMetaInfo
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
team: user.team,
|
||||
}}
|
||||
action="/api/url/meta"
|
||||
urlId={short.id!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="link" />
|
||||
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any url yet. Start creating url.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
pageSize={pageSize}
|
||||
setPageSize={setPageSize}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent className="space-y-3" value="Links">
|
||||
{renderList()}
|
||||
<LiveLog admin={action.indexOf("admin") > -1} />
|
||||
<ApiReference
|
||||
badge="POST /api/v1/short"
|
||||
target="creating short urls"
|
||||
link="/docs/short-urls#api-reference"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="Realtime">
|
||||
<Globe isAdmin={action.indexOf("admin") > -1} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
344
app/(protected)/setup/guide.tsx
Normal file
344
app/(protected)/setup/guide.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { cn, removeUrlSuffix } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { FormSectionColumns } from "@/components/dashboard/form-section-columns";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
export default function StepGuide({
|
||||
user,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [direction, setDirection] = useState(0);
|
||||
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Set up an administrator",
|
||||
description:
|
||||
"Begin by entering your website URL or selecting an example site to reimagine your website with modern themes.",
|
||||
component: () => <SetAdminRole id={user.id} email={user.email} />,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Add the first domain",
|
||||
description:
|
||||
"Check out your reimagined site and click to Migrate & Download.",
|
||||
component: () => <AddDomain onNextStep={goToNextStep} />,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Congrats on completing setup 🎉",
|
||||
description:
|
||||
"Navigate to your GitHub dashboard where you'll manage your repository and project files.",
|
||||
component: () => <Congrats />,
|
||||
},
|
||||
];
|
||||
|
||||
const goToNextStep = () => {
|
||||
if (currentStep < steps.length) {
|
||||
setDirection(1);
|
||||
setCurrentStep(currentStep + 1);
|
||||
if (!completedSteps.includes(currentStep)) {
|
||||
setCompletedSteps([...completedSteps, currentStep]);
|
||||
}
|
||||
} else if (currentStep === steps.length) {
|
||||
router.push("/admin");
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousStep = () => {
|
||||
if (currentStep > 1) {
|
||||
setDirection(-1);
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const currentStepData =
|
||||
steps.find((step) => step.id === currentStep) || steps[0];
|
||||
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 100 : -100,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction < 0 ? 100 : -100,
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal className="md:max-w-2xl">
|
||||
<div className="w-full px-4 py-2 md:px-8 md:py-4">
|
||||
<div className="mb-6 mt-3 flex items-center justify-between gap-4">
|
||||
<h2 className="text-2xl font-bold">Admin Setup Guide</h2>
|
||||
<div className="flex items-center gap-2 rounded-full bg-muted/50 px-3 py-1.5 text-sm font-medium">
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
{currentStep}
|
||||
</span>
|
||||
<span className="text-muted-foreground">of</span>
|
||||
<span>{steps.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="relative w-full rounded-lg">
|
||||
<AnimatePresence custom={direction} mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||
className="flex flex-col justify-center gap-6"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="mb-2 flex items-center gap-1 rounded-lg bg-neutral-100 p-2 dark:bg-neutral-800">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
||||
{currentStep}
|
||||
</span>
|
||||
<motion.h3
|
||||
className="text-base font-semibold"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
{currentStepData.title}
|
||||
</motion.h3>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="h-full"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
{currentStepData.component()}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="mt-auto flex justify-between px-4 pb-4 pt-3 md:px-8 md:pb-6"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
>
|
||||
<button
|
||||
onClick={goToPreviousStep}
|
||||
disabled={currentStep === 1}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 transition-colors",
|
||||
currentStep === 1
|
||||
? "cursor-not-allowed bg-muted text-muted-foreground"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNextStep}
|
||||
// disabled={currentStep === steps.length}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90",
|
||||
)}
|
||||
>
|
||||
{currentStep === steps.length ? "🚀 Start" : "Next"}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SetAdminRole({ id, email }: { id: string; email: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const handleSetAdmin = async () => {
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/setup");
|
||||
if (res.ok) {
|
||||
setIsAdmin(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ReadyBadge = (
|
||||
<Badge className="text-xs font-semibold" variant="green">
|
||||
<Icons.check className="mr-1 size-3" />
|
||||
Ready
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Allow Sign Up:
|
||||
</span>
|
||||
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Set {email} as ADMIN:
|
||||
</span>
|
||||
{isAdmin ? (
|
||||
ReadyBadge
|
||||
) : (
|
||||
<Button
|
||||
className=""
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={handleSetAdmin}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && (
|
||||
<Icons.spinner className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
Active Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
|
||||
<p className="flex items-start gap-1">
|
||||
📢 Only by becoming an administrator can one access the admin panel
|
||||
and add domain names.
|
||||
</p>
|
||||
<p className="my-1">
|
||||
📢 Administrators can set all user permissions, allocate quotas, view
|
||||
and edit all resources (short links, subdomains, email), etc.
|
||||
</p>
|
||||
<p>
|
||||
📢 Via{" "}
|
||||
<a
|
||||
className="text-blue-500"
|
||||
target="_blank"
|
||||
href="/docs/developer/quick-start"
|
||||
>
|
||||
quick start
|
||||
</a>{" "}
|
||||
docs to get more information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDomain({ onNextStep }: { onNextStep: () => void }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [domain, setDomain] = useState("");
|
||||
const handleCreateDomain = async () => {
|
||||
if (!domain) {
|
||||
toast.warning("Domain name cannot be empty");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/admin/domain", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
domain_name: removeUrlSuffix(domain),
|
||||
enable_short_link: true,
|
||||
enable_email: true,
|
||||
enable_dns: true,
|
||||
cf_zone_id: "",
|
||||
cf_api_key: "",
|
||||
cf_email: "",
|
||||
cf_api_key_encrypted: false,
|
||||
max_short_links: 0,
|
||||
max_email_forwards: 0,
|
||||
max_dns_records: 0,
|
||||
active: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
onNextStep();
|
||||
} else {
|
||||
toast.error("Created Failed!", {
|
||||
description: await res.text(),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
|
||||
<FormSectionColumns title="Domain Name">
|
||||
<div className="flex w-full flex-col items-start justify-between gap-2">
|
||||
<Label className="sr-only" htmlFor="domain_name">
|
||||
Domain Name
|
||||
</Label>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner dark:bg-neutral-600"
|
||||
size={32}
|
||||
placeholder="example.com"
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Please enter a valid domain name (must be hosted on Cloudflare).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex w-full items-center justify-end gap-3">
|
||||
<Button
|
||||
className="text-xs text-muted-foreground"
|
||||
variant={"ghost"}
|
||||
size={"sm"}
|
||||
onClick={onNextStep}
|
||||
>
|
||||
Or add later
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
size={"sm"}
|
||||
variant={"blue"}
|
||||
disabled={isPending}
|
||||
onClick={handleCreateDomain}
|
||||
>
|
||||
{isPending && (
|
||||
<Icons.spinner className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Congrats() {
|
||||
return <></>;
|
||||
}
|
||||
15
app/(protected)/setup/loading.tsx
Normal file
15
app/(protected)/setup/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Manage Short URLs"
|
||||
text="List and manage short urls."
|
||||
/>
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
app/(protected)/setup/page.tsx
Normal file
28
app/(protected)/setup/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getAllUsersCount } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
|
||||
import SetupGuide from "./guide";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Setup Guide",
|
||||
description: "Setup Guide",
|
||||
});
|
||||
|
||||
export default async function SetupPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user?.id) redirect("/login");
|
||||
|
||||
if (user.role === "ADMIN") redirect("/admin");
|
||||
|
||||
const count = await getAllUsersCount();
|
||||
|
||||
if (count === 1 && user.role === "USER") {
|
||||
return <SetupGuide user={{ id: user.id, email: user.email! }} />;
|
||||
}
|
||||
|
||||
return redirect("/admin");
|
||||
}
|
||||
145
app/api/admin/domain/route.ts
Normal file
145
app/api/admin/domain/route.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
createDomain,
|
||||
deleteDomain,
|
||||
getAllDomains,
|
||||
updateDomain,
|
||||
} from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
// Get domains list
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const page = url.searchParams.get("page");
|
||||
const size = url.searchParams.get("size");
|
||||
const target = url.searchParams.get("target") || "";
|
||||
|
||||
const data = await getAllDomains(
|
||||
Number(page || "1"),
|
||||
Number(size || "10"),
|
||||
target,
|
||||
);
|
||||
|
||||
return Response.json(data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Create domain
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { data } = await req.json();
|
||||
if (!data || !data.domain_name) {
|
||||
return Response.json("domain_name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const newDomain = await createDomain({
|
||||
domain_name: data.domain_name,
|
||||
enable_short_link: !!data.enable_short_link,
|
||||
enable_email: !!data.enable_email,
|
||||
enable_dns: !!data.enable_dns,
|
||||
cf_zone_id: data.cf_zone_id,
|
||||
cf_api_key: data.cf_api_key,
|
||||
cf_email: data.cf_email,
|
||||
cf_api_key_encrypted: false,
|
||||
max_short_links: data.max_short_links,
|
||||
max_email_forwards: data.max_email_forwards,
|
||||
max_dns_records: data.max_dns_records,
|
||||
active: true,
|
||||
});
|
||||
|
||||
return Response.json(newDomain, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const {
|
||||
domain_name,
|
||||
enable_short_link,
|
||||
enable_email,
|
||||
enable_dns,
|
||||
cf_zone_id,
|
||||
cf_api_key,
|
||||
cf_email,
|
||||
max_short_links,
|
||||
max_email_forwards,
|
||||
max_dns_records,
|
||||
active,
|
||||
id,
|
||||
} = await req.json();
|
||||
if (!id) {
|
||||
return Response.json("domain id is required", { status: 400 });
|
||||
}
|
||||
|
||||
const updatedDomain = await updateDomain(id, {
|
||||
domain_name,
|
||||
enable_short_link: !!enable_short_link,
|
||||
enable_email: !!enable_email,
|
||||
enable_dns: !!enable_dns,
|
||||
active: !!active,
|
||||
cf_zone_id,
|
||||
cf_api_key,
|
||||
cf_email,
|
||||
cf_api_key_encrypted: false,
|
||||
max_short_links,
|
||||
max_email_forwards,
|
||||
max_dns_records,
|
||||
});
|
||||
|
||||
return Response.json(updatedDomain, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete domain
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
if (user.role !== "ADMIN") {
|
||||
return Response.json("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { domain_name } = await req.json();
|
||||
if (!domain_name) {
|
||||
return Response.json("domain_name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const deletedDomain = await deleteDomain(domain_name);
|
||||
|
||||
return Response.json(deletedDomain, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
34
app/api/domain/route.ts
Normal file
34
app/api/domain/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { FeatureMap, getDomainsByFeatureClient } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Get domains by feature for frontend
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const feature = url.searchParams.get("feature") || "";
|
||||
|
||||
if (!Object.keys(FeatureMap).includes(feature)) {
|
||||
return Response.json(
|
||||
"Invalid feature parameter. Use 'short', 'email', or 'record'.",
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const domainList = await getDomainsByFeatureClient(FeatureMap[feature]);
|
||||
|
||||
return Response.json(domainList, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
27
app/api/location/route.ts
Normal file
27
app/api/location/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { geolocation } from "@vercel/functions";
|
||||
|
||||
interface CurrentLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
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,4 +1,3 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { createDNSRecord } from "@/lib/cloudflare";
|
||||
import {
|
||||
@@ -6,31 +5,27 @@ import {
|
||||
getUserRecordByTypeNameContent,
|
||||
getUserRecordCount,
|
||||
} from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { reservedDomains } from "@/lib/enums";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { generateSecret, parseZones } from "@/lib/utils";
|
||||
import { generateSecret } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
return Response.json("API key、zone iD and email are required", {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json("Please add at least one domain", {
|
||||
status: 400,
|
||||
statusText: "API key、zone iD and email are required",
|
||||
statusText: "Please add at least one domain",
|
||||
});
|
||||
}
|
||||
|
||||
const { total } = await getUserRecordCount(user.id);
|
||||
if (
|
||||
user.role !== "ADMIN" &&
|
||||
total >= TeamPlanQuota[user.team].RC_NewRecords
|
||||
) {
|
||||
if (total >= TeamPlanQuota[user.team].RC_NewRecords) {
|
||||
return Response.json("Your records have reached the free limit.", {
|
||||
status: 409,
|
||||
});
|
||||
@@ -49,7 +44,7 @@ export async function POST(req: Request) {
|
||||
let matchedZone;
|
||||
|
||||
for (const zone of zones) {
|
||||
if (record.zone_name === zone.zone_name) {
|
||||
if (record.zone_name === zone.domain_name) {
|
||||
matchedZone = zone;
|
||||
break;
|
||||
}
|
||||
@@ -85,22 +80,22 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const data = await createDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id,
|
||||
matchedZone.cf_api_key,
|
||||
matchedZone.cf_email,
|
||||
record,
|
||||
);
|
||||
|
||||
if (!data.success || !data.result?.id) {
|
||||
console.log("[data]", data);
|
||||
// console.log("[data]", data);
|
||||
return Response.json(data.messages, {
|
||||
status: 501,
|
||||
});
|
||||
} else {
|
||||
const res = await createUserRecord(user.id, {
|
||||
record_id: data.result.id,
|
||||
zone_id: matchedZone.zone_id,
|
||||
zone_name: matchedZone.zone_name,
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
zone_name: matchedZone.domain_name,
|
||||
name: data.result.name,
|
||||
type: data.result.type,
|
||||
content: data.result.content,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { createDNSRecord } from "@/lib/cloudflare";
|
||||
import {
|
||||
@@ -6,10 +5,11 @@ import {
|
||||
getUserRecordByTypeNameContent,
|
||||
getUserRecordCount,
|
||||
} from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
|
||||
import { reservedDomains } from "@/lib/enums";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { generateSecret, parseZones } from "@/lib/utils";
|
||||
import { generateSecret } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -22,13 +22,11 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
return Response.json("API key、zone iD and email are required", {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json("Please add at least one domain", {
|
||||
status: 400,
|
||||
statusText: "API key、zone iD and email are required",
|
||||
statusText: "Please add at least one domain",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,7 +58,7 @@ export async function POST(req: Request) {
|
||||
let matchedZone;
|
||||
|
||||
for (const zone of zones) {
|
||||
if (record.zone_name === zone.zone_name) {
|
||||
if (record.zone_name === zone.domain_name) {
|
||||
matchedZone = zone;
|
||||
break;
|
||||
}
|
||||
@@ -96,9 +94,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const data = await createDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id,
|
||||
matchedZone.cf_api_key,
|
||||
matchedZone.cf_email,
|
||||
record,
|
||||
);
|
||||
|
||||
@@ -110,8 +108,8 @@ export async function POST(req: Request) {
|
||||
} else {
|
||||
const res = await createUserRecord(target_user.id, {
|
||||
record_id: data.result.id,
|
||||
zone_id: matchedZone.zone_id,
|
||||
zone_name: matchedZone.zone_name,
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
zone_name: matchedZone.domain_name,
|
||||
name: data.result.name,
|
||||
type: data.result.type,
|
||||
content: data.result.content,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { deleteDNSRecord } from "@/lib/cloudflare";
|
||||
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { parseZones } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -24,20 +23,15 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{
|
||||
status: 400,
|
||||
statusText: "Missing required configuration",
|
||||
},
|
||||
);
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json("Please add at least one domain", {
|
||||
status: 400,
|
||||
statusText: "Please add at least one domain",
|
||||
});
|
||||
}
|
||||
|
||||
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
|
||||
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
|
||||
if (!matchedZone) {
|
||||
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
|
||||
status: 400,
|
||||
@@ -48,9 +42,9 @@ export async function POST(req: Request) {
|
||||
// force delete
|
||||
await deleteUserRecord(userId, record_id, zone_id, active);
|
||||
await deleteDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id!,
|
||||
matchedZone.cf_api_key!,
|
||||
matchedZone.cf_email!,
|
||||
record_id,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { updateDNSRecord } from "@/lib/cloudflare";
|
||||
import { updateUserRecord } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { parseZones } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -16,16 +15,11 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{
|
||||
status: 400,
|
||||
statusText: "Missing required configuration",
|
||||
},
|
||||
{ status: 401, statusText: "Missing required configuration" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +38,7 @@ export async function POST(req: Request) {
|
||||
let matchedZone;
|
||||
|
||||
for (const zone of zones) {
|
||||
if (record.zone_name === zone.zone_name) {
|
||||
if (record.zone_name === zone.domain_name) {
|
||||
matchedZone = zone;
|
||||
break;
|
||||
}
|
||||
@@ -61,9 +55,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const data = await updateDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id,
|
||||
matchedZone.cf_api_key,
|
||||
matchedZone.cf_email,
|
||||
recordId,
|
||||
{ ...record, name: record_name },
|
||||
);
|
||||
@@ -80,8 +74,8 @@ export async function POST(req: Request) {
|
||||
|
||||
const res = await updateUserRecord(userId, {
|
||||
record_id: data.result.id,
|
||||
zone_id: matchedZone.zone_id,
|
||||
zone_name: matchedZone.zone_name,
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
zone_name: matchedZone.domain_name,
|
||||
name: data.result.name,
|
||||
type: data.result.type,
|
||||
content: data.result.content,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { deleteDNSRecord } from "@/lib/cloudflare";
|
||||
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { parseZones } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -12,20 +12,15 @@ export async function POST(req: Request) {
|
||||
|
||||
const { record_id, zone_id, active } = await req.json();
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{
|
||||
status: 400,
|
||||
statusText: "API key, zone configuration, and email are required",
|
||||
},
|
||||
);
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json("Please add at least one domain", {
|
||||
status: 400,
|
||||
statusText: "Please add at least one domain",
|
||||
});
|
||||
}
|
||||
|
||||
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
|
||||
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
|
||||
if (!matchedZone) {
|
||||
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
|
||||
status: 400,
|
||||
@@ -34,9 +29,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const res = await deleteDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id!,
|
||||
matchedZone.cf_api_key!,
|
||||
matchedZone.cf_email!,
|
||||
record_id,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { updateDNSRecord } from "@/lib/cloudflare";
|
||||
import {
|
||||
updateUserRecord,
|
||||
updateUserRecordState,
|
||||
} from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { reservedDomains } from "@/lib/enums";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { parseZones } from "@/lib/utils";
|
||||
|
||||
// Update DNS record
|
||||
export async function POST(req: Request) {
|
||||
@@ -15,10 +14,8 @@ export async function POST(req: Request) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{ status: 401, statusText: "Missing required configuration" },
|
||||
@@ -40,7 +37,7 @@ export async function POST(req: Request) {
|
||||
let matchedZone;
|
||||
|
||||
for (const zone of zones) {
|
||||
if (record.zone_name === zone.zone_name) {
|
||||
if (record.zone_name === zone.domain_name) {
|
||||
matchedZone = zone;
|
||||
break;
|
||||
}
|
||||
@@ -64,14 +61,13 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const data = await updateDNSRecord(
|
||||
matchedZone.zone_id,
|
||||
CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL,
|
||||
matchedZone.cf_zone_id,
|
||||
matchedZone.cf_api_key,
|
||||
matchedZone.cf_email,
|
||||
recordId,
|
||||
{ ...record, name: record_name },
|
||||
);
|
||||
|
||||
console.log("[updateDNSRecord]", data);
|
||||
if (!data.success || !data.result?.id) {
|
||||
return Response.json(
|
||||
data.errors?.[0]?.message || "Failed to update DNS record",
|
||||
@@ -81,8 +77,8 @@ export async function POST(req: Request) {
|
||||
|
||||
const res = await updateUserRecord(user.id, {
|
||||
record_id: data.result.id,
|
||||
zone_id: matchedZone.zone_id, // Use matched zone_id
|
||||
zone_name: matchedZone.zone_name, // Use matched zone_name
|
||||
zone_id: matchedZone.cf_zone_id,
|
||||
zone_name: matchedZone.domain_name,
|
||||
name: data.result.name,
|
||||
type: data.result.type,
|
||||
content: data.result.content,
|
||||
@@ -118,10 +114,8 @@ export async function PUT(req: Request) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
|
||||
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
|
||||
|
||||
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
|
||||
const zones = await getDomainsByFeature("enable_dns", true);
|
||||
if (!zones.length) {
|
||||
return Response.json(
|
||||
"API key, zone configuration, and email are required",
|
||||
{ status: 401, statusText: "Missing required configuration" },
|
||||
@@ -136,7 +130,7 @@ export async function PUT(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
|
||||
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
|
||||
if (!matchedZone) {
|
||||
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
|
||||
status: 400,
|
||||
@@ -153,9 +147,9 @@ export async function PUT(req: Request) {
|
||||
isTargetAccessible = target_res.status === 200;
|
||||
} catch (fetchError) {
|
||||
isTargetAccessible = false;
|
||||
console.log(
|
||||
`[Fetch Error] Failed to access target ${target}: ${fetchError}`,
|
||||
);
|
||||
// console.log(
|
||||
// `[Fetch Error] Failed to access target ${target}: ${fetchError}`,
|
||||
// );
|
||||
}
|
||||
|
||||
const res = await updateUserRecordState(
|
||||
|
||||
31
app/api/setup/route.ts
Normal file
31
app/api/setup/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {
|
||||
checkUserStatus,
|
||||
getAllUsersCount,
|
||||
setFirstUserAsAdmin,
|
||||
} from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const count = await getAllUsersCount();
|
||||
|
||||
if (count === 1 && user.role === "USER") {
|
||||
const res = await setFirstUserAsAdmin(user.id);
|
||||
if (res) {
|
||||
return Response.json({ admin: res.role === "ADMIN" }, { status: 201 });
|
||||
}
|
||||
return Response.json({ admin: false }, { status: 400 });
|
||||
}
|
||||
|
||||
return redirect("/admin");
|
||||
} catch (error) {
|
||||
return Response.json(error?.statusText || error, {
|
||||
status: error.status || 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { createUserShortUrl } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
@@ -23,6 +24,18 @@ export async function POST(req: Request) {
|
||||
|
||||
const { target, url, prefix, visible, active, expiration, password } =
|
||||
createUrlSchema.parse(data);
|
||||
|
||||
const zones = await getDomainsByFeature("enable_short_link");
|
||||
if (
|
||||
!zones.length ||
|
||||
!zones.map((zone) => zone.domain_name).includes(prefix)
|
||||
) {
|
||||
return Response.json("Invalid domain", {
|
||||
status: 400,
|
||||
statusText: "Invalid domain",
|
||||
});
|
||||
}
|
||||
|
||||
const res = await createUserShortUrl({
|
||||
userId: user.id,
|
||||
userName: user.name || "Anonymous",
|
||||
|
||||
278
app/api/url/admin/locations/route.ts
Normal file
278
app/api/url/admin/locations/route.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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}`;
|
||||
|
||||
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;
|
||||
|
||||
// console.log(
|
||||
// `Fetched ${rawData.length} records, aggregated to ${uniqueLocations} locations, total clicks: ${totalClicks}`,
|
||||
// );
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
// finally {
|
||||
// await prisma.$disconnect();
|
||||
// }
|
||||
}
|
||||
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
// finally {
|
||||
// await prisma.$disconnect();
|
||||
// }
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserShortUrls } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { checkApiKey } from "@/lib/dto/api-key";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { createUserEmail, deleteUserEmailByAddress } from "@/lib/dto/email";
|
||||
import { reservedAddressSuffix } from "@/lib/enums";
|
||||
import { restrictByTimeRange } from "@/lib/team";
|
||||
@@ -53,7 +54,12 @@ export async function POST(req: NextRequest) {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!siteConfig.emailDomains.includes(suffix)) {
|
||||
|
||||
const zones = await getDomainsByFeature("enable_email", true);
|
||||
if (
|
||||
!zones.length ||
|
||||
!zones.map((zone) => zone.domain_name).includes(suffix)
|
||||
) {
|
||||
return NextResponse.json("Invalid email suffix address", { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { ipAddress } from "@vercel/functions";
|
||||
|
||||
import { getIpInfo } from "@/lib/utils";
|
||||
import { extractRealIP, 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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { checkApiKey } from "@/lib/dto/api-key";
|
||||
import { getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { createUserShortUrl } from "@/lib/dto/short-urls";
|
||||
import { restrictByTimeRange } from "@/lib/team";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
@@ -41,6 +42,17 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const zones = await getDomainsByFeature("enable_short_link");
|
||||
if (
|
||||
!zones.length ||
|
||||
!zones.map((zone) => zone.domain_name).includes(prefix)
|
||||
) {
|
||||
return Response.json("Invalid domain", {
|
||||
status: 409,
|
||||
statusText: "Invalid domain",
|
||||
});
|
||||
}
|
||||
|
||||
const res = await createUserShortUrl({
|
||||
userId: user.id,
|
||||
userName: user.name || "Anonymous",
|
||||
|
||||
@@ -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.5.1",
|
||||
"versionName": "0.6.2",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
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: {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
@@ -122,10 +123,13 @@ 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>
|
||||
|
||||
@@ -145,14 +145,12 @@ export function HeroCard({
|
||||
);
|
||||
}
|
||||
|
||||
export async function ScrapeInfoCard({
|
||||
userId,
|
||||
export async function StaticInfoCard({
|
||||
title,
|
||||
desc,
|
||||
link,
|
||||
icon = "users",
|
||||
}: {
|
||||
userId: string;
|
||||
title: string;
|
||||
desc?: string;
|
||||
link: string;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SectionColumnsType {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
@@ -14,7 +16,12 @@ export function FormSectionColumns({
|
||||
className,
|
||||
}: SectionColumnsType) {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 items-center gap-x-12 gap-y-2 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full grid-cols-1 items-center gap-x-12 gap-y-2 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="col-span-4 flex items-start gap-0.5 text-sm leading-none">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
{required && (
|
||||
|
||||
@@ -77,9 +77,7 @@ export default function EmailSidebar({
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [showEmailModal, setShowEmailModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [domainSuffix, setDomainSuffix] = useState<string | null>(
|
||||
siteConfig.shortDomains[0],
|
||||
);
|
||||
const [domainSuffix, setDomainSuffix] = useState<string | null>("wr.do");
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [emailToDelete, setEmailToDelete] = useState<string | null>(null);
|
||||
const [deleteInput, setDeleteInput] = useState("");
|
||||
@@ -109,6 +107,13 @@ export default function EmailSidebar({
|
||||
},
|
||||
);
|
||||
|
||||
const { data: emailDomains, isLoading: isLoadingDomains } = useSWR<
|
||||
{ domain_name: string }[]
|
||||
>("/api/domain?feature=email", fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
});
|
||||
|
||||
if (!selectedEmailAddress && data && data.list.length > 0) {
|
||||
onSelectEmail(data.list[0].emailAddress);
|
||||
}
|
||||
@@ -595,25 +600,38 @@ export default function EmailSidebar({
|
||||
isEdit ? selectedEmailAddress?.split("@")[0] || "" : ""
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setDomainSuffix(value);
|
||||
}}
|
||||
name="suffix"
|
||||
defaultValue={domainSuffix || siteConfig.emailDomains[0]}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteConfig.emailDomains.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
@{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoadingDomains ? (
|
||||
<Skeleton className="h-9 w-1/3 rounded-none border-x-0 shadow-inner" />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setDomainSuffix(value);
|
||||
}}
|
||||
name="suffix"
|
||||
defaultValue={domainSuffix || "wr.do"}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailDomains && emailDomains.length > 0 ? (
|
||||
emailDomains.map((v) => (
|
||||
<SelectItem
|
||||
key={v.domain_name}
|
||||
value={v.domain_name}
|
||||
>
|
||||
@{v.domain_name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
type="button"
|
||||
|
||||
418
components/forms/domain-form.tsx
Normal file
418
components/forms/domain-form.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, 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 { DomainFormData } from "@/lib/dto/domains";
|
||||
import { EXPIRATION_ENUMS } from "@/lib/enums";
|
||||
import { generateUrlSuffix } from "@/lib/utils";
|
||||
import { createDomainSchema } from "@/lib/validations/domain";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
import { FormSectionColumns } from "../dashboard/form-section-columns";
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export type FormData = DomainFormData;
|
||||
|
||||
export type FormType = "add" | "edit";
|
||||
|
||||
export interface DomainFormProps {
|
||||
user: Pick<User, "id" | "name">;
|
||||
isShowForm: boolean;
|
||||
setShowForm: Dispatch<SetStateAction<boolean>>;
|
||||
type: FormType;
|
||||
initData?: DomainFormData | null;
|
||||
action: string;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function DomainForm({
|
||||
setShowForm,
|
||||
type,
|
||||
initData,
|
||||
action,
|
||||
onRefresh,
|
||||
}: DomainFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
const [currentRecordStatus, setCurrentRecordStatus] = useState(
|
||||
initData?.enable_dns || false,
|
||||
);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createDomainSchema),
|
||||
defaultValues: {
|
||||
id: initData?.id || "",
|
||||
domain_name: initData?.domain_name || "",
|
||||
enable_short_link: initData?.enable_short_link || false,
|
||||
enable_email: initData?.enable_email || false,
|
||||
enable_dns: initData?.enable_dns || false,
|
||||
cf_zone_id: initData?.cf_zone_id || "",
|
||||
cf_api_key: initData?.cf_api_key || "",
|
||||
cf_email: initData?.cf_email || "",
|
||||
cf_api_key_encrypted: initData?.cf_api_key_encrypted || false,
|
||||
max_short_links: initData?.max_short_links || 0,
|
||||
max_email_forwards: initData?.max_email_forwards || 0,
|
||||
max_dns_records: initData?.max_dns_records || 0,
|
||||
active: initData?.active || true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateDomain(data);
|
||||
} else if (type === "edit") {
|
||||
handleUpdateDomain(data);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateDomain = async (data: DomainFormData) => {
|
||||
startTransition(async () => {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Created Failed!", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
// const res = await response.json();
|
||||
toast.success(`Created successfully!`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateDomain = async (data: DomainFormData) => {
|
||||
startTransition(async () => {
|
||||
if (type === "edit") {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Update Failed", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
await response.json();
|
||||
toast.success(`Update successfully!`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteDomain = async () => {
|
||||
if (type === "edit") {
|
||||
startDeleteTransition(async () => {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({
|
||||
domain_name: initData?.domain_name,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Delete Failed", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
await response.json();
|
||||
toast.success(`Success`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
|
||||
{type === "add" ? "Create" : "Edit"} Domain
|
||||
</div>
|
||||
<form className="p-4" onSubmit={onSubmit}>
|
||||
<div className="relative flex flex-col items-center justify-start gap-0 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
Base
|
||||
</h2>
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="domain_name">
|
||||
Domain Name:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("domain_name")}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.domain_name ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.domain_name.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Required. eg: example.com
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="" htmlFor="active">
|
||||
Active:
|
||||
</Label>
|
||||
<Switch
|
||||
id="active"
|
||||
{...register("active")}
|
||||
defaultChecked={initData?.active ?? true}
|
||||
onCheckedChange={(value) => setValue("active", value)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2 flex flex-col items-center justify-start gap-4 rounded-md bg-neutral-100 p-4 pt-10 dark:bg-neutral-800">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
Services(Optional)
|
||||
</h2>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="" htmlFor="short_url_service">
|
||||
Short URL Service:
|
||||
</Label>
|
||||
<Switch
|
||||
id="short_url_service"
|
||||
{...register("enable_short_link")}
|
||||
defaultChecked={initData?.enable_short_link ?? false}
|
||||
onCheckedChange={(value) => setValue("enable_short_link", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="" htmlFor="email_service">
|
||||
Email Service:
|
||||
</Label>
|
||||
<Switch
|
||||
id="email_service"
|
||||
{...register("enable_email")}
|
||||
defaultChecked={initData?.enable_email ?? false}
|
||||
onCheckedChange={(value) => setValue("enable_email", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="cursor-pointer" htmlFor="dns_record_service">
|
||||
DNS Record Service:
|
||||
</Label>
|
||||
<Switch
|
||||
id="dns_record_service"
|
||||
{...register("enable_dns")}
|
||||
defaultChecked={initData?.enable_dns ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
setValue("enable_dns", value);
|
||||
setCurrentRecordStatus(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
<h2 className="absolute left-2 top-4 text-xs font-semibold text-neutral-400">
|
||||
Cloudflare Configs(Optional)
|
||||
</h2>
|
||||
<Icons.chevronDown className="ml-auto size-4" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{!currentRecordStatus && (
|
||||
<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 "DNS Record
|
||||
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">
|
||||
Zone ID:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("cf_zone_id")}
|
||||
disabled={!currentRecordStatus}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.cf_zone_id ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.cf_zone_id.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Optional.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
How to get zone id?
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
|
||||
API Token:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("cf_api_key")}
|
||||
disabled={!currentRecordStatus}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.cf_api_key ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.cf_api_key.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Optional.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
How to get api token?
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="email">
|
||||
Account Email:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("cf_email")}
|
||||
disabled={!currentRecordStatus}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.cf_email ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.cf_email.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Optional.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
How to get cloudflare account email?
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex justify-end gap-3">
|
||||
{type === "edit" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-[80px] px-0"
|
||||
onClick={() => handleDeleteDomain()}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>Delete</p>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="reset"
|
||||
variant="outline"
|
||||
className="w-[80px] px-0"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
Cancle
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="blue"
|
||||
disabled={isPending}
|
||||
className="w-[80px] shrink-0 px-0"
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>{type === "edit" ? "Update" : "Save"}</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { CreateDNSRecord, RecordType } from "@/lib/cloudflare";
|
||||
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { RECORD_TYPE_ENUMS, TTL_ENUMS } from "@/lib/enums";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { createRecordSchema } from "@/lib/validations/record";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export type FormData = CreateDNSRecord;
|
||||
@@ -77,6 +80,16 @@ export function RecordForm({
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch the record domains
|
||||
const { data: recordDomains, isLoading } = useSWR<{ domain_name: string }[]>(
|
||||
"/api/domain?feature=record",
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateRecord(data);
|
||||
@@ -196,26 +209,36 @@ export function RecordForm({
|
||||
|
||||
<div className="items-center justify-start gap-4 md:flex">
|
||||
<FormSectionColumns title="Domain" required>
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("zone_name", value);
|
||||
setCurrentZoneName(value);
|
||||
}}
|
||||
name="zone_name"
|
||||
defaultValue={String(initData?.zone_name || "wr.do")}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteConfig.recordDomains.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-full" />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("zone_name", value);
|
||||
setCurrentZoneName(value);
|
||||
}}
|
||||
name="zone_name"
|
||||
defaultValue={String(initData?.zone_name || "wr.do")}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recordDomains && recordDomains.length > 0 ? (
|
||||
recordDomains.map((v) => (
|
||||
<SelectItem key={v.domain_name} value={v.domain_name}>
|
||||
{v.domain_name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Required. Select a domain.
|
||||
</p>
|
||||
@@ -356,21 +379,23 @@ export function RecordForm({
|
||||
Optional. Time To Live.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="Proxy">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="proxy">
|
||||
Proxy
|
||||
</Label>
|
||||
<Switch
|
||||
id="proxied"
|
||||
{...register("proxied")}
|
||||
onCheckedChange={(value) => setValue("proxied", value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Proxy status.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
{["A", "CNAME"].includes(currentRecordType) && (
|
||||
<FormSectionColumns title="Proxy">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="proxy">
|
||||
Proxy
|
||||
</Label>
|
||||
<Switch
|
||||
id="proxied"
|
||||
{...register("proxied")}
|
||||
onCheckedChange={(value) => setValue("proxied", value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Proxy status.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
@@ -6,11 +6,12 @@ import { User } from "@prisma/client";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
||||
import { EXPIRATION_ENUMS } from "@/lib/enums";
|
||||
import { generateUrlSuffix } from "@/lib/utils";
|
||||
import { fetcher, generateUrlSuffix } from "@/lib/utils";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export type FormData = ShortUrlFormData;
|
||||
|
||||
@@ -64,13 +65,22 @@ export function UrlForm({
|
||||
target: initData?.target || "",
|
||||
url: initData?.url || "",
|
||||
active: initData?.active || 1,
|
||||
prefix: initData?.prefix || siteConfig.shortDomains[0],
|
||||
prefix: initData?.prefix || "wr.do",
|
||||
visible: initData?.visible || 0,
|
||||
expiration: initData?.expiration || "-1",
|
||||
password: initData?.password || "",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: shortDomains, isLoading } = useSWR<{ domain_name: string }[]>(
|
||||
"/api/domain?feature=short",
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateUrl(data);
|
||||
@@ -191,25 +201,35 @@ export function UrlForm({
|
||||
</Label>
|
||||
|
||||
<div className="relative flex w-full items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("prefix", value);
|
||||
}}
|
||||
name="prefix"
|
||||
defaultValue={initData?.prefix || siteConfig.shortDomains[0]}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteConfig.shortDomains.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-1/3 rounded-r-none border-r-0 shadow-inner" />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("prefix", value);
|
||||
}}
|
||||
name="prefix"
|
||||
defaultValue={initData?.prefix || "wr.do"}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shortDomains && shortDomains.length > 0 ? (
|
||||
shortDomains.map((v) => (
|
||||
<SelectItem key={v.domain_name} value={v.domain_name}>
|
||||
{v.domain_name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<Button className="w-full" variant="ghost">
|
||||
No domain
|
||||
</Button>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Input
|
||||
id="url"
|
||||
className="w-full rounded-none pl-[8px] shadow-inner"
|
||||
@@ -296,48 +316,6 @@ export function UrlForm({
|
||||
Expiration time, default for never.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
|
||||
{/* <div>
|
||||
<p className="text-sm text-gray-700 dark:text-white">
|
||||
Your Final URL:
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 dark:text-white">
|
||||
{getValues("prefix")}/s/{getValues("url")}
|
||||
</p>
|
||||
</div> */}
|
||||
{/* <FormSectionColumns title="Visible">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="visible">
|
||||
Visible
|
||||
</Label>
|
||||
<Switch
|
||||
id="visible"
|
||||
{...register("visible")}
|
||||
disabled
|
||||
defaultChecked={initData?.visible === 1 || false}
|
||||
onCheckedChange={(value) => setValue("visible", value ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Public or private short url.
|
||||
</p>
|
||||
</FormSectionColumns> */}
|
||||
{/* <FormSectionColumns title="Active">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="active">
|
||||
Active
|
||||
</Label>
|
||||
<Switch
|
||||
id="active"
|
||||
{...register("active")}
|
||||
defaultChecked={initData?.active === 1 || true}
|
||||
onCheckedChange={(value) => setValue("active", value ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Enable or disable short url.
|
||||
</p>
|
||||
</FormSectionColumns> */}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
@@ -10,27 +10,19 @@ interface GitHubResponse {
|
||||
}
|
||||
|
||||
async function getGitHubStars(owner: string, repo: string) {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
|
||||
if (!githubToken) {
|
||||
throw new Error("GitHub token is not configured");
|
||||
}
|
||||
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
Authorization: `token ${githubToken}`,
|
||||
"User-Agent": "NextJS-App",
|
||||
const res = await fetch(
|
||||
`https://wr.do/api/github?owner=${owner}&repo=${repo}`,
|
||||
{
|
||||
next: { revalidate: 3600 },
|
||||
},
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch GitHub stars");
|
||||
}
|
||||
|
||||
const data: GitHubResponse = await res.json();
|
||||
return data.stargazers_count;
|
||||
const data = await res.json();
|
||||
return data.stars;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -301,4 +301,33 @@ export const Icons = {
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
cloudflare: ({ ...props }: LucideProps) => (
|
||||
<svg
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 -70 256 256"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
{...props}
|
||||
>
|
||||
<g>
|
||||
<g transform="translate(0.000000, -1.000000)">
|
||||
<path
|
||||
d="M202.3569,50.394 L197.0459,48.27 C172.0849,104.434 72.7859,70.289 66.8109,86.997 C65.8149,98.283 121.0379,89.143 160.5169,91.056 C172.5559,91.639 178.5929,100.727 173.4809,115.54 L183.5499,115.571 C195.1649,79.362 232.2329,97.841 233.7819,85.891 C231.2369,78.034 191.1809,85.891 202.3569,50.394 Z"
|
||||
fill="#FFFFFF"
|
||||
></path>
|
||||
<path
|
||||
d="M176.332,109.3483 C177.925,104.0373 177.394,98.7263 174.739,95.5393 C172.083,92.3523 168.365,90.2283 163.585,89.6973 L71.17,88.6343 C70.639,88.6343 70.108,88.1033 69.577,88.1033 C69.046,87.5723 69.046,87.0413 69.577,86.5103 C70.108,85.4483 70.639,84.9163 71.701,84.9163 L164.647,83.8543 C175.801,83.3233 187.486,74.2943 191.734,63.6723 L197.046,49.8633 C197.046,49.3313 197.577,48.8003 197.046,48.2693 C191.203,21.1823 166.772,0.9993 138.091,0.9993 C111.535,0.9993 88.697,17.9953 80.73,41.8963 C75.419,38.1783 69.046,36.0533 61.61,36.5853 C48.863,37.6473 38.772,48.2693 37.178,61.0163 C36.647,64.2033 37.178,67.3903 37.71,70.5763 C16.996,71.1073 0,88.1033 0,109.3483 C0,111.4723 0,113.0663 0.531,115.1903 C0.531,116.2533 1.593,116.7843 2.125,116.7843 L172.614,116.7843 C173.676,116.7843 174.739,116.2533 174.739,115.1903 L176.332,109.3483 Z"
|
||||
fill="#F4811F"
|
||||
></path>
|
||||
<path
|
||||
d="M205.5436,49.8628 L202.8876,49.8628 C202.3566,49.8628 201.8256,50.3938 201.2946,50.9248 L197.5766,63.6718 C195.9836,68.9828 196.5146,74.2948 199.1706,77.4808 C201.8256,80.6678 205.5436,82.7918 210.3236,83.3238 L229.9756,84.3858 C230.5066,84.3858 231.0376,84.9168 231.5686,84.9168 C232.0996,85.4478 232.0996,85.9788 231.5686,86.5098 C231.0376,87.5728 230.5066,88.1038 229.4436,88.1038 L209.2616,89.1658 C198.1076,89.6968 186.4236,98.7258 182.1746,109.3478 L181.1116,114.1288 C180.5806,114.6598 181.1116,115.7218 182.1746,115.7218 L252.2826,115.7218 C253.3446,115.7218 253.8756,115.1908 253.8756,114.1288 C254.9376,109.8798 255.9996,105.0998 255.9996,100.3188 C255.9996,72.7008 233.1616,49.8628 205.5436,49.8628"
|
||||
fill="#FAAD3F"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
blue: "bg-blue-500 hover:bg-blue-600 border-transparent text-white",
|
||||
green: "bg-green-500 hover:bg-green-600 border-transparent text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
{ href: "/dashboard", icon: "dashboard", title: "Dashboard" },
|
||||
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
|
||||
{ href: "/emails", icon: "mail", title: "Emails" },
|
||||
{ href: "/dashboard/records", icon: "globeLock", title: "DNS Records" },
|
||||
{ href: "/dashboard/records", icon: "globe", title: "DNS Records" },
|
||||
{ href: "/chat", icon: "messages", title: "WRoom" },
|
||||
],
|
||||
},
|
||||
@@ -54,6 +54,12 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
title: "Admin Panel",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/domains",
|
||||
icon: "globeLock",
|
||||
title: "Domains",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/users",
|
||||
icon: "users",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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 short_domains = env.NEXT_PUBLIC_SHORT_DOMAINS || "";
|
||||
const email_domains = env.NEXT_PUBLIC_EMAIL_DOMAINS || "";
|
||||
const email_r2_domain = env.NEXT_PUBLIC_EMAIL_R2_DOMAIN || "";
|
||||
const record_domains = env.NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME || "";
|
||||
|
||||
export const siteConfig: SiteConfig = {
|
||||
name: "WR.DO",
|
||||
@@ -23,9 +20,6 @@ export const siteConfig: SiteConfig = {
|
||||
},
|
||||
mailSupport: "support@wr.do",
|
||||
openSignup: open_signup === "1" ? true : false,
|
||||
shortDomains: short_domains.split(","),
|
||||
emailDomains: email_domains.split(","),
|
||||
recordDomains: record_domains.split(","),
|
||||
emailR2Domain: email_r2_domain,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,49 @@ description: How to config the cloudflare api.
|
||||
|
||||
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
|
||||
|
||||
## Overview
|
||||
|
||||
Administrators can manage domain configurations at `/admin/domains`, including adding, deleting, and modifying domains.
|
||||
|
||||

|
||||
|
||||
### Domain Configuration Form
|
||||
|
||||
The `Short URL Service` and `Email Service` require no additional configuration and can be activated to enable short URL and email functionalities (email requires worker forwarding setup).
|
||||
|
||||
To enable the `DNS Record Service`, you must complete the `Cloudflare Configs(Optional)` form with the following fields:
|
||||
|
||||
- Zone ID
|
||||
- API Token
|
||||
- Email
|
||||
|
||||
These fields are used to configure the Cloudflare API. If your domain is hosted through Cloudflare, you can find these details in the Cloudflare dashboard.
|
||||
|
||||
### Zone ID
|
||||
|
||||
The unique identifier for a domain hosted on Cloudflare, located at:
|
||||
|
||||
https://dash.cloudflare.com/[account_id]/[zone_name]
|
||||
|
||||
### API Token
|
||||
|
||||
Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
|
||||
|
||||
### Email
|
||||
|
||||
Email for registering a Cloudflare account
|
||||
|
||||
<Callout type="info">
|
||||
You can manage domains hosted under different Cloudflare accounts,
|
||||
provided the API Token and Email are sourced from the same account.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
# This section is Deprecated since version v0.6.0
|
||||
|
||||
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
|
||||
|
||||
In this section, you can update these variables:
|
||||
|
||||
```js title=".env"
|
||||
|
||||
48
content/docs/developer/deploy.mdx
Normal file
48
content/docs/developer/deploy.mdx
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Deploy Guide
|
||||
description: Choose your deployment method
|
||||
---
|
||||
|
||||
## 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`、`.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
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -48,22 +48,12 @@ 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. |
|
||||
| CLOUDFLARE_ZONE | `[{"zone_id":"abc465","zone_name":"example.com"}]` | The zone info for Cloudflare. |
|
||||
| NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME | `example.com,example2.com` | The zone name for Cloudflare. |
|
||||
| CLOUDFLARE_API_KEY | `123465` | The API key for Cloudflare. |
|
||||
| CLOUDFLARE_EMAIL | `123465` | The email for Cloudflare. |
|
||||
| NEXT_PUBLIC_OPEN_SIGNUP | `1` | Open signup. |
|
||||
| SCREENSHOTONE_BASE_URL | `https://api.example.com` | pending |
|
||||
| GITHUB_TOKEN | `ghp_sscsfarwetqet` | https://github.com/settings/tokens |
|
||||
| NEXT_PUBLIC_SHORT_DOMAINS | `wr.do,uv.do` | The list of short domains. Separated by `,` |
|
||||
| NEXT_PUBLIC_EMAIL_DOMAINS | `wr.do,uv.do` | The list of email domains. Separated by `,` |
|
||||
|
||||
> `NEXT_PUBLIC_SHORT_DOMAINS`、`NEXT_PUBLIC_EMAIL_DOMAINS` is used to limit the short domain name and email domain name.
|
||||
|
||||
- How to get `GOOGLE_CLIENT_ID`、`GITHUB_ID`, see [Authentification](/docs/developer/authentification).
|
||||
- How to get `RESEND_API_KEY`, see [Email](/docs/developer/email).
|
||||
- How to get `CLOUDFLARE_ZONE`、`CLOUDFLARE_API_KEY`、`CLOUDFLARE_EMAIL`、`NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME`, see [Cloudflare Configs](/docs/developer/cloudflare).
|
||||
- How to get `DATABASE_URL`, see [Database](/docs/developer/database).
|
||||
- How to active email worker, see [Email Worker](/docs/developer/cloudflare-email-worker).
|
||||
|
||||
For step by step installation, see [Quick Start](/docs/developer/quick-start).
|
||||
|
||||
@@ -45,25 +45,23 @@ DATABASE_URL=
|
||||
|
||||
### Deploy Postgres
|
||||
|
||||
#### Manually install (Recommended)
|
||||
|
||||
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations/20240705091917_init/migration.sql),
|
||||
copy the sql code to the database to initialize the database schema.
|
||||
|
||||
#### or
|
||||
|
||||
```bash
|
||||
pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
#### Or manually init
|
||||
|
||||
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations),
|
||||
copy the sql code to the database to initialize the database schema.
|
||||
|
||||
### Add the AUTH_SECRET Environment Variable
|
||||
|
||||
The `AUTH_SECRET` environment variable is used to encrypt tokens and email verification hashes(NextAuth.js).
|
||||
You can generate one from https://generate-secret.vercel.app/32:
|
||||
|
||||
```js title=".env"
|
||||
AUTH_SECRET=a3e686f39b2a878c6866e4604e6f1b1b
|
||||
AUTH_SECRET=10000032bsfasfafk4lkkfsa
|
||||
```
|
||||
|
||||
## 2. Configure Authentication Service
|
||||
@@ -136,40 +134,7 @@ RESEND_API_KEY = re_your_resend_api_key;
|
||||
|
||||
</Steps>
|
||||
|
||||
## 3. Cloudflare Configs
|
||||
|
||||
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
|
||||
|
||||
### Add the CLOUDFLARE_ZONE Environment Variable
|
||||
|
||||
A JSON array of objects, each containing a zone_id and zone_name for your Cloudflare zones. The zone_id is the unique identifier for a domain, and the zone_name is the domain name (e.g., example.com).
|
||||
|
||||
> Follow [this way](https://dash.cloudflare.com/Your_Acount_Id/wr.do), and scroll down to `Zone ID`.
|
||||
|
||||
### Add the CLOUDFLARE_API_KEY Environment Variable
|
||||
|
||||
This is the API key that you use to authenticate requests to the Cloudflare API. You can generate or find your API key in the Cloudflare dashboard under the `profile` -> `api-tokens` section.
|
||||
|
||||
> Follow [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens), and scroll down to `API Token`, the `Global API Key` should be used.
|
||||
|
||||
### Add the CLOUDFLARE_EMAIL Environment Variable
|
||||
|
||||
This is the email address associated with your Cloudflare account. It is used for authentication alongside the API key.
|
||||
|
||||
### Add the NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME Environment Variable
|
||||
|
||||
A comma-separated list of domain names (e.g., `example.com,example2.com`) used for frontend display. These must correspond to the zone_name values in CLOUDFLARE_ZONE.
|
||||
|
||||
In this section, you can update these variables:
|
||||
|
||||
```js title=".env"
|
||||
CLOUDFLARE_ZONE=[{"zone_id":"abc465","zone_name":"example.com"},{"zone_id":"abc465","zone_name":"example2.com"}]
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=example.com,example2.com
|
||||
CLOUDFLARE_API_KEY=1234567890abcdef1234567890abcdef
|
||||
CLOUDFLARE_EMAIL=user@example.com
|
||||
```
|
||||
|
||||
## 4. Email Worker Configs
|
||||
## 3. Email Worker Configs
|
||||
|
||||
See detail in [Email Worker](/docs/developer/cloudflare-email-worker).
|
||||
|
||||
@@ -187,20 +152,14 @@ Via:
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN=https://email-attachment.wr.do
|
||||
```
|
||||
|
||||
## 5. Add the Bussiness Configs
|
||||
## 4. Add the Bussiness Configs
|
||||
|
||||
```js title=".env"
|
||||
# Allow anyone to sign up
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
|
||||
# Short domains. Separated by `,`
|
||||
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
|
||||
|
||||
# Email domains. Separated by `,`
|
||||
NEXT_PUBLIC_EMAIL_DOMAINS=wr.do,uv.do
|
||||
```
|
||||
|
||||
## 6. Add the SCREENSHOTONE_BASE_URL Environment Variable
|
||||
## 5. Add the SCREENSHOTONE_BASE_URL Environment Variable
|
||||
|
||||
It's the base URL for the screenshotone API.
|
||||
|
||||
@@ -211,20 +170,44 @@ Deploy docs via [here](https://jasonraimondi.github.io/url-to-png/)
|
||||
SCREENSHOTONE_BASE_URL=https://api.screenshotone.com
|
||||
```
|
||||
|
||||
## 7. Add the GITHUB_TOKEN Environment Variable
|
||||
## 6. Add the GITHUB_TOKEN Environment Variable
|
||||
|
||||
Via https://github.com/settings/tokens to get your token.
|
||||
|
||||
```js title=".env"
|
||||
GITHUB_TOKEN=
|
||||
```
|
||||
## 8. Start the Dev Server
|
||||
|
||||
## 7. Start the Dev Server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
Via [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## 8. Setup System
|
||||
|
||||
#### Create the first account and Change the account's role to ADMIN
|
||||
|
||||
Follow the steps below:
|
||||
|
||||
- 1. Via [http://localhost:3000/login](http://localhost:3000/login), login with your account.
|
||||
- 2. Via [http://localhost:3000/setup](http://localhost:3000/setup), change the account's role to ADMIN.
|
||||
- 3. Then follow the **panel guide** to config the system and add the first domain.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
<Callout type="info">
|
||||
After change the account's role to ADMIN, then you can refresh the website and access http://localhost:3000/admin.
|
||||
|
||||
<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
|
||||
|
||||
@@ -238,17 +221,6 @@ https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
|
||||
|
||||
Change the `SSL/TLS Encryption` Mode to `Full` in the Cloudflare dashboard.
|
||||
|
||||
### How can I access the admin panel after first deployment?
|
||||
|
||||
You need to first register an account and log in,
|
||||
and modify the `role` field of this account to `ADMIN` in the `users` table of the database.
|
||||
Then, refresh the website and access http://localhost:3000/admin.
|
||||
|
||||
<Callout type="note">
|
||||
Although it may not be convenient to do so, this is currently the fastest way to become an administrator.
|
||||
In future versions, we will implement the function of automatically setting up administrators as soon as possible.
|
||||
</Callout>
|
||||
|
||||
### How can I change the team plan quota?
|
||||
|
||||
Via team.ts:
|
||||
|
||||
@@ -15,13 +15,20 @@ description: Welcome to the WR.DO documentation.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
53
docker-compose-localdb.yml
Normal file
53
docker-compose-localdb.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
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}
|
||||
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
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
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}
|
||||
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
|
||||
21
env.mjs
21
env.mjs
@@ -6,28 +6,22 @@ export const env = createEnv({
|
||||
// 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(),
|
||||
CLOUDFLARE_ZONE: z.string().min(1),
|
||||
CLOUDFLARE_API_KEY: z.string().min(1),
|
||||
CLOUDFLARE_EMAIL: z.string().min(1),
|
||||
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_SHORT_DOMAINS: z.string().min(1).default(""),
|
||||
NEXT_PUBLIC_EMAIL_DOMAINS: z.string().min(1).default(""),
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().min(1),
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME: z.string().min(1),
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
@@ -40,14 +34,7 @@ export const env = createEnv({
|
||||
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
NEXT_PUBLIC_OPEN_SIGNUP: process.env.NEXT_PUBLIC_OPEN_SIGNUP,
|
||||
NEXT_PUBLIC_SHORT_DOMAINS: process.env.NEXT_PUBLIC_SHORT_DOMAINS,
|
||||
NEXT_PUBLIC_EMAIL_DOMAINS: process.env.NEXT_PUBLIC_EMAIL_DOMAINS,
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN: process.env.NEXT_PUBLIC_EMAIL_R2_DOMAIN,
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME:
|
||||
process.env.NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME,
|
||||
CLOUDFLARE_ZONE: process.env.CLOUDFLARE_ZONE,
|
||||
CLOUDFLARE_API_KEY: process.env.CLOUDFLARE_API_KEY,
|
||||
CLOUDFLARE_EMAIL: process.env.CLOUDFLARE_EMAIL,
|
||||
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 HTMLElement>(
|
||||
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 };
|
||||
}
|
||||
34
lib/domainConfig.ts
Normal file
34
lib/domainConfig.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getAllDomains } from "@/lib/dto/domains";
|
||||
|
||||
export async function getDomainConfig() {
|
||||
return await getAllDomains();
|
||||
}
|
||||
|
||||
export async function getCloudflareCredentials(domain_name: string) {
|
||||
try {
|
||||
const domains = await getAllDomains();
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
let apiKey = domain.cf_api_key;
|
||||
if (domain.cf_api_key_encrypted) {
|
||||
// TODO
|
||||
apiKey = decrypt(apiKey);
|
||||
}
|
||||
|
||||
return {
|
||||
api_key: apiKey,
|
||||
email: domain.cf_email,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch credentials: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(encryptedKey: string) {
|
||||
return encryptedKey; // Replace with actual decryption logic
|
||||
}
|
||||
139
lib/dto/domains.ts
Normal file
139
lib/dto/domains.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Domain } from "@prisma/client";
|
||||
|
||||
import { prisma } from "../db";
|
||||
|
||||
// In-memory cache
|
||||
let domainConfigCache: Domain[] | null = null;
|
||||
let lastCacheUpdate = 0;
|
||||
const CACHE_DURATION = 60 * 1000;
|
||||
|
||||
export const FeatureMap = {
|
||||
short: "enable_short_link",
|
||||
email: "enable_email",
|
||||
record: "enable_dns",
|
||||
};
|
||||
|
||||
export interface DomainConfig {
|
||||
domain_name: string;
|
||||
enable_short_link: boolean;
|
||||
enable_email: boolean;
|
||||
enable_dns: boolean;
|
||||
cf_zone_id: string | null;
|
||||
cf_api_key: string | null;
|
||||
cf_email: string | null;
|
||||
cf_api_key_encrypted: boolean;
|
||||
max_short_links: number | null;
|
||||
max_email_forwards: number | null;
|
||||
max_dns_records: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface DomainFormData extends DomainConfig {
|
||||
id?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export async function getAllDomains(page = 1, size = 10, target: string = "") {
|
||||
try {
|
||||
let option: any;
|
||||
|
||||
if (target) {
|
||||
option = {
|
||||
domain_name: {
|
||||
contains: target,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { list, total };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch domain config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDomainsByFeature(
|
||||
feature: string,
|
||||
admin: boolean = false,
|
||||
) {
|
||||
try {
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: { [feature]: true },
|
||||
select: {
|
||||
domain_name: true,
|
||||
enable_short_link: admin,
|
||||
enable_email: admin,
|
||||
enable_dns: admin,
|
||||
cf_zone_id: admin,
|
||||
cf_api_key: admin,
|
||||
cf_email: admin,
|
||||
},
|
||||
});
|
||||
return domains;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch domain config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDomainsByFeatureClient(feature: string) {
|
||||
try {
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: { [feature]: true },
|
||||
select: {
|
||||
domain_name: true,
|
||||
},
|
||||
});
|
||||
return domains;
|
||||
} 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 });
|
||||
return createdDomain;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create domain: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDomain(id: string, data) {
|
||||
try {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return updatedDomain;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update domain: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDomain(domain_name: string) {
|
||||
try {
|
||||
const deletedDomain = await prisma.domain.delete({
|
||||
where: { domain_name },
|
||||
});
|
||||
return deletedDomain;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to delete domain`);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,17 @@ export async function getAllUsersCount() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function setFirstUserAsAdmin(userId: string) {
|
||||
try {
|
||||
return await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role: UserRole.ADMIN },
|
||||
});
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllUsersActiveApiKeyCount() {
|
||||
try {
|
||||
return await prisma.user.count({ where: { apiKey: { not: null } } });
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
lib/enums.ts
10
lib/enums.ts
@@ -312,3 +312,13 @@ 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;
|
||||
|
||||
150
lib/geo.ts
Normal file
150
lib/geo.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
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(
|
||||
req,
|
||||
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",
|
||||
};
|
||||
}
|
||||
64
lib/utils.ts
64
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,33 +245,6 @@ 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 toCamelCase(str: string) {
|
||||
return str
|
||||
.split("-")
|
||||
@@ -388,25 +366,3 @@ export function extractHost(url: string): string {
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
// 解析 CLOUDFLARE_ZONE 环境变量并返回结构化的域名配置
|
||||
export function parseZones(raw: string) {
|
||||
let zones;
|
||||
|
||||
try {
|
||||
zones = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(zones)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedZones = zones.map((zone) => {
|
||||
const { zone_id, zone_name } = zone;
|
||||
return { zone_id, zone_name };
|
||||
});
|
||||
|
||||
return parsedZones;
|
||||
}
|
||||
|
||||
17
lib/validations/domain.ts
Normal file
17
lib/validations/domain.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const createDomainSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
domain_name: z.string().min(2),
|
||||
enable_short_link: z.boolean(),
|
||||
enable_email: z.boolean(),
|
||||
enable_dns: z.boolean(),
|
||||
cf_zone_id: z.string().optional(),
|
||||
cf_api_key: z.string().optional(),
|
||||
cf_email: z.string().optional(),
|
||||
cf_api_key_encrypted: z.boolean().default(false),
|
||||
max_short_links: z.number().optional(),
|
||||
max_email_forwards: z.number().optional(),
|
||||
max_dns_records: z.number().optional(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
@@ -1,15 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { geolocation } from "@vercel/functions";
|
||||
import { ipAddress } from "@vercel/functions";
|
||||
import { auth } from "auth";
|
||||
import { NextAuthRequest } from "next-auth/lib";
|
||||
import UAParser from "ua-parser-js";
|
||||
|
||||
import { siteConfig } from "./config/site";
|
||||
import { extractRealIP, getGeolocation, getUserAgent } from "./lib/geo";
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
|
||||
const isVercel = process.env.VERCEL;
|
||||
|
||||
const redirectMap = {
|
||||
"Missing[0000]": "/docs/short-urls#missing-links",
|
||||
"Expired[0001]": "/docs/short-urls#expired-links",
|
||||
@@ -19,7 +21,6 @@ const redirectMap = {
|
||||
"IncorrectPassword[0005]": "/password-prompt?error=1&slug=",
|
||||
};
|
||||
|
||||
// 提取短链接处理逻辑
|
||||
async function handleShortUrl(req: NextAuthRequest) {
|
||||
if (!req.url.includes("/s/")) return NextResponse.next();
|
||||
|
||||
@@ -27,9 +28,11 @@ async function handleShortUrl(req: NextAuthRequest) {
|
||||
if (!slug)
|
||||
return NextResponse.redirect(`${siteConfig.url}/docs/short-urls`, 302);
|
||||
|
||||
const geo = geolocation(req);
|
||||
const headers = req.headers;
|
||||
const { browser, device } = parseUserAgent(headers.get("user-agent") || "");
|
||||
const ip = isVercel ? ipAddress(req) : extractRealIP(headers);
|
||||
const ua = getUserAgent(req);
|
||||
|
||||
const geo = await getGeolocation(req, ip || "::1");
|
||||
|
||||
const url = new URL(req.url);
|
||||
const password = url.searchParams.get("password") || "";
|
||||
@@ -37,19 +40,25 @@ async function handleShortUrl(req: NextAuthRequest) {
|
||||
const trackingData = {
|
||||
slug,
|
||||
referer: headers.get("referer") || "(None)",
|
||||
ip: headers.get("X-Forwarded-For"),
|
||||
ip,
|
||||
city: geo?.city,
|
||||
region: geo?.region,
|
||||
country: geo?.country,
|
||||
latitude: geo?.latitude,
|
||||
longitude: geo?.longitude,
|
||||
flag: geo?.flag,
|
||||
lang: headers.get("accept-language")?.split(",")[0],
|
||||
device: device.model || "Unknown",
|
||||
browser: browser.name || "Unknown",
|
||||
lang: headers.get("accept-language")?.split(",")[0] || "Unknown",
|
||||
device: ua.device.model || "Unknown",
|
||||
browser: ua.browser.name || "Unknown",
|
||||
engine: ua.engine.name || "",
|
||||
os: ua.os.name || "",
|
||||
cpu: ua.cpu.architecture || "",
|
||||
isBot: ua.isBot,
|
||||
password,
|
||||
};
|
||||
|
||||
// console.log("Tracking data:", trackingData, siteConfig.url);
|
||||
|
||||
const res = await fetch(`${siteConfig.url}/api/s`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -90,22 +99,11 @@ async function handleShortUrl(req: NextAuthRequest) {
|
||||
return NextResponse.redirect(target, 302);
|
||||
}
|
||||
|
||||
// 提取 slug
|
||||
function extractSlug(url: string): string | null {
|
||||
const match = url.match(/([^/?]+)(?:\?.*)?$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// 解析用户代理
|
||||
const parser = new UAParser();
|
||||
function parseUserAgent(ua: string) {
|
||||
parser.setUA(ua);
|
||||
return {
|
||||
browser: parser.getBrowser(),
|
||||
device: parser.getDevice(),
|
||||
};
|
||||
}
|
||||
|
||||
export default auth(async (req) => {
|
||||
try {
|
||||
return await handleShortUrl(req);
|
||||
|
||||
@@ -6,6 +6,7 @@ import("./env.mjs");
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wr.do",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.2",
|
||||
"author": {
|
||||
"name": "oiov",
|
||||
"url": "https://github.com/oiov"
|
||||
@@ -11,16 +11,23 @@
|
||||
"postbuild": "next-sitemap",
|
||||
"turbo": "next dev --turbo",
|
||||
"start": "next start",
|
||||
"start-docker": "npm-run-all check-db start-server",
|
||||
"start-server": "node server.js",
|
||||
"lint": "next lint",
|
||||
"preview": "next build && next start",
|
||||
"postinstall": "prisma generate",
|
||||
"db:push": "npx prisma migrate deploy",
|
||||
"email": "email dev --dir emails --port 3333",
|
||||
"remove-content": "node ./setup.mjs"
|
||||
"remove-content": "node ./setup.mjs",
|
||||
"check-db": "node scripts/check-db.js"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "./prisma/schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.4.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
"@prisma/client": "^5.17.0",
|
||||
"@radix-ui/react-accessible-icon": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -55,6 +62,10 @@
|
||||
"@react-email/html": "0.0.8",
|
||||
"@scaleway/random-name": "^5.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.11.0",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-scale-chromatic": "^3.1.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/three": "^0.176.0",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@uiw/react-json-view": "2.0.0-alpha.26",
|
||||
"@unovis/react": "^1.4.3",
|
||||
@@ -63,15 +74,21 @@
|
||||
"@vercel/functions": "^1.4.0",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"chalk": "^4.1.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"contentlayer2": "^0.5.0",
|
||||
"crypto": "^1.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-scale-chromatic": "^3.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"framer-motion": "^12.5.0",
|
||||
"globe.gl": "^2.41.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.414.0",
|
||||
"lucide-static": "^0.460.0",
|
||||
"minimist": "^1.2.8",
|
||||
@@ -84,14 +101,17 @@
|
||||
"next-themes": "^0.3.0",
|
||||
"next-view-transitions": "^0.3.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"peerjs": "^1.5.4",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-email": "2.1.5",
|
||||
"react-globe.gl": "^2.33.2",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
@@ -101,8 +121,10 @@
|
||||
"shiki": "^1.11.0",
|
||||
"sonner": "^1.5.0",
|
||||
"swr": "^2.2.5",
|
||||
"semver": "^7.5.4",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.176.0",
|
||||
"turndown": "^7.2.0",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"vaul": "^0.9.1",
|
||||
|
||||
686
pnpm-lock.yaml
generated
686
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -274,7 +274,7 @@ CREATE TABLE "user_send_emails"
|
||||
"html" TEXT DEFAULT '',
|
||||
"replyTo" TEXT DEFAULT '',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX "user_send_emails_userId_idx" ON "user_send_emails" ("userId");
|
||||
|
||||
18
prisma/migrations/20250520104322/migration.sql
Normal file
18
prisma/migrations/20250520104322/migration.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE domains
|
||||
(
|
||||
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"domain_name" TEXT NOT NULL UNIQUE,
|
||||
"enable_short_link" BOOLEAN DEFAULT FALSE,
|
||||
"enable_email" BOOLEAN DEFAULT FALSE,
|
||||
"enable_dns" BOOLEAN DEFAULT FALSE,
|
||||
"cf_zone_id" TEXT NOT NULL,
|
||||
"cf_api_key" TEXT NOT NULL,
|
||||
"cf_email" TEXT NOT NULL,
|
||||
"cf_api_key_encrypted" BOOLEAN DEFAULT FALSE,
|
||||
"max_short_links" INTEGER,
|
||||
"max_email_forwards" INTEGER,
|
||||
"max_dns_records" INTEGER,
|
||||
"active" BOOLEAN DEFAULT TRUE,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -247,3 +247,24 @@ model UserSendEmail {
|
||||
@@index([createdAt])
|
||||
@@map("user_send_emails")
|
||||
}
|
||||
|
||||
model Domain {
|
||||
id String @id @default(uuid())
|
||||
domain_name String @unique
|
||||
enable_short_link Boolean @default(false)
|
||||
enable_email Boolean @default(false)
|
||||
enable_dns Boolean @default(false)
|
||||
cf_zone_id String?
|
||||
cf_api_key String?
|
||||
cf_email String?
|
||||
cf_api_key_encrypted Boolean
|
||||
max_short_links Int?
|
||||
max_email_forwards Int?
|
||||
max_dns_records Int?
|
||||
active Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
|
||||
@@map("domains")
|
||||
}
|
||||
|
||||
BIN
public/_static/docs/domain-form.png
Normal file
BIN
public/_static/docs/domain-form.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 333 KiB |
BIN
public/_static/docs/setup-1.png
Normal file
BIN
public/_static/docs/setup-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
BIN
public/_static/docs/setup-2.png
Normal file
BIN
public/_static/docs/setup-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
public/_static/images/domains.png
Normal file
BIN
public/_static/images/domains.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
BIN
public/_static/images/realtime-globe.png
Normal file
BIN
public/_static/images/realtime-globe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
1
public/colos.json
Normal file
1
public/colos.json
Normal file
File diff suppressed because one or more lines are too long
26017
public/countries.geojson
Normal file
26017
public/countries.geojson
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.5.1",
|
||||
"versionName": "0.6.2",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -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.5.1",
|
||||
"versionName": "0.6.2",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-10T03:08:41.904Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
File diff suppressed because one or more lines are too long
96
scripts/check-db.js
Normal file
96
scripts/check-db.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/* eslint-disable no-console */
|
||||
require("dotenv").config();
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const chalk = require("chalk");
|
||||
const { execSync } = require("child_process");
|
||||
const semver = require("semver");
|
||||
|
||||
if (process.env.SKIP_DB_CHECK === "true") {
|
||||
console.log("Skipping database check.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function getDatabaseType(url = process.env.DATABASE_URL) {
|
||||
const type = url && url.split(":")[0];
|
||||
|
||||
if (type === "postgres") {
|
||||
return "postgresql";
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function success(msg) {
|
||||
console.log(chalk.greenBright(`✓ ${msg}`));
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
console.log(chalk.redBright(`✗ ${msg}`));
|
||||
}
|
||||
|
||||
async function checkEnv() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is not defined.");
|
||||
} else {
|
||||
success("DATABASE_URL is defined.");
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConnection() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
|
||||
success("Database connection successful.");
|
||||
} catch (e) {
|
||||
throw new Error("Unable to connect to the database: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDatabaseVersion() {
|
||||
const query = await prisma.$queryRaw`select version() as version`;
|
||||
const version = semver.valid(semver.coerce(query[0].version));
|
||||
|
||||
const databaseType = getDatabaseType();
|
||||
const minVersion = databaseType === "postgresql" ? "9.4.0" : "5.7.0";
|
||||
|
||||
if (semver.lt(version, minVersion)) {
|
||||
throw new Error(
|
||||
`Database version is not compatible. Please upgrade ${databaseType} version to ${minVersion} or greater`,
|
||||
);
|
||||
}
|
||||
|
||||
success("Database version check successful.");
|
||||
}
|
||||
|
||||
async function applyMigration() {
|
||||
if (process.env.SKIP_DB_MIGRATION === "false") {
|
||||
console.log(execSync("prisma generate").toString());
|
||||
console.log(execSync("prisma migrate deploy").toString());
|
||||
|
||||
success("Database is up to date.");
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let err = false;
|
||||
for (let fn of [
|
||||
checkEnv,
|
||||
checkConnection,
|
||||
checkDatabaseVersion,
|
||||
applyMigration,
|
||||
]) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
error(e.message);
|
||||
err = true;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
if (err) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -88,6 +88,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.globe-tooltip {
|
||||
background: rgba(0, 0, 0, 0.8) !important;
|
||||
color: white !important;
|
||||
padding: 8px !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: 12px !important;
|
||||
z-index: 1000 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.text-gradient_indigo-purple {
|
||||
background: linear-gradient(90deg, #6366f1 0%, rgb(168 85 247 / 0.8) 100%);
|
||||
-webkit-background-clip: text;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user