52 Commits

Author SHA1 Message Date
oiov
cc4c6c5e96 fix(docker): prisma client package 2025-05-27 11:11:00 +08:00
oiov
3301570213 fix(docker): install prisma on runner 2025-05-27 11:01:52 +08:00
oiov
c65176e607 fix(docker): check db do not run 2025-05-27 10:50:07 +08:00
oiov
55aa93d117 feats(docker): add check db script 2025-05-27 10:06:49 +08:00
oiov
7c61b7fc44 docs(deploy): add deploy methods 2025-05-26 21:56:47 +08:00
oiov
bc7f86119c fixup type error 2025-05-26 21:39:01 +08:00
oiov
bc1490f0fd fixup 2025-05-26 21:25:54 +08:00
oiov
7bf2aa8b3c test 2025-05-26 20:43:56 +08:00
oiov
ba086b602f test 2025-05-26 20:19:04 +08:00
oiov
0d793ee31c test 2025-05-26 19:59:43 +08:00
oiov
7579be007f test 2025-05-26 19:53:02 +08:00
oiov
515e7d2719 fixup error 2025-05-26 18:05:23 +08:00
oiov
c9cfdfc07a upd 2025-05-26 17:59:28 +08:00
oiov
c589afd859 fixup url error 2025-05-26 17:50:39 +08:00
oiov
f10f8af0f6 chore 2025-05-26 17:42:37 +08:00
oiov
cbeba449ef chore: get geo and ua info without vercel 2025-05-26 17:40:01 +08:00
oiov
fa02ca000b test 2025-05-26 16:41:50 +08:00
oiov
af01d60d9b fixup 2025-05-26 16:36:36 +08:00
oiov
06f06a8a52 test 2025-05-26 16:16:48 +08:00
oiov
fc54d9e176 fixup 2025-05-26 16:06:30 +08:00
oiov
8fab48f849 test 2025-05-26 15:57:34 +08:00
oiov
69878126f6 test geo 2025-05-26 14:40:26 +08:00
oiov
0185520445 upd 2025-05-25 22:34:12 +08:00
oiov
00cb224e84 test 2025-05-25 22:20:18 +08:00
oiov
c5a932b9f1 prisma/prisma/discussions/19341 2025-05-25 22:09:44 +08:00
oiov
becc328811 emmm 2025-05-25 21:45:37 +08:00
oiov
c2ae4c78f7 fixup 2025-05-25 21:32:04 +08:00
oiov
40f2483332 chore 2025-05-25 21:15:39 +08:00
oiov
a27eb84d61 upd 2025-05-25 21:02:14 +08:00
oiov
01b80eaf9e test 2025-05-25 20:59:10 +08:00
oiov
1e713ea613 test 2025-05-25 17:16:24 +08:00
oiov
1eb7c71ff9 test 2025-05-25 17:01:06 +08:00
oiov
b9bf2733f9 change workflow 2025-05-25 15:27:34 +08:00
oiov
1e48c209f7 chore 2025-05-25 15:20:13 +08:00
oiov
fff455312e chore 2025-05-25 15:15:45 +08:00
oiov
a5626ebefe chore 2025-05-25 15:07:33 +08:00
oiov
400b1aac8d test docker build 2025-05-25 15:02:16 +08:00
oiov
872baa7933 chore 2025-05-25 12:07:52 +08:00
oiov
04b47b62ad fix(docker): change github star api 2025-05-25 11:56:17 +08:00
oiov
8894d2daae fix(docker): build error 2025-05-25 11:17:38 +08:00
oiov
3145ef884d feats: deploy with docker 2025-05-25 10:57:26 +08:00
oiov
5421285a29 bump version 0.6.2 2025-05-24 22:37:52 +08:00
oiov
59727b6be9 style: adjust url list layout 2025-05-24 22:36:31 +08:00
oiov
142cdf8b41 style(realtime): globe size 2025-05-24 21:46:55 +08:00
oiov
24ae1bc45e fix(realtime): fix charts display 2025-05-24 21:28:43 +08:00
oiov
a1cd74e90f feats: realtime globe and visits charts 2025-05-24 17:28:25 +08:00
oiov
6e8b1ccefd update domain page title 2025-05-21 19:41:17 +08:00
oiov
4d9c20d90d adjust button styles 2025-05-21 19:39:51 +08:00
oiov
91d3f06f38 add pagenation for domain list 2025-05-21 19:15:10 +08:00
oiov
a5f5312476 chore setup style for dark model 2025-05-21 18:51:25 +08:00
oiov
36254e048e adjust mobile style for domain list 2025-05-21 18:50:30 +08:00
oiov
72f76b8bca hide proxy on TXT 2025-05-21 18:40:44 +08:00
65 changed files with 29405 additions and 692 deletions

7
.dockerignore Normal file
View File

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

View File

@@ -4,9 +4,10 @@
NEXT_PUBLIC_APP_URL=http://localhost:3000
# -----------------------------------------------------------------------------
# Authentication (NextAuth.js)
# Authentication (NextAuth.js 5.0.x)
# -----------------------------------------------------------------------------
AUTH_SECRET=
AUTH_SECRET=abc123
AUTH_URL=http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
@@ -37,5 +38,6 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
# GitHub api token for getting gitHub stars count
GITHUB_TOKEN=
# Skip DB check and migration (only for docker), default is true. if false, will check and migrate database each time start docker compose.
SKIP_DB_CHECK=true
SKIP_DB_MIGRATION=true

64
.github/workflows/docker-build-push.yml vendored Normal file
View 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
View File

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

View File

@@ -14,24 +14,27 @@
- <20>😀 **权限管理**:方便审核的管理员面板
- 🔒 **安全可靠**:基于 Cloudflare 强大的 DNS API
## Screenshots
## 截图预览
![screenshot](https://wr.do/_static/images/light-preview.png)
![screenshot](https://wr.do/_static/images/example_01.png)
![screenshot](https://wr.do/_static/images/example_02.png)
![screenshot](https://wr.do/_static/images/example_03.png)
<table>
<tr>
<td><img src="https://wr.do/_static/images/light-preview.png" /></td>
<td><img src="https://wr.do/_static/images/example_02.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_01.png" /></td>
<td><img src="https://wr.do/_static/images/realtime-globe.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_03.png" /></td>
<td><img src="https://wr.do/_static/images/domains.png" /></td>
</tr>
</table>
## 快速开始
查看开发者[快速开始](https://wr.do/docs/developer/quick-start)的详细文档。
查看有关[快速开始](https://wr.do/docs/quick-start)的文档。
## 自托管教程
### 要求
- [Vercel](https://vercel.com) 账户用于部署应用
@@ -43,6 +46,23 @@
查看 [email worker](https://wr.do/docs/developer/cloudflare-email-worker) 文档用于邮件接收。
## 自部署教程
### 使用 Vercel 部署
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
记得填写必要的环境变量。
### 使用 Docker Compose 部署
在服务器中创建一个文件夹,进入该文件夹并新建`docker-compose.yml`文件,填写必要的环境变量,然后执行:
```bash
docker compose up -d
```
## 本地开发
`.env.example` 复制为 `.env` 并填写必要的环境变量。
@@ -63,7 +83,7 @@ pnpm postinstall
pnpm db:push
```
#### 激活管理员面板
#### 管理员初始化
Follow https://localhost:3000/setup

View File

@@ -17,20 +17,24 @@
## Screenshots
![screenshot](https://wr.do/_static/images/light-preview.png)
<table>
<tr>
<td><img src="https://wr.do/_static/images/light-preview.png" /></td>
<td><img src="https://wr.do/_static/images/example_02.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_01.png" /></td>
<td><img src="https://wr.do/_static/images/realtime-globe.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_03.png" /></td>
<td><img src="https://wr.do/_static/images/domains.png" /></td>
</tr>
</table>
![screenshot](https://wr.do/_static/images/example_02.png)
![screenshot](https://wr.do/_static/images/example_01.png)
![screenshot](https://wr.do/_static/images/example_03.png)
## Quick Start
See usage docs about [guide](https://wr.do/docs/quick-start) for quick start.
## Self-hosted Tutorial
See step by step installation tutorial at [Quick Start for Developer](https://wr.do/docs/developer/quick-start).
### Requirements
@@ -44,15 +48,41 @@ 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
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
Remember to fill in the necessary environment variables.
### Deploy with Docker Compose
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
```
@@ -68,21 +98,12 @@ pnpm db:push
Follow https://localhost:3000/setup
## Legitimacy review
- To avoid abuse, applications without website content will be rejected
- To avoid domain name conflicts, please check before applying
- Completed website construction or released open source project (ready to build website for open source project)
- Political sensitivity, violence, pornography, link jumping, VPN, reverse proxy services, and other illegal or sensitive content must not appear on the website
**Administrators will conduct domain name checks periodically to clean up domain names that violate the above rules, have no content, and are not open source related**
## Community Group
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
![](https://wr.do/s/group)
<img width="300" src="https://wr.do/s/group" />
## License

View File

@@ -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]">

View File

@@ -16,6 +16,7 @@ import {
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
@@ -40,25 +41,25 @@ export interface DomainListProps {
function TableColumnSekleton() {
return (
<TableRow className="grid grid-cols-7 items-center">
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-7">
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-32" />
</TableCell>
@@ -73,10 +74,6 @@ export default function DomainList({ user, action }: DomainListProps) {
useState<DomainFormData | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// const [isShowDomainInfo, setShowDomainInfo] = useState(false);
// const [selectedDomain, setSelectedDomain] = useState<DomainFormData | null>(
// null,
// );
const [searchParams, setSearchParams] = useState({
slug: "",
target: "",
@@ -88,13 +85,13 @@ export default function DomainList({ user, action }: DomainListProps) {
total: number;
list: DomainFormData[];
}>(
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
fetcher,
);
const handleRefresh = () => {
mutate(
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
undefined,
);
};
@@ -119,6 +116,7 @@ export default function DomainList({ user, action }: DomainListProps) {
const data = await res.json();
if (data) {
toast.success("Successed!");
handleRefresh();
}
} else {
toast.error("Activation failed!");
@@ -128,11 +126,15 @@ export default function DomainList({ user, action }: DomainListProps) {
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
<CardDescription className="text-balance text-lg font-bold">
<span>Total Domains:</span>{" "}
<span className="font-bold">{data && data.total}</span>
</CardDescription>
<CardHeader className="flex flex-row items-center gap-2">
<div className="flex items-center gap-1 text-lg font-bold">
<span className="text-nowrap">Total Domains:</span>
{isLoading ? (
<Skeleton className="h-6 w-16" />
) : (
<span>{data && data.total}</span>
)}
</div>
<div className="ml-auto flex items-center justify-end gap-3">
<Button
@@ -147,7 +149,7 @@ export default function DomainList({ user, action }: DomainListProps) {
)}
</Button>
<Button
className="w-[120px] shrink-0 gap-1"
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditDomain(null);
@@ -156,55 +158,58 @@ export default function DomainList({ user, action }: DomainListProps) {
setShowForm(!isShowForm);
}}
>
Add Domain
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add Domain</span>
</Button>
</div>
</CardHeader>
<CardContent>
{/* <div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by slug..."
value={searchParams.slug}
placeholder="Search by domain name..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
slug: e.target.value,
target: e.target.value,
});
}}
/>
{searchParams.slug && (
{searchParams.target && (
<Button
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
onClick={() =>
setSearchParams({ ...searchParams, target: "" })
}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
</div> */}
</div>
<Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-7 items-center text-xs">
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-7">
<TableHead className="col-span-1 flex items-center font-bold">
Domain
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Shorten Service
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Shorten
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Email Service
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Email
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
DNS Service
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Subdomain
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Active
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
<TableHead className="col-span-1 flex items-center font-bold">
Updated
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
@@ -224,7 +229,7 @@ export default function DomainList({ user, action }: DomainListProps) {
) : data && data.list && data.list.length ? (
data.list.map((domain) => (
<div className="border-b" key={domain.id}>
<TableRow className="grid grid-cols-7 items-center">
<TableRow className="grid grid-cols-4 items-center sm:grid-cols-7">
<TableCell className="col-span-1 flex items-center gap-1">
<Link
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
@@ -236,7 +241,7 @@ export default function DomainList({ user, action }: DomainListProps) {
{domain.domain_name}
</Link>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_short_link}
onCheckedChange={(value) =>
@@ -248,7 +253,7 @@ export default function DomainList({ user, action }: DomainListProps) {
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_email}
onCheckedChange={(value) =>
@@ -256,7 +261,7 @@ export default function DomainList({ user, action }: DomainListProps) {
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_dns}
onCheckedChange={(value) =>
@@ -273,7 +278,7 @@ export default function DomainList({ user, action }: DomainListProps) {
}
/>
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
<TableCell className="col-span-1 flex items-center truncate">
{timeAgo(domain.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">

View File

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

View File

@@ -4,7 +4,6 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import LiveLog from "../../dashboard/urls/live-logs";
import UserUrlsList from "../../dashboard/urls/url-list";
export const metadata = constructMetadata({
@@ -25,6 +24,7 @@ export default async function DashboardPage() {
link="/docs/short-urls"
linkText="short urls."
/>
<UserUrlsList
user={{
id: user.id,
@@ -35,7 +35,6 @@ export default async function DashboardPage() {
}}
action="/api/url/admin"
/>
<LiveLog admin={true} />
</>
);
}

View File

@@ -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&apos;t have any record yet. Start creating record.

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

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

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -4,8 +4,6 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "../../../../components/shared/api-reference";
import LiveLog from "./live-logs";
import UserUrlsList from "./url-list";
export const metadata = constructMetadata({
@@ -36,12 +34,6 @@ export default async function DashboardPage() {
}}
action="/api/url"
/>
<LiveLog admin={false} />
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</>
);
}

View File

@@ -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&apos;t have any url yet. Start creating url.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</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,264 +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
defaultChecked={short.active === 1}
onCheckedChange={(value) =>
handleChangeStatu(value, short.id || "")
}
/>
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
{expirationTime(short.expiration, short.updatedAt)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.createdAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">Edit</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setSelectedUrl(short);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="mx-0.5 size-4" />
</Button>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant="outline"
onClick={() => {
setSelectedUrl(short);
if (isShowStats && selectedUrl?.id !== short.id) {
} else {
setShowStats(!isShowStats);
}
}}
>
<Icons.lineChart className="mx-0.5 size-4" />
</Button>
</TableCell>
</TableRow>
{isShowStats && selectedUrl?.id === short.id && (
<UserUrlMetaInfo
user={{
id: user.id,
name: user.name || "",
team: user.team,
}}
action="/api/url/meta"
urlId={short.id!}
/>
)}
</div>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="link" />
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any url yet. Start creating url.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</CardContent>
</Card>
<TabsContent className="space-y-3" value="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

View File

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

View File

@@ -26,7 +26,6 @@ export default function StepGuide({
const [currentStep, setCurrentStep] = useState(1);
const [direction, setDirection] = useState(0);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const [isMobile, setIsMobile] = useState(false);
const steps = [
{
@@ -52,19 +51,6 @@ export default function StepGuide({
},
];
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, []);
const goToNextStep = () => {
if (currentStep < steps.length) {
setDirection(1);
@@ -130,7 +116,7 @@ export default function StepGuide({
className="flex flex-col justify-center gap-6"
>
<div className="flex h-full w-full flex-col">
<div className="mb-2 flex items-center gap-1 rounded-lg bg-neutral-100 p-2">
<div className="mb-2 flex items-center gap-1 rounded-lg bg-neutral-100 p-2 dark:bg-neutral-800">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{currentStep}
</span>
@@ -213,16 +199,16 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
);
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4">
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-500">
<span className="text-sm font-semibold text-muted-foreground">
Allow Sign Up:
</span>
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-500">
<span className="text-sm font-semibold text-muted-foreground">
Set {email} as ADMIN:
</span>
{isAdmin ? (
@@ -306,7 +292,7 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
});
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4">
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<FormSectionColumns title="Domain Name">
<div className="flex w-full flex-col items-start justify-between gap-2">
<Label className="sr-only" htmlFor="domain_name">
@@ -315,7 +301,7 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
<div className="w-full">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
className="flex-1 bg-neutral-50 shadow-inner dark:bg-neutral-600"
size={32}
placeholder="example.com"
onChange={(e) => setDomain(e.target.value)}

View File

@@ -4,7 +4,6 @@ import {
createDomain,
deleteDomain,
getAllDomains,
invalidateDomainConfigCache,
updateDomain,
} from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
@@ -19,13 +18,18 @@ export async function GET(req: NextRequest) {
return Response.json("Unauthorized", { status: 401 });
}
// TODO: Add pagination
const domains = await getAllDomains();
const url = new URL(req.url);
const page = url.searchParams.get("page");
const size = url.searchParams.get("size");
const target = url.searchParams.get("target") || "";
return Response.json(
{ list: domains, total: domains.length },
{ status: 200 },
const data = await getAllDomains(
Number(page || "1"),
Number(size || "10"),
target,
);
return Response.json(data, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
@@ -61,8 +65,6 @@ export async function POST(req: NextRequest) {
active: true,
});
invalidateDomainConfigCache();
return Response.json(newDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
@@ -112,8 +114,6 @@ export async function PUT(req: NextRequest) {
max_dns_records,
});
invalidateDomainConfigCache();
return Response.json(updatedDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
@@ -137,8 +137,6 @@ export async function DELETE(req: NextRequest) {
const deletedDomain = await deleteDomain(domain_name);
invalidateDomainConfigCache();
return Response.json(deletedDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);

View File

@@ -4,6 +4,8 @@ import { FeatureMap, getDomainsByFeatureClient } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export const dynamic = "force-dynamic";
// Get domains by feature for frontend
export async function GET(req: NextRequest) {
try {

27
app/api/location/route.ts Normal file
View 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 },
);
}
}

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

View File

@@ -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,

View File

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

View File

@@ -2,7 +2,8 @@ import cheerio from "cheerio";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink, removeUrlSuffix } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { isLink, removeUrlSuffix } from "@/lib/utils";
export const revalidate = 600;
export const dynamic = "force-dynamic";
@@ -84,9 +85,9 @@ export async function GET(req: Request) {
$("meta[name='author']").attr("content") ||
$("meta[property='author']").attr("content");
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "meta-info",
referer: stats.referer,
city: stats.city,

View File

@@ -2,9 +2,10 @@ import { ImageResponse } from "@vercel/og";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo } from "@/lib/geo";
import { WRDO_QR_LOGO } from "@/lib/qr/constants";
import { QRCodeSVG } from "@/lib/qr/utils";
import { getIpInfo, getSearchParams } from "@/lib/utils";
import { getSearchParams } from "@/lib/utils";
import { getQRCodeQuerySchema } from "@/lib/validations/qr";
const CORS_HEADERS = {
@@ -41,9 +42,9 @@ export async function GET(req: Request) {
);
}
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "qrcode",
referer: stats.referer,
city: stats.city,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { SidebarNavItem, SiteConfig } from "types";
import { env } from "@/env.mjs";
const site_url = env.NEXT_PUBLIC_APP_URL;
const site_url = env.NEXT_PUBLIC_APP_URL || "http://localhost:3030";
const open_signup = env.NEXT_PUBLIC_OPEN_SIGNUP;
const email_r2_domain = env.NEXT_PUBLIC_EMAIL_R2_DOMAIN || "";

View File

@@ -0,0 +1,48 @@
---
title: Deploy Guide
description: Choose your deployment method
---
## Deploy with Vercel (Recommended)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
Remember to fill in the necessary environment variables.
## Deploy with Docker Compose
<Callout type="warning" twClass="mt-4">
Please create your database instance before deployment.
Set `SKIP_DB_CHECK` and `SKIP_DB_MIGRATION` to `false` in the `.env` file, this will start the database check and migration.
</Callout>
Create a new folder and copy the `docker-compose.yml`、`.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
```

View File

@@ -205,6 +205,10 @@ Follow the steps below:
<strong>You must add at least one domain to start using short links, email or subdomain management features.</strong>
</Callout>
## 9. Deploy
See [Deploy Guide](/docs/developer/deploy).
## Q & A
### Worker Error - Too many redirects

View File

@@ -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

View 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
View 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

View File

@@ -6,22 +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(),
SCREENSHOTONE_BASE_URL: z.string().optional(),
GITHUB_TOKEN: z.string().optional(),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().optional(),
NEXT_PUBLIC_OPEN_SIGNUP: z.string().min(1).default("1"),
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().min(1),
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().optional(),
},
runtimeEnv: {
NEXTAUTH_URL: process.env.NEXTAUTH_URL,

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

@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "lodash-es";
interface ElementSize {
width: number;
height: number;
}
interface UseElementSizeOptions {
box?: "content-box" | "border-box" | "device-pixel-content-box";
}
export function useElementSize<T extends 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 };
}

View File

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

View File

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

View File

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

View File

@@ -312,3 +312,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
View 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",
};
}

View File

@@ -1,12 +1,9 @@
import crypto from "crypto";
import { Metadata } from "next";
import { geolocation } from "@vercel/functions";
import { clsx, type ClassValue } from "clsx";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import UAParser from "ua-parser-js";
import { env } from "@/env.mjs";
import { siteConfig } from "@/config/site";
import { TIME_RANGES } from "./enums";
@@ -88,8 +85,16 @@ export function formatDate(input: string | number): string {
});
}
export function absoluteUrl(path: string) {
return `${env.NEXT_PUBLIC_APP_URL}${path}`;
export function formatTime(input: string | number): string {
const date = new Date(input);
const locale = navigator.language || "en-US";
return date.toLocaleTimeString(locale, {
// second: "numeric",
minute: "numeric",
hour: "numeric",
});
}
// Utils from precedent.dev
@@ -240,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("-")

View File

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

View File

@@ -6,6 +6,7 @@ import("./env.mjs");
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: "standalone",
images: {
remotePatterns: [
{

View File

@@ -1,6 +1,6 @@
{
"name": "wr.do",
"version": "0.6.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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

1
public/colos.json Normal file

File diff suppressed because one or more lines are too long

26017
public/countries.geojson Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-20T14:43:25.018Z</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
View 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);
}
}
}
})();

View File

@@ -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;

2
types/index.d.ts vendored
View File

@@ -47,3 +47,5 @@ export type DocsConfig = {
mainNav: MainNavItem[];
sidebarNav: SidebarNavItem[];
};
// declare module "globe.gl";