18 Commits

Author SHA1 Message Date
oiov
f4c9bad648 test env 2025-06-03 15:58:20 +08:00
oiov
80796cdcca env 2025-06-03 15:51:39 +08:00
oiov
970dc5bbe8 test env 2025-06-03 15:38:58 +08:00
oiov
6cade53ec5 test env 2025-06-03 15:31:20 +08:00
oiov
34981f821d test env 2025-06-03 15:25:18 +08:00
oiov
22f1686ff7 test env 2025-06-03 15:17:54 +08:00
oiov
5d34f3707a add logs 2025-06-03 15:07:12 +08:00
oiov
d8ec5683d1 chore 2025-06-03 15:06:44 +08:00
oiov
06a70b6680 chore 2025-06-03 14:47:18 +08:00
oiov
938fcd4422 test env 2025-06-03 14:44:46 +08:00
oiov
d86467674e add NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY env 2025-06-03 14:39:01 +08:00
oiov
a21ce6e8d6 chore no domain config discription 2025-06-02 19:39:24 +08:00
oiov
0a4507bbd0 bump version to v0.6.5 2025-06-01 14:09:20 +08:00
oiov
2f27c330a1 adjust stats layout 2025-06-01 14:08:19 +08:00
oiov
cff4579ff1 enhance list pagenation layout 2025-06-01 13:49:29 +08:00
oiov
f2de129ba8 docs(dev): add docker image url 2025-05-31 15:50:41 +08:00
oiov
2a9a242f50 add app version on sidebar 2025-05-31 13:14:57 +08:00
oiov
4e74053017 chore docs codes 2025-05-31 11:50:09 +08:00
43 changed files with 772 additions and 232 deletions

View File

@@ -30,6 +30,9 @@ RESEND_FROM_EMAIL="wrdo <support@wr.do>"
# Open Signup
NEXT_PUBLIC_OPEN_SIGNUP=1
# Enable subdomain apply, default is false(0). If set to 1, will enable subdomain apply
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY=1
# Google Analytics
NEXT_PUBLIC_GOOGLE_ID=
@@ -42,3 +45,4 @@ 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

View File

@@ -4,8 +4,9 @@ on:
push:
branches:
- main
- fix/docker
tags:
- 'v*.*.*'
- "v*.*.*"
pull_request:
branches:
- main

View File

@@ -1,6 +1,6 @@
<div align="center">
<h1>WR.DO</h1>
<p><a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
<p><a href="https://wr.do/docs/developer">开发文档</a> · <a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
<p>生成短链接, 创建 DNS 记录, 管理临时邮箱</p>
<!-- <img src="https://wr.do/_static/images/light-preview.png"/> -->
</div>

View File

@@ -1,6 +1,6 @@
<div align="center">
<h1>WR.DO</h1>
<p><a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
<p><a href="https://wr.do/docs/developer">Docs</a> · <a href="https://discord.gg/AHPQYuZu3m">Discord</a> · English | <a href="/README-zh.md">简体中文</a></p>
<p>Make Short Links, Manage DNS Records, Receive Emails.</p>
</div>

View File

@@ -9,6 +9,7 @@ import useSWR, { useSWRConfig } from "swr";
import { DomainFormData } from "@/lib/dto/domains";
import { fetcher, timeAgo } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -68,6 +69,7 @@ function TableColumnSekleton() {
}
export default function DomainList({ user, action }: DomainListProps) {
const { isMobile } = useMediaQuery();
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditDomain, setCurrentEditDomain] =
@@ -326,6 +328,7 @@ export default function DomainList({ user, action }: DomainListProps) {
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}

View File

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

View File

@@ -73,6 +73,7 @@ function TableColumnSekleton({ className }: { className?: string }) {
}
export default function UsersList({ user }: UrlListProps) {
const { isMobile } = useMediaQuery();
const [isShowForm, setShowForm] = useState(false);
const [currentEditUser, setcurrentEditUser] = useState<User | null>(null);
const [currentPage, setCurrentPage] = useState(1);
@@ -282,6 +283,7 @@ export default function UsersList({ user }: UrlListProps) {
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}

View File

@@ -10,6 +10,7 @@ import useSWR, { useSWRConfig } from "swr";
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
import { TTL_ENUMS } from "@/lib/enums";
import { fetcher, timeAgo } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -79,12 +80,15 @@ function TableColumnSekleton() {
}
export default function UserRecordsList({ user, action }: RecordListProps) {
const { isMobile } = useMediaQuery();
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditRecord, setCurrentEditRecord] =
useState<UserRecordFormData | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [tab, setTab] = useState("app");
const isAdmin = action.includes("/admin");
const { mutate } = useSWRConfig();
@@ -134,11 +138,13 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
}
};
const rendeApplyList = () => {};
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
{action.includes("/admin") ? (
{isAdmin ? (
<CardDescription className="text-balance text-lg font-bold">
<span>Total Subdomains:</span>{" "}
<span className="font-bold">{data && data.total}</span>
@@ -328,6 +334,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}

View File

@@ -98,12 +98,12 @@ const LogsTable = ({ userId, target }) => {
onChange={(e) => handleFilterChange("type", e.target.value)}
className="h-8 max-w-xs placeholder:text-xs"
/>
<Input
{/* <Input
placeholder="Filter by IP..."
value={filters.ip}
onChange={(e) => handleFilterChange("ip", e.target.value)}
className="h-8 max-w-xs placeholder:text-xs"
/>
/> */}
{
<>
<Input
@@ -139,16 +139,15 @@ const LogsTable = ({ userId, target }) => {
<div className="rounded-md border">
<Table>
<TableHeader className="bg-muted">
<TableRow className="">
<TableRow className="grid grid-cols-5 items-center sm:grid-cols-6">
<TableHead className="hidden items-center justify-start px-2 sm:flex">
Date
</TableHead>
<TableHead className="px-2">Type</TableHead>
<TableHead className="hidden items-center justify-start px-2 sm:flex">
IP
<TableHead className="flex items-center px-2">Type</TableHead>
<TableHead className="col-span-3 flex items-center px-2">
Link
</TableHead>
<TableHead className="px-2">Link</TableHead>
<TableHead className="px-2">User</TableHead>
<TableHead className="flex items-center px-2">User</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -161,9 +160,6 @@ const LogsTable = ({ userId, target }) => {
<TableCell>
<Skeleton className="h-2 w-[80px]" />
</TableCell>
<TableCell className="hidden sm:inline-block">
<Skeleton className="h-2 w-[120px]" />
</TableCell>
<TableCell>
<Skeleton className="h-2 w-[200px]" />
</TableCell>
@@ -173,15 +169,15 @@ const LogsTable = ({ userId, target }) => {
</TableRow>
))
: logs.map((log) => (
<TableRow className="text-xs hover:bg-muted" key={log.id}>
<TableRow
className="grid grid-cols-5 items-center text-xs hover:bg-muted sm:grid-cols-6"
key={log.id}
>
<TableCell className="hidden truncate p-2 sm:inline-block">
{new Date(log.createdAt).toLocaleString()}
</TableCell>
<TableCell className="p-2">{log.type}</TableCell>
<TableCell className="hidden p-2 sm:inline-block">
{log.ip}
</TableCell>
<TableCell className="max-w-md truncate p-2">
<TableCell className="col-span-3 max-w-full truncate p-2">
{log.link}
</TableCell>
<TableCell className="max-w-md truncate p-2">
@@ -193,12 +189,13 @@ const LogsTable = ({ userId, target }) => {
</Table>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-start justify-between gap-2 sm:items-center">
<p className="ml-auto text-nowrap text-sm">
{nFormatter(data?.total || 0)} logs
</p>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
className="m-0"
total={data.total}
currentPage={page}
setCurrentPage={setPage}

View File

@@ -200,19 +200,30 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
{error ? (
<div className="text-center text-red-500">{error.message}</div>
) : logs.length === 0 && !newLogs ? (
// <Skeleton className="h-8 w-full" />
<></>
) : (
<div className="scrollbar-hidden h-96 overflow-y-auto bg-primary-foreground">
<Table>
<TableHeader>
<TableRow className="bg-gray-100/50 text-sm dark:bg-primary-foreground">
<TableHead className="h-8 w-1/6 px-1">Time</TableHead>
<TableHead className="h-8 w-1/12 px-1">Slug</TableHead>
<TableHead className="h-8 px-1">Target</TableHead>
<TableHead className="h-8 w-1/12 px-1">IP</TableHead>
<TableHead className="h-8 w-1/6 px-1">Location</TableHead>
<TableHead className="h-8 w-1/12 px-1">Clicks</TableHead>
<TableRow className="grid grid-cols-5 bg-gray-100/50 text-sm dark:bg-primary-foreground sm:grid-cols-9">
<TableHead className="col-span-2 flex h-8 items-center">
Time
</TableHead>
<TableHead className="col-span-1 flex h-8 items-center">
Slug
</TableHead>
<TableHead className="col-span-3 hidden h-8 items-center sm:flex">
Target
</TableHead>
<TableHead className="col-span-1 hidden h-8 items-center sm:flex">
IP
</TableHead>
<TableHead className="col-span-1 flex h-8 items-center">
Location
</TableHead>
<TableHead className="col-span-1 flex h-8 items-center">
Clicks
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -234,23 +245,24 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
ease: "linear",
},
}}
className="font-mono text-xs hover:bg-gray-200 dark:border-gray-800"
className="grid grid-cols-5 font-mono text-xs hover:bg-gray-200 dark:border-gray-800 sm:grid-cols-9"
>
<TableCell className="whitespace-nowrap px-1 py-1.5">
<TableCell className="col-span-2 truncate py-1.5">
{new Date(log.updatedAt).toLocaleString()}
</TableCell>
<TableCell className="font-midium px-1 py-1.5 text-green-700">
<TableCell className="font-midium col-span-1 truncate py-1.5 text-green-700">
{log.slug}
</TableCell>
<TableCell className="max-w-10 truncate px-1 py-1.5 hover:underline">
<TableCell className="col-span-3 hidden max-w-full truncate py-1.5 hover:underline sm:flex">
<a href={log.target} target="_blank" title={log.target}>
{log.target}
</a>
</TableCell>
<TableCell className="px-1 py-1.5">{log.ip}</TableCell>
<TableCell className="col-span-1 hidden truncate py-1.5 sm:flex">
{log.ip}
</TableCell>
<TableCell
className="max-w-6 truncate px-1 py-1.5"
className="col-span-1 truncate py-1.5"
title={getCountryName(log.country || "")}
>
{decodeURIComponent(
@@ -259,7 +271,7 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
: "-",
)}
</TableCell>
<TableCell className="px-1 py-1.5 text-green-700">
<TableCell className="col-span-1 py-1.5 text-green-700">
{log.click}
</TableCell>
</motion.tr>

View File

@@ -10,9 +10,17 @@ import { WorldMapTopoJSON } from "@unovis/ts/maps";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { TeamPlanQuota } from "@/config/team";
import { getCountryName, getDeviceVendor } from "@/lib/contries";
import {
getBotName,
getCountryName,
getDeviceVendor,
getEngineName,
getLanguageName,
getRegionName,
} from "@/lib/contries";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { isLink, removeUrlSuffix, timeAgo } from "@/lib/utils";
import { useElementSize } from "@/hooks/use-element-size";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -34,6 +42,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Icons } from "@/components/shared/icons";
const chartConfig = {
@@ -117,6 +126,14 @@ function generateStatsList(
? getCountryName(rawValue as string) // 国家代码转为国家名称
: dimension === "device"
? getDeviceVendor(rawValue as string) // 设备型号转为厂商名称
: dimension === "engine"
? getEngineName(rawValue as string) // 引擎名称
: dimension === "region"
? getRegionName(rawValue as string) // 区域名称
: dimension === "lang"
? getLanguageName(rawValue as string) // 语言名称
: dimension === "isBot"
? getBotName(rawValue as boolean) // 是否为机器人
: rawValue; // 其他维度直接使用原始值
const click = record.click || 0; // 确保 click 是数字,默认 0 如果未定义
@@ -154,6 +171,7 @@ export function DailyPVUVChart({
setTimeRange: React.Dispatch<React.SetStateAction<string>>;
user: Pick<User, "id" | "name" | "team">;
}) {
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("pv");
@@ -162,7 +180,6 @@ export function DailyPVUVChart({
pv: entry.clicks,
uv: new Set(entry.ips).size,
}));
// .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const dataTotal = calculateUVAndPV(data);
@@ -214,9 +231,15 @@ export function DailyPVUVChart({
const deviceStats = generateStatsList(data, "device");
const browserStats = generateStatsList(data, "browser");
const countryStats = generateStatsList(data, "country");
const osStats = generateStatsList(data, "os");
const cpuStats = generateStatsList(data, "cpu");
const engineStats = generateStatsList(data, "engine");
const languageStats = generateStatsList(data, "lang");
const regionStats = generateStatsList(data, "region");
const isBotStats = generateStatsList(data, "isBot");
return (
<Card className="">
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-2 sm:py-3">
<CardTitle>Link Analytics</CardTitle>
@@ -279,7 +302,7 @@ export function DailyPVUVChart({
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<CardContent className="px-2 sm:p-6" ref={wrapperRef}>
<ChartContainer
config={chartConfig}
className="aspect-auto h-[225px] w-full"
@@ -351,9 +374,6 @@ export function DailyPVUVChart({
/>
}
/>
{/* <Bar dataKey="uv" fill={`var(--color-uv)`} stackId="a" />
<Bar dataKey="pv" fill={`var(--color-pv)`} stackId="a" /> */}
<Area
type="monotone"
dataKey="uv"
@@ -371,31 +391,103 @@ export function DailyPVUVChart({
</AreaChart>
</ChartContainer>
<VisSingleContainer data={{ areas: areaData }}>
<VisTopoJSONMap
topojson={WorldMapTopoJSON}
// pointRadius={1.6}
// mapFitToPoints={true}
/>
<VisSingleContainer
data={{ areas: areaData }}
width={wrapperWidth * 0.99}
>
<VisTopoJSONMap topojson={WorldMapTopoJSON} />
<VisTooltip triggers={triggers} />
</VisSingleContainer>
<div className="my-5 grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Referrers、isBotStats */}
<Tabs defaultValue="referrer">
<TabsList>
<TabsTrigger value="referrer">Referrers</TabsTrigger>
<TabsTrigger value="isBot">Traffic Type</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="referrer">
{refererStats.length > 0 && (
<StatsList data={refererStats} title="Referrers" />
)}
</TabsContent>
<TabsContent className="h-[calc(100%-40px)]" value="isBot">
{isBotStats.length > 0 && (
<StatsList data={isBotStats} title="Is Bot" />
)}
</TabsContent>
</Tabs>
{/* 国家、城市 */}
<Tabs defaultValue="country">
<TabsList>
<TabsTrigger value="country">Country</TabsTrigger>
<TabsTrigger value="city">City</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="country">
{countryStats.length > 0 && (
<StatsList data={countryStats} title="Countries" />
)}
</TabsContent>
<TabsContent className="h-[calc(100%-40px)]" value="city">
{cityStats.length > 0 && (
<StatsList data={cityStats} title="Cities" />
)}
</TabsContent>
</Tabs>
{/* browserStats、engineStats */}
<Tabs defaultValue="browser">
<TabsList>
<TabsTrigger value="browser">Browser</TabsTrigger>
<TabsTrigger value="engine">Browser Engine</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="browser">
{browserStats.length > 0 && (
<StatsList data={browserStats} title="Browsers" />
)}
</TabsContent>
<TabsContent className="h-[calc(100%-40px)]" value="engine">
{engineStats.length > 0 && (
<StatsList data={engineStats} title="Engines" />
)}
</TabsContent>
</Tabs>
{/* Languages、regionStats */}
<Tabs className="h-full" defaultValue="language">
<TabsList>
<TabsTrigger value="language">Language</TabsTrigger>
<TabsTrigger value="region">Region</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="language">
{languageStats.length > 0 && (
<StatsList data={languageStats} title="Languages" />
)}
</TabsContent>
<TabsContent className="h-[calc(100%-40px)]" value="region">
{regionStats.length > 0 && (
<StatsList data={regionStats} title="Regions" />
)}
</TabsContent>
</Tabs>
{/* deviceStats、osStats、cpuStats */}
<Tabs defaultValue="device">
<TabsList>
<TabsTrigger value="device">Device</TabsTrigger>
<TabsTrigger value="os">OS</TabsTrigger>
<TabsTrigger value="cpu">CPU</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="device">
{deviceStats.length > 0 && (
<StatsList data={deviceStats} title="Devices" />
)}
</TabsContent>
<TabsContent className="h-[calc(100%-40px)]" value="os">
{osStats.length > 0 && <StatsList data={osStats} title="OS" />}
</TabsContent>
<TabsContent className="h-[calc(100%-40px)]" value="cpu">
{cpuStats.length > 0 && <StatsList data={cpuStats} title="CPU" />}
</TabsContent>
</Tabs>
</div>
</CardContent>
</Card>
@@ -407,12 +499,15 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
const displayedData = showAll ? data.slice(0, 50) : data.slice(0, 8);
return (
<div className="rounded-lg border p-4">
<h1 className="text-lg font-bold">{title}</h1>
<div className="h-full rounded-lg border">
<div className="flex items-center justify-between border-b px-5 py-2 text-xs font-medium text-muted-foreground">
<span></span>
<span className=""></span>
</div>
<div
className={`scrollbar-hidden overflow-hidden overflow-y-auto transition-all duration-500 ease-in-out`}
className={`scrollbar-hidden overflow-hidden overflow-y-auto px-4 pb-4 pt-2 transition-all duration-500 ease-in-out`}
style={{
maxHeight: "18rem", // 动态计算最大高度
maxHeight: "18rem",
}}
>
{displayedData.map((ref) => (
@@ -454,7 +549,7 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
</div>
{data.length > 8 && (
<div className="mt-3 text-center">
<div className="mb-3 mt-1 text-center">
<Button
variant={"outline"}
onClick={() => setShowAll(!showAll)}

View File

@@ -18,12 +18,12 @@ import {
removeUrlSuffix,
timeAgo,
} from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -100,6 +100,7 @@ function TableColumnSekleton() {
export default function UserUrlsList({ user, action }: UrlListProps) {
const pathname = usePathname();
const { isMobile } = useMediaQuery();
const [currentView, setCurrentView] = useState<string>("List");
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
@@ -447,6 +448,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
@@ -470,23 +472,24 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
data.list.map((short) => (
<div
className={cn(
"h-24 rounded-lg border bg-neutral-50/50 p-1 dark:bg-neutral-800",
"h-24 rounded-lg border p-1 shadow-inner dark:bg-neutral-800",
)}
key={short.id}
>
<div className="flex h-full flex-col rounded-lg border border-dotted bg-white px-3 py-1.5 shadow backdrop-blur-lg dark:bg-black">
<div className="flex h-full flex-col rounded-lg border border-dotted bg-white px-3 py-1.5 backdrop-blur-lg dark:bg-black">
<div className="flex items-center justify-between gap-1">
<BlurImage
src={`https://unavatar.io/${extractHostname(short.target)}?fallback=https://wr.do/logo.png`}
alt="logo"
width={30}
height={30}
className="rounded-md"
/>
<div className="ml-2 mr-auto flex flex-col justify-between truncate">
{/* url */}
<div className="flex items-center">
<Link
className="overflow-hidden text-ellipsis whitespace-normal text-sm font-semibold text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
className="overflow-hidden text-ellipsis whitespace-normal text-sm font-semibold text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-300"
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank"
prefetch={false}
@@ -501,6 +504,17 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
"duration-250 transition-all group-hover:opacity-100",
)}
/>
<Button
className="duration-250 size-[26px] p-1.5 text-foreground transition-all hover:border hover:text-foreground dark:text-foreground"
size="sm"
variant="ghost"
onClick={() => {
setSelectedUrl(short);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="size-4" />
</Button>
{short.password && (
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
)}
@@ -532,38 +546,6 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<PenLine className="size-4" />
Edit URL
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => {
setSelectedUrl(short);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="size-4" />
QR Code
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
@@ -582,19 +564,54 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
Analytics
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Button
className="flex w-full items-center gap-2"
size="sm"
variant="ghost"
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<PenLine className="size-4" />
Edit URL
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-auto flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger className="truncate">
{short.userName ?? "Anonymous"}
</TooltipTrigger>
<TooltipContent>
{short.userName ?? "Anonymous"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Separator
className="h-4/5"
orientation="vertical"
></Separator>
{short.expiration !== "-1" && (
<>
<span>
Expiration:
Expiration:{" "}
{expirationTime(short.expiration, short.updatedAt)}
</span>
<Separator
className="h-4/5"
orientation="vertical"
></Separator>
</>
)}
{timeAgo(short.updatedAt as Date)}
<Switch
className="scale-[0.6]"
@@ -614,6 +631,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
layout={isMobile ? "right" : "split"}
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}

View File

@@ -215,8 +215,7 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
ReadyBadge
) : (
<Button
className=""
variant={"outline"}
variant={"default"}
size={"sm"}
onClick={handleSetAdmin}
disabled={isPending}

View File

@@ -1,12 +1,14 @@
import { env } from "@/env.mjs";
// import { env } from "@/env.mjs";
export async function GET() {
return new Response(
JSON.stringify({
google: !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
github: !!(env.GITHUB_ID && env.GITHUB_SECRET),
linuxdo: !!(env.LinuxDo_CLIENT_ID && env.LinuxDo_CLIENT_SECRET),
resend: !!(env.RESEND_API_KEY && env.RESEND_FROM_EMAIL),
}),
);
export async function GET(req: Request) {
try {
return Response.json({
google: true,
github: true,
linuxdo: true,
resend: true,
});
} catch (error) {
console.log("[Error]", error);
}
}

View File

@@ -1,3 +1,4 @@
import { siteConfig } from "@/config/site";
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
@@ -75,10 +76,38 @@ export async function POST(req: Request) {
);
if (user_record && user_record.length > 0) {
return Response.json("Record already exists", {
status: 403,
status: 400,
});
}
// apply subdomain
if (siteConfig.enableSubdomainApply) {
const res = await createUserRecord(user.id, {
record_id: generateSecret(16),
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: record.name,
type: record.type,
content: record.content,
proxied: record.proxied,
proxiable: false,
ttl: record.ttl,
comment: record.comment,
tags: "",
created_on: new Date().toISOString(),
modified_on: new Date().toISOString(),
active: 2, // pending
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
});
}
// send email to admin
return Response.json(res.data?.id);
}
const data = await createDNSRecord(
matchedZone.cf_zone_id,
matchedZone.cf_api_key,

View File

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

View File

@@ -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.4",
"versionName": "0.6.5",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -7,6 +7,7 @@ import useSWR from "swr";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { cn, fetcher, nFormatter } from "@/lib/utils";
import { useElementSize } from "@/hooks/use-element-size";
import {
Card,
CardContent,
@@ -62,6 +63,7 @@ const chartConfig = {
} satisfies ChartConfig;
export function InteractiveBarChart() {
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const [timeRange, setTimeRange] = useState<string>("7d");
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("users");
@@ -134,7 +136,7 @@ export function InteractiveBarChart() {
</Select>
</div>
<div className="flex">
<div className="grid grid-cols-3 sm:grid-cols-6">
{["users", "records", "urls", "emails", "inbox", "sends"].map(
(key) => {
const chart = key as keyof typeof chartConfig;
@@ -144,13 +146,13 @@ export function InteractiveBarChart() {
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-l border-t p-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:p-6"
className="relative z-30 flex flex-col justify-center gap-1 border-l border-t p-3 text-left transition-colors hover:bg-muted/30 data-[active=true]:bg-muted/50 sm:border-t-0 sm:p-4"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none sm:text-3xl">
<span className="text-base font-bold leading-none sm:text-lg">
{nFormatter(data.total[key])}
</span>
<span
@@ -170,7 +172,7 @@ export function InteractiveBarChart() {
)}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<CardContent ref={wrapperRef} className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[200px] w-full"
@@ -178,6 +180,7 @@ export function InteractiveBarChart() {
<BarChart
accessibilityLayer
data={data.list}
width={wrapperWidth}
margin={{
left: 12,
right: 12,

View File

@@ -9,6 +9,7 @@ import { Callout } from "@/components/shared/callout";
import { CopyButton } from "@/components/shared/copy-button";
import { DocsLang } from "../shared/docs-lang";
import { Separator } from "../ui/separator";
const components = {
h1: ({ className, ...props }) => (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -426,7 +426,7 @@ export default function QRCodeEditor({
{/* Api Key Mask */}
{!user.apiKey && (
<div className="absolute left-0 top-0 flex size-full flex-col items-center justify-center gap-2 bg-neutral-100/20 px-4 backdrop-blur">
<div className="absolute left-0 top-0 z-20 flex size-full flex-col items-center justify-center gap-2 bg-neutral-100/20 px-4 backdrop-blur">
<p className="text-center text-sm">
Please create a <strong>api key</strong> before use this feature.{" "}
<br /> Learn more about{" "}

View File

@@ -2,6 +2,7 @@ import { User } from "@prisma/client";
import { AvatarProps } from "@radix-ui/react-avatar";
import { generateGradientClasses } from "@/lib/enums";
import { cn } from "@/lib/utils";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
interface UserAvatarProps extends AvatarProps {
@@ -11,23 +12,14 @@ interface UserAvatarProps extends AvatarProps {
export function UserAvatar({ user, ...props }: UserAvatarProps) {
return (
<Avatar {...props}>
{user.image ? (
<AvatarImage
alt="Picture"
src={user.image}
src={
user.image ??
`https://unavatar.io/${user.email}?ttl=1h&fallback=https://wr.do/_static/avatar.png`
}
referrerPolicy="no-referrer"
/>
) : (
<RandomAvatar text={user.name || user.email || ""} />
)}
</Avatar>
);
}
export function RandomAvatar({ text }: { text?: string }) {
return (
<div
className={`flex size-full shrink-0 items-center justify-center rounded-full text-white ${generateGradientClasses(text || "wr.do")}`}
></div>
);
}

View File

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

View File

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

View File

@@ -7,9 +7,11 @@ description: 选择你的部署方式
## 使用 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)
<Callout type="warning" twClass="mt-4">
请在部署前先创建你的数据库实例。
</Callout>
记得填写必要的环境变量。
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
## 使用 Docker Compose 部署
@@ -53,3 +55,15 @@ docker compose up -d
```bash
docker compose up -d
```
## 官方镜像
```bash
docker pull ghcr.io/oiov/wr.do/wrdo:main
```
在 [container/wr.do](https://github.com/oiov/wr.do/pkgs/container/wr.do%2Fwrdo) 可以找到官方镜像。
## 打包镜像
Fork 此仓库后,在 Actions 中触发打包镜像。

View File

@@ -50,3 +50,15 @@ Fill in the environment variables in the `.env` file, then:
```bash
docker compose up -d
```
## Official Image
```bash
docker pull ghcr.io/oiov/wr.do/wrdo:main
```
Find the official image here: [container/wr.do](https://github.com/oiov/wr.do/pkgs/container/wr.do%2Fwrdo)
## Build Image
Fork this repository and trigger the build image action in Actions.

View File

@@ -1,6 +1,6 @@
services:
app:
image: ghcr.io/oiov/wr.do/wrdo:${TAG:-main}
image: ghcr.io/oiov/wr.do/wrdo:${TAG:-latest}
container_name: wrdo
ports:
- "3000:3000"

View File

@@ -1,6 +1,6 @@
services:
app:
image: ghcr.io/oiov/wr.do/wrdo:${TAG:-main}
image: ghcr.io/oiov/wr.do/wrdo:${TAG:-latest}
container_name: wrdo
ports:
- "3000:3000"

View File

@@ -3,8 +3,6 @@ import { z } from "zod";
export const env = createEnv({
server: {
// This is optional because it's only used in development.
// See https://next-auth.js.org/deployment.
NEXTAUTH_URL: z.string().url().optional(),
AUTH_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -23,6 +21,7 @@ export const env = createEnv({
NEXT_PUBLIC_APP_URL: z.string().optional(),
NEXT_PUBLIC_OPEN_SIGNUP: z.string().min(1).default("1"),
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().optional(),
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY: z.string().min(1).default("0"),
},
runtimeEnv: {
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
@@ -37,6 +36,8 @@ export const env = createEnv({
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_OPEN_SIGNUP: process.env.NEXT_PUBLIC_OPEN_SIGNUP,
NEXT_PUBLIC_EMAIL_R2_DOMAIN: process.env.NEXT_PUBLIC_EMAIL_R2_DOMAIN,
NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY:
process.env.NEXT_PUBLIC_ENABLE_SUBDOMAIN_APPLY,
SCREENSHOTONE_BASE_URL: process.env.SCREENSHOTONE_BASE_URL,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
LinuxDo_CLIENT_ID: process.env.LinuxDo_CLIENT_ID,

View File

@@ -10,7 +10,7 @@ interface UseElementSizeOptions {
box?: "content-box" | "border-box" | "device-pixel-content-box";
}
export function useElementSize<T extends HTMLElement>(
export function useElementSize<T extends HTMLDivElement>(
initialSize: ElementSize = { width: 0, height: 0 },
options: UseElementSizeOptions = {},
): { ref: React.RefObject<T>; width: number; height: number } {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "wr.do",
"version": "0.6.4",
"version": "0.6.5",
"author": {
"name": "oiov",
"url": "https://github.com/oiov"

BIN
public/_static/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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.4",
"versionName": "0.6.5",
"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.4",
"versionName": "0.6.5",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -1,40 +1,46 @@
<?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-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/deploy</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-28T14:53:12.008Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/api/feature</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/chat</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker-zh</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/deploy</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/deploy-zh</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email-zh</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation-zh</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start-zh</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/pricing</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/privacy</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/terms</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-03T07:16:22.972Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
</urlset>

File diff suppressed because one or more lines are too long

1
types/index.d.ts vendored
View File

@@ -18,6 +18,7 @@ export type SiteConfig = {
};
openSignup: boolean;
emailR2Domain: string;
enableSubdomainApply: boolean;
};
export type NavItem = {