Merge pull request #29 from oiov/i18n

Support I18n
This commit is contained in:
oiov
2025-06-08 15:18:13 +08:00
committed by GitHub
96 changed files with 2114 additions and 848 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
# GitHub api token for getting gitHub stars count
GITHUB_TOKEN=
# Skip DB check and migration (only for docker). if false, will check and migrate database each time start docker compose.
# Skip DB check and migration. if false, will check and migrate database each time start docker compose.
SKIP_DB_CHECK=false
SKIP_DB_MIGRATION=false
+1
View File
@@ -5,6 +5,7 @@ on:
branches:
- main
- fix/docker
- i18n
tags:
- "v*.*.*"
pull_request:
+9 -1
View File
@@ -109,10 +109,18 @@ pnpm db:push
Follow https://localhost:3000/setup
## Environment Variables
## 环境变量
查看 [开发者文档](https://wr.do/docs/developer).
## 技术栈
- Next.js + React + TypeScript
- Tailwind CSS 用于样式设计
- Prisma ORM 作为数据库工具
- Cloudflare 作为主要的云基础设施
- Vercel 作为推荐的部署平台
## 社区群组
- Discord: https://discord.gg/AHPQYuZu3m
+8
View File
@@ -123,6 +123,14 @@ Follow https://localhost:3000/setup
Via [Installation For Developer](https://wr.do/docs/developer).
## Technology Stack
- Next.js + React + TypeScript
- Tailwind CSS for styling and design
- Prisma ORM as the database toolkit
- Cloudflare as the primary cloud infrastructure
- Vercel as the recommended deployment platform
## Community Group
- Discord: https://discord.gg/AHPQYuZu3m
+9 -7
View File
@@ -1,6 +1,7 @@
import { Suspense } from "react";
import { Metadata } from "next";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
@@ -14,6 +15,7 @@ export const metadata: Metadata = {
};
export default function LoginPage() {
const t = useTranslations("Auth");
return (
<div className="container flex h-screen w-screen flex-col items-center justify-center">
<Link
@@ -25,20 +27,20 @@ export default function LoginPage() {
>
<>
<Icons.chevronLeft className="mr-2 size-4" />
Back
{t("Back")}
</>
</Link>
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<Icons.logo className="mx-auto size-12" />
<div className="text-2xl font-semibold tracking-tight">
<span>Welcome to</span>{" "}
<span>{t("Welcome to")}</span>{" "}
<span style={{ fontFamily: "Bahamas Bold" }}>
{siteConfig.name}
</span>
</div>
<p className="text-sm text-muted-foreground">
Choose your login method to continue
{t("Choose your login method to continue")}
</p>
</div>
<Suspense>
@@ -50,19 +52,19 @@ export default function LoginPage() {
</p> */}
<p className="px-8 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our{" "}
{t("By clicking continue, you agree to our")}{" "}
<Link
href="/terms"
className="hover:text-brand underline underline-offset-4"
>
Terms of Service
{t("Terms of Service")}
</Link>{" "}
and{" "}
{t("and")}{" "}
<Link
href="/privacy"
className="hover:text-brand underline underline-offset-4"
>
Privacy Policy
{t("Privacy Policy")}
</Link>
.
</p>
+4 -2
View File
@@ -1,10 +1,12 @@
import { getCurrentUser } from "@/lib/session";
import HeroLanding, { LandingImages } from "@/components/sections/hero-landing";
import { PricingSection } from "@/components/sections/pricing";
export default function IndexPage() {
export default async function IndexPage() {
const user = await getCurrentUser();
return (
<>
<HeroLanding />
<HeroLanding userId={user?.id} />
<LandingImages />
<PricingSection />
</>
@@ -1,6 +1,7 @@
"use client";
import { TrendingUp } from "lucide-react";
import { useTranslations } from "next-intl";
import {
Label,
PolarGrid,
@@ -29,6 +30,7 @@ export function RadialShapeChart({
total: number;
totalUser: number;
}) {
const t = useTranslations("Components");
const chartData = [
{ browser: "safari", actived: total, fill: "var(--color-safari)" },
];
@@ -94,7 +96,7 @@ export function RadialShapeChart({
<TrendingUp className="size-4" />
</div>
<div className="leading-none text-muted-foreground">
Cumulative proportion of activated <strong>Api Key</strong> users
{t("Activated Api Key users")}
</div>
</CardFooter>
</Card>
+19 -14
View File
@@ -4,11 +4,12 @@ import { useState } from "react";
import Link from "next/link";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
import { DomainFormData } from "@/lib/dto/domains";
import { fetcher, timeAgo } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import {
@@ -34,6 +35,7 @@ import { FormType } from "@/components/forms/record-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import { PaginationWrapper } from "@/components/shared/pagination";
import { TimeAgoIntl } from "@/components/shared/time-ago";
export interface DomainListProps {
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
@@ -70,6 +72,7 @@ function TableColumnSekleton() {
export default function DomainList({ user, action }: DomainListProps) {
const { isMobile } = useMediaQuery();
const t = useTranslations("List");
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditDomain, setCurrentEditDomain] =
@@ -130,7 +133,7 @@ export default function DomainList({ user, action }: DomainListProps) {
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center gap-2">
<div className="flex items-center gap-1 text-lg font-bold">
<span className="text-nowrap">Total Domains:</span>
<span className="text-nowrap">{t("Total Domains")}:</span>
{isLoading ? (
<Skeleton className="h-6 w-16" />
) : (
@@ -161,7 +164,7 @@ export default function DomainList({ user, action }: DomainListProps) {
}}
>
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add Domain</span>
<span className="hidden sm:inline">{t("Add Domain")}</span>
</Button>
</div>
</CardHeader>
@@ -170,7 +173,7 @@ export default function DomainList({ user, action }: DomainListProps) {
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by domain name..."
placeholder={t("Search by domain name") + "..."}
value={searchParams.target}
onChange={(e) => {
setSearchParams({
@@ -197,25 +200,25 @@ export default function DomainList({ user, action }: DomainListProps) {
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-4 items-center text-xs sm:grid-cols-7">
<TableHead className="col-span-1 flex items-center font-bold">
Domain
{t("Domain Name")}
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Shorten
{t("Shorten Service")}
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Email
{t("Email Service")}
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Subdomain
{t("Subdomain Service")}
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Active
{t("Active")}
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
Updated
{t("Updated")}
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
Actions
{t("Actions")}
</TableHead>
</TableRow>
</TableHeader>
@@ -289,7 +292,7 @@ export default function DomainList({ user, action }: DomainListProps) {
/>
</TableCell>
<TableCell className="col-span-1 flex items-center truncate">
{timeAgo(domain.updatedAt as Date)}
<TimeAgoIntl date={domain.updatedAt as Date} />
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Button
@@ -303,7 +306,7 @@ export default function DomainList({ user, action }: DomainListProps) {
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">Edit</p>
<p className="hidden sm:block">{t("Edit")}</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
</TableCell>
@@ -316,7 +319,9 @@ export default function DomainList({ user, action }: DomainListProps) {
) : (
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="globeLock" />
<EmptyPlaceholder.Title>No Domains</EmptyPlaceholder.Title>
<EmptyPlaceholder.Title>
{t("No Domains")}
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any domains yet. Start creating one.
</EmptyPlaceholder.Description>
+3 -3
View File
@@ -19,10 +19,10 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;&nbsp;Domains"
text="List and manage domains."
heading="Domains Management"
text="List and manage domains"
link="/docs/developer/cloudflare"
linkText="domains."
linkText="domains"
/>
<DomainList
user={{
@@ -1,6 +1,7 @@
"use client";
import { ScrapeMeta } from "@prisma/client";
import { useTranslations } from "next-intl";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { useElementSize } from "@/hooks/use-element-size";
@@ -58,6 +59,8 @@ export function LineChartMultiple({
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const processedData = processChartData(chartData, type1, type2);
const t = useTranslations("Components");
const chartConfig = {
source1: {
label: type1,
@@ -69,13 +72,14 @@ export function LineChartMultiple({
},
} satisfies ChartConfig;
const message = type2
? t("total-requests-two-types", { type1, type2 })
: t("total-requests-one-type", { type1 });
return (
<Card>
<CardHeader>
<CardDescription>
Total requests of {type1}
{type2 && ` and ${type2}`}.
</CardDescription>
<CardDescription>{message}</CardDescription>
</CardHeader>
<CardContent ref={wrapperRef}>
<ChartContainer config={chartConfig}>
+1 -1
View File
@@ -6,7 +6,7 @@ export default function AdminPanelLoading() {
<>
<DashboardHeader
heading="Admin Panel"
text="Access only for users with ADMIN role."
text="Access only for users with ADMIN role"
/>
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">
+2 -6
View File
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { useTranslations } from "next-intl";
import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record";
import {
@@ -134,7 +135,6 @@ async function RequestStatsSection() {
return hasStats ? (
<>
<h2 className="my-1 text-xl font-semibold">Request Statistics</h2>
<DailyPVUVChart
data={screenshot_stats
.concat(meta_stats)
@@ -200,7 +200,6 @@ async function MarkdownTextChartSection() {
async function LogsSection({ userId }: { userId: string }) {
return (
<>
<h2 className="my-1 text-xl font-semibold">Request Logs</h2>
<LogsTable userId={userId} target={"/api/v1/scraping/admin/logs"} />
</>
);
@@ -213,10 +212,7 @@ export default async function AdminPage() {
return (
<>
<DashboardHeader
heading="Admin Panel"
text="Access only for users with ADMIN role."
/>
<DashboardHeader heading="Admin Panel" text="" />
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-3">
<ErrorBoundary
+4 -1
View File
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="DNS Records" text="" />
<DashboardHeader
heading="Manage DNS Records"
text="List and manage records"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
+3 -3
View File
@@ -19,10 +19,10 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;&nbsp;DNS&nbsp;&nbsp;Records"
text="List and manage records."
heading="Manage DNS Records"
text="List and manage records"
link="/docs/dns-records"
linkText="DNS records."
linkText="DNS records"
/>
<UserRecordsList
user={{
+4 -1
View File
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardUrlsLoading() {
return (
<>
<DashboardHeader heading="Short Urls" text="" />
<DashboardHeader
heading="Manage Short URLs"
text="List and manage short urls"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
+3 -3
View File
@@ -19,10 +19,10 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
heading="Manage Short URLs"
text="List and manage short urls"
link="/docs/short-urls"
linkText="short urls."
linkText="short urls"
/>
<UserUrlsList
+1 -1
View File
@@ -6,7 +6,7 @@ export default function OrdersLoading() {
<>
<DashboardHeader
heading="User Management"
text="List and manage all users."
text="List and manage all users"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
+1 -1
View File
@@ -20,7 +20,7 @@ export default async function UsersPage() {
<>
<DashboardHeader
heading="User Management"
text="List and manage all users."
text="List and manage all users"
/>
<UsersList user={{ id: user.id, name: user.name || "" }} />
</>
+4 -5
View File
@@ -5,7 +5,7 @@ import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import useSWR, { useSWRConfig } from "swr";
import { fetcher, timeAgo } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,6 +37,7 @@ import { UserForm } from "@/components/forms/user-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import { PaginationWrapper } from "@/components/shared/pagination";
import { TimeAgoIntl } from "@/components/shared/time-ago";
import CountUpFn from "../../../../components/dashboard/count-up";
@@ -105,9 +106,7 @@ export default function UsersList({ user }: UrlListProps) {
<CardHeader className="flex flex-row items-center">
<CardDescription className="text-balance text-lg font-bold">
<span>Total Users:</span>{" "}
<span className="font-bold">
{data && <CountUpFn count={data.total} />}
</span>
<span className="font-bold">{data && data.total}</span>
</CardDescription>
<div className="ml-auto flex items-center justify-end gap-3">
<Button
@@ -252,7 +251,7 @@ export default function UsersList({ user }: UrlListProps) {
<Switch defaultChecked={user.active === 1} />
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
{timeAgo(user.createdAt || "")}
<TimeAgoIntl date={user.updatedAt as Date} />
</TableCell>
<TableCell className="col-span-1 flex justify-center">
<Button
-2
View File
@@ -1,10 +1,8 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardLoading() {
return (
<>
<DashboardHeader heading="Dashboard" text="" />
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">
<Skeleton className="h-32 w-full rounded-lg" />
+1 -7
View File
@@ -13,11 +13,9 @@ import {
DashboardInfoCard,
HeroCard,
} from "@/components/dashboard/dashboard-info-card";
import { DashboardHeader } from "@/components/dashboard/header";
import { ErrorBoundary } from "@/components/shared/error-boundary";
import UserRecordsList from "./records/record-list";
import LiveLog from "./urls/live-logs";
import UserUrlsList from "./urls/url-list";
export const metadata = constructMetadata({
@@ -87,10 +85,6 @@ async function DnsRecordsCardSection({
);
}
async function LiveLogSection() {
return <LiveLog admin={false} />;
}
async function UserUrlsListSection({
user,
}: {
@@ -148,7 +142,7 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader heading="Dashboard" text="" />
{/* <DashboardHeader heading="Dashboard" text="" /> */}
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 xl:grid-cols-3">
<ErrorBoundary
@@ -5,8 +5,8 @@ export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;&nbsp;DNS&nbsp;&nbsp;Records"
text="List and manage records."
heading="Manage DNS Records"
text="List and manage records"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
+3 -3
View File
@@ -19,10 +19,10 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;&nbsp;DNS&nbsp;&nbsp;Records"
text="List and manage records."
heading="Manage DNS Records"
text="List and manage records"
link="/docs/dns-records"
linkText="DNS records."
linkText="DNS records"
/>
<UserRecordsList
user={{
@@ -4,12 +4,13 @@ import { useState } from "react";
import Link from "next/link";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
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 { fetcher } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -42,6 +43,7 @@ import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
import { PaginationWrapper } from "@/components/shared/pagination";
import { TimeAgoIntl } from "@/components/shared/time-ago";
export interface RecordListProps {
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role">;
@@ -87,9 +89,10 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
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 t = useTranslations("List");
const { mutate } = useSWRConfig();
const { data, isLoading } = useSWR<{
@@ -144,30 +147,30 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
<CardHeader className="flex flex-row items-center">
{isAdmin ? (
<CardDescription className="text-balance text-lg font-bold">
<span>Total Subdomains:</span>{" "}
<span>{t("Total Subdomains")}:</span>{" "}
<span className="font-bold">{data && data.total}</span>
</CardDescription>
) : (
<div className="grid gap-2">
<CardTitle>Subdomains</CardTitle>
<CardTitle>{t("Subdomain List")}</CardTitle>
<CardDescription className="hidden text-balance sm:block">
Please read the{" "}
{t("Please read the")}{" "}
<Link
target="_blank"
className="font-semibold text-yellow-600 after:content-['↗'] hover:underline"
href="/docs/dns-records#legitimacy-review"
>
Legitimacy review
{t("legitimacy review")}
</Link>{" "}
before using. See{" "}
{t("before using")}. {t("See")}{" "}
<Link
target="_blank"
className="text-blue-500 hover:underline"
href="/docs/examples/vercel"
>
examples
{t("examples")}
</Link>{" "}
for more usage.
{t("for more usage")}.
</CardDescription>
</div>
)}
@@ -194,7 +197,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
}}
>
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add Record</span>
<span className="hidden sm:inline">{t("Add Record")}</span>
</Button>
</div>
</CardHeader>
@@ -203,28 +206,28 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-9">
<TableHead className="col-span-1 flex items-center font-bold">
Type
{t("Type")}
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
Name
{t("Name")}
</TableHead>
<TableHead className="col-span-2 hidden items-center font-bold sm:flex">
Content
{t("Content")}
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
TTL
{t("TTL")}
</TableHead>
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
Status
{t("Status")}
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
User
{t("User")}
</TableHead>
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
Updated
{t("Updated")}
</TableHead>
<TableHead className="col-span-1 flex items-center justify-center font-bold">
Actions
{t("Actions")}
</TableHead>
</TableRow>
</TableHeader>
@@ -278,8 +281,11 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
onChangeStatu={handleChangeStatu}
/>
) : (
<Badge className="rounded-md" variant={"yellow"}>
Pending
<Badge
className="text-nowrap rounded-md"
variant={"yellow"}
>
{t("Pending")}
</Badge>
)}
{record.active !== 1 && (
@@ -291,16 +297,21 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
<TooltipContent>
{record.active === 0 && (
<ul className="list-disc px-3">
<li>The target is currently inaccessible.</li>
<li>
Please check the target and try again.
{t("The target is currently inaccessible")}.
</li>
<li>
If the target is not activated within 3
days, <br />
the administrator will{" "}
{t("Please check the target and try again")}
.
</li>
<li>
{t(
"If the target is not activated within 3 days",
)}
, <br />
{t("the administrator will")}{" "}
<strong className="text-red-500">
delete this record
{t("delete this record")}
</strong>
.
</li>
@@ -309,8 +320,10 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
{record.active === 2 && (
<ul className="list-disc px-3">
<li>
The record is currently pending for admin
approval.
{t(
"The record is currently pending for admin approval",
)}
.
</li>
</ul>
)}
@@ -332,12 +345,14 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
</TooltipProvider>
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
{timeAgo(record.modified_on as unknown as Date)}
<TimeAgoIntl
date={record.modified_on as unknown as Date}
/>
</TableCell>
<TableCell className="col-span-1 flex justify-center">
{record.active !== 2 ? (
<Button
className="text-sm hover:bg-slate-100 dark:hover:text-primary-foreground"
className="text-nowrap text-sm hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
@@ -347,7 +362,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
setShowForm(!isShowForm);
}}
>
<p>Edit</p>
<p>{t("Edit")}</p>
<PenLine className="ml-1 size-4" />
</Button>
) : record.active === 2 &&
@@ -364,7 +379,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
setShowForm(!isShowForm);
}}
>
<p>Review</p>
<p>{t("Review")}</p>
</Button>
) : (
"--"
@@ -375,7 +390,9 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
) : (
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="globe" />
<EmptyPlaceholder.Title>No Subdomain</EmptyPlaceholder.Title>
<EmptyPlaceholder.Title>
{t("No Subdomains")}
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any subdomain yet. Start creating
record.
@@ -398,7 +415,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
</Card>
<Modal
className="max-h-[90vh] overflow-y-auto md:max-w-2xl"
className="max-h-[99vh] overflow-y-auto md:max-w-2xl"
showModal={isShowForm}
setShowModal={setShowForm}
>
+1 -6
View File
@@ -1,6 +1,5 @@
import {
getScrapeStatsByTypeAndUserId,
getScrapeStatsByUserId,
getScrapeStatsByUserId1,
} from "@/lib/dto/scrape";
@@ -21,10 +20,7 @@ export default async function DashboardScrapeCharts({ id }: { id: string }) {
return (
<>
{all_user_logs && all_user_logs.length > 0 && (
<>
<h2 className="my-1 text-xl font-semibold">Request Statistics</h2>
<DailyPVUVChart data={all_user_logs} />
</>
<DailyPVUVChart data={all_user_logs} />
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{(screenshot_stats.length > 0 || meta_stats.length > 0) && (
@@ -43,7 +39,6 @@ export default async function DashboardScrapeCharts({ id }: { id: string }) {
)}
</div>
<h2 className="my-1 text-xl font-semibold">Request Logs</h2>
<LogsTable userId={id} target={"/api/v1/scraping/logs"} />
</>
);
@@ -3,9 +3,10 @@
import * as React from "react";
import Link from "next/link";
import { ScrapeMeta } from "@prisma/client";
import { useTranslations } from "next-intl";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { isLink, nFormatter, removeUrlSuffix, timeAgo } from "@/lib/utils";
import { isLink, nFormatter, removeUrlSuffix } from "@/lib/utils";
import {
Card,
CardContent,
@@ -18,7 +19,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import CountUp from "@/components/dashboard/count-up";
import { TimeAgoIntl } from "@/components/shared/time-ago";
const chartConfig = {
request: {
@@ -102,18 +103,21 @@ export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
(a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(),
);
const latestEntry = sort_data[sort_data.length - 1];
const latestDate = timeAgo(latestEntry.updatedAt);
const latestFrom = latestEntry.type;
const t = useTranslations("Components");
const lastRequestInfo = t.rich("last-request-info", {
location: latestFrom,
timeAgo: () => <TimeAgoIntl date={latestEntry.updatedAt} />,
});
return (
<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-5 py-4">
<CardTitle>Total Requests of APIs in Last 30 Days</CardTitle>
<CardDescription>
Last request from <strong>{latestFrom}</strong> api about{" "}
{latestDate}.
</CardDescription>
<CardTitle>{t("Total Requests of APIs in Last 30 Days")}</CardTitle>
<CardDescription>{lastRequestInfo}</CardDescription>
</div>
<div className="flex">
{["request", "ip"].map((key) => {
@@ -125,8 +129,8 @@ export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
className="relative z-30 flex flex-1 flex-col items-center justify-center gap-1 border-t px-6 py-2 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-3"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
<span className="text-nowrap text-xs text-muted-foreground">
{t(chartConfig[chart].label)}
</span>
<span className="text-lg font-bold leading-none">
{nFormatter(dataTotal[key])}
+2 -2
View File
@@ -5,8 +5,8 @@ export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader
heading="Scraping&nbsp;&nbsp;API&nbsp;&nbsp;Overview"
text="Quickly extract valuable structured website data. It's free and unlimited to use!"
heading="Scraping API Overview"
text="Quickly extract valuable structured website data"
/>
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3">
+11 -4
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import useSWR, { useSWRConfig } from "swr";
import { nFormatter } from "@/lib/utils";
@@ -49,6 +50,8 @@ const LogsTable = ({ userId, target }) => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const t = useTranslations("Components");
const [filters, setFilters] = useState({
type: "",
ip: "",
@@ -141,13 +144,17 @@ const LogsTable = ({ userId, target }) => {
<TableHeader className="bg-muted">
<TableRow className="grid grid-cols-5 items-center sm:grid-cols-6">
<TableHead className="hidden items-center justify-start px-2 sm:flex">
Date
{t("Date")}
</TableHead>
<TableHead className="flex items-center px-2">
{t("Type")}
</TableHead>
<TableHead className="flex items-center px-2">Type</TableHead>
<TableHead className="col-span-3 flex items-center px-2">
Link
{t("Link")}
</TableHead>
<TableHead className="flex items-center px-2">
{t("User")}
</TableHead>
<TableHead className="flex items-center px-2">User</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Scraping API" text="" />
<DashboardHeader
heading="Url to Markdown"
text="Quickly extract website content and convert it to Markdown format"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
@@ -21,10 +21,10 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;Markdown"
text="Quickly extract website content and convert it to Markdown format."
heading="Url to Markdown"
text="Quickly extract website content and convert it to Markdown format"
link="/docs/open-api/markdown"
linkText="Markdown API."
linkText="Markdown API"
/>
<ApiReference
badge="GET /api/v1/scraping/markdown"
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Scraping API" text="" />
<DashboardHeader
heading="Url to Meta Info"
text="Quickly extract valuable structured website data"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
@@ -5,7 +5,6 @@ import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "@/components/shared/api-reference";
import DashboardScrapeCharts from "../charts";
import { MetaScraping } from "../scrapes";
export const metadata = constructMetadata({
@@ -21,10 +20,10 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;Meta&nbsp;&nbsp;Info"
text="Quickly extract valuable structured website data."
heading="Url to Meta Info"
text="Quickly extract valuable structured website data"
link="/docs/open-api/meta-info"
linkText="Meta Info API."
linkText="Meta Info API"
/>
<ApiReference
badge="GET /api/v1/scraping/meta"
+7 -7
View File
@@ -20,27 +20,27 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Scraping&nbsp;&nbsp;API&nbsp;&nbsp;Overview"
text="Quickly extract valuable structured website data. It's free and unlimited to use!"
heading="Scraping API Overview"
text="Quickly extract valuable structured website data"
link="/docs/open-api"
linkText="Open API."
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StaticInfoCard
title="Url to Screenshot"
desc="Take a screenshot of the webpage."
desc="Take a screenshot of the webpage"
link="/dashboard/scrape/screenshot"
icon="camera"
/>
<StaticInfoCard
title="Url to Meta Info"
desc="Extract website metadata."
desc="Extract website metadata"
link="/dashboard/scrape/meta-info"
icon="globe"
/>
<StaticInfoCard
title="Url to QR Code"
desc="Generate QR Code from URL."
desc="Generate QR Code from URL"
link="/dashboard/scrape/qrcode"
icon="qrcode"
/>
@@ -48,13 +48,13 @@ export default async function DashboardPage() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StaticInfoCard
title="Url to Markdown"
desc="Convert website content to Markdown format."
desc="Convert website content to Markdown format"
link="/dashboard/scrape/markdown"
icon="heading1"
/>
<StaticInfoCard
title="Url to Text"
desc="Extract website text."
desc="Convert website content to text"
link="/dashboard/scrape/markdown"
icon="fileText"
/>
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Scraping API" text="" />
<DashboardHeader
heading="Url to QR Code"
text="Generate QR Code from URL"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
@@ -6,7 +6,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "@/components/shared/api-reference";
import QRCodeEditor from "@/components/shared/qr";
import { CodeLight, QrCodeScraping } from "../scrapes";
import { CodeLight } from "../scrapes";
export const metadata = constructMetadata({
title: "Url to QR Code API - WR.DO",
@@ -21,10 +21,10 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;QR&nbsp;&nbsp;Code"
heading="Url to QR Code"
text="Generate QR Code from URL"
link="/docs/open-api/qrcode"
linkText="QR Code API."
linkText="QR Code API"
/>
<ApiReference
badge="GET /api/v1/scraping/qrcode"
+25 -19
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import JsonView from "@uiw/react-json-view";
import { githubLightTheme } from "@uiw/react-json-view/githubLight";
import { vscodeTheme } from "@uiw/react-json-view/vscode";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { toast } from "sonner";
@@ -50,6 +51,7 @@ export function ScreenshotScraping({
}: {
user: { id: string; apiKey: string };
}) {
const t = useTranslations("Scrape");
const { theme } = useTheme();
const [protocol, setProtocol] = useState("https://");
@@ -87,10 +89,12 @@ export function ScreenshotScraping({
<CodeLight content={`https://wr.do/api/v1/scraping/screenshot`} />
<Card className="bg-gray-50 dark:bg-gray-900">
<CardHeader>
<CardTitle>Playground</CardTitle>
<CardTitle>{t("Playground")}</CardTitle>
<CardDescription>
Automate your website screenshots and turn them into stunning
visuals for your applications.
{t(
"Automate your website screenshots and turn them into stunning visuals for your applications",
)}
.
</CardDescription>
</CardHeader>
<CardContent>
@@ -126,9 +130,9 @@ export function ScreenshotScraping({
variant="blue"
onClick={handleScrapingScreenshot}
disabled={isShoting}
className="rounded-l-none"
className="w-28 rounded-l-none"
>
{isShoting ? "Scraping..." : "Send"}
{isShoting ? t("Scraping") : t("Start")}
</Button>
</div>
@@ -164,6 +168,7 @@ export function MetaScraping({
}: {
user: { id: string; apiKey: string };
}) {
const t = useTranslations("Scrape");
const { theme } = useTheme();
const [currentLink, setCurrentLink] = useState("wr.do");
const [protocol, setProtocol] = useState("https://");
@@ -203,8 +208,10 @@ export function MetaScraping({
<CodeLight content={`https://wr.do/api/v1/scraping/meta`} />
<Card className="bg-gray-50 dark:bg-gray-900">
<CardHeader>
<CardTitle>Playground</CardTitle>
<CardDescription>Scrape the meta data of a website.</CardDescription>
<CardTitle>{t("Playground")}</CardTitle>
<CardDescription>
{t("Scrape the meta data of a website")}.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center">
@@ -239,9 +246,9 @@ export function MetaScraping({
variant="blue"
onClick={handleScrapingMeta}
disabled={isScraping}
className="rounded-l-none"
className="w-28 rounded-l-none"
>
{isScraping ? "Scraping..." : "Send"}
{isScraping ? t("Scraping") : t("Start")}
</Button>
</div>
@@ -264,6 +271,7 @@ export function MarkdownScraping({
}: {
user: { id: string; apiKey: string };
}) {
const t = useTranslations("Scrape");
const { theme } = useTheme();
const [currentLink, setCurrentLink] = useState("wr.do");
const [protocol, setProtocol] = useState("https://");
@@ -334,9 +342,9 @@ export function MarkdownScraping({
variant="blue"
onClick={handleScrapingMeta}
disabled={isScraping}
className="rounded-l-none"
className="w-28 rounded-l-none"
>
{isScraping ? "Scraping..." : "Send"}
{isScraping ? t("Scraping") : t("Start")}
</Button>
</div>
@@ -359,6 +367,7 @@ export function TextScraping({
}: {
user: { id: string; apiKey: string };
}) {
const t = useTranslations("Scrape");
const { theme } = useTheme();
const [currentLink, setCurrentLink] = useState("wr.do");
const [protocol, setProtocol] = useState("https://");
@@ -394,7 +403,7 @@ export function TextScraping({
<CodeLight content={`https://wr.do/api/v1/scraping/text`} />
<Card className="bg-gray-50 dark:bg-gray-900">
<CardHeader>
<CardTitle>Text</CardTitle>
<CardTitle>{t("Text")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center">
@@ -429,9 +438,9 @@ export function TextScraping({
variant="blue"
onClick={handleScrapingMeta}
disabled={isScraping}
className="rounded-l-none"
className="w-28 rounded-l-none"
>
{isScraping ? "Scraping..." : "Send"}
{isScraping ? t("Scraping") : t("Start")}
</Button>
</div>
@@ -454,6 +463,7 @@ export function QrCodeScraping({
}: {
user: { id: string; apiKey: string };
}) {
const t = useTranslations("Scrape");
const { theme } = useTheme();
const [protocol, setProtocol] = useState("https://");
@@ -487,11 +497,7 @@ export function QrCodeScraping({
<CodeLight content={`https://wr.do/api/v1/scraping/qrcode`} />
<Card className="bg-gray-50 dark:bg-gray-900">
<CardHeader>
<CardTitle>Playground</CardTitle>
<CardDescription>
Automate your website screenshots and turn them into stunning
visuals for your applications.
</CardDescription>
<CardTitle>{t("Playground")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center">
@@ -4,7 +4,10 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Scraping API" text="" />
<DashboardHeader
heading="Url to Screenshot"
text="Quickly extract website screenshots"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
@@ -22,10 +22,10 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Url&nbsp;&nbsp;to&nbsp;&nbsp;Screenshot"
text="Quickly extract website screenshots."
heading="Url to Screenshot"
text="Quickly extract website screenshots"
link="/docs/open-api/screenshot"
linkText="Screenshot API."
linkText="Screenshot API"
/>
<ApiReference
badge="GET /api/v1/scraping/screenshot"
@@ -5,8 +5,8 @@ export default function DashboardSettingsLoading() {
return (
<>
<DashboardHeader
heading="Settings"
text="Manage account and website settings."
heading="Account Settings"
text="Manage account and website settings"
/>
<div className="divide-y divide-muted pb-10">
<SkeletonSection />
+1 -1
View File
@@ -22,7 +22,7 @@ export default async function SettingsPage() {
<>
<DashboardHeader
heading="Account Settings"
text="Manage account and website settings."
text="Manage account and website settings"
/>
<div className="divide-y divide-muted pb-10">
<UserNameForm user={{ id: user.id, name: user.name || "" }} />
@@ -1,13 +1,7 @@
"use client";
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "next-intl";
import { Bar, BarChart, Tooltip, XAxis, YAxis } from "recharts";
import { cn } from "@/lib/utils";
import StatusDot from "@/components/dashboard/status-dot";
@@ -29,6 +23,7 @@ export const RealtimeChart = ({
chartData,
totalClicks,
}: RealtimeChartProps) => {
const t = useTranslations("Components");
const getTickInterval = (dataLength: number) => {
if (dataLength <= 6) return 0;
if (dataLength <= 12) return 1;
@@ -45,7 +40,7 @@ export const RealtimeChart = ({
<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>
<h3 className="ml-2">{t("Realtime Visits")}</h3>
<Icons.mousePointerClick className="ml-auto size-4 text-muted-foreground" />
</div>
<p className="mb-2 text-lg font-semibold">{totalClicks}</p>
+14 -10
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import useSWR, { useSWRConfig } from "swr";
@@ -54,6 +55,8 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
const [limitDiplay, setLimitDisplay] = useState(100);
const newLogsRef = useRef<Set<string>>(new Set()); // Track new log keys
const t = useTranslations("Components");
const {
data: newLogs,
error,
@@ -151,10 +154,10 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base text-gray-800 dark:text-gray-100">
Live Log
{t("Live Logs")}
</CardTitle>
<CardDescription>
Real-time logs of short link visits.
{t("Real-time logs of short link visits")}.
</CardDescription>
</div>
@@ -166,7 +169,8 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
isLive ? "border-dashed border-blue-600 text-blue-500" : ""
}`}
>
<Icons.CirclePlay className="h-4 w-4" /> {isLive ? "Stop" : "Live"}
<Icons.CirclePlay className="h-4 w-4" />{" "}
{isLive ? t("Stop") : t("Live")}
</Button>
<Button
className="bg-primary-foreground"
@@ -207,22 +211,22 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
<TableHeader>
<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
{t("Time")}
</TableHead>
<TableHead className="col-span-1 flex h-8 items-center">
Slug
{t("Slug")}
</TableHead>
<TableHead className="col-span-3 hidden h-8 items-center sm:flex">
Target
{t("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
{t("Location")}
</TableHead>
<TableHead className="col-span-1 flex h-8 items-center">
Clicks
{t("Clicks")}
</TableHead>
</TableRow>
</TableHeader>
@@ -283,7 +287,7 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
)}
{isLive && (
<div className="flex w-full items-center justify-end gap-2 border-t border-dashed pt-4 text-sm text-gray-500">
<p>{logs.length}</p> of
<p>{logs.length}</p> {t("of")}
<Select
onValueChange={(value: string) => {
setLimitDisplay(Number(value));
@@ -302,7 +306,7 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
))}
</SelectContent>
</Select>
<p>total logs</p>
<p>{t("total logs")}</p>
</div>
)}
</CardContent>
+2 -2
View File
@@ -5,8 +5,8 @@ export default function DashboardUrlsLoading() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
heading="Manage Short URLs"
text="List and manage short urls"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
+27 -21
View File
@@ -7,6 +7,7 @@ import { UrlMeta, User } from "@prisma/client";
import { VisSingleContainer, VisTooltip, VisTopoJSONMap } from "@unovis/react";
import { TopoJSONMap } from "@unovis/ts";
import { WorldMapTopoJSON } from "@unovis/ts/maps";
import { useTranslations } from "next-intl";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { TeamPlanQuota } from "@/config/team";
@@ -19,7 +20,7 @@ import {
getRegionName,
} from "@/lib/contries";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { isLink, removeUrlSuffix, timeAgo } from "@/lib/utils";
import { isLink, removeUrlSuffix } from "@/lib/utils";
import { useElementSize } from "@/hooks/use-element-size";
import { Button } from "@/components/ui/button";
import {
@@ -44,6 +45,7 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Icons } from "@/components/shared/icons";
import { TimeAgoIntl } from "@/components/shared/time-ago";
const chartConfig = {
pv: {
@@ -175,6 +177,8 @@ export function DailyPVUVChart({
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("pv");
const t = useTranslations("Components");
const processedData = processUrlMeta(data).map((entry) => ({
date: entry.date,
pv: entry.clicks,
@@ -184,7 +188,6 @@ export function DailyPVUVChart({
const dataTotal = calculateUVAndPV(data);
const latestEntry = data[data.length - 1];
const latestDate = timeAgo(latestEntry.updatedAt);
const latestFrom = [
latestEntry.city ? decodeURIComponent(latestEntry.city) : "",
latestEntry.country ? `(${getCountryName(latestEntry.country)})` : "",
@@ -238,14 +241,17 @@ export function DailyPVUVChart({
const regionStats = generateStatsList(data, "region");
const isBotStats = generateStatsList(data, "isBot");
const lastVisitorInfo = t.rich("last-visitor-info", {
location: latestFrom,
timeAgo: () => <TimeAgoIntl date={latestEntry.updatedAt} />,
});
return (
<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>
<CardDescription>
Last visitor from {latestFrom} about {latestDate}.
</CardDescription>
<CardTitle>{t("Link Analytics")}</CardTitle>
<CardDescription>{lastVisitorInfo}</CardDescription>
</div>
<div className="flex items-center">
<Select
@@ -291,8 +297,8 @@ export function DailyPVUVChart({
className="relative z-30 flex flex-1 flex-col items-center justify-center gap-1 border-t px-6 py-2 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-3"
onClick={() => setActiveChart(chart)}
>
<span className="text-sm font-semibold text-muted-foreground">
{chartConfig[chart].label}
<span className="text-nowrap text-sm font-semibold text-muted-foreground">
{t(chartConfig[chart].label)}
</span>
<span className="text-lg font-bold leading-none">
{dataTotal[key as keyof typeof dataTotal].toLocaleString()}
@@ -403,8 +409,8 @@ export function DailyPVUVChart({
{/* Referrers、isBotStats */}
<Tabs defaultValue="referrer">
<TabsList>
<TabsTrigger value="referrer">Referrers</TabsTrigger>
<TabsTrigger value="isBot">Traffic Type</TabsTrigger>
<TabsTrigger value="referrer">{t("Referrers")}</TabsTrigger>
<TabsTrigger value="isBot">{t("Traffic Type")}</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="referrer">
{refererStats.length > 0 && (
@@ -420,8 +426,8 @@ export function DailyPVUVChart({
{/* 国家、城市 */}
<Tabs defaultValue="country">
<TabsList>
<TabsTrigger value="country">Country</TabsTrigger>
<TabsTrigger value="city">City</TabsTrigger>
<TabsTrigger value="country">{t("Country")}</TabsTrigger>
<TabsTrigger value="city">{t("City")}</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="country">
{countryStats.length > 0 && (
@@ -437,8 +443,8 @@ export function DailyPVUVChart({
{/* browserStats、engineStats */}
<Tabs defaultValue="browser">
<TabsList>
<TabsTrigger value="browser">Browser</TabsTrigger>
<TabsTrigger value="engine">Browser Engine</TabsTrigger>
<TabsTrigger value="browser">{t("Browser")}</TabsTrigger>
<TabsTrigger value="engine">{t("Engine")}</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="browser">
{browserStats.length > 0 && (
@@ -455,8 +461,8 @@ export function DailyPVUVChart({
{/* Languages、regionStats */}
<Tabs className="h-full" defaultValue="language">
<TabsList>
<TabsTrigger value="language">Language</TabsTrigger>
<TabsTrigger value="region">Region</TabsTrigger>
<TabsTrigger value="language">{t("Language")}</TabsTrigger>
<TabsTrigger value="region">{t("Region")}</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="language">
{languageStats.length > 0 && (
@@ -472,8 +478,8 @@ export function DailyPVUVChart({
{/* deviceStats、osStats、cpuStats */}
<Tabs defaultValue="device">
<TabsList>
<TabsTrigger value="device">Device</TabsTrigger>
<TabsTrigger value="os">OS</TabsTrigger>
<TabsTrigger value="device">{t("Device")}</TabsTrigger>
<TabsTrigger value="os">{t("OS")}</TabsTrigger>
<TabsTrigger value="cpu">CPU</TabsTrigger>
</TabsList>
<TabsContent className="h-[calc(100%-40px)]" value="device">
@@ -497,12 +503,12 @@ export function DailyPVUVChart({
export function StatsList({ data, title }: { data: Stat[]; title: string }) {
const [showAll, setShowAll] = useState(false);
const displayedData = showAll ? data.slice(0, 50) : data.slice(0, 8);
const t = useTranslations("Components");
return (
<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>
<span>{t("Name")}</span>
<span className="">{t("Visitors")}</span>
</div>
<div
className={`scrollbar-hidden overflow-hidden overflow-y-auto px-4 pb-4 pt-2 transition-all duration-500 ease-in-out`}
+3 -3
View File
@@ -19,10 +19,10 @@ export default async function DashboardPage() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
heading="Manage Short URLs"
text="List and manage short urls"
link="/docs/short-urls"
linkText="short urls."
linkText="short urls"
/>
<UserUrlsList
user={{
+22 -20
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
@@ -16,7 +17,6 @@ import {
fetcher,
nFormatter,
removeUrlSuffix,
timeAgo,
} from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -57,6 +57,7 @@ import { Icons } from "@/components/shared/icons";
import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
import { PaginationWrapper } from "@/components/shared/pagination";
import QRCodeEditor from "@/components/shared/qr";
import { TimeAgoIntl } from "@/components/shared/time-ago";
import Globe from "./globe";
import LiveLog from "./live-logs";
@@ -101,6 +102,7 @@ function TableColumnSekleton() {
export default function UserUrlsList({ user, action }: UrlListProps) {
const pathname = usePathname();
const { isMobile } = useMediaQuery();
const t = useTranslations("List");
const [currentView, setCurrentView] = useState<string>("List");
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
@@ -185,7 +187,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
const rendeEmpty = () => (
<EmptyPlaceholder className="col-span-full shadow-none">
<EmptyPlaceholder.Icon name="link" />
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
<EmptyPlaceholder.Title>{t("No urls")}</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any url yet. Start creating url.
</EmptyPlaceholder.Description>
@@ -197,7 +199,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by slug..."
placeholder={t("Search by slug") + "..."}
value={searchParams.slug}
onChange={(e) => {
setSearchParams({
@@ -220,7 +222,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by target..."
placeholder={t("Search by target") + "..."}
value={searchParams.target}
onChange={(e) => {
setSearchParams({
@@ -244,7 +246,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by user name..."
placeholder={t("Search by username") + "..."}
value={searchParams.userName}
onChange={(e) => {
setSearchParams({
@@ -299,28 +301,28 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<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
{t("Slug")}
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Target
{t("Target")}
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
User
{t("User")}
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Enabled
{t("Enabled")}
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Expiration
{t("Expiration")}
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Clicks
{t("Clicks")}
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Updated
{t("Updated")}
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Actions
{t("Actions")}
</TableHead>
</TableRow>
</TableHeader>
@@ -394,7 +396,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</div>
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.updatedAt as Date)}
<TimeAgoIntl date={short.updatedAt as Date} />
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Button
@@ -408,7 +410,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">Edit</p>
<p className="hidden sm:block">{t("Edit")}</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
<Button
@@ -561,7 +563,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
}}
>
<Icons.lineChart className="size-4" />
Analytics
{t("Analytics")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -578,7 +580,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
}}
>
<PenLine className="size-4" />
Edit URL
{t("Edit URL")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -612,7 +614,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
></Separator>
</>
)}
{timeAgo(short.updatedAt as Date)}
<TimeAgoIntl date={short.updatedAt as Date} />
<Switch
className="scale-[0.6]"
defaultChecked={short.active === 1}
@@ -662,7 +664,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
{/* Tabs */}
<div className="mb-4 flex items-center justify-between gap-2">
{pathname === "/dashboard" && (
<h2 className="mr-3 text-lg font-semibold">Short URLs</h2>
<h2 className="mr-3 text-lg font-semibold">{t("Short URLs")}</h2>
)}
<TabsList>
<TabsTrigger onClick={() => setCurrentView("List")} value="List">
@@ -715,7 +717,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
}}
>
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add URL</span>
<span className="hidden sm:inline">{t("Add URL")}</span>
</Button>
</div>
</div>
+38 -30
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
@@ -27,26 +28,22 @@ export default function StepGuide({
const [direction, setDirection] = useState(0);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const t = useTranslations("Common");
const steps = [
{
id: 1,
title: "Set up an administrator",
description:
"Begin by entering your website URL or selecting an example site to reimagine your website with modern themes.",
title: t("Set up an administrator"),
component: () => <SetAdminRole id={user.id} email={user.email} />,
},
{
id: 2,
title: "Add the first domain",
description:
"Check out your reimagined site and click to Migrate & Download.",
title: t("Add the first domain"),
component: () => <AddDomain onNextStep={goToNextStep} />,
},
{
id: 3,
title: "Congrats on completing setup 🎉",
description:
"Navigate to your GitHub dashboard where you'll manage your repository and project files.",
title: t("Congrats on completing setup 🎉"),
component: () => <Congrats />,
},
];
@@ -92,7 +89,7 @@ export default function StepGuide({
<Modal className="md:max-w-2xl">
<div className="w-full px-4 py-2 md:px-8 md:py-4">
<div className="mb-6 mt-3 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold">Admin Setup Guide</h2>
<h2 className="text-2xl font-bold">{t("Admin Setup Guide")}</h2>
<div className="flex items-center gap-2 rounded-full bg-muted/50 px-3 py-1.5 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
{currentStep}
@@ -161,7 +158,7 @@ export default function StepGuide({
)}
>
<ChevronLeft className="h-4 w-4" />
Previous
{t("Previous")}
</button>
<button
@@ -171,7 +168,7 @@ export default function StepGuide({
"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90",
)}
>
{currentStep === steps.length ? "🚀 Start" : "Next"}
{currentStep === steps.length ? t("🚀 Start") : t("Next")}
<ChevronRight className="h-4 w-4" />
</button>
</motion.div>
@@ -182,6 +179,7 @@ export default function StepGuide({
function SetAdminRole({ id, email }: { id: string; email: string }) {
const [isPending, startTransition] = useTransition();
const [isAdmin, setIsAdmin] = useState(false);
const t = useTranslations("Common");
const handleSetAdmin = async () => {
startTransition(async () => {
const res = await fetch("/api/setup");
@@ -194,7 +192,7 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
const ReadyBadge = (
<Badge className="text-xs font-semibold" variant="green">
<Icons.check className="mr-1 size-3" />
Ready
{t("Ready")}
</Badge>
);
@@ -202,14 +200,14 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground">
Allow Sign Up:
{t("Allow Sign Up")}:
</span>
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground">
Set {email} as ADMIN:
{t("Set {email} as ADMIN", { email })}:
</span>
{isAdmin ? (
ReadyBadge
@@ -223,30 +221,36 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Active Now
{t("Active Now")}
</Button>
)}
</div>
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
<p className="flex items-start gap-1">
📢 Only by becoming an administrator can one access the admin panel
and add domain names.
{" "}
{t(
"Only by becoming an administrator can one access the admin panel and add domain names",
)}
.
</p>
<p className="my-1">
📢 Administrators can set all user permissions, allocate quotas, view
and edit all resources (short links, subdomains, email), etc.
{" "}
{t(
"Administrators can set all user permissions, allocate quotas, view and edit all resources (short links, subdomains, email), etc",
)}
.
</p>
<p>
📢 Via{" "}
{t("Via")}{" "}
<a
className="text-blue-500"
className="text-blue-500 after:content-['_↗']"
target="_blank"
href="/docs/developer/quick-start"
>
quick start
{t("quick start")}
</a>{" "}
docs to get more information.
{t("docs to get more information")}.
</p>
</div>
</div>
@@ -256,6 +260,7 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
function AddDomain({ onNextStep }: { onNextStep: () => void }) {
const [isPending, startTransition] = useTransition();
const [domain, setDomain] = useState("");
const t = useTranslations("Common");
const handleCreateDomain = async () => {
if (!domain) {
toast.warning("Domain name cannot be empty");
@@ -292,10 +297,10 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<FormSectionColumns title="Domain Name">
<FormSectionColumns title={t("Domain Name")}>
<div className="flex w-full flex-col items-start justify-between gap-2">
<Label className="sr-only" htmlFor="domain_name">
Domain Name
{t("Domain Name")}
</Label>
<div className="w-full">
<Input
@@ -307,7 +312,10 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
/>
</div>
<p className="text-xs text-muted-foreground">
Please enter a valid domain name (must be hosted on Cloudflare).
{t(
"Please enter a valid domain name (must be hosted on Cloudflare)",
)}
.
</p>
</div>
@@ -318,7 +326,7 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
size={"sm"}
onClick={onNextStep}
>
Or add later
{t("Or add later")}
</Button>
<Button
className="flex items-center gap-1"
@@ -330,7 +338,7 @@ function AddDomain({ onNextStep }: { onNextStep: () => void }) {
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Submit
{t("Submit")}
</Button>
</div>
</FormSectionColumns>
+1 -4
View File
@@ -4,10 +4,7 @@ import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardLoading() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
/>
<DashboardHeader heading="Setup Guide" text="" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
+20 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getEmailsByEmailAddress } from "@/lib/dto/email";
import { deleteEmailsByIds, getEmailsByEmailAddress } from "@/lib/dto/email";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -35,3 +35,22 @@ export async function GET(req: NextRequest) {
);
}
}
export async function DELETE(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { ids } = await req.json();
if (!ids) {
return Response.json("ids is required", { status: 400 });
}
await deleteEmailsByIds(ids);
return Response.json("success", { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ export async function POST(req: Request) {
const { record: reviewRecord, userId, id } = await req.json();
const record = {
...reviewRecord,
comment: "Created by wr.do (review mode)",
// comment: "Created by wr.do (review mode)",
id,
};
+20 -14
View File
@@ -2,6 +2,8 @@ import "@/styles/globals.css";
import { fontHeading, fontSans, fontSatoshi } from "@/assets/fonts";
import { SessionProvider } from "next-auth/react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { ThemeProvider } from "next-themes";
import { ViewTransitions } from "next-view-transitions";
@@ -18,10 +20,12 @@ interface RootLayoutProps {
export const metadata = constructMetadata();
export default function RootLayout({ children }: RootLayoutProps) {
export default async function RootLayout({ children }: RootLayoutProps) {
const locale = await getLocale();
const messages = await getMessages();
return (
<ViewTransitions>
<html lang="en" suppressHydrationWarning>
<html lang={locale} suppressHydrationWarning>
<head>
<script
defer
@@ -37,18 +41,20 @@ export default function RootLayout({ children }: RootLayoutProps) {
fontSatoshi.variable,
)}
>
<SessionProvider>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
<ModalProvider>{children}</ModalProvider>
<Toaster richColors closeButton />
<TailwindIndicator />
</ThemeProvider>
</SessionProvider>
<NextIntlClientProvider messages={messages}>
<SessionProvider>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
<ModalProvider>{children}</ModalProvider>
<Toaster richColors closeButton />
<TailwindIndicator />
</ThemeProvider>
</SessionProvider>
</NextIntlClientProvider>
<GoogleAnalytics />
</body>
</html>
+6 -3
View File
@@ -2,6 +2,7 @@
import * as React from "react";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import useSWR from "swr";
@@ -68,6 +69,8 @@ export function InteractiveBarChart() {
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("users");
const t = useTranslations("Components");
const { data, isLoading } = useSWR<{
list: [
{
@@ -110,8 +113,8 @@ export function InteractiveBarChart() {
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex w-full flex-1 justify-between gap-2 px-6 py-5 sm:flex-col sm:py-6">
<div className="flex flex-col justify-center gap-1">
<CardTitle>Data Increase</CardTitle>
<CardDescription>Showing data increase in:</CardDescription>
<CardTitle>{t("Data Increase")}</CardTitle>
<CardDescription>{t("Showing data increase in")}:</CardDescription>
</div>
<Select
onValueChange={(value: string) => {
@@ -150,7 +153,7 @@ export function InteractiveBarChart() {
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
{t(chartConfig[chart].label)}
</span>
<span className="text-base font-bold leading-none sm:text-lg">
{nFormatter(data.total[key])}
+21 -11
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { Link as LinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { nFormatter } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -23,6 +23,7 @@ export async function UserInfoCard({
icon?: keyof typeof Icons;
}) {
const Icon = Icons[icon || "arrowRight"];
const t = useTranslations("Components");
return (
<Card className="grids group bg-gray-50/70 backdrop-blur-lg dark:bg-primary-foreground">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -31,7 +32,7 @@ export async function UserInfoCard({
className="font-semibold text-slate-500 duration-500 group-hover:text-blue-500 group-hover:underline"
href={link}
>
{title}
{t(title)}
</Link>
</CardTitle>
<Icon className="size-4 text-muted-foreground" />
@@ -49,7 +50,7 @@ export async function UserInfoCard({
)}
</div>
)}
<p className="text-xs text-muted-foreground">total</p>
<p className="text-xs text-muted-foreground">{t("total")}</p>
</CardContent>
</Card>
);
@@ -72,6 +73,7 @@ export async function DashboardInfoCard({
link: string;
icon?: keyof typeof Icons;
}) {
const t = useTranslations("Components");
const Icon = Icons[icon || "arrowRight"];
return (
<Card className="grids group animate-fade-in bg-gray-50/70 backdrop-blur-lg dark:bg-primary-foreground">
@@ -81,7 +83,7 @@ export async function DashboardInfoCard({
className="font-semibold text-slate-500 duration-500 group-hover:text-blue-500 group-hover:underline"
href={link}
>
{title}
{t(title)}
</Link>
</CardTitle>
<Icon className="size-4 text-muted-foreground" />
@@ -94,12 +96,15 @@ export async function DashboardInfoCard({
<CountUp count={monthTotal} />
{total !== undefined && (
<p className="align-top text-base text-slate-500">
/ {nFormatter(limit)} <span className="text-xs">(monthly)</span>
/ {nFormatter(limit)}{" "}
<span className="text-xs">({t("monthly")})</span>
</p>
)}
</div>
)}
<p className="text-xs text-muted-foreground">total: {total}</p>
<p className="text-xs text-muted-foreground">
{t("total")}: {total}
</p>
</CardContent>
</Card>
);
@@ -114,6 +119,7 @@ export function HeroCard({
monthTotal: number;
limit: number;
}) {
const t = useTranslations("Components");
return (
<div className="grids group relative mb-4 h-full w-full shrink-0 origin-left overflow-hidden rounded-lg border bg-gray-50/70 px-5 pt-5 text-left duration-500 before:absolute before:right-1 before:top-1 before:z-[2] before:h-12 before:w-12 before:rounded-full before:bg-violet-500 before:blur-lg before:duration-500 after:absolute after:right-8 after:top-3 after:z-[2] after:h-20 after:w-20 after:rounded-full after:bg-rose-300 after:blur-lg after:duration-500 hover:border-cyan-600 hover:decoration-2 hover:duration-500 hover:before:-bottom-8 hover:before:right-12 hover:before:blur hover:before:[box-shadow:_20px_20px_20px_30px_#a21caf] hover:after:-right-8 group-hover:before:duration-500 group-hover:after:duration-500 dark:bg-primary-foreground md:max-w-[350px]">
<div className="flex flex-row items-center justify-between">
@@ -121,7 +127,7 @@ export function HeroCard({
href="/emails"
className="text-lg font-bold duration-500 group-hover:text-blue-500 group-hover:underline"
>
Email box
{t("Email box")}
</Link>
<Icons.mail className="size-4 text-muted-foreground" />
</div>
@@ -134,12 +140,15 @@ export function HeroCard({
<CountUp count={monthTotal} />
{total !== undefined && (
<p className="align-top text-base text-slate-500">
/ {nFormatter(limit)} <span className="text-xs">(monthly)</span>
/ {nFormatter(limit)}{" "}
<span className="text-xs">({t("monthly")})</span>
</p>
)}
</div>
)}
<p className="text-xs text-muted-foreground">total: {total}</p>
<p className="text-xs text-muted-foreground">
{t("total")}: {total}
</p>
</div>
</div>
);
@@ -157,6 +166,7 @@ export async function StaticInfoCard({
icon?: keyof typeof Icons;
}) {
const Icon = Icons[icon || "arrowRight"];
const t = useTranslations("Components");
return (
<Card className="grids group bg-gray-50/70 backdrop-blur-lg dark:bg-primary-foreground">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -165,13 +175,13 @@ export async function StaticInfoCard({
className="font-semibold text-slate-500 duration-500 group-hover:text-blue-500 group-hover:underline"
href={link}
>
{title}
{t(title)}
</Link>
</CardTitle>
<Icon className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">{desc}</p>
{desc && <p className="text-xs text-muted-foreground">{t(desc)}</p>}
</CardContent>
</Card>
);
+15 -8
View File
@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { siteConfig } from "@/config/site";
import { Button } from "@/components/ui/button";
import { SectionColumns } from "@/components/dashboard/section-columns";
@@ -7,6 +9,7 @@ import { useDeleteAccountModal } from "@/components/modals/delete-account-modal"
import { Icons } from "@/components/shared/icons";
export function DeleteAccountSection() {
const t = useTranslations("Setting");
const { setShowDeleteAccountModal, DeleteAccountModal } =
useDeleteAccountModal();
@@ -16,27 +19,31 @@ export function DeleteAccountSection() {
<>
<DeleteAccountModal />
<SectionColumns
title="Delete Account"
description="This is a danger zone - Be careful !"
title={t("Delete Account")}
description={t("This is a danger zone - Be careful !")}
>
<div className="flex flex-col gap-4 rounded-xl border border-red-400 p-4 dark:border-red-900">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-[15px] font-medium">Are you sure ?</span>
<span className="text-[15px] font-medium">
{t("Are you sure")} ?
</span>
{userPaidPlan ? (
<div className="flex items-center gap-1 rounded-md bg-red-600/10 p-1 pr-2 text-xs font-medium text-red-600 dark:bg-red-500/10 dark:text-red-500">
<div className="m-0.5 rounded-full bg-red-600 p-[3px]">
<Icons.close size={10} className="text-background" />
</div>
Active Subscription
{t("Active Subscription")}
</div>
) : null}
</div>
<div className="text-balance text-sm text-muted-foreground">
Permanently delete your {siteConfig.name} account
{userPaidPlan ? " and your subscription" : ""}. This action cannot
be undone - please proceed with caution.
{t("Permanently delete your {name} account", {
name: siteConfig.name,
})}
{userPaidPlan ? t(" and your subscription") : ""}.{" "}
{t("This action cannot be undone - please proceed with caution")}.
</div>
</div>
<div className="flex items-center gap-2">
@@ -46,7 +53,7 @@ export function DeleteAccountSection() {
onClick={() => setShowDeleteAccountModal(true)}
>
<Icons.trash className="mr-2 size-4" />
<span>Delete Account</span>
<span>{t("Delete Account")}</span>
</Button>
</div>
</div>
+6 -3
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
interface DashboardHeaderProps {
heading: string;
@@ -15,17 +16,18 @@ export function DashboardHeader({
linkText,
children,
}: DashboardHeaderProps) {
const t = useTranslations("Components");
return (
<div className="flex items-center justify-between">
<div className="grid gap-1">
<h1 className="font-heading text-2xl font-semibold">{heading}</h1>
<h1 className="font-heading text-2xl font-semibold">{t(heading)}</h1>
<p className="text-sm text-muted-foreground">
{text && <span>{text}</span>}
{text && <span>{t(text)}.</span>}
{link && (
<span>
{" "}
See documentation about{" "}
{t("See documentation")}:{" "}
<Link
href={link}
target="_blank"
@@ -33,6 +35,7 @@ export function DashboardHeader({
>
{linkText}
</Link>
.
</span>
)}
</p>
+13 -10
View File
@@ -11,6 +11,7 @@ import {
FileText,
FileVideo,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { siteConfig } from "@/config/site";
import { cn, downloadFile, formatDate, formatFileSize } from "@/lib/utils";
@@ -85,7 +86,8 @@ export default function EmailDetail({
onClose,
onMarkAsRead,
}: EmailDetailProps) {
const [previewImage, setPreviewImage] = useState<string | null>(null); // 控制图片预览 Modal
const [previewImage, setPreviewImage] = useState<string | null>(null);
const t = useTranslations("Email");
function getFileIcon(type: string): React.ComponentType<any> {
const icon = Object.keys(fileTypeIcons).find((key) =>
@@ -178,7 +180,8 @@ export default function EmailDetail({
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger className="line-clamp-2 text-wrap text-left text-xs">
<strong>From:</strong> {email.fromName} &lt;{email.from}&gt;
<strong>{t("From")}:</strong> {email.fromName} &lt;{email.from}
&gt;
</TooltipTrigger>
<TooltipContent side="bottom" className="w-60 text-wrap text-xs">
{email.fromName} <br />
@@ -187,19 +190,19 @@ export default function EmailDetail({
</Tooltip>
</TooltipProvider>
<p className="text-xs">
<strong>To:</strong> {email.to}
<strong>{t("To")}:</strong> {email.to}
</p>
{email.replyTo && email.replyTo !== '""' && (
<p className="text-xs">
<strong>Reply-To:</strong> {email.replyTo}
<strong>{t("Reply-To")}:</strong> {email.replyTo}
</p>
)}
<p className="text-xs">
<strong>Date:</strong> {formatDate(email.date as any)}
<strong>{t("Date")}:</strong> {formatDate(email.date as any)}
</p>
{attachments.length > 0 && (
<p className="text-xs">
<strong>Attachments</strong>: {attachments.length}
<strong>{t("Attachments")}</strong>: {attachments.length}
</p>
)}
</div>
@@ -225,7 +228,7 @@ export default function EmailDetail({
{attachments.length > 0 && (
<div className="mt-auto border-t border-dashed px-2 py-3">
<h3 className="mb-2 text-sm font-semibold text-neutral-700 dark:text-neutral-400">
Attachments ({attachments.length})
{t("Attachments")} ({attachments.length})
</h3>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
{attachments.map((attachment, index) => {
@@ -266,11 +269,11 @@ export default function EmailDetail({
</div>
<Button
onClick={() => handleDownload(attachment)}
className="absolute right-0 top-0 hidden animate-fade-in px-2 group-hover:block"
className="absolute right-1 top-1 hidden h-7 animate-fade-in px-2 group-hover:block"
size="sm"
variant="ghost"
variant="default"
>
<Icons.download className="size-4" />
<Icons.download className="size-3" />
</Button>
</div>
);
+78 -32
View File
@@ -1,16 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { ForwardEmail } from "@prisma/client";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR from "swr";
import { cn, fetcher, htmlToText, timeAgo } from "@/lib/utils";
import { cn, fetcher, htmlToText } from "@/lib/utils";
import BlurImage from "../shared/blur-image";
import { Icons } from "../shared/icons";
import { PaginationWrapper } from "../shared/pagination";
import { TimeAgoIntl } from "../shared/time-ago";
// import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
@@ -48,6 +50,7 @@ export default function EmailList({
className,
isAdminModel,
}: EmailListProps) {
const t = useTranslations("Email");
const [isRefreshing, setIsRefreshing] = useState(false);
const [isAutoRefresh, setIsAutoRefresh] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@@ -55,6 +58,8 @@ export default function EmailList({
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [showMutiCheckBox, setShowMutiCheckBox] = useState(false);
const [isDeleting, startDeleteTransition] = useTransition();
const { data, error, isLoading, mutate } = useSWR<{
total: number;
list: ForwardEmail[];
@@ -81,15 +86,11 @@ export default function EmailList({
}, [emailAddress, data, selectedEmailId]);
const handleMarkAsRead = async (emailId: string) => {
try {
await fetch("/api/email/read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emailId }),
}).then(() => mutate());
} catch (error) {
console.log("Error marking email as read");
}
await fetch("/api/email/read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emailId }),
}).then(() => mutate());
};
const handleMarkSelectedAsRead = async () => {
@@ -125,6 +126,10 @@ export default function EmailList({
);
};
const handleSelectAllEmails = () => {
setSelectedEmails(data?.list.map((email) => email.id) || []);
};
const handleSetAutoRefresh = (value: boolean) => {
setIsAutoRefresh(value);
};
@@ -147,15 +152,26 @@ export default function EmailList({
onSelectEmail(emailId);
};
const handleDeletEmails = async (ids: string[]) => {
startDeleteTransition(async () => {
await fetch("/api/email/inbox", {
method: "DELETE",
body: JSON.stringify({ ids }),
}).then((v) => {
v.status === 200 && mutate();
});
});
};
if (!emailAddress) {
return EmptyInboxSection();
return <EmptyInboxSection />;
}
return (
<div className={cn("grids flex flex-1 flex-col", className)}>
<div className="flex items-center gap-2 bg-neutral-200/40 p-2 text-base font-semibold text-neutral-600 backdrop-blur dark:bg-neutral-800 dark:text-neutral-50">
<Icons.inbox size={20} />
<span>INBOX</span>
<span>{t("INBOX")}</span>
<div className="ml-auto flex items-center justify-center gap-2">
<SendEmailModal emailAddress={emailAddress} onSuccess={mutate} />
<TooltipProvider>
@@ -168,7 +184,7 @@ export default function EmailList({
aria-label="Auto refresh"
/>
</TooltipTrigger>
<TooltipContent side="bottom">Auto refresh</TooltipContent>
<TooltipContent side="bottom">{t("Auto refresh")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
@@ -196,7 +212,7 @@ export default function EmailList({
>
<Icons.listChecks className="size-4" />
</Button>
{selectedEmails.length > 0 && (
{showMutiCheckBox && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
@@ -204,7 +220,7 @@ export default function EmailList({
size="sm"
className="flex w-full items-center gap-1"
>
<span className="text-sm">more</span>
<span className="text-sm">{t("more")}</span>
<Icons.chevronDown className="mt-0.5 size-4" />
</Button>
</DropdownMenuTrigger>
@@ -213,16 +229,41 @@ export default function EmailList({
<Button
variant="ghost"
size="sm"
onClick={handleMarkSelectedAsRead}
onClick={handleSelectAllEmails}
className="w-full"
>
<span className="text-xs">Mask as read</span>
<span className="text-xs">{t("Select all")}</span>
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild disabled>
<Button variant="ghost" size="sm" className="w-full">
<span className="text-xs">Delete selected</span>
<DropdownMenuItem
asChild
disabled={selectedEmails.length === 0}
>
<Button
variant="ghost"
size="sm"
onClick={handleMarkSelectedAsRead}
className="w-full"
>
<span className="text-xs">{t("Mask as read")}</span>
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
asChild
disabled={isDeleting || selectedEmails.length === 0}
>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => handleDeletEmails(selectedEmails)}
>
{isDeleting && (
<Icons.spinner className="mr-1 size-4 animate-spin" />
)}
<span className="text-xs">{t("Delete selected")}</span>
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -261,9 +302,9 @@ export default function EmailList({
onClick={(e) => e.stopPropagation()}
>
<Checkbox
defaultChecked={selectedEmails.includes(email.id)}
checked={selectedEmails.includes(email.id)}
onCheckedChange={() => handleSelectEmail(email.id)}
className="mr-2 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
className="mr-3 size-4 border-neutral-300 bg-neutral-100 data-[state=checked]:border-neutral-900 data-[state=checked]:bg-neutral-600 data-[state=checked]:text-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:data-[state=checked]:border-neutral-300 dark:data-[state=checked]:bg-neutral-300"
/>
</div>
)}
@@ -276,7 +317,9 @@ export default function EmailList({
{email.fromName || email.subject || "Untitled"}
</span>
<span className="ml-auto text-xs text-neutral-600 dark:text-neutral-400">
{timeAgo((email.date as any) || email.createdAt)}
<TimeAgoIntl
date={(email.date as any) || email.createdAt}
/>
</span>
{email.readAt && (
<Icons.checkCheck className="ml-2 size-3 text-green-600" />
@@ -298,7 +341,7 @@ export default function EmailList({
<div className="flex h-[calc(100vh-135px)] flex-col items-center justify-center gap-8">
<Loader />
<p className="font-mono font-semibold text-neutral-500">
Waiting for emails...
{t("Waiting for emails")}...
</p>
</div>
)}
@@ -321,6 +364,7 @@ export default function EmailList({
}
export function EmptyInboxSection() {
const t = useTranslations("Email");
return (
<div className="grids flex flex-1 animate-fade-in flex-col items-center justify-center p-4 text-center text-neutral-600 dark:text-neutral-400">
<BlurImage
@@ -330,10 +374,12 @@ export function EmptyInboxSection() {
width={200}
alt="Inbox"
/>
<h2 className="my-2 text-lg font-semibold">No Email Address Selected</h2>
<h2 className="my-2 text-lg font-semibold">
{t("No Email Address Selected")}
</h2>
<p className="max-w-md text-sm">
Please select an email address from the list to view your inbox. Once
selected, your emails will appear here automatically.
{t("Please select an email address from the list to view your inbox")}.
{t("Once selected, your emails will appear here automatically")}.
</p>
<ul className="mt-3 list-disc text-left">
<li>
@@ -343,7 +389,7 @@ export function EmptyInboxSection() {
target="_blank"
rel="noreferrer"
>
How to use email to send or receive emails?
{t("How to use email to send or receive emails?")}
</Link>
</li>
<li>
@@ -353,7 +399,7 @@ export function EmptyInboxSection() {
target="_blank"
rel="noreferrer"
>
Will my email or inbox expire?
{t("Will my email or inbox expire?")}
</Link>
</li>
<li>
@@ -363,7 +409,7 @@ export function EmptyInboxSection() {
target="_blank"
rel="noreferrer"
>
What is the limit? It's free?
{t("What is the limit? It's free?")}
</Link>
</li>
<li>
@@ -373,7 +419,7 @@ export function EmptyInboxSection() {
target="_blank"
rel="noreferrer"
>
How to create emails with api?
{t("How to create emails with api?")}
</Link>
</li>
</ul>
+43 -29
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { User, UserEmail } from "@prisma/client";
import randomName from "@scaleway/random-name";
import {
@@ -11,18 +11,20 @@ import {
Sparkles,
SquarePlus,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR from "swr";
import { UserEmailList } from "@/lib/dto/email";
import { reservedAddressSuffix } from "@/lib/enums";
import { cn, fetcher, nFormatter, timeAgo } from "@/lib/utils";
import { cn, fetcher, nFormatter } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { CopyButton } from "../shared/copy-button";
import { EmptyPlaceholder } from "../shared/empty-placeholder";
import { Icons } from "../shared/icons";
import { PaginationWrapper } from "../shared/pagination";
import { TimeAgoIntl } from "../shared/time-ago";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
@@ -67,13 +69,14 @@ export default function EmailSidebar({
setAdminModel,
}: EmailSidebarProps) {
const { isMobile } = useMediaQuery();
const t = useTranslations("Email");
const [isRefreshing, setIsRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [isEdit, setIsEdit] = useState(false);
const [showEmailModal, setShowEmailModal] = useState(false);
const [isPending, startTransition] = useTransition();
const [domainSuffix, setDomainSuffix] = useState<string | null>("wr.do");
const [domainSuffix, setDomainSuffix] = useState<string | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [emailToDelete, setEmailToDelete] = useState<string | null>(null);
const [deleteInput, setDeleteInput] = useState("");
@@ -110,6 +113,12 @@ export default function EmailSidebar({
dedupingInterval: 10000,
});
useEffect(() => {
if (!domainSuffix && emailDomains && emailDomains.length > 0) {
setDomainSuffix(emailDomains[0].domain_name);
}
}, [domainSuffix, emailDomains]);
if (!selectedEmailAddress && data && data.list.length > 0) {
onSelectEmail(data.list[0].emailAddress);
}
@@ -261,7 +270,7 @@ export default function EmailSidebar({
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search emails..."
placeholder={t("Search emails")}
className="h-[30px] w-full border p-1 pl-8 text-xs placeholder:text-xs"
/>
<Search className="absolute left-2 top-2 size-4 text-gray-500" />
@@ -296,7 +305,9 @@ export default function EmailSidebar({
}}
>
<SquarePlus className="size-4" />
{!isCollapsed && <span className="text-xs">Create New Email</span>}
{!isCollapsed && (
<span className="text-xs">{t("Create New Email")}</span>
)}
</Button>
{!isCollapsed && (
@@ -306,7 +317,7 @@ export default function EmailSidebar({
<div className="flex items-center gap-1">
<Icons.mail className="size-3" />
<p className="line-clamp-1 text-start font-medium">
Email Address
{t("Email Address")}
</p>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
@@ -319,7 +330,7 @@ export default function EmailSidebar({
<div className="flex items-center gap-1">
<Icons.inbox className="size-3" />
<p className="line-clamp-1 text-start font-medium">
Inbox Emails
{t("Inbox Emails")}
</p>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
@@ -342,7 +353,7 @@ export default function EmailSidebar({
<div className="flex items-center gap-1">
<Icons.mailOpen className="size-3" />
<p className="line-clamp-1 text-start font-medium">
Unread Emails
{t("Unread Emails")}
</p>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
@@ -353,7 +364,7 @@ export default function EmailSidebar({
<TooltipTrigger>
<Icons.listFilter className="absolute bottom-1 right-1 size-3" />
</TooltipTrigger>
<TooltipContent>Filter unread emails</TooltipContent>
<TooltipContent>{t("Filter unread emails")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -372,7 +383,7 @@ export default function EmailSidebar({
<div className="flex items-center gap-1">
<Icons.send className="size-3" />
<p className="line-clamp-1 text-start font-medium">
Sent Emails
{t("Sent Emails")}
</p>
</div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
@@ -384,7 +395,7 @@ export default function EmailSidebar({
{!isCollapsed && user.role === "ADMIN" && (
<div className="mt-2 flex items-center gap-2 text-sm">
Admin Mode:{" "}
{t("Admin Mode")}:{" "}
<Switch
defaultChecked={isAdminModel}
onCheckedChange={(v) => setAdminModel(v)}
@@ -420,7 +431,9 @@ export default function EmailSidebar({
<div className="flex h-full items-center justify-center">
<EmptyPlaceholder className="shadow-none">
<EmptyPlaceholder.Icon name="mailPlus" />
<EmptyPlaceholder.Title>No emails</EmptyPlaceholder.Title>
<EmptyPlaceholder.Title>
{t("No emails")}
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any email yet. Start creating email.
</EmptyPlaceholder.Description>
@@ -527,13 +540,13 @@ export default function EmailSidebar({
{email.unreadCount > 0 && (
<Badge variant="default">{email.unreadCount}</Badge>
)}
{email.count} recived
{t("{email} recived", { email: email.count })}
</div>
<span className="line-clamp-1 hover:line-clamp-none">
{isAdminModel
? `Created by ${email.user || email.email.slice(0, 5)} at`
: ""}{" "}
{timeAgo(email.createdAt)}
<TimeAgoIntl date={email.createdAt} />
</span>
</div>
)}
@@ -568,7 +581,7 @@ export default function EmailSidebar({
<Modal showModal={showEmailModal} setShowModal={setShowEmailModal}>
<div className="p-6">
<h2 className="mb-4 text-lg font-semibold">
{isEdit ? "Edit" : "Create new"} email
{isEdit ? t("Edit email") : t("Create new email")}
</h2>
<form
onSubmit={(e) => {
@@ -582,14 +595,14 @@ export default function EmailSidebar({
htmlFor="emailAddress"
className="mb-1 block text-sm font-medium text-gray-700"
>
Email Address
{t("Email Address")}
</label>
<div className="flex items-center justify-center">
<Input
id="emailAddress"
name="emailAddress"
type="text"
placeholder="Enter email suffix"
placeholder={t("Enter email prefix")}
className="w-full rounded-r-none"
required
defaultValue={
@@ -622,7 +635,7 @@ export default function EmailSidebar({
))
) : (
<Button className="w-full" variant="ghost">
No domains configured
{t("No domains configured")}
</Button>
)}
</SelectContent>
@@ -652,10 +665,10 @@ export default function EmailSidebar({
variant="outline"
onClick={() => setShowEmailModal(false)}
>
Cancel
{t("Cancel")}
</Button>
<Button type="submit" variant="default" disabled={isPending}>
{isEdit ? "Update" : "Create"}
{isEdit ? t("Update") : t("Create")}
</Button>
</div>
</form>
@@ -667,19 +680,20 @@ export default function EmailSidebar({
{showDeleteModal && (
<Modal showModal={showDeleteModal} setShowModal={setShowDeleteModal}>
<div className="p-6">
<h2 className="mb-4 text-lg font-semibold">Delete email</h2>
<h2 className="mb-4 text-lg font-semibold">{t("Delete email")}</h2>
<p className="mb-4 text-sm text-neutral-600">
You are about to delete the following email, once deleted, it
cannot be recovered. All emails in inbox will be deleted at the
same time. Are you sure you want to continue?
{t(
"You are about to delete the following email, once deleted, it cannot be recovered",
)}
. {t("All emails in inbox will be deleted at the same time")}.{" "}
{t("Are you sure you want to continue?")}
</p>
<p className="mb-4 text-sm text-neutral-600">
To confirm, please type{" "}
{t("To confirm, please type")}{" "}
<strong>
delete{" "}
{userEmails.find((e) => e.id === emailToDelete)?.emailAddress}
</strong>{" "}
below:
</strong>
</p>
<Input
value={deleteInput}
@@ -696,7 +710,7 @@ export default function EmailSidebar({
setEmailToDelete(null);
}}
>
Cancel
{t("Cancel")}
</Button>
<Button
variant="destructive"
@@ -710,7 +724,7 @@ export default function EmailSidebar({
}`
}
>
Delete
{t("Confirm Delete")}
</Button>
</div>
</div>
+11 -7
View File
@@ -18,6 +18,8 @@ import { Input } from "../ui/input";
import "react-quill/dist/quill.snow.css";
import { useTranslations } from "next-intl";
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
interface SendEmailModalProps {
@@ -37,6 +39,8 @@ export function SendEmailModal({
const [sendForm, setSendForm] = useState({ to: "", subject: "", html: "" });
const [isPending, startTransition] = useTransition();
const t = useTranslations("Email");
const handleSendEmail = async () => {
if (!emailAddress) {
toast.error("No email address selected");
@@ -94,7 +98,7 @@ export function SendEmailModal({
<DrawerContent className="fixed bottom-0 right-0 top-0 w-full rounded-none sm:max-w-xl">
<DrawerHeader>
<DrawerTitle className="flex items-center gap-1">
Send Email{" "}
{t("Send Email")}{" "}
<Icons.help className="size-5 text-neutral-600 hover:text-neutral-400" />
</DrawerTitle>
<DrawerClose asChild>
@@ -106,13 +110,13 @@ export function SendEmailModal({
<div className="scrollbar-hidden h-[calc(100vh)] space-y-4 overflow-y-auto p-6">
<div>
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
From
{t("From")}
</label>
<Input value={emailAddress || ""} disabled className="mt-1" />
</div>
<div>
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
To
{t("To")}
</label>
<Input
value={sendForm.to}
@@ -125,7 +129,7 @@ export function SendEmailModal({
</div>
<div>
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Subject
{t("Subject")}
</label>
<Input
value={sendForm.subject}
@@ -138,7 +142,7 @@ export function SendEmailModal({
</div>
<div>
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Content
{t("Content")}
</label>
<ReactQuill
value={sendForm.html}
@@ -152,7 +156,7 @@ export function SendEmailModal({
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline" disabled={isPending}>
Cancel
{t("Cancel")}
</Button>
</DrawerClose>
<Button
@@ -160,7 +164,7 @@ export function SendEmailModal({
disabled={isPending}
variant="default"
>
{isPending ? "Sending..." : "Send"}
{isPending ? t("Sending") : t("Send")}
</Button>
</DrawerFooter>
</DrawerContent>
+8 -5
View File
@@ -2,9 +2,10 @@
import { useCallback, useState } from "react";
import { UserSendEmail } from "@prisma/client";
import { useTranslations } from "next-intl";
import useSWR from "swr";
import { cn, fetcher, formatDate, htmlToText } from "@/lib/utils";
import { fetcher, formatDate, htmlToText } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
@@ -26,6 +27,8 @@ export default function SendsEmailList({
const [searchQuery, setSearchQuery] = useState("");
const t = useTranslations("Email");
const { data, isLoading, error } = useSWR<{
list: UserSendEmail[];
total: number;
@@ -49,12 +52,12 @@ export default function SendsEmailList({
return (
<Card className="mx-auto w-full max-w-4xl border-none">
<CardHeader>
<CardTitle>Sent Emails</CardTitle>
<CardTitle>{t("Sent Emails")}</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-2 flex items-center justify-between gap-4">
<Input
placeholder="Search by send to email..."
placeholder={t("Search by send to email")}
value={searchQuery}
onChange={handleSearch}
className="w-full bg-neutral-50"
@@ -68,11 +71,11 @@ export default function SendsEmailList({
</div>
) : error ? (
<div className="text-center text-red-500">
Failed to load emails. Please try again.
{t("Failed to load emails")}. {t("Please try again")}.
</div>
) : !data || data.list.length === 0 ? (
<div className="text-center text-muted-foreground">
No emails found.
{t("No emails found")}.
</div>
) : (
<div className="scrollbar-hidden max-h-[50vh] overflow-y-auto">
+33 -31
View File
@@ -10,6 +10,7 @@ import {
import Link from "next/link";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -52,6 +53,7 @@ export function DomainForm({
action,
onRefresh,
}: DomainFormProps) {
const t = useTranslations("List");
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const [isCheckingCf, startCheckCfTransition] = useTransition();
@@ -238,24 +240,24 @@ export function DomainForm({
>
{isChecking && <Icons.spinner className="mr-1 size-3 animate-spin" />}
{isChecked && !isChecking && <Icons.check className="mr-1 size-3" />}
{isChecked ? "Ready" : "Access Check"}
{isChecked ? t("Verified") : t("Verify Configuration")}
</Badge>
);
return (
<div>
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
{type === "add" ? "Create" : "Edit"} Domain
{type === "add" ? t("Create Domain") : t("Edit Domain")}
</div>
<form className="p-4" onSubmit={onSubmit}>
<div className="relative flex flex-col items-center justify-start gap-0 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
Base
{t("Base")}
</h2>
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="domain_name">
Domain Name:
{t("Domain Name")}:
</Label>
<div className="w-full sm:w-3/5">
<Input
@@ -271,7 +273,7 @@ export function DomainForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Required. eg: example.com
{t("Required")}. {t("Example")} example.com
</p>
)}
</div>
@@ -281,7 +283,7 @@ export function DomainForm({
<div className="flex w-full items-center justify-between gap-2">
<Label className="" htmlFor="active">
Active:
{t("Active")}:
</Label>
<Switch
id="active"
@@ -295,12 +297,12 @@ export function DomainForm({
<div className="relative mt-2 flex flex-col items-center justify-start gap-4 rounded-md bg-neutral-100 p-4 pt-10 dark:bg-neutral-800">
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
Services(Optional)
{t("Services")} ({t("Optional")})
</h2>
<div className="flex w-full items-center justify-between gap-2">
<Label className="" htmlFor="short_url_service">
Short URL Service:
{t("Shorten Service")}:
</Label>
<Switch
id="short_url_service"
@@ -312,7 +314,7 @@ export function DomainForm({
<div className="flex w-full items-center justify-between gap-2">
<Label className="" htmlFor="email_service">
Email Service:
{t("Email Service")}:
</Label>
<Switch
id="email_service"
@@ -327,7 +329,7 @@ export function DomainForm({
<div className="flex w-full items-center justify-between gap-2">
<Label className="cursor-pointer" htmlFor="dns_record_service">
DNS Record Service:
{t("Subdomain Service")}:
</Label>
<Switch
id="dns_record_service"
@@ -344,7 +346,7 @@ export function DomainForm({
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<CollapsibleTrigger className="flex w-full items-center justify-between">
<h2 className="absolute left-2 top-5 flex gap-2 text-xs font-semibold text-neutral-400">
Cloudflare Configs (Optional)
{t("Cloudflare Configs")} ({t("Optional")})
<Icons.cloudflare className="mx-0.5 size-4" />
</h2>
{ReadyBadge(
@@ -358,14 +360,14 @@ export function DomainForm({
<CollapsibleContent>
{!currentRecordStatus && (
<div className="mt-3 flex items-center gap-1 rounded bg-neutral-200 p-2 text-xs dark:bg-neutral-700">
<Icons.help className="size-3" /> Associate with "DNS Record
Service" status
<Icons.help className="size-3" />{" "}
{t("Associate with 'Subdomain Service' status")}
</div>
)}
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
Zone ID:
{"Zone ID"}:
</Label>
<div className="w-full sm:w-3/5">
<Input
@@ -382,13 +384,13 @@ export function DomainForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
{t("Optional")}.{" "}
<Link
className="text-blue-500"
href="/docs/developer/cloudflare"
target="_blank"
>
How to get zone id?
{t("How to get zone id?")}
</Link>
</p>
)}
@@ -399,7 +401,7 @@ export function DomainForm({
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
API Token:
{t("API Token")}:
</Label>
<div className="w-full sm:w-3/5">
<Input
@@ -416,13 +418,13 @@ export function DomainForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
{t("Optional")}.{" "}
<Link
className="text-blue-500"
href="/docs/developer/cloudflare"
target="_blank"
>
How to get api token?
{t("How to get api token?")}
</Link>
</p>
)}
@@ -433,7 +435,7 @@ export function DomainForm({
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="email">
Account Email:
{t("Account Email")}:
</Label>
<div className="w-full sm:w-3/5">
<Input
@@ -450,13 +452,13 @@ export function DomainForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
{t("Optional")}.{" "}
<Link
className="text-blue-500"
href="/docs/developer/cloudflare"
target="_blank"
>
How to get cloudflare account email?
{t("How to get cloudflare account email?")}
</Link>
</p>
)}
@@ -470,7 +472,7 @@ export function DomainForm({
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<CollapsibleTrigger className="flex w-full items-center justify-between">
<h2 className="absolute left-2 top-5 flex gap-2 text-xs font-semibold text-neutral-400">
Resend Configs (Optional)
{t("Resend Configs")} ({t("Optional")})
<Icons.resend className="mx-0.5 size-4" />
</h2>
{ReadyBadge(
@@ -484,14 +486,14 @@ export function DomainForm({
<CollapsibleContent>
{!currentEmailStatus && (
<div className="mt-3 flex items-center gap-1 rounded bg-neutral-200 p-2 text-xs dark:bg-neutral-700">
<Icons.help className="size-3" /> Associate with "Email Service"
status
<Icons.help className="size-3" />{" "}
{t("Associate with 'Email Service' status")}
</div>
)}
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
API Key (send email service):
{t("API Key")} ({t("send email service")}):
</Label>
<div className="w-full sm:w-3/5">
<Input
@@ -508,13 +510,13 @@ export function DomainForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
{t("Optional")}.{" "}
<Link
className="text-blue-500"
href="/docs/developer/email"
target="_blank"
>
How to get resend api key?
{t("How to get resend api key?")}
</Link>
</p>
)}
@@ -538,7 +540,7 @@ export function DomainForm({
{isDeleting ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>Delete</p>
<p>{t("Delete")}</p>
)}
</Button>
)}
@@ -548,7 +550,7 @@ export function DomainForm({
className="w-[80px] px-0"
onClick={() => setShowForm(false)}
>
Cancle
{t("Cancel")}
</Button>
<Button
type="submit"
@@ -559,7 +561,7 @@ export function DomainForm({
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>{type === "edit" ? "Update" : "Save"}</p>
<p>{type === "edit" ? t("Update") : t("Save")}</p>
)}
</Button>
</div>
+61 -34
View File
@@ -1,8 +1,15 @@
"use client";
import { Dispatch, SetStateAction, useState, useTransition } from "react";
import {
Dispatch,
SetStateAction,
useMemo,
useState,
useTransition,
} from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
@@ -61,9 +68,11 @@ export function RecordForm({
const [currentZoneName, setCurrentZoneName] = useState(
initData?.zone_name || "wr.do",
);
const [email, setEmail] = useState(user.email);
const [email, setEmail] = useState(initData?.user.email || user.email);
const isAdmin = action.indexOf("admin") > -1;
const t = useTranslations("List");
const {
handleSubmit,
register,
@@ -92,6 +101,19 @@ export function RecordForm({
},
);
const validDefaultDomain = useMemo(() => {
if (!recordDomains?.length) return undefined;
if (
initData?.zone_name &&
recordDomains.some((d) => d.domain_name === initData.zone_name)
) {
return initData.zone_name;
}
return recordDomains[0].domain_name;
}, [recordDomains, initData?.zone_name]);
const onSubmit = handleSubmit((data) => {
if (isAdmin && type === "edit" && initData?.active === 2) {
handleApplyRecord(data);
@@ -204,24 +226,26 @@ export function RecordForm({
return (
<div>
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
{type === "add" ? "Create" : "Edit"} record
{type === "add" ? t("Create record") : t("Edit record")}
</div>
{siteConfig.enableSubdomainApply && (
<ul className="m-2 list-disc gap-1 rounded-md bg-yellow-600/10 p-2 px-5 pr-2 text-xs font-medium text-yellow-600 dark:bg-yellow-500/10 dark:text-yellow-500">
<li>The administrator has enabled application mode.</li>
<li>{t("The administrator has enabled application mode")}.</li>
<li>
After submission, you need to wait for administrator approval before
the record takes effect.
{t(
"After submission, you need to wait for administrator approval before the record takes effect",
)}
.
</li>
</ul>
)}
<form className="p-4" onSubmit={onSubmit}>
{isAdmin && (
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns required title="User email">
<FormSectionColumns required title={t("User email")}>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="content">
User email
{t("User email")}
</Label>
<Input
id="email"
@@ -229,6 +253,7 @@ export function RecordForm({
size={32}
defaultValue={email || ""}
onChange={(e) => setEmail(e.target.value)}
disabled={type === "edit"}
/>
</div>
<div className="flex flex-col justify-between p-1">
@@ -248,12 +273,12 @@ export function RecordForm({
{siteConfig.enableSubdomainApply && (
<FormSectionColumns
title="What are you planning to use the subdomain for?"
title={t("What are you planning to use the subdomain for?")}
required
>
<div className="flex items-center gap-2">
<Label className="sr-only" htmlFor="comment">
What are you planning to use the subdomain for?
{t("What are you planning to use the subdomain for?")}
</Label>
<Textarea
id="comment"
@@ -263,12 +288,12 @@ export function RecordForm({
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
At least 20 characters
{t("At least 20 characters")}
</p>
</FormSectionColumns>
)}
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="Domain" required>
<FormSectionColumns title={t("Domain")} required>
{isLoading ? (
<Skeleton className="h-9 w-full" />
) : (
@@ -278,7 +303,7 @@ export function RecordForm({
setCurrentZoneName(value);
}}
name="zone_name"
defaultValue={String(initData?.zone_name || "wr.do")}
defaultValue={validDefaultDomain}
disabled={type === "edit"}
>
<SelectTrigger className="w-full shadow-inner">
@@ -293,17 +318,17 @@ export function RecordForm({
))
) : (
<Button className="w-full" variant="ghost">
No domains configured
{t("No domains configured")}
</Button>
)}
</SelectContent>
</Select>
)}
<p className="p-1 text-[13px] text-muted-foreground">
Required. Select a domain.
{t("Required")}. {t("Select a domain")}.
</p>
</FormSectionColumns>
<FormSectionColumns title="Type" required>
<FormSectionColumns title={t("Type")} required>
<Select
onValueChange={(value: RecordType) => {
setValue("type", value);
@@ -323,14 +348,16 @@ export function RecordForm({
))}
</SelectContent>
</Select>
<p className="p-1 text-[13px] text-muted-foreground">Required.</p>
<p className="p-1 text-[13px] text-muted-foreground">
{t("Required")}.
</p>
</FormSectionColumns>
</div>
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="Name" required>
<FormSectionColumns title={t("Name")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="name">
Name (required)
{t("Name")}
</Label>
<div className="relative w-full">
<Input
@@ -354,7 +381,7 @@ export function RecordForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Required. E.g. www.
{t("Required")}. {t("Example")} www
</p>
)}
</div>
@@ -363,15 +390,15 @@ export function RecordForm({
required
title={
currentRecordType === "CNAME"
? "Content"
? t("Content")
: currentRecordType === "A"
? "IPv4 address"
: "Content"
? t("IPv4 address")
: t("Content")
}
>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="content">
Content
t("Content")
</Label>
<Input
id="content"
@@ -388,10 +415,10 @@ export function RecordForm({
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
{currentRecordType === "CNAME"
? "Required. E.g. www.example.com"
? `${t("Required")}. ${t("Example")} www.example.com`
: currentRecordType === "A"
? "Required. E.g. 8.8.8.8"
: "Required."}
? `${t("Required")}. ${t("Example")} 8.8.8.8`
: t("Required")}
</p>
)}
</div>
@@ -418,14 +445,14 @@ export function RecordForm({
</SelectContent>
</Select>
<p className="p-1 text-[13px] text-muted-foreground">
Optional. Time To Live.
{t("Optional")}. {t("Time To Live")}.
</p>
</FormSectionColumns>
{["A", "CNAME"].includes(currentRecordType) && (
<FormSectionColumns title="Proxy">
<FormSectionColumns title={t("Proxy")}>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="proxy">
Proxy
{t("Proxy")}
</Label>
<Switch
id="proxied"
@@ -434,7 +461,7 @@ export function RecordForm({
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Proxy status.
{t("Proxy status")}.
</p>
</FormSectionColumns>
)}
@@ -452,7 +479,7 @@ export function RecordForm({
{isDeleting ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>Delete</p>
<p>{t("Delete")}</p>
)}
</Button>
)}
@@ -462,7 +489,7 @@ export function RecordForm({
className="w-[80px] px-0"
onClick={() => setShowForm(false)}
>
Cancle
{t("Cancel")}
</Button>
<Button
type="submit"
@@ -473,7 +500,7 @@ export function RecordForm({
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>{type === "edit" ? "Update" : "Save"}</p>
<p>{type === "edit" ? t("Update") : t("Save")}</p>
)}
</Button>
</div>
+33 -17
View File
@@ -1,9 +1,10 @@
"use client";
import { Dispatch, SetStateAction, useTransition } from "react";
import { Dispatch, SetStateAction, useMemo, useTransition } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { Sparkles } from "lucide-react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
@@ -51,6 +52,7 @@ export function UrlForm({
}: RecordFormProps) {
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const t = useTranslations("List");
const {
handleSubmit,
@@ -81,6 +83,19 @@ export function UrlForm({
},
);
const validDefaultDomain = useMemo(() => {
if (!shortDomains?.length) return undefined;
if (
initData?.prefix &&
shortDomains.some((d) => d.domain_name === initData.prefix)
) {
return initData.prefix;
}
return shortDomains[0].domain_name;
}, [shortDomains, initData?.prefix]);
const onSubmit = handleSubmit((data) => {
if (type === "add") {
handleCreateUrl(data);
@@ -166,14 +181,14 @@ export function UrlForm({
return (
<div>
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
{type === "add" ? "Create" : "Edit"} short link
{type === "add" ? t("Create short link") : t("Edit short link")}
</div>
<form className="p-4" onSubmit={onSubmit}>
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="Target URL" required>
<FormSectionColumns title={t("Target URL")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="target">
Target
{t("Target")}
</Label>
<Input
id="target"
@@ -189,12 +204,12 @@ export function UrlForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Required. https://your-origin-url
{t("Required")}. https://your-origin-url
</p>
)}
</div>
</FormSectionColumns>
<FormSectionColumns title="Short Link" required>
<FormSectionColumns title={t("Short Link")} required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="url">
Url
@@ -209,7 +224,7 @@ export function UrlForm({
setValue("prefix", value);
}}
name="prefix"
defaultValue={initData?.prefix || "wr.do"}
defaultValue={validDefaultDomain}
disabled={type === "edit"}
>
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
@@ -258,7 +273,8 @@ export function UrlForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
A random url suffix. Final url likewr.do/s/suffix
{t("A random url suffix")}. {t("Final url like")}
wr.do/s/suffix
</p>
)}
</div>
@@ -266,10 +282,10 @@ export function UrlForm({
</div>
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="Password (Optional)">
<FormSectionColumns title={`${t("Password")} (${t("Optional")})`}>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="password">
Password
{t("Password")}
</Label>
<Input
id="password"
@@ -277,7 +293,7 @@ export function UrlForm({
size={32}
maxLength={6}
type="password"
placeholder="Enter 6 character password"
placeholder={t("Enter 6 character password")}
{...register("password")}
/>
</div>
@@ -288,12 +304,12 @@ export function UrlForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional. If you want to protect your link.
{t("Optional")}. {t("If you want to protect your link")}.
</p>
)}
</div>
</FormSectionColumns>
<FormSectionColumns title="Expiration" required>
<FormSectionColumns title={t("Expiration")} required>
<Select
onValueChange={(value: string) => {
setValue("expiration", value);
@@ -313,7 +329,7 @@ export function UrlForm({
</SelectContent>
</Select>
<p className="p-1 text-[13px] text-muted-foreground">
Expiration time, default for never.
{t("Expiration time, default for never")}.
</p>
</FormSectionColumns>
</div>
@@ -331,7 +347,7 @@ export function UrlForm({
{isDeleting ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>Delete</p>
<p>{t("Delete")}</p>
)}
</Button>
)}
@@ -341,7 +357,7 @@ export function UrlForm({
className="w-[80px] px-0"
onClick={() => setShowForm(false)}
>
Cancle
{t("Cancel")}
</Button>
<Button
type="submit"
@@ -352,7 +368,7 @@ export function UrlForm({
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>{type === "edit" ? "Update" : "Save"}</p>
<p>{type === "edit" ? t("Update") : t("Save")}</p>
)}
</Button>
</div>
+7 -4
View File
@@ -3,6 +3,7 @@
import { useState, useTransition } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -28,6 +29,8 @@ export function UserApiKeyForm({ user }: UserNameFormProps) {
const [isPending, startTransition] = useTransition();
const [apiKey, setApiKey] = useState(user?.apiKey || "");
const t = useTranslations("Setting");
const {
handleSubmit,
formState: { errors },
@@ -57,12 +60,12 @@ export function UserApiKeyForm({ user }: UserNameFormProps) {
return (
<form onSubmit={onSubmit}>
<SectionColumns
title="API Key"
description="Generate a new API key to access the open apis."
title={t("API Key")}
description={t("Generate a new API key to access the open apis")}
>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="name">
API Key
{t("API Key")}
</Label>
<div className="flex w-full items-center">
<input
@@ -89,7 +92,7 @@ export function UserApiKeyForm({ user }: UserNameFormProps) {
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
"Generate"
t("Generate")
)}
</Button>
</div>
+6 -3
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
@@ -40,6 +41,8 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
React.useState<boolean>(false);
const searchParams = useSearchParams();
const t = useTranslations("Auth");
async function onSubmit(data: FormData) {
setIsLoading(true);
@@ -76,7 +79,7 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
{t("Or continue with")}
</span>
</div>
</div>
@@ -213,8 +216,8 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
{type === "register"
? "Sign Up with Email"
: "Sign In with Email"}
? t("Sign Up with Email")
: t("Sign In with Email")}
</button>
</div>
</form>
+10 -8
View File
@@ -5,6 +5,7 @@ import { updateUserName, type FormData } from "@/actions/update-user-name";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { useSession } from "next-auth/react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -25,6 +26,8 @@ export function UserNameForm({ user }: UserNameFormProps) {
const [isPending, startTransition] = useTransition();
const updateUserNameWithId = updateUserName.bind(null, user.id);
const t = useTranslations("Setting");
const checkUpdate = (value) => {
setUpdated(user.name !== value);
};
@@ -59,12 +62,12 @@ export function UserNameForm({ user }: UserNameFormProps) {
return (
<form onSubmit={onSubmit}>
<SectionColumns
title="Your Name"
description="Please enter a display name you are comfortable with."
title={t("Your Name")}
description={t("Please enter a display name you are comfortable with")}
>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="name">
Name
{t("Name")}
</Label>
<Input
id="name"
@@ -82,10 +85,7 @@ export function UserNameForm({ user }: UserNameFormProps) {
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>
Save
<span className="hidden sm:inline-flex">&nbsp;Changes</span>
</p>
<p>{t("Save")}</p>
)}
</Button>
</div>
@@ -95,7 +95,9 @@ export function UserNameForm({ user }: UserNameFormProps) {
{errors.name.message}
</p>
)}
<p className="text-[13px] text-muted-foreground">Max 32 characters</p>
<p className="text-[13px] text-muted-foreground">
{t("Max 32 characters")}
</p>
</div>
</SectionColumns>
</form>
+8 -13
View File
@@ -5,6 +5,7 @@ import { updateUserRole, type FormData } from "@/actions/update-user-role";
import { zodResolver } from "@hookform/resolvers/zod";
import { User, UserRole } from "@prisma/client";
import { useSession } from "next-auth/react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -42,6 +43,8 @@ export function UserRoleForm({ user }: UserNameFormProps) {
const roles = Object.values(UserRole);
const [role, setRole] = useState(user.role);
const t = useTranslations("Setting");
const form = useForm<FormData>({
resolver: zodResolver(userRoleSchema),
values: {
@@ -69,8 +72,8 @@ export function UserRoleForm({ user }: UserNameFormProps) {
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<SectionColumns
title="Your Role"
description="Select the role what you want for test the app."
title={t("Your Role")}
description={t("Select the role what you want for this app")}
>
<div className="flex w-full items-center gap-2">
<FormField
@@ -78,7 +81,7 @@ export function UserRoleForm({ user }: UserNameFormProps) {
name="role"
render={({ field }) => (
<FormItem className="w-full space-y-0">
<FormLabel className="sr-only">Role</FormLabel>
<FormLabel className="sr-only">{t("Role")}</FormLabel>
<Select
// TODO:(FIX) Option value not update. Use useState for the moment
onValueChange={(value: UserRole) => {
@@ -97,7 +100,7 @@ export function UserRoleForm({ user }: UserNameFormProps) {
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role.toString()}>
{role}
{t(role)}
</SelectItem>
))}
</SelectContent>
@@ -115,18 +118,10 @@ export function UserRoleForm({ user }: UserNameFormProps) {
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>
Save
<span className="hidden sm:inline-flex">&nbsp;Changes</span>
</p>
<p>{t("Save")}</p>
)}
</Button>
</div>
<div className="flex flex-col justify-between p-1">
<p className="text-[13px] text-muted-foreground">
Remove this field on real production
</p>
</div>
</SectionColumns>
</form>
</Form>
+13 -10
View File
@@ -5,8 +5,9 @@ import Image from "next/image";
import { usePathname } from "next/navigation";
import { SidebarNavItem } from "@/types";
import { Menu, PanelLeftClose, PanelRightClose } from "lucide-react";
import { useTranslations } from "next-intl";
import { Link } from "next-view-transitions";
import { name, version } from "package.json";
import pkg from "package.json";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
@@ -28,6 +29,7 @@ interface DashboardSidebarProps {
}
export function DashboardSidebar({ links }: DashboardSidebarProps) {
const t = useTranslations("System");
const path = usePathname();
const { isTablet } = useMediaQuery();
@@ -98,7 +100,7 @@ export function DashboardSidebar({ links }: DashboardSidebarProps) {
>
{isSidebarExpanded ? (
<p className="text-xs text-muted-foreground">
{section.title}
{t(section.title)}
</p>
) : (
<div className="h-4" />
@@ -122,7 +124,7 @@ export function DashboardSidebar({ links }: DashboardSidebarProps) {
)}
>
<Icon className="size-5" />
{item.title}
{t(item.title)}
{item.badge && (
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full">
{item.badge}
@@ -150,7 +152,7 @@ export function DashboardSidebar({ links }: DashboardSidebarProps) {
</Link>
</TooltipTrigger>
<TooltipContent side="right">
{item.title}
{t(item.title)}
</TooltipContent>
</Tooltip>
)}
@@ -172,9 +174,9 @@ export function DashboardSidebar({ links }: DashboardSidebarProps) {
rel="noreferrer"
className="font-medium underline-offset-2 hover:underline"
>
{name}
{pkg.name}
</Link>
v{version}
v{pkg.version}
</p>
)}
</div>
@@ -189,6 +191,7 @@ export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
const path = usePathname();
const [open, setOpen] = useState(false);
const { isSm, isMobile } = useMediaQuery();
const t = useTranslations("System");
if (isSm || isMobile) {
return (
@@ -229,7 +232,7 @@ export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
className="flex flex-col gap-0.5"
>
<p className="text-xs text-muted-foreground">
{section.title}
{t(section.title)}
</p>
{section.items.map((item) => {
@@ -253,7 +256,7 @@ export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
)}
>
<Icon className="size-5" />
{item.title}
{t(item.title)}
{item.badge && (
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full">
{item.badge}
@@ -276,9 +279,9 @@ export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
rel="noreferrer"
className="font-medium underline-offset-2 hover:underline"
>
{name}
{pkg.name}
</Link>
v{version}
v{pkg.version}
</p>
{/* <div className="mt-auto">
+6 -4
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { Menu, X } from "lucide-react";
import { useSession } from "next-auth/react";
import { useTranslations } from "next-intl";
import { docsConfig } from "@/config/docs";
import { marketingConfig } from "@/config/marketing";
@@ -16,6 +17,7 @@ import { Icons } from "@/components/shared/icons";
import { ModeToggle } from "./mode-toggle";
export function NavMobile() {
const t = useTranslations("System");
const { data: session } = useSession();
const [open, setOpen] = useState(false);
const selectedLayout = useSelectedLayoutSegment();
@@ -69,7 +71,7 @@ export function NavMobile() {
onClick={() => setOpen(false)}
className="flex w-full font-medium capitalize"
>
{title}
{t(title)}
</Link>
</li>
))}
@@ -83,7 +85,7 @@ export function NavMobile() {
onClick={() => setOpen(false)}
className="flex w-full font-medium capitalize"
>
Admin
{t("Admin")}
</Link>
</li>
) : null}
@@ -94,7 +96,7 @@ export function NavMobile() {
onClick={() => setOpen(false)}
className="flex w-full font-medium capitalize"
>
Dashboard
{t("Dashboard")}
</Link>
</li>
</>
@@ -106,7 +108,7 @@ export function NavMobile() {
onClick={() => setOpen(false)}
className="flex w-full font-medium capitalize"
>
Sign in
{t("Sign in")}
</Link>
</li>
</>
+14 -12
View File
@@ -1,19 +1,21 @@
"use client"
"use client";
import * as React from "react"
import { useTheme } from "next-themes"
import * as React from "react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button"
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Icons } from "@/components/shared/icons"
} from "@/components/ui/dropdown-menu";
import { Icons } from "@/components/shared/icons";
export function ModeToggle() {
const { setTheme } = useTheme()
const { setTheme } = useTheme();
const t = useTranslations("System");
return (
<DropdownMenu>
@@ -21,23 +23,23 @@ export function ModeToggle() {
<Button variant="ghost" size="sm" className="size-8 px-0">
<Icons.sun className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Icons.moon className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
<span className="sr-only">{t("Toggle theme")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Icons.sun className="mr-2 size-4" />
<span>Light</span>
<span>{t("Light")}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Icons.moon className="mr-2 size-4" />
<span>Dark</span>
<span>{t("Dark")}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Icons.laptop className="mr-2 size-4" />
<span>System</span>
<span>{t("System")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
);
}
+5 -6
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useSession } from "next-auth/react";
import { useTranslations } from "next-intl";
import { docsConfig } from "@/config/docs";
import { marketingConfig } from "@/config/marketing";
@@ -10,13 +10,11 @@ import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
import { useScroll } from "@/hooks/use-scroll";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { DocsSearch } from "@/components/docs/search";
import { Icons } from "@/components/shared/icons";
import MaxWidthWrapper from "@/components/shared/max-width-wrapper";
import { ModeToggle } from "./mode-toggle";
import { UserAccountNav } from "./user-account-nav";
interface NavBarProps {
scroll?: boolean;
@@ -25,7 +23,6 @@ interface NavBarProps {
export function NavBar({ scroll = false }: NavBarProps) {
const scrolled = useScroll(50);
const { data: session, status } = useSession();
const selectedLayout = useSelectedLayoutSegment();
const documentation = selectedLayout === "docs";
@@ -37,6 +34,8 @@ export function NavBar({ scroll = false }: NavBarProps) {
const links =
(selectedLayout && configMap[selectedLayout]) || marketingConfig.mainNav;
const t = useTranslations("System");
return (
<header
className={`sticky top-0 z-40 flex w-full justify-center bg-background/60 backdrop-blur-xl transition-all ${
@@ -75,7 +74,7 @@ export function NavBar({ scroll = false }: NavBarProps) {
item.disabled && "cursor-not-allowed opacity-80",
)}
>
{item.title}
{t(item.title)}
</Link>
))}
</nav>
@@ -108,7 +107,7 @@ export function NavBar({ scroll = false }: NavBarProps) {
className="hidden text-sm font-medium text-foreground/60 transition-colors hover:text-foreground/80 md:block"
>
<Button className="" variant="outline" size="sm" rounded="lg">
Dashboard
{t("Dashboard")}
</Button>
</Link>
<div className="hidden md:flex">
+6 -4
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import Link from "next/link";
import { name, version } from "package.json";
import { footerLinks, siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
@@ -56,16 +57,17 @@ export function SiteFooter({ className }: React.HTMLAttributes<HTMLElement>) {
<div className="border-t py-4">
<div className="container flex max-w-6xl items-center justify-between">
<p className="font-mono text-sm 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>
v{version}
. <br className="block sm:hidden" /> Built with{" "}
<Link
href="https://www.cloudflare.com?ref=wrdo"
+10 -8
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { LayoutDashboard, Lock, LogOut, Settings } from "lucide-react";
import { signOut, useSession } from "next-auth/react";
import { useTranslations } from "next-intl";
import { Drawer } from "vaul";
import { useMediaQuery } from "@/hooks/use-media-query";
@@ -28,6 +29,7 @@ export function UserAccountNav() {
};
const { isMobile } = useMediaQuery();
const t = useTranslations("System");
if (!user)
return (
@@ -85,7 +87,7 @@ export function UserAccountNav() {
className="flex w-full items-center gap-3 px-2.5 py-2"
>
<Lock className="size-4" />
<p className="text-sm">Admin</p>
<p className="text-sm">{t("Admin")}</p>
</Link>
</li>
) : null}
@@ -97,7 +99,7 @@ export function UserAccountNav() {
className="flex w-full items-center gap-3 px-2.5 py-2"
>
<LayoutDashboard className="size-4" />
<p className="text-sm">Dashboard</p>
<p className="text-sm">{t("Dashboard")}</p>
</Link>
</li>
@@ -108,7 +110,7 @@ export function UserAccountNav() {
className="flex w-full items-center gap-3 px-2.5 py-2"
>
<Settings className="size-4" />
<p className="text-sm">Settings</p>
<p className="text-sm">{t("Settings")}</p>
</Link>
</li>
@@ -123,7 +125,7 @@ export function UserAccountNav() {
>
<div className="flex w-full items-center gap-3 px-2.5 py-2">
<LogOut className="size-4" />
<p className="text-sm">Log out </p>
<p className="text-sm">{t("Log out")}</p>
</div>
</li>
</ul>
@@ -170,7 +172,7 @@ export function UserAccountNav() {
<DropdownMenuItem asChild>
<Link href="/admin" className="flex items-center space-x-2.5">
<Lock className="size-4" />
<p className="text-sm">Admin</p>
<p className="text-sm">{t("Admin")}</p>
</Link>
</DropdownMenuItem>
) : null}
@@ -178,7 +180,7 @@ export function UserAccountNav() {
<DropdownMenuItem asChild>
<Link href="/dashboard" className="flex items-center space-x-2.5">
<LayoutDashboard className="size-4" />
<p className="text-sm">Dashboard</p>
<p className="text-sm">{t("Dashboard")}</p>
</Link>
</DropdownMenuItem>
@@ -188,7 +190,7 @@ export function UserAccountNav() {
className="flex items-center space-x-2.5"
>
<Settings className="size-4" />
<p className="text-sm">Settings</p>
<p className="text-sm">{t("Settings")}</p>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -203,7 +205,7 @@ export function UserAccountNav() {
>
<div className="flex items-center space-x-2.5">
<LogOut className="size-4" />
<p className="text-sm">Log out </p>
<p className="text-sm">{t("Log out")}</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
+9 -6
View File
@@ -6,6 +6,7 @@ import {
useState,
} from "react";
import { signOut, useSession } from "next-auth/react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -23,6 +24,8 @@ function DeleteAccountModal({
const { data: session } = useSession();
const [deleting, setDeleting] = useState(false);
const t = useTranslations("Setting");
async function deleteAccount() {
setDeleting(true);
await fetch(`/api/user`, {
@@ -63,13 +66,13 @@ function DeleteAccountModal({
email: session?.user?.email || null,
}}
/>
<h3 className="text-lg font-semibold">Delete Account</h3>
<h3 className="text-lg font-semibold">{t("Delete Account")}</h3>
<p className="text-center text-sm text-muted-foreground">
<b>Warning:</b> This will permanently delete your account and your
active subscription!
<b>{t("Warning")}:</b>{" "}
{t(
"This will permanently delete your account and your active subscription!",
)}
</p>
{/* TODO: Use getUserSubscriptionPlan(session.user.id) to display the user's subscription if he have a paid plan */}
</div>
<form
@@ -89,7 +92,7 @@ function DeleteAccountModal({
<span className="font-semibold text-black dark:text-white">
confirm delete account
</span>{" "}
below
below:
</label>
<Input
type="text"
+46 -55
View File
@@ -1,9 +1,11 @@
"user client";
import { ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { siteConfig } from "@/config/site";
import { getCurrentUser } from "@/lib/session";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { Icons } from "@/components/shared/icons";
@@ -11,8 +13,12 @@ import { Icons } from "@/components/shared/icons";
import EmailManagerExp from "./email";
import UrlShortener from "./url-shortener";
export default async function HeroLanding() {
const user = await getCurrentUser();
export default function HeroLanding({
userId,
}: {
userId: string | undefined;
}) {
const t = useTranslations("Landing");
return (
<section className="custom-bg relative space-y-6 py-12 sm:py-20 lg:py-24">
<div className="container flex max-w-screen-lg flex-col items-center gap-5 text-center">
@@ -24,23 +30,24 @@ export default async function HeroLanding() {
"px-4",
)}
>
<span className="mr-1">🚀</span>Deploy with&nbsp;
<span className="mr-1">🚀</span>
{t("deployWithVercel")}&nbsp;
<span className="font-bold" style={{ fontFamily: "Bahamas Bold" }}>
Vercel
</span>
&nbsp;now!
&nbsp;
{t("now")}
</Link>
<h1 className="text-balance font-satoshi text-[40px] font-black leading-[1.15] tracking-tight sm:text-5xl md:text-6xl md:leading-[1.15]">
One platform powers{" "}
{t("onePlatformPowers")}
<span className="bg-gradient-to-r from-violet-600 via-blue-600 to-cyan-500 bg-clip-text text-transparent">
endless solutions
{t("endlessSolutions")}
</span>
</h1>
<p className="max-w-2xl text-balance text-muted-foreground sm:text-lg">
Link shortening, domain hosting, email manager and <br />
open api, everything you need to build better.
{t("platformDescription")}
</p>
<div className="flex items-center justify-center gap-4">
@@ -57,7 +64,7 @@ export default async function HeroLanding() {
"gap-2 bg-primary-foreground px-4 text-[15px] font-semibold text-primary hover:bg-slate-100",
)}
>
<span>Documents</span>
<span>{t("documents")}</span>
<Icons.bookOpen className="size-4" />
</Link>
<Link
@@ -68,7 +75,7 @@ export default async function HeroLanding() {
"px-4 text-[15px] font-semibold",
)}
>
<span>{user?.id ? "Dashboard" : "Sign in for free"}</span>
<span>{userId ? t("Dashboard") : t("signInForFree")}</span>
{/* <Icons.arrowRight className="size-4" /> */}
</Link>
</div>
@@ -80,13 +87,14 @@ export default async function HeroLanding() {
}
export function LandingImages() {
const t = useTranslations("Landing");
return (
<>
<div className="mx-auto mt-10 w-full max-w-6xl px-6">
<div className="my-14 flex flex-col items-center justify-around gap-10 md:flex-row-reverse">
<Image
className="size-[260px] rounded-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
alt={t("exampleImageAlt")}
src="/_static/landing/link.svg"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
@@ -95,21 +103,16 @@ export function LandingImages() {
/>
<div className="grids grids-dark px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
URL Shortening
{t("urlShorteningTitle")}
</h3>
<p className="text-lg">
📖 Instantly transform long, unwieldy URLs into short, memorable
links that are easy to share. Enjoy built-in analytics to track
clicks, monitor performance, and gain insights into your
audienceall in real time.
</p>
<p className="text-lg">{t("urlShorteningDescription")}</p>
</div>
</div>
<div className="flex flex-col items-center justify-around gap-10 md:flex-row">
<Image
className="size-[260px] rounded-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
alt={t("exampleImageAlt")}
src="/_static/landing/hosting.svg"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
@@ -118,21 +121,16 @@ export function LandingImages() {
/>
<div className="grids grids-dark px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
Free Subdomain Hosting
{t("freeSubdomainHostingTitle")}
</h3>
<p className="text-lg">
🎉 Kickstart your online presence with free, fully customizable
subdomains. Whether you&apos;re launching a personal project or
testing a business idea, get started quickly with no cost and
reliable hosting you can trust.
</p>
<p className="text-lg">{t("freeSubdomainHostingDescription")}</p>
</div>
</div>
<div className="my-14 flex flex-col items-center justify-around gap-10 md:flex-row-reverse">
<Image
className="size-[260px] rounded-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
alt={t("exampleImageAlt")}
src="/_static/landing/email.svg"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
@@ -141,13 +139,17 @@ export function LandingImages() {
/>
<div className="grids grids-dark px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
Email Receivers & Senders
{t("emailReceiversSendersTitle")}
</h3>
<p className="text-lg">
📧 Seamlessly receive and send emails from any email provider with
top-notch security. Stay connected and manage your communications
effortlessly, knowing your data is protected with robust
encryption and privacy features.
{t("emailReceiversSendersDescription")}{" "}
<a
className="underline"
href="/dashboard/settings"
target="_blank"
>
{t("applyYourApiKey")}
</a>
</p>
</div>
</div>
@@ -155,7 +157,7 @@ export function LandingImages() {
<div className="flex flex-col items-center justify-around gap-10 md:flex-row">
<Image
className="size-[260px] rounded-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
alt={t("exampleImageAlt")}
src="/_static/landing/domain.svg"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
@@ -164,21 +166,16 @@ export function LandingImages() {
/>
<div className="grids grids-dark px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
Multiple Domains
{t("multipleDomainsTitle")}
</h3>
<p className="text-lg">
🤩 Empower your business with the flexibility of multiple domains,
such as wr.do, uv.do, and more. Establish a strong digital
footprint, create branded links, or manage diverse projectsall
under one unified platform.
</p>
<p className="text-lg">{t("multipleDomainsDescription")}</p>
</div>
</div>
<div className="flex flex-col items-center justify-around gap-10 md:flex-row-reverse">
<Image
className="size-[260px] rounded-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
alt={t("exampleImageAlt")}
src="/_static/landing/screenshot.svg"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
@@ -187,19 +184,16 @@ export function LandingImages() {
/>
<div className="grids grids-dark px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
Website Screenshot API
{t("websiteScreenshotApiTitle")}
</h3>
<p className="text-lg">
📷 Capture high-quality screenshots of any webpage instantly with
our powerful Screenshot API. Integrate seamlessly into your
applications, access third-party services, and unlock advanced
features by applying your unique API key.
{t("websiteScreenshotApiDescription")}{" "}
<a
className="underline"
href="/dashboard/settings"
target="_blank"
>
Apply your api key--&gt;
{t("applyYourApiKey")}
</a>
</p>
</div>
@@ -208,7 +202,7 @@ export function LandingImages() {
<div className="my-14 flex flex-col items-center justify-around gap-10 md:flex-row">
<Image
className="size-[260px] rounded-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
alt={t("exampleImageAlt")}
src="/_static/landing/info.svg"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
@@ -217,19 +211,16 @@ export function LandingImages() {
/>
<div className="grids grids-dark px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
Meta Information API
{t("metaInformationApiTitle")}
</h3>
<p className="text-lg">
🍥 Extract rich, structured web data effortlessly with our smart
Meta Information API. Perfect for developers, businesses, or
researchers, this tool offers seamless integration, third-party
service access, and enhanced functionality.
{t("metaInformationApiDescription")}{" "}
<a
className="underline"
href="/dashboard/settings"
target="_blank"
>
Apply your api key--&gt;
{t("applyYourApiKey")}
</a>
</p>
</div>
+16 -15
View File
@@ -5,6 +5,7 @@ import type { CSSProperties, ReactNode } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import { X } from "lucide-react";
import { useTranslations } from "next-intl";
import { TeamPlanQuota } from "@/config/team";
import { cn, nFormatter } from "@/lib/utils";
@@ -66,6 +67,7 @@ const getBenefits = (plan) => [
];
export const PricingSection = () => {
const t = useTranslations("Landing");
return (
<section
id="pricing"
@@ -75,49 +77,48 @@ export const PricingSection = () => {
<div className="relative z-10 mx-auto max-w-5xl px-4 py-20 md:px-8">
<div className="mb-12 space-y-3">
<h2 className="text-center text-xl font-semibold leading-tight sm:text-3xl sm:leading-tight md:text-4xl md:leading-tight">
Pricing
{t("pricingTitle")}
</h2>
<p className="text-center text-base text-zinc-600 dark:text-zinc-400 md:text-lg">
Use it for free for yourself, upgrade when your team needs advanced
control.
{t("pricingDescription")}
</p>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<PriceCard
tier="Free"
price="$0/mo"
bestFor="For hobbyists and individuals looking to manage their links"
tier={t("freeTier")}
price={t("freePrice")}
bestFor={t("freeBestFor")}
CTA={
<Link href={"/dashboard"}>
<Button className="w-full" variant={"default"}>
Get started free
{t("getStartedFree")}
</Button>
</Link>
}
benefits={getBenefits(TeamPlanQuota.free)}
/>
{/* <PriceCard
tier="Premium"
price="$5/mo"
bestFor="Best for 5-50 users"
tier={t("premiumTier")}
price={t("premiumPrice")}
bestFor={t("premiumBestFor")}
CTA={
<Link href={"/pricing"}>
<Button className="w-full bg-zinc-800 text-zinc-50 hover:bg-zinc-700 hover:text-zinc-50 dark:bg-zinc-50 dark:text-zinc-950 dark:hover:bg-zinc-200 dark:hover:text-zinc-900">
Get free trial
{t("getFreeTrial")}
</Button>
</Link>
}
benefits={getBenefits(TeamPlanQuota.premium)}
/> */}
<PriceCard
tier="Enterprise"
price="Contact us"
bestFor="For large organizations with custom needs"
tier={t("enterpriseTier")}
price={t("enterprisePrice")}
bestFor={t("enterpriseBestFor")}
CTA={
<Link href={"mailto:support@wr.do"}>
<Button className="w-full" variant="outline">
Contact us
{t("contactUs")}
</Button>
</Link>
}
+8 -11
View File
@@ -1,13 +1,8 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function ApiReference({
badge,
@@ -18,22 +13,24 @@ export default function ApiReference({
target: string;
link: string;
}) {
const t = useTranslations("Components");
return (
<Card>
<CardHeader>
<CardTitle>API Reference</CardTitle>
<CardTitle>{t("API Reference")}</CardTitle>
</CardHeader>
<CardContent>
<Badge>{badge}</Badge>
<div className="mt-2">
<span style={{ fontFamily: "Bahamas Bold" }}>WR.DO</span> provide a
api for {target}. View the usage tutorial document{" "}
<span style={{ fontFamily: "Bahamas Bold" }}>WR.DO</span>{" "}
{t("provide a api for {target}", { target: t(target) })}.{" "}
{t("View the usage tutorial")}{" "}
<Link
href={link}
target="_blank"
className="font-semibold after:content-['_↗'] hover:text-blue-500 hover:underline"
>
here
{t("document")}
</Link>
.
</div>
+5 -2
View File
@@ -1,4 +1,5 @@
import React from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
@@ -44,6 +45,8 @@ export function PaginationWrapper({
// Calculate total pages based on pageSize
const totalPages = Math.ceil(total / pageSize);
const t = useTranslations("List");
const shouldShowPage = (page: number) => {
return (
page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2
@@ -144,7 +147,7 @@ export function PaginationWrapper({
>
{setPageSize && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Show</span>
<span className="text-muted-foreground">{t("Show")}</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
@@ -172,7 +175,7 @@ export function PaginationWrapper({
"hidden sm:inline-block",
)}
>
per page
{t("per page")}
</span>
</div>
)}
+20 -18
View File
@@ -10,6 +10,7 @@ import {
} from "react";
import Link from "next/link";
import { debounce } from "lodash";
import { useTranslations } from "next-intl";
import { HexColorPicker } from "react-colorful";
import { toast } from "sonner";
@@ -45,6 +46,7 @@ export default function QRCodeEditor({
user: { id: string; apiKey: string; team: string };
url: string;
}) {
const t = useTranslations("List");
const [params, setParams] = useState({
key: user.apiKey,
url,
@@ -145,13 +147,13 @@ export default function QRCodeEditor({
return (
<div className="relative w-full max-w-lg rounded-lg bg-white p-4 shadow-lg dark:bg-neutral-900">
<h2 className="mb-4 text-lg font-semibold">QR Code Design</h2>
<h2 className="mb-4 text-lg font-semibold">{t("QR Code Design")}</h2>
{/* QR Code Preview */}
<div className="mb-3">
<div className="flex items-center justify-between gap-1">
<h3 className="text-sm font-semibold text-neutral-600 dark:text-neutral-300">
Preview
{t("Preview")}
</h3>
<DropdownMenu>
<DropdownMenuTrigger className="ml-auto px-2 py-2 hover:bg-accent hover:text-accent-foreground">
@@ -166,7 +168,7 @@ export default function QRCodeEditor({
>
<div className="flex items-center gap-2 text-neutral-500">
<Icons.media className="size-4" />
<span className="font-semibold">Download SVG</span>
<span className="font-semibold">{t("Download SVG")}</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
@@ -181,7 +183,7 @@ export default function QRCodeEditor({
>
<div className="flex items-center gap-2 text-neutral-500">
<Icons.media className="size-4" />
<span className="font-semibold">Download PNG</span>
<span className="font-semibold">{t("Download PNG")}</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
@@ -196,7 +198,7 @@ export default function QRCodeEditor({
>
<div className="flex items-center gap-2 text-neutral-500">
<Icons.media className="size-4" />
<span className="font-semibold">Download JPG</span>
<span className="font-semibold">{t("Download JPG")}</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -234,7 +236,7 @@ export default function QRCodeEditor({
<div className="group mb-3 flex items-center justify-between">
<h3 className="text-nowrap text-sm font-semibold text-neutral-600 transition-all group-hover:ml-1 group-hover:font-bold dark:text-neutral-300">
Url
{t("Url")}
</h3>
<Input
className="ml-auto w-3/5"
@@ -248,7 +250,7 @@ export default function QRCodeEditor({
<div className="mb-3">
<div className="group mb-3 flex items-center justify-between">
<h3 className="text-nowrap text-sm font-semibold text-neutral-600 transition-all group-hover:ml-1 group-hover:font-bold dark:text-neutral-300">
Logo
{t("Logo")}
</h3>
<TooltipProvider>
<Tooltip delayDuration={0}>
@@ -256,13 +258,13 @@ export default function QRCodeEditor({
<Icons.help className="ml-1 size-4 text-neutral-400" />
</TooltipTrigger>
<TooltipContent className="max-w-64 text-left">
Display your logo in the center of the QR code.{" "}
{t("Display your logo in the center of the QR code")}.{" "}
<Link
className="border-b text-neutral-500"
href="/docs/open-api/qrcode"
target="_blank"
>
Learn more
{t("Learn more")}
</Link>
.
</TooltipContent>
@@ -277,7 +279,7 @@ export default function QRCodeEditor({
<details className="group">
<summary className="flex w-full cursor-pointer items-center justify-between">
<h3 className="text-nowrap text-sm font-semibold text-neutral-600 transition-all group-hover:ml-1 group-hover:font-bold dark:text-neutral-300">
Custom Logo
{t("Custom Logo")}
</h3>
<TooltipProvider>
<Tooltip delayDuration={0}>
@@ -291,13 +293,13 @@ export default function QRCodeEditor({
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-64 text-left">
Customize your QR code logo.{" "}
{t("Customize your QR code logo")}.{" "}
<Link
className="border-b text-neutral-500"
href="/docs/open-api/qrcode"
target="_blank"
>
Learn more
{t("Learn more")}
</Link>
.
</TooltipContent>
@@ -319,7 +321,7 @@ export default function QRCodeEditor({
<details className="group mb-3">
<summary className="flex w-full cursor-pointer items-center justify-between">
<h3 className="text-nowrap text-sm font-semibold text-neutral-600 transition-all group-hover:ml-1 group-hover:font-bold dark:text-neutral-300">
Front Color
{t("Front Color")}
</h3>
<Icons.chevronDown className="ml-auto size-4" />
</summary>
@@ -373,7 +375,7 @@ export default function QRCodeEditor({
<details className="group" open={true}>
<summary className="flex w-full cursor-pointer items-center justify-between">
<h3 className="text-nowrap text-sm font-semibold text-neutral-600 transition-all group-hover:ml-1 group-hover:font-bold dark:text-neutral-300">
Background Color
{t("Background Color")}
</h3>
<Icons.chevronDown className="ml-auto size-4" />
</summary>
@@ -428,20 +430,20 @@ export default function QRCodeEditor({
{!user.apiKey && (
<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{" "}
{t("Please create a api key before use this feature")}. <br />{" "}
{t("Learn more about")}{" "}
<Link
className="py-1 text-blue-600 hover:text-blue-400 hover:underline dark:hover:text-primary-foreground"
href={"/docs/open-api#api-key"}
target="_blank"
>
api key
Api key
</Link>
.
</p>
<Link href={"/dashboard/settings"}>
<Button>Create Api Key</Button>
<Button>{t("Create Api Key")}</Button>
</Link>
</div>
)}
+10
View File
@@ -0,0 +1,10 @@
import { useLocale } from "next-intl";
import TimeAgo from "timeago-react";
export function TimeAgoIntl({ date }: { date: Date }) {
const locale = useLocale();
return (
<TimeAgo datetime={date} locale={locale === "zh" ? "zh_CN" : locale} />
);
}
@@ -81,8 +81,8 @@ git clone https://github.com/oiov/cf-email-forwarding-worker.git
cd cf-email-forwarding-worker
pnpm install
wranler login
wranler deploy
wrangler login
wrangler deploy
```
在部署前,记得在 `wrangler.jsonc` 中添加你的环境变量。
@@ -83,8 +83,8 @@ git clone https://github.com/oiov/cf-email-forwarding-worker.git
cd cf-email-forwarding-worker
pnpm install
wranler login
wranler deploy
wrangler login
wrangler deploy
```
Remember to add your environment variables in `wrangler.jsonc` before deploy.
+4
View File
@@ -5,6 +5,10 @@ description: 选择你的部署方式
<DocsLang en="/docs/developer/deploy" zh="/docs/developer/deploy-zh" />
<Callout type="warning" twClass="mt-4">
在阅读此文档之前,建议首先阅读 [快速开始](/docs/developer/quick-start-zh),以确认准备好依赖的环境、变量。
</Callout>
## 使用 Vercel 部署(推荐)
<Callout type="warning" twClass="mt-4">
+5
View File
@@ -5,6 +5,11 @@ description: Choose your deployment method
<DocsLang en="/docs/developer/deploy" zh="/docs/developer/deploy-zh" />
<Callout type="warning" twClass="mt-4">
Before reading this document, it is recommended to first read [Quick Start](/docs/developer/quick-start),
to confirm that the necessary environment variables are ready.
</Callout>
## 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)
+45
View File
@@ -0,0 +1,45 @@
import { headers } from "next/headers";
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async () => {
const locales = ["en", "zh"];
const defaultLocale = "en";
const headersList = headers();
// 获取 cookie 中的语言设置
const cookieLanguage = headersList
.get("cookie")
?.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith("language="))
?.split("=")[1];
// 如果 cookie 中有有效的语言设置,直接使用
if (cookieLanguage && locales.includes(cookieLanguage)) {
return {
locale: cookieLanguage,
messages: (await import(`../locales/${cookieLanguage}.json`)).default,
};
}
const acceptLanguage = headersList.get("accept-language") || "";
// 解析用户偏好的语言列表
const userLanguages = acceptLanguage
.split(",")
.map((lang) => {
const [language, weight = "q=1.0"] = lang.split(";");
return {
language: language.split("-")[0], // 只取主要语言代码
weight: parseFloat(weight.split("=")[1]),
};
})
.sort((a, b) => b.weight - a.weight);
// 查找第一个匹配的支持语言
const matchedLocale = userLanguages.find(({ language }) =>
locales.includes(language),
);
const locale = matchedLocale ? matchedLocale.language : defaultLocale;
return {
locale,
messages: (await import(`../locales/${locale}.json`)).default,
};
});
+3
View File
@@ -97,6 +97,9 @@ export async function getDomainsByFeatureClient(feature: string) {
select: {
domain_name: true,
},
orderBy: {
updatedAt: "desc",
},
});
return domains;
} catch (error) {
+7 -107
View File
@@ -69,112 +69,6 @@ export async function saveForwardEmail(emailData: OriginalEmail) {
return res.id;
}
// 查询所有 UserEmail
// export async function getAllUserEmails(
// userId: string,
// page: number,
// size: number,
// search: string,
// admin: boolean,
// unreadOnly: boolean = false,
// ) {
// let whereOptions = {};
// if (admin) {
// whereOptions = {
// // deletedAt: null,
// emailAddress: { contains: search, mode: "insensitive" },
// };
// } else {
// whereOptions = {
// userId,
// deletedAt: null,
// emailAddress: { contains: search, mode: "insensitive" },
// };
// }
// // Fetch paginated UserEmail records
// const userEmailsPromise = prisma.userEmail.findMany({
// where: { ...whereOptions },
// select: {
// id: true,
// userId: true,
// emailAddress: true,
// createdAt: true,
// updatedAt: true,
// deletedAt: true,
// _count: { select: { forwardEmails: true } }, // Count of forwardEmails for this UserEmail
// user: { select: { name: true, email: true } },
// forwardEmails: {
// select: {
// readAt: true,
// },
// },
// },
// skip: (page - 1) * size,
// take: size,
// orderBy: {
// updatedAt: "desc",
// },
// });
// // Fetch total count of UserEmail records
// const totalPromise = prisma.userEmail.count({
// where: { ...whereOptions },
// });
// // Fetch all emailAddress values that match the whereOptions
// const emailAddressesPromise = prisma.userEmail.findMany({
// where: { ...whereOptions },
// select: { emailAddress: true },
// });
// const [userEmails, total, emailAddresses] = await Promise.all([
// userEmailsPromise,
// totalPromise,
// emailAddressesPromise,
// ]);
// // Extract all email addresses for the total counts query
// const emailAddressList = emailAddresses.map((e) => e.emailAddress);
// // Fetch total inbox and unread counts across all matching UserEmails
// const [totalInboxCount, totalUnreadCount] = await Promise.all([
// prisma.forwardEmail.count({
// where: {
// to: { in: emailAddressList },
// },
// }),
// prisma.forwardEmail.count({
// where: {
// to: { in: emailAddressList },
// readAt: null,
// },
// }),
// ]);
// const result = userEmails.map((email) => {
// const unreadCount = email.forwardEmails.filter(
// (mail) => mail.readAt === null,
// ).length;
// return {
// ...email,
// count: email._count.forwardEmails, // Total emails for this specific UserEmail
// unreadCount: unreadCount, // Unread emails for this specific UserEmail
// user: email.user.name,
// email: email.user.email,
// forwardEmails: undefined,
// };
// });
// return {
// list: result,
// total, // Total number of UserEmail records
// totalInboxCount, // Total number of ForwardEmail records for all matching UserEmails
// totalUnreadCount, // Total number of unread ForwardEmail records for all matching UserEmails
// };
// }
// 查询所有 UserEmail
export async function getAllUserEmails(
userId: string,
@@ -396,7 +290,6 @@ export async function deleteUserEmailByAddress(email: string) {
const userEmail = await prisma.userEmail.findFirst({
where: { emailAddress: email, deletedAt: null },
});
console.log("userEmail", userEmail);
if (userEmail) {
await prisma.userEmail.update({
@@ -574,6 +467,13 @@ export async function markAllEmailsAsRead(userEmailId: string, userId: string) {
}
}
// 删除邮件
export async function deleteEmailsByIds(ids: string[]) {
return prisma.forwardEmail.deleteMany({
where: { id: { in: ids } },
});
}
export async function saveUserSendEmail(
userId: string,
from: string,
-1
View File
@@ -97,7 +97,6 @@ export function formatTime(input: string | number): string {
});
}
// Utils from precedent.dev
export const timeAgo = (timestamp: Date, timeOnly?: boolean): string => {
if (!timestamp) return "never";
return `${ms(Date.now() - new Date(timestamp).getTime())}${
+413
View File
@@ -0,0 +1,413 @@
{
"Common": {
"Set up an administrator": "Set up an administrator",
"Add the first domain": "Add the first domain",
"Congrats on completing setup 🎉": "Congrats on completing setup 🎉",
"Admin Setup Guide": "Admin Setup Guide",
"Previous": "Previous",
"Next": "Next",
"🚀 Start": "🚀 Start",
"Ready": "Ready",
"Allow Sign Up": "Allow Sign Up",
"Set {email} as ADMIN": "Set {email} as ADMIN",
"Active Now": "Active Now",
"Only by becoming an administrator can one access the admin panel and add domain names": "Only by becoming an administrator can one access the admin panel and add domain names",
"Administrators can set all user permissions, allocate quotas, view and edit all resources (short links, subdomains, email), etc": "Administrators can set all user permissions, allocate quotas, view and edit all resources (short links, subdomains, email), etc",
"Via": "Via",
"quick start": "quick start",
"docs to get more information": "docs to get more information",
"Domain Name": "Domain Name",
"Please enter a valid domain name (must be hosted on Cloudflare)": "Please enter a valid domain name (must be hosted on Cloudflare)",
"Or add later": "Or add later",
"Submit": "Submit"
},
"List": {
"Short URLs": "Short URLs",
"Manage Short URLs": "Manage Short URLs",
"Add URL": "Add URL",
"Slug": "Slug",
"Target": "Target",
"User": "User",
"Enabled": "Enabled",
"Expiration": "Expiration",
"Clicks": "Clicks",
"Updated": "Updated",
"Actions": "Actions",
"Edit": "Edit",
"Search by slug": "Search by slug",
"Search by target": "Search by target",
"Search by username": "Search by username",
"Create short link": "Create short link",
"Edit short link": "Edit short link",
"Delete": "Delete",
"Target URL": "Target URL",
"Required": "Required",
"Short Link": "Short Link",
"A random url suffix": "A random url suffix",
"Final url like": "Final url like",
"Password": "Password",
"Optional": "Optional",
"If you want to protect your link": "If you want to protect your link",
"Enter 6 character password": "Enter 6 character password",
"Expiration time, default for never": "Expiration time, default for never",
"Save": "Save",
"Cancel": "Cancel",
"Update": "Update",
"QR Code Design": "QR Code Design",
"Preview": "Preview",
"Download SVG": "Download SVG",
"Download PNG": "Download PNG",
"Download JPG": "Download JPG",
"Url": "Url",
"Logo": "Logo",
"Custom Logo": "Custom Logo",
"Front Color": "Front Color",
"Background Color": "Background Color",
"Display your logo in the center of the QR code": "Display your logo in the center of the QR code",
"Learn more": "Learn more",
"Customize your QR code logo": "Customize your QR code logo",
"Please create a api key before use this feature": "Please create a api key before use this feature",
"Learn more about": "Learn more about",
"Create Api Key": "Create Api Key",
"Show": "Show",
"per page": "per page",
"Total Subdomains": "Total Subdomains",
"Subdomain List": "Subdomains",
"Please read the": "Please read the",
"Legitimacy review": "Legitimacy review",
"before using": "before using",
"See": "See",
"examples": "examples",
"for more usage": "for more usage",
"Add Record": "Add Record",
"Type": "Type",
"Name": "Name",
"Content": "Content",
"TTL": "TTL",
"Status": "Status",
"Pending": "Pending",
"The record is currently pending for admin approval": "The record is currently pending for admin approval",
"The target is currently inaccessible": "The target is currently inaccessible",
"Please check the target and try again": "Please check the target and try again",
"If the target is not activated within 3 days": "If the target is not activated within 3 days",
"the administrator will": "the administrator will",
"delete this record": "delete this record",
"Review": "Review",
"No Subdomains": "No Subdomains",
"No urls": "No urls",
"Create record": "Create record",
"Edit record": "Edit record",
"The administrator has enabled application mode": "The administrator has enabled application mode",
"After submission, you need to wait for administrator approval before the record takes effect": "After submission, you need to wait for administrator approval before the record takes effect",
"What are you planning to use the subdomain for?": "What are you planning to use the subdomain for?",
"At least 20 characters": "At least 20 characters",
"User email": "User email",
"Domain": "Domain",
"No domains configured": "No domains configured",
"Select a domain": "Select a domain",
"IPv4 address": "IPv4 address",
"Example": "E.g.",
"Time To Live": "Time To Live",
"Proxy": "Proxy",
"Proxy status": "DNS response is replaced by Cloudflare Anycast IP",
"Total Domains": "Total Domains",
"Add Domain": "Add Domain",
"Domain Name": "Domain Name",
"Shorten Service": "Shorten Service",
"Email Service": "Email Service",
"Subdomain Service": "Subdomain Service",
"Active": "Active",
"Search by domain name": "Search by domain name",
"No Domains": "No Domains",
"Verified": "Verified",
"Verify Configuration": "Verify Configuration",
"Create Domain": "Create Domain",
"Edit Domain": "Edit Domain",
"Base": "Base",
"Services": "Services",
"Cloudflare Configs": "Cloudflare Configs",
"Zone ID": "Zone ID",
"API Token": "API Token",
"Account Email": "Account Email",
"How to get zone id?": "How to get zone id?",
"How to get api token?": "How to get api token?",
"How to get cloudflare account email?": "How to get cloudflare account email?",
"Resend Configs": "Resend Configs",
"Associate with 'Subdomain Service' status": "Associate with 'Subdomain Service' status",
"Associate with 'Email Service' status": "Associate with 'Email Service' status",
"API Key": "API Key",
"send email service": "send email service",
"How to get resend api key?": "How to get resend api key?",
"Analytics": "Analytics",
"Edit URL": "Edit URL"
},
"Components": {
"Dashboard": "Dashboard",
"Live Logs": "Live Logs",
"Real-time logs of short link visits": "Real-time logs of short link visits",
"Stop": "Stop",
"Live": "Live",
"Time": "Time",
"Slug": "Slug",
"Target": "Target",
"Location": "Location",
"Clicks": "Clicks",
"of": "/",
"total logs": "total logs",
"API Reference": "API Reference",
"provide a api for {target}": "provide a api for {target}",
"creating short urls": "creating short urls",
"View the usage tutorial": "View the usage tutorial",
"document": "document",
"Realtime Visits": "Realtime Visits",
"See documentation": "See documentation",
"Manage Short URLs": "Manage Short URLs",
"List and manage short urls": "List and manage short urls",
"Manage DNS Records": "Manage DNS Records",
"List and manage records": "List and manage records",
"Scraping API Overview": "Scraping API Overview",
"Quickly extract valuable structured website data": "Quickly extract valuable structured website data",
"Url to Screenshot": "Url to Screenshot",
"Quickly extract website screenshots": "Quickly extract website screenshots",
"extracting url as screenshot": "extracting url as screenshot",
"Url to QR Code": "Url to QR Code",
"Quickly extract website QR codes": "Quickly extract website QR codes",
"extracting url as qrcode": "extracting url as qrcode",
"Url to Meta Info": "Url to Meta Info",
"extracting url as meta info": "extracting url as meta info",
"Url to Markdown": "Url to Markdown",
"Quickly extract website content and convert it to Markdown format": "Quickly extract website content and convert it to Markdown format",
"extracting url as markdown": "extracting url as markdown",
"extracting url as text": "extracting url as text",
"Account Settings": "Account Settings",
"Manage account and website settings": "Manage account and website settings",
"Setup Guide": "Setup Guide",
"Admin Panel": "Admin Panel",
"Access only for users with ADMIN role": "Access only for users with ADMIN role",
"Domains Management": "Domains Management",
"List and manage domains": "List and manage domains",
"User Management": "User Management",
"List and manage all users": "List and manage all users",
"Email box": "Email box",
"monthly": "monthly",
"total": "total",
"Short URLs": "Short URLs",
"DNS Records": "DNS Records",
"Url to Text": "Url to Text",
"Take a screenshot of the webpage": "Take a screenshot of the webpage",
"Extract website metadata": "Extract website metadata",
"Convert website content to Markdown format": "Convert website content to Markdown format",
"Convert website content to text": "Convert website content to text",
"Emails": "Emails",
"Inbox": "Inbox",
"Users": "Users",
"Total Requests of APIs in Last 30 Days": "Total Requests of APIs in Last 30 Days",
"Last request from {latestFrom} api about {latestDate}": "Last request from {latestFrom} api about {latestDate}",
"last-request-info": "Last request from {location} about <timeAgo></timeAgo>",
"Requests": "Requests",
"IP": "IP",
"Date": "Date",
"Type": "Type",
"Link": "Link",
"User": "User",
"Data Increase": "Data Increase",
"Showing data increase in": "Showing data increase in",
"Records": "Records",
"URLs": "URLs",
"Sends": "Sends",
"Link Analytics": "Link Analytics",
"Last visitor from {latestFrom} about {latestDate}": "Last visitor from {latestFrom} about {latestDate}",
"last-visitor-info": "Last visitor from {location} about <timeAgo></timeAgo>",
"Views": "Views",
"Visits": "Visits",
"Referrers": "Referrers",
"Traffic Type": "Traffic Type",
"Country": "Country",
"City": "City",
"Browser": "Browser",
"Engine": "Engine",
"Language": "Language",
"Region": "Region",
"Device": "Device",
"OS": "OS",
"CPU": "CPU",
"Visitors": "Visitors",
"Name": "Name",
"Activated Api Key users": "Activated Api Key users",
"total-requests-one-type": "Total requests of {type1}",
"total-requests-two-types": "Total requests of {type1} and {type2}"
},
"Landing": {
"settings": "Settings",
"Dashboard": "Dashboard",
"deployWithVercel": "Deploy with",
"now": "now!",
"onePlatformPowers": "One platform powers",
"endlessSolutions": "endless solutions",
"platformDescription": "Link shortening, domain hosting, email manager and open api, everything you need to build better.",
"documents": "Documents",
"signInForFree": "Sign in for free",
"exampleImageAlt": "example",
"urlShorteningTitle": "URL Shortening",
"urlShorteningDescription": "📖 Instantly transform long, unwieldy URLs into short, memorable links that are easy to share. Enjoy built-in analytics to track clicks, monitor performance, and gain insights into your audience—all in real time.",
"freeSubdomainHostingTitle": "Free Subdomain Hosting",
"freeSubdomainHostingDescription": "🎉 Kickstart your online presence with free, fully customizable subdomains. Whether you're launching a personal project or testing a business idea, get started quickly with no cost and reliable hosting you can trust.",
"emailReceiversSendersTitle": "Email Receivers & Senders",
"emailReceiversSendersDescription": "📧 Seamlessly receive and send emails from any email provider with top-notch security. Stay connected and manage your communications effortlessly, knowing your data is protected with robust encryption and privacy features.",
"multipleDomainsTitle": "Multiple Domains",
"multipleDomainsDescription": "🤩 Empower your business with the flexibility of multiple domains, such as wr.do, uv.do, and more. Establish a strong digital footprint, create branded links, or manage diverse projects—all under one unified platform.",
"websiteScreenshotApiTitle": "Website Screenshot API",
"websiteScreenshotApiDescription": "📷 Capture high-quality screenshots of any webpage instantly with our powerful Screenshot API. Integrate seamlessly into your applications, access third-party services, and unlock advanced features by applying your unique API key.",
"metaInformationApiTitle": "Meta Information API",
"metaInformationApiDescription": "🍥 Extract rich, structured web data effortlessly with our smart Meta Information API. Perfect for developers, businesses, or researchers, this tool offers seamless integration, third-party service access, and enhanced functionality.",
"applyYourApiKey": "Apply your api key",
"pricingTitle": "Pricing",
"pricingDescription": "Use it for free for yourself, upgrade when your team needs advanced control.",
"freeTier": "Free",
"freePrice": "$0/mo",
"freeBestFor": "For hobbyists and individuals looking to manage their links",
"getStartedFree": "Get started free",
"premiumTier": "Premium",
"premiumPrice": "$5/mo",
"premiumBestFor": "Best for 5-50 users",
"getFreeTrial": "Get free trial",
"enterpriseTier": "Enterprise",
"enterprisePrice": "Contact us",
"enterpriseBestFor": "For large organizations with custom needs",
"contactUs": "Contact us"
},
"Auth": {
"Back": "Back",
"Welcome to": "Welcome to",
"Choose your login method to continue": "Choose your login method to continue",
"By clicking continue, you agree to our": "By clicking continue, you agree to our",
"Terms of Service": "Terms of Service",
"and": "and",
"Privacy Policy": "Privacy Policy",
"Or continue with": "Or continue with",
"Email": "Email",
"Sign Up with Email": "Sign Up with Email",
"Sign In with Email": "Sign In with Email"
},
"System": {
"MENU": "Menu",
"Dashboard": "Dashboard",
"Short Urls": "Short Urls",
"Emails": "Emails",
"DNS Records": "DNS Records",
"WRoom": "WRoom",
"OPEN API": "OPEN API",
"Overview": "Overview",
"Screenshot": "Screenshot",
"QR Code": "QR Code",
"Meta Info": "Meta Info",
"Markdown": "Markdown",
"ADMIN": "ADMIN",
"Admin Panel": "Admin Panel",
"Domains": "Domains",
"Users": "Users",
"URLs": "URLs",
"Records": "Records",
"OPTIONS": "OPTIONS",
"Settings": "Settings",
"Documentation": "Documentation",
"Feedback": "Feedback",
"Support": "Support",
"Docs": "Docs",
"Toggle theme": "Toggle theme",
"Light": "Light",
"Dark": "Dark",
"System": "System",
"Admin": "Admin",
"Sign in": "Sign in",
"Log out": "Log out"
},
"Email": {
"Search emails": "Search emails",
"Create New Email": "Create New Email",
"Email Address": "Email Address",
"Inbox Emails": "Inbox Emails",
"Unread Emails": "Unread Emails",
"Filter unread emails": "Filter unread emails",
"Sent Emails": "Sent Emails",
"Admin Mode": "Admin Mode",
"No emails": "No emails",
"{email} recived": "{email} recived",
"Edit email": "Edit email",
"Create new email": "Create new email",
"Enter email prefix": "Enter email prefix",
"No domains configured": "No domains configured",
"Cancel": "Cancel",
"Update": "Update",
"Create": "Create",
"Failed to load emails": "Failed to load emails",
"Please try again": "Please try again",
"No emails found": "No emails found",
"Search by send to email": "Search by send to email",
"Delete email": "Delete email",
"You are about to delete the following email, once deleted, it cannot be recovered": "You are about to delete the following email, once deleted, it cannot be recovered",
"All emails in inbox will be deleted at the same time": "All emails in inbox will be deleted at the same time",
"Are you sure you want to continue?": "Are you sure you want to continue?",
"To confirm, please type": "To confirm, please type",
"delete": "delete",
"below": "below",
"Confirm Delete": "Confirm Delete",
"INBOX": "INBOX",
"Auto refresh": "Auto refresh",
"more": "more",
"Select all": "Select all",
"Mask as read": "Mask as read",
"Delete selected": "Delete selected",
"Waiting for emails": "Waiting for emails",
"No Email Address Selected": "No Email Address Selected",
"Please select an email address from the list to view your inbox": "Please select an email address from the list to view your inbox",
"Once selected, your emails will appear here automatically": "Once selected, your emails will appear here automatically",
"How to use email to send or receive emails?": "How to use email to send or receive emails?",
"Will my email or inbox expire?": "Will my email or inbox expire?",
"What is the limit? It's free?": "What is the limit? It's free?",
"How to create emails with api?": "How to create emails with api?",
"Send Email": "Send Email",
"From": "From",
"To": "To",
"Subject": "Subject",
"Content": "Content",
"Send": "Send",
"Sending": "Sending",
"Reply-To": "Reply-To",
"Attachments": "Attachments",
"Date": "Date"
},
"Scrape": {
"Playground": "Playground",
"Automate your website screenshots and turn them into stunning visuals for your applications": "Automate your website screenshots and turn them into stunning visuals for your applications",
"Start": "Start",
"Scraping": "Scraping",
"Scrape the meta data of a website": "Scrape the meta data of a website",
"Text": "Text"
},
"Setting": {
"Name": "Name",
"Your Name": "Your Name",
"Save": "Save",
"Please enter a display name you are comfortable with": "Please enter a display name you are comfortable with",
"Max 32 characters": "Max 32 characters",
"Your Role": "Your Role",
"Role": "Role",
"ADMIN": "ADMIN",
"USER": "USER",
"Select the role what you want for this app": "Select the role what you want for this app",
"API Key": "API Key",
"Generate": "Generate",
"Generate a new API key to access the open apis": "Generate a new API key to access the open apis",
"Delete Account": "Delete Account",
"This is a danger zone - Be careful !": "This is a danger zone - Be careful !",
"Are you sure": "Are you sure",
"Active Subscription": "Active Subscription",
"Permanently delete your {name} account": "Permanently delete your {name} account",
" and your subscription": " and your subscription",
"This action cannot be undone - please proceed with caution": "This action cannot be undone - please proceed with caution",
"Warning": "Warning",
"This will permanently delete your account and your active subscription!": "This will permanently delete your account and your active subscription!",
"verification": "verification"
}
}
+413
View File
@@ -0,0 +1,413 @@
{
"Common": {
"Set up an administrator": "设置管理员",
"Add the first domain": "添加第一个域名",
"Congrats on completing setup 🎉": "恭喜完成初始化配置 🎉",
"Admin Setup Guide": "初始化引导",
"Previous": "上一步",
"Next": "下一步",
"🚀 Start": "🚀 开始",
"Ready": "已开启",
"Allow Sign Up": "允许注册",
"Set {email} as ADMIN": "设置 {email} 为管理员",
"Active Now": "立即激活",
"Only by becoming an administrator can one access the admin panel and add domain names": "只有成为管理员后才能访问管理员面板并添加域名",
"Administrators can set all user permissions, allocate quotas, view and edit all resources (short links, subdomains, email), etc": "管理员可以设置所有用户权限,分配配额,查看和编辑所有资源(短链,子域名,邮件)",
"Via": "查看",
"quick start": "快速开始",
"docs to get more information": "文档获取更多信息",
"Domain Name": "域名",
"Please enter a valid domain name (must be hosted on Cloudflare)": "请输入有效的域名(确保已经托管到 Cloudflare)",
"Or add later": "或稍后添加",
"Submit": "提交"
},
"List": {
"Short URLs": "短链列表",
"Manage Short URLs": "管理短链",
"Add URL": "创建短链",
"Slug": "短链",
"Target": "目标",
"User": "用户",
"Enabled": "启用",
"Expiration": "有效期",
"Clicks": "点击量",
"Updated": "更新",
"Actions": "操作",
"Edit": "编辑",
"Search by slug": "搜索短链",
"Search by target": "搜索目标链接",
"Search by username": "搜索用户名",
"Create short link": "创建短链",
"Edit short link": "编辑短链",
"Delete": "删除",
"Target URL": "目标链接",
"Required": "必填",
"Short Link": "短链",
"A random url suffix": "短链后缀",
"Final url like": "最终链接为",
"Password": "访问密码",
"Optional": "可选",
"If you want to protect your link": "如果您想保护您的链接",
"Enter 6 character password": "请输入6位密码",
"Expiration time, default for never": "过期时间,默认为永不过期",
"Save": "保存",
"Cancel": "取消",
"Update": "更新",
"QR Code Design": "自定义二维码",
"Preview": "预览",
"Download SVG": "下载 SVG 图片",
"Download PNG": "下载 PNG 图片",
"Download JPG": "下载 JPG 图片",
"Url": "链接",
"Logo": "图标",
"Custom Logo": "自定义图标",
"Front Color": "前景色",
"Background Color": "背景色",
"Display your logo in the center of the QR code": "在二维码中央显示您的图标",
"Learn more": "了解更多",
"Customize your QR code logo": "自定义二维码图标",
"Please create a api key before use this feature": "请在使用此功能之前创建一个 API 密钥",
"Learn more about": "了解更多关于",
"Create Api Key": "创建 API 密钥",
"Show": "显示",
"per page": "条/页",
"Total Subdomains": "总计",
"Subdomain List": "子域名列表",
"Please read the": "请阅读",
"legitimacy review": "链接合法性审查",
"before using": "在使用之前",
"See": "查看",
"examples": "示例",
"for more usage": "了解更多用法",
"Add Record": "添加记录",
"Type": "类型",
"Name": "名称",
"Content": "内容",
"TTL": "TTL",
"Status": "状态",
"Pending": "审核中",
"The record is currently pending for admin approval": "正在等待管理员审核",
"The target is currently inaccessible": "目标链接目前无法访问",
"Please check the target and try again": "请检查解析记录并重试",
"If the target is not activated within 3 days": "如果目标链接在 3 天内依然无法访问",
"the administrator will": "管理员将",
"delete this record": "删除此记录",
"Review": "审核",
"No Subdomains": "暂无子域名",
"No urls": "暂无短链接",
"Create record": "创建记录",
"Edit record": "编辑记录",
"The administrator has enabled application mode": "管理员已启用 [用户申请 - 管理员审核] 模式",
"After submission, you need to wait for administrator approval before the record takes effect": "提交后, 您需要等待管理员审核才能生效",
"What are you planning to use the subdomain for?": "您计划使用此域名做什么?",
"At least 20 characters": "至少 20 个字符",
"User email": "用户邮箱",
"Domain": "根域名",
"No domains configured": "未配置域名",
"Select a domain": "选择一个域名",
"IPv4 address": "IPv4 地址",
"Example": "例如",
"Time To Live": "生效时间",
"Proxy": "代理记录",
"Proxy status": "DNS 响应被 Cloudflare Anycast IP 替代",
"Total Domains": "总计",
"Add Domain": "添加域名",
"Domain Name": "域名",
"Shorten Service": "短链服务",
"Email Service": "邮件服务",
"Subdomain Service": "子域名服务",
"Active": "启用",
"Search by domain name": "搜索域名",
"No Domains": "暂无域名",
"Verified": "已就绪",
"Verify Configuration": "验证配置",
"Create Domain": "创建域名",
"Edit Domain": "编辑域名",
"Base": "基础配置",
"Services": "服务配置",
"Cloudflare Configs": "Cloudflare 配置",
"Zone ID": "Zone ID",
"API Token": "API Token",
"Account Email": "账户邮箱",
"How to get zone id?": "如何获取 Zone ID?",
"How to get api token?": "如何获取 API Token?",
"How to get cloudflare account email?": "如何获取账户邮箱?",
"Resend Configs": "Resend 配置",
"Associate with 'Subdomain Service' status": "与 '子域名服务' 启用状态关联",
"Associate with 'Email Service' status": "与 '邮件服务' 启用状态关联",
"API Key": "API 密钥",
"send email service": "用于发送邮件服务",
"How to get resend api key?": "如何获取 Resend API 密钥?",
"Analytics": "访客分析",
"Edit URL": "编辑短链"
},
"Components": {
"Dashboard": "用户面板",
"Live Logs": "实时日志",
"Real-time logs of short link visits": "展示短链实时访问日志",
"Stop": "停止",
"Live": "实时",
"Time": "时间戳",
"Slug": "短链",
"Target": "目标",
"Location": "位置",
"Clicks": "点击量",
"of": "/",
"total logs": "条日志",
"API Reference": "API 使用说明",
"provide a api for {target}": "为 {target} 提供了 API",
"creating short urls": "创建短链",
"View the usage tutorial": "查看使用教程",
"document": "文档",
"Realtime Visits": "访问量",
"See documentation": "查看相关文档",
"Manage Short URLs": "管理短链",
"List and manage short urls": "展示短链接列表并管理你的短链",
"Manage DNS Records": "管理 DNS 记录",
"List and manage records": "展示 DNS 记录列表并管理你的 DNS 记录",
"Scraping API Overview": "Scraping API 概览",
"Quickly extract valuable structured website data": "快速提取有价值的网站数据",
"Url to Screenshot": "网址转截图",
"Quickly extract website screenshots": "快速提取网站截图",
"extracting url as screenshot": "提取 URL 为截图",
"Url to QR Code": "网址转二维码",
"Generate QR Code from URL": "从 URL 生成二维码",
"extracting url as QR code": "提取 URL 为二维码",
"Url to Meta Info": "网站元数据提取",
"extracting url as meta info": "提取 URL 网址中的元数据",
"Url to Markdown": "网页内容转 Markdown",
"Quickly extract website content and convert it to Markdown format": "快速提取网站内容并将其转换为 Markdown 格式",
"extracting url as markdown": "提取 URL 为 Markdown",
"extracting url as text": "提取 URL 为文本",
"Account Settings": "账户设置",
"Manage account and website settings": "管理账户信息和网站设置",
"Setup Guide": "初始化引导",
"Admin Panel": "管理员面板",
"Access only for users with ADMIN role": "仅管理员可访问",
"Domains Management": "域名管理",
"List and manage domains": "展示域名列表并管理你的域名服务",
"User Management": "用户管理",
"List and manage all users": "展示用户列表并管理所有用户",
"Email box": "邮件箱",
"monthly": "每月",
"total": "总计",
"Short URLs": "短链接",
"DNS Records": "DNS 记录",
"Url to Text": "网址转文本",
"Take a screenshot of the webpage": "使用 API 提取网页的截图",
"Extract website metadata": "使用 API 提取网页的元数据",
"Convert website content to Markdown format": "使用 API 将网页内容转换为 Markdown 格式",
"Convert website content to text": "使用 API 将网页内容转换为文本",
"Emails": "邮箱",
"Inbox": "收件箱",
"Users": "用户",
"Total Requests of APIs in Last 30 Days": "过去 30 天的 API 请求总量",
"Last request from {latestFrom} api about {latestDate}": "最近的请求来自 {latestFrom} 的 API 于 {latestDate}",
"last-request-info": "最近的请求来自 {location} 于 <timeAgo></timeAgo>",
"Requests": "请求量",
"IP": "IP 数",
"Date": "日期",
"Type": "类型",
"Link": "链接",
"User": "用户",
"Data Increase": "数据增长",
"Showing data increase in": "Showing data increase in",
"Records": "子域名",
"URLs": "短链接",
"Sends": "发件箱",
"Link Analytics": "访客分析",
"Last visitor from {latestFrom} about {latestDate}": "最近的访客来自 {latestFrom} 于 {latestDate}",
"last-visitor-info": "最后访问者来自 {location} 于 <timeAgo></timeAgo>",
"Views": "浏览量",
"Visits": "访问次数",
"Referrers": "来源域名",
"Traffic Type": "流量类型",
"Country": "国家",
"City": "城市",
"Browser": "浏览器",
"Engine": "引擎",
"Language": "语言",
"Region": "地区",
"Device": "设备",
"OS": "操作系统",
"CPU": "CPU",
"Visitors": "访客",
"Name": "名称",
"Activated Api Key users": "已启用 API Key 的用户",
"total-requests-one-type": "Open API 的 {type1} 的总请求数",
"total-requests-two-types": "Open API 的 {type1} 和 {type2} 的总请求数"
},
"Landing": {
"settings": "设置",
"Dashboard": "控制面板",
"deployWithVercel": "使用",
"now": "部署私有版本",
"onePlatformPowers": " ",
"endlessSolutions": "一站式域名服务平台",
"platformDescription": "集成短链生成、子域名托管、无限邮箱服务,以及开放API接口,一站式域名管理解决方案,释放你的域名潜力",
"documents": "参考文档",
"signInForFree": "免费登录",
"exampleImageAlt": "示例",
"urlShorteningTitle": "短链生成器",
"urlShorteningDescription": "📖 立即将冗长、复杂的URL转换为短小易记的链接,便于分享。享受内置的分析功能,实时跟踪点击量、监控性能并深入了解您的受众。支持设置密码保护、自定义二维码生成,支持调用API生成。",
"freeSubdomainHostingTitle": "子域名托管",
"freeSubdomainHostingDescription": "🎉 管理多 Cloudflare 账户下的多个域名的 DNS 记录,支持创建多种 DNS 记录类型(CNAME、A、TXT 等)。",
"emailReceiversSendersTitle": "电子邮件接收与发送",
"emailReceiversSendersDescription": "📧 创建无限个自定义前缀邮箱,接收无限制邮件,支持调用 API 创建邮箱、调用 API 获取收件箱邮件,支持过滤未读邮件列表。",
"multipleDomainsTitle": "多域名支持",
"multipleDomainsDescription": "🤩 通过多个域名的灵活性增强您的业务,例如wr.do、uv.do等。建立强大的数字足迹,创建品牌链接,或在一个统一平台下管理多样化项目。",
"websiteScreenshotApiTitle": "网站截图API",
"websiteScreenshotApiDescription": "📷 使用我们强大的截图API即时捕获任何网页的高质量截图。无缝集成到您的应用程序中,访问第三方服务,并通过申请您的唯一API密钥解锁功能。",
"metaInformationApiTitle": "元信息API",
"metaInformationApiDescription": "🍥 使用我们智能的元信息API轻松提取丰富、结构化的网页数据。适合开发者、商家或研究人员,此工具提供无缝集成、第三方服务访问和增强功能。",
"applyYourApiKey": "申请您的API密钥",
"pricingTitle": "定价",
"pricingDescription": "免费为个人使用,当您的团队需要高级控制时可升级。",
"freeTier": "免费体验",
"freePrice": "$0/mo",
"freeBestFor": "适合爱好者和需要管理链接的个人",
"getStartedFree": "免费开始",
"premiumTier": "高级",
"premiumPrice": "$5/mo",
"premiumBestFor": "最适合5-50人的团队",
"getFreeTrial": "获取免费试用",
"enterpriseTier": "企业计划",
"enterprisePrice": "联系我们",
"enterpriseBestFor": "适合有定制需求的大型组织",
"contactUs": "联系我们"
},
"Auth": {
"Back": "返回",
"Welcome to": "欢迎使用",
"Choose your login method to continue": "选择登录方式以继续",
"By clicking continue, you agree to our": "点击继续即表示您同意我们的",
"Terms of Service": "服务条款",
"and": "和",
"Privacy Policy": "隐私政策",
"Or continue with": "或使用",
"Email": "邮箱",
"Sign Up with Email": "使用邮箱注册",
"Sign In with Email": "使用邮箱登录"
},
"System": {
"MENU": "菜单",
"Dashboard": "控制台",
"Short Urls": "短链接",
"Emails": "邮件箱",
"DNS Records": "子域名",
"WRoom": "聊天室",
"OPEN API": "开放API",
"Overview": "概览面板",
"Screenshot": "截图API",
"QR Code": "二维码API",
"Meta Info": "元数据API",
"Markdown": "Markdown",
"ADMIN": "管理员",
"Admin Panel": "概览面板",
"Domains": "域名管理",
"Users": "用户管理",
"URLs": "短链管理",
"Records": "子域名管理",
"OPTIONS": "选项",
"Settings": "账户设置",
"Documentation": "使用文档",
"Feedback": "反馈",
"Support": "联系站长",
"Docs": "文档",
"Toggle theme": "主题切换",
"Light": "浅色",
"Dark": "深色",
"System": "跟随系统",
"Admin": "管理面板",
"Sign in": "登录",
"Log out": "退出登录"
},
"Email": {
"Search emails": "搜索邮箱...",
"Create New Email": "创建新邮箱",
"Email Address": "邮箱地址",
"Inbox Emails": "收件箱",
"Unread Emails": "未读邮件",
"Filter unread emails": "过滤未读邮件",
"Sent Emails": "已发送",
"Admin Mode": "管理员模式",
"No emails": "暂无邮件",
"{email} recived": "{email} 封邮件",
"Edit email": "编辑邮箱",
"Create new email": "创建新邮箱",
"Enter email prefix": "请输入邮箱前缀",
"No domains configured": "未配置域名",
"Cancel": "取消",
"Update": "更新",
"Create": "创建",
"Failed to load emails": "加载邮件失败",
"Please try again": "请重试",
"No emails found": "未找到邮件",
"Search by send to email": "搜索收件人邮箱...",
"Delete email": "删除邮箱",
"You are about to delete the following email, once deleted, it cannot be recovered": "您即将删除此邮件,一旦删除,无法恢复",
"All emails in inbox will be deleted at the same time": "所有收件箱中的邮件将同时删除",
"Are you sure you want to continue?": "确定要继续吗?",
"To confirm, please type": "若确认请在输入框输入",
"delete": "删除",
"below": "下面",
"Confirm Delete": "确认删除",
"INBOX": "收件箱",
"Auto refresh": "自动刷新",
"more": "更多",
"Select all": "全部选中",
"Mask as read": "标记为已读",
"Delete selected": "删除选中",
"Waiting for emails": "等待接收邮件",
"No Email Address Selected": "暂未选择邮箱地址",
"Please select an email address from the list to view your inbox": "请从列表中选择一个邮箱地址以查看收件箱",
"Once selected, your emails will appear here automatically": "选择后,您的邮件将自动显示在这里",
"How to use email to send or receive emails?": "如何使用邮箱发送或接收邮件?",
"Will my email or inbox expire?": "我的邮箱或收件箱是否会过期?",
"What is the limit? It's free?": "邮箱使用有限制吗?是否免费?",
"How to create emails with api?": "如何使用 API 创建邮箱地址?",
"Send Email": "发送邮件",
"From": "发件人",
"To": "收件人",
"Subject": "主题",
"Content": "内容",
"Send": "发送",
"Sending": "发送中...",
"Reply-To": "回复",
"Attachments": "附件",
"Date": "日期"
},
"Scrape": {
"Playground": "在线体验",
"Automate your website screenshots and turn them into stunning visuals for your applications": "自动化截图并转换成多种格式图片",
"Start": "开始",
"Scraping": "处理中...",
"Scrape the meta data of a website": "快速从网站中抓取元数据",
"Text": "文本"
},
"Setting": {
"Name": "昵称",
"Your Name": "您的昵称",
"Save": "保存",
"Please enter a display name you are comfortable with": "请输入您的昵称,未设置则为匿名",
"Max 32 characters": "最多32个字符",
"Your Role": "您的角色",
"Role": "角色",
"ADMIN": "系统管理员",
"USER": "普通用户",
"Select the role what you want for this app": "设置您的权限角色",
"API Key": "API 密钥",
"Generate": "生成",
"Generate a new API key to access the open apis": "生成你的专属 API 密钥",
"Delete Account": "删除账户",
"This is a danger zone - Be careful !": "这是一个危险操作!",
"Are you sure": "确定删除?",
"Active Subscription": "包含订阅",
"Permanently delete your {name} account": "永久删除您的 {name} 账户",
" and your subscription": "和您的订阅",
"This action cannot be undone - please proceed with caution": "此操作不可逆,请谨慎操作",
"Warning": "警告",
"This will permanently delete your account and your active subscription!": "这将永久删除您的账户和订阅!",
"verification": "为了验证,请在下方输入 <confirm>确认删除账户</confirm>"
}
}
+8 -3
View File
@@ -1,4 +1,8 @@
const { withContentlayer } = require("next-contentlayer2");
import { withContentlayer } from "next-contentlayer2";
import createNextIntlPlugin from "next-intl/plugin";
import nextPWA from "next-pwa";
const withNextIntl = createNextIntlPlugin();
import("./env.mjs");
@@ -113,9 +117,10 @@ const nextConfig = {
},
};
const withPWA = require("next-pwa")({
const withPWA = nextPWA({
dest: "public",
disable: false,
});
module.exports = withContentlayer(withPWA(nextConfig));
// module.exports = withContentlayer(withPWA(withNextIntl(nextConfig)));
export default withContentlayer(withPWA(withNextIntl(nextConfig)));
+6 -2
View File
@@ -65,6 +65,8 @@
"@types/d3-scale": "^4.0.9",
"@types/d3-scale-chromatic": "^3.1.0",
"@types/lodash-es": "^4.17.12",
"@types/ms": "^2.1.0",
"@types/next-pwa": "^5.6.9",
"@types/three": "^0.176.0",
"@typescript-eslint/parser": "^7.16.1",
"@uiw/react-json-view": "2.0.0-alpha.26",
@@ -73,8 +75,8 @@
"@vercel/analytics": "^1.3.1",
"@vercel/functions": "^1.4.0",
"@vercel/og": "^0.6.2",
"cheerio": "1.0.0-rc.12",
"chalk": "^4.1.1",
"cheerio": "1.0.0-rc.12",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
@@ -96,6 +98,7 @@
"next": "14.2.28",
"next-auth": "5.0.0-beta.18",
"next-contentlayer2": "^0.5.0",
"next-intl": "^4.1.0",
"next-pwa": "^5.6.0",
"next-sitemap": "^4.2.3",
"next-themes": "^0.3.0",
@@ -117,14 +120,15 @@
"react-textarea-autosize": "^8.5.3",
"recharts": "^2.12.7",
"resend": "^3.4.0",
"semver": "^7.5.4",
"sharp": "^0.33.4",
"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",
"timeago-react": "^3.0.7",
"turndown": "^7.2.0",
"ua-parser-js": "^1.0.38",
"vaul": "^0.9.1",
+284 -31
View File
@@ -128,6 +128,12 @@ importers:
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
'@types/ms':
specifier: ^2.1.0
version: 2.1.0
'@types/next-pwa':
specifier: ^5.6.9
version: 5.6.9(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/three':
specifier: ^0.176.0
version: 0.176.0
@@ -221,6 +227,9 @@ importers:
next-contentlayer2:
specifier: ^0.5.0
version: 0.5.0(contentlayer2@0.5.0(esbuild@0.19.11))(esbuild@0.19.11)(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-intl:
specifier: ^4.1.0
version: 4.1.0(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.5.3)
next-pwa:
specifier: ^5.6.0
version: 5.6.0(@babel/core@7.24.5)(esbuild@0.19.11)(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11))
@@ -308,6 +317,9 @@ importers:
three:
specifier: ^0.176.0
version: 0.176.0
timeago-react:
specifier: ^3.0.7
version: 3.0.7(react@18.3.1)
turndown:
specifier: ^7.2.0
version: 7.2.0
@@ -1412,6 +1424,24 @@ packages:
'@floating-ui/utils@0.1.6':
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
'@formatjs/ecma402-abstract@2.3.4':
resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==}
'@formatjs/fast-memoize@2.2.7':
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
'@formatjs/icu-messageformat-parser@2.11.2':
resolution: {integrity: sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==}
'@formatjs/icu-skeleton-parser@1.8.14':
resolution: {integrity: sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==}
'@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
'@formatjs/intl-localematcher@0.6.1':
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
'@grpc/grpc-js@1.10.6':
resolution: {integrity: sha512-xP58G7wDQ4TCmN/cMUHh00DS7SRDv/+lC+xFLrTkMIN8h55X5NhZMLYbvy7dSELP15qlI6hPhNCRWVMtZMwqLA==}
engines: {node: '>=12.10.0'}
@@ -1477,84 +1507,72 @@ packages:
engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.2':
resolution: {integrity: sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==}
engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.2':
resolution: {integrity: sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==}
engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.2':
resolution: {integrity: sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==}
engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.2':
resolution: {integrity: sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==}
engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.2':
resolution: {integrity: sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==}
engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.4':
resolution: {integrity: sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==}
engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.4':
resolution: {integrity: sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==}
engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.4':
resolution: {integrity: sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==}
engines: {glibc: '>=2.31', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.4':
resolution: {integrity: sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==}
engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.4':
resolution: {integrity: sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==}
engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.4':
resolution: {integrity: sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==}
engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.4':
resolution: {integrity: sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==}
@@ -1685,6 +1703,12 @@ packages:
'@next/eslint-plugin-next@14.2.5':
resolution: {integrity: sha512-LY3btOpPh+OTIpviNojDpUdIbHW9j0JBYBjsIp8IxtDFfYFyORvw3yNq6N231FVqQA7n7lwaf7xHbVJlA1ED7g==}
'@next/swc-darwin-arm64@13.5.9':
resolution: {integrity: sha512-pVyd8/1y1l5atQRvOaLOvfbmRwefxLhqQOzYo/M7FQ5eaRwA1+wuCn7t39VwEgDd7Aw1+AIWwd+MURXUeXhwDw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-arm64@14.1.4':
resolution: {integrity: sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==}
engines: {node: '>= 10'}
@@ -1697,6 +1721,12 @@ packages:
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@13.5.9':
resolution: {integrity: sha512-DwdeJqP7v8wmoyTWPbPVodTwCybBZa02xjSJ6YQFIFZFZ7dFgrieKW4Eo0GoIcOJq5+JxkQyejmI+8zwDp3pwA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-darwin-x64@14.1.4':
resolution: {integrity: sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==}
engines: {node: '>= 10'}
@@ -1709,61 +1739,83 @@ packages:
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@13.5.9':
resolution: {integrity: sha512-wdQsKsIsGSNdFojvjW3Ozrh8Q00+GqL3wTaMjDkQxVtRbAqfFBtrLPO0IuWChVUP2UeuQcHpVeUvu0YgOP00+g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-gnu@14.1.4':
resolution: {integrity: sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-gnu@14.2.28':
resolution: {integrity: sha512-9ARHLEQXhAilNJ7rgQX8xs9aH3yJSj888ssSjJLeldiZKR4D7N08MfMqljk77fAwZsWwsrp8ohHsMvurvv9liQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@13.5.9':
resolution: {integrity: sha512-6VpS+bodQqzOeCwGxoimlRoosiWlSc0C224I7SQWJZoyJuT1ChNCo+45QQH+/GtbR/s7nhaUqmiHdzZC9TXnXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@14.1.4':
resolution: {integrity: sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-arm64-musl@14.2.28':
resolution: {integrity: sha512-p6gvatI1nX41KCizEe6JkF0FS/cEEF0u23vKDpl+WhPe/fCTBeGkEBh7iW2cUM0rvquPVwPWdiUR6Ebr/kQWxQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@13.5.9':
resolution: {integrity: sha512-XxG3yj61WDd28NA8gFASIR+2viQaYZEFQagEodhI/R49gXWnYhiflTeeEmCn7Vgnxa/OfK81h1gvhUZ66lozpw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-gnu@14.1.4':
resolution: {integrity: sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-gnu@14.2.28':
resolution: {integrity: sha512-nsiSnz2wO6GwMAX2o0iucONlVL7dNgKUqt/mDTATGO2NY59EO/ZKnKEr80BJFhuA5UC1KZOMblJHWZoqIJddpA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@13.5.9':
resolution: {integrity: sha512-/dnscWqfO3+U8asd+Fc6dwL2l9AZDl7eKtPNKW8mKLh4Y4wOpjJiamhe8Dx+D+Oq0GYVjuW0WwjIxYWVozt2bA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@14.1.4':
resolution: {integrity: sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-musl@14.2.28':
resolution: {integrity: sha512-+IuGQKoI3abrXFqx7GtlvNOpeExUH1mTIqCrh1LGFf8DnlUcTmOOCApEnPJUSLrSbzOdsF2ho2KhnQoO0I1RDw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@13.5.9':
resolution: {integrity: sha512-T/iPnyurOK5a4HRUcxAlss8uzoEf5h9tkd+W2dSWAfzxv8WLKlUgbfk+DH43JY3Gc2xK5URLuXrxDZ2mGfk/jw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-arm64-msvc@14.1.4':
resolution: {integrity: sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==}
@@ -1777,6 +1829,12 @@ packages:
cpu: [arm64]
os: [win32]
'@next/swc-win32-ia32-msvc@13.5.9':
resolution: {integrity: sha512-BLiPKJomaPrTAb7ykjA0LPcuuNMLDVK177Z1xe0nAem33+9FIayU4k/OWrtSn9SAJW/U60+1hoey5z+KCHdRLQ==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@next/swc-win32-ia32-msvc@14.1.4':
resolution: {integrity: sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==}
engines: {node: '>= 10'}
@@ -1789,6 +1847,12 @@ packages:
cpu: [ia32]
os: [win32]
'@next/swc-win32-x64-msvc@13.5.9':
resolution: {integrity: sha512-/72/dZfjXXNY/u+n8gqZDjI6rxKMpYsgBBYNZKWOQw0BpBF7WCnPflRy3ZtvQ2+IYI3ZH2bPyj7K+6a6wNk90Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@next/swc-win32-x64-msvc@14.1.4':
resolution: {integrity: sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==}
engines: {node: '>= 10'}
@@ -2934,6 +2998,9 @@ packages:
resolution: {integrity: sha512-DelRK+56UBUEoRr8pQGwTE7oyC019pExis6iG6zZPcjuz4nLbMB5LIosmclnsvajfIBEQiD1Dv9JI/MgnPwygQ==}
engines: {node: '>=20.x'}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
@@ -2974,28 +3041,24 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.3.101':
resolution: {integrity: sha512-OGjYG3H4BMOTnJWJyBIovCez6KiHF30zMIu4+lGJTCrxRI2fAjGLml3PEXj8tC3FMcud7U2WUn6TdG0/te2k6g==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-x64-gnu@1.3.101':
resolution: {integrity: sha512-/kBMcoF12PRO/lwa8Z7w4YyiKDcXQEiLvM+S3G9EvkoKYGgkkz4Q6PSNhF5rwg/E3+Hq5/9D2R+6nrkF287ihg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.3.101':
resolution: {integrity: sha512-kDN8lm4Eew0u1p+h1l3JzoeGgZPQ05qDE0czngnjmfpsH2sOZxVj1hdiCwS5lArpy7ktaLu5JdRnx70MkUzhXw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.3.101':
resolution: {integrity: sha512-9Wn8TTLWwJKw63K/S+jjrZb9yoJfJwCE2RV5vPCCWmlMf3U1AXj5XuWOLUX+Rp2sGKau7wZKsvywhheWm+qndQ==}
@@ -3262,8 +3325,11 @@ packages:
'@types/minimatch@5.1.2':
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
'@types/ms@0.7.34':
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/next-pwa@5.6.9':
resolution: {integrity: sha512-KcymH+MtFYB5KVKIOH1DMqd0wUb8VLCxzHtsaRQQ7S8sGOaTH24Lo2vGZf6/0Ok9e+xWCKhqsSt6cgDJTk91Iw==}
'@types/node@20.14.11':
resolution: {integrity: sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==}
@@ -4340,6 +4406,9 @@ packages:
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.5.0:
resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==}
decode-named-character-reference@1.0.2:
resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
@@ -5232,6 +5301,9 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
intl-messageformat@10.7.16:
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@@ -5979,6 +6051,10 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -6006,6 +6082,16 @@ packages:
react: '*'
react-dom: '*'
next-intl@4.1.0:
resolution: {integrity: sha512-JNJRjc7sdnfUxhZmGcvzDszZ60tQKrygV/VLsgzXhnJDxQPn1cN2rVpc53adA1SvBJwPK2O6Sc6b4gYSILjCzw==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
next-pwa@5.6.0:
resolution: {integrity: sha512-XV8g8C6B7UmViXU8askMEYhWwQ4qc/XqJGnexbLV68hzKaGHZDMtHsm2TNxFcbR7+ypVuth/wwpiIlMwpRJJ5A==}
peerDependencies:
@@ -6031,6 +6117,21 @@ packages:
react: ^18.2.0
react-dom: ^18.2.0
next@13.5.11:
resolution: {integrity: sha512-WUPJ6WbAX9tdC86kGTu92qkrRdgRqVrY++nwM+shmWQwmyxt4zhZfR59moXSI4N8GDYCBY3lIAqhzjDd4rTC8Q==}
engines: {node: '>=16.14.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
react: ^18.2.0
react-dom: ^18.2.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
sass:
optional: true
next@14.1.4:
resolution: {integrity: sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==}
engines: {node: '>=18.17.0'}
@@ -7350,6 +7451,14 @@ packages:
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
timeago-react@3.0.7:
resolution: {integrity: sha512-5LSQuq+mzfEpMHkJQgMWnOs27dGh25aUQhffQpAW6q431vVVmHP194KYsM4bhzli0XfohUgN8+r3Pq6GVprN4A==}
peerDependencies:
react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
timeago.js@4.0.2:
resolution: {integrity: sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
@@ -7570,6 +7679,11 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
use-intl@4.1.0:
resolution: {integrity: sha512-mQvDYFvoGn+bm/PWvlQOtluKCknsQ5a9F1Cj0hMfBjMBVTwnOqLPd6srhjvVdEQEQFVyHM1PfyifKqKYb11M9Q==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
use-isomorphic-layout-effect@1.1.2:
resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
peerDependencies:
@@ -8593,7 +8707,7 @@ snapshots:
dependencies:
'@babel/core': 7.24.5
'@babel/helper-plugin-utils': 7.25.9
'@babel/types': 7.24.6
'@babel/types': 7.26.0
esutils: 2.0.3
'@babel/runtime@7.23.2':
@@ -9069,6 +9183,36 @@ snapshots:
'@floating-ui/utils@0.1.6': {}
'@formatjs/ecma402-abstract@2.3.4':
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.1
decimal.js: 10.5.0
tslib: 2.8.1
'@formatjs/fast-memoize@2.2.7':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@2.11.2':
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
'@formatjs/icu-skeleton-parser': 1.8.14
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@1.8.14':
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
tslib: 2.8.1
'@formatjs/intl-localematcher@0.5.10':
dependencies:
tslib: 2.8.1
'@formatjs/intl-localematcher@0.6.1':
dependencies:
tslib: 2.8.1
'@grpc/grpc-js@1.10.6':
dependencies:
'@grpc/proto-loader': 0.7.12
@@ -9317,54 +9461,81 @@ snapshots:
dependencies:
glob: 10.3.10
'@next/swc-darwin-arm64@13.5.9':
optional: true
'@next/swc-darwin-arm64@14.1.4':
optional: true
'@next/swc-darwin-arm64@14.2.28':
optional: true
'@next/swc-darwin-x64@13.5.9':
optional: true
'@next/swc-darwin-x64@14.1.4':
optional: true
'@next/swc-darwin-x64@14.2.28':
optional: true
'@next/swc-linux-arm64-gnu@13.5.9':
optional: true
'@next/swc-linux-arm64-gnu@14.1.4':
optional: true
'@next/swc-linux-arm64-gnu@14.2.28':
optional: true
'@next/swc-linux-arm64-musl@13.5.9':
optional: true
'@next/swc-linux-arm64-musl@14.1.4':
optional: true
'@next/swc-linux-arm64-musl@14.2.28':
optional: true
'@next/swc-linux-x64-gnu@13.5.9':
optional: true
'@next/swc-linux-x64-gnu@14.1.4':
optional: true
'@next/swc-linux-x64-gnu@14.2.28':
optional: true
'@next/swc-linux-x64-musl@13.5.9':
optional: true
'@next/swc-linux-x64-musl@14.1.4':
optional: true
'@next/swc-linux-x64-musl@14.2.28':
optional: true
'@next/swc-win32-arm64-msvc@13.5.9':
optional: true
'@next/swc-win32-arm64-msvc@14.1.4':
optional: true
'@next/swc-win32-arm64-msvc@14.2.28':
optional: true
'@next/swc-win32-ia32-msvc@13.5.9':
optional: true
'@next/swc-win32-ia32-msvc@14.1.4':
optional: true
'@next/swc-win32-ia32-msvc@14.2.28':
optional: true
'@next/swc-win32-x64-msvc@13.5.9':
optional: true
'@next/swc-win32-x64-msvc@14.1.4':
optional: true
@@ -10764,9 +10935,11 @@ snapshots:
'@rollup/plugin-babel@5.3.1(@babel/core@7.24.5)(rollup@2.79.2)':
dependencies:
'@babel/core': 7.24.5
'@babel/helper-module-imports': 7.24.6
'@babel/helper-module-imports': 7.25.9
'@rollup/pluginutils': 3.1.0(rollup@2.79.2)
rollup: 2.79.2
transitivePeerDependencies:
- supports-color
'@rollup/plugin-node-resolve@11.2.1(rollup@2.79.2)':
dependencies:
@@ -10795,6 +10968,8 @@ snapshots:
'@scaleway/random-name@5.1.1': {}
'@schummar/icu-type-parser@1.21.5': {}
'@selderee/plugin-htmlparser2@0.11.0':
dependencies:
domhandler: 5.0.3
@@ -11075,7 +11250,7 @@ snapshots:
'@types/debug@4.1.12':
dependencies:
'@types/ms': 0.7.34
'@types/ms': 2.1.0
'@types/eslint-scope@3.7.7':
dependencies:
@@ -11140,7 +11315,24 @@ snapshots:
'@types/minimatch@5.1.2': {}
'@types/ms@0.7.34': {}
'@types/ms@2.1.0': {}
'@types/next-pwa@5.6.9(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@types/node': 20.14.11
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
next: 13.5.11(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
workbox-build: 6.6.0
transitivePeerDependencies:
- '@babel/core'
- '@opentelemetry/api'
- '@types/babel__core'
- babel-plugin-macros
- react
- react-dom
- sass
- supports-color
'@types/node@20.14.11':
dependencies:
@@ -12365,6 +12557,8 @@ snapshots:
decimal.js-light@2.5.1: {}
decimal.js@10.5.0: {}
decode-named-character-reference@1.0.2:
dependencies:
character-entities: 2.0.2
@@ -13537,6 +13731,13 @@ snapshots:
internmap@2.0.3: {}
intl-messageformat@10.7.16:
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.2
tslib: 2.8.1
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
@@ -14526,6 +14727,8 @@ snapshots:
negotiator@0.6.3: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {}
next-auth@5.0.0-beta.18(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.14)(react@18.3.1):
@@ -14550,6 +14753,16 @@ snapshots:
- markdown-wasm
- supports-color
next-intl@4.1.0(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.5.3):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
negotiator: 1.0.0
next: 14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
use-intl: 4.1.0(react@18.3.1)
optionalDependencies:
typescript: 5.5.3
next-pwa@5.6.0(@babel/core@7.24.5)(esbuild@0.19.11)(next@14.2.28(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11)):
dependencies:
babel-loader: 8.4.1(@babel/core@7.24.5)(webpack@5.90.3(@swc/core@1.3.101(@swc/helpers@0.5.5))(esbuild@0.19.11))
@@ -14587,6 +14800,32 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
next@13.5.11(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 13.5.11
'@swc/helpers': 0.5.2
busboy: 1.6.0
caniuse-lite: 1.0.30001707
postcss: 8.4.31
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-jsx: 5.1.1(@babel/core@7.24.5)(react@18.3.1)
watchpack: 2.4.0
optionalDependencies:
'@next/swc-darwin-arm64': 13.5.9
'@next/swc-darwin-x64': 13.5.9
'@next/swc-linux-arm64-gnu': 13.5.9
'@next/swc-linux-arm64-musl': 13.5.9
'@next/swc-linux-x64-gnu': 13.5.9
'@next/swc-linux-x64-musl': 13.5.9
'@next/swc-win32-arm64-msvc': 13.5.9
'@next/swc-win32-ia32-msvc': 13.5.9
'@next/swc-win32-x64-msvc': 13.5.9
'@opentelemetry/api': 1.8.0
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
next@14.1.4(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 14.1.4
@@ -15566,7 +15805,7 @@ snapshots:
rollup-plugin-terser@7.0.2(rollup@2.79.2):
dependencies:
'@babel/code-frame': 7.24.6
'@babel/code-frame': 7.26.2
jest-worker: 26.6.2
rollup: 2.79.2
serialize-javascript: 4.0.0
@@ -16171,6 +16410,13 @@ snapshots:
through@2.3.8: {}
timeago-react@3.0.7(react@18.3.1):
dependencies:
react: 18.3.1
timeago.js: 4.0.2
timeago.js@4.0.2: {}
tiny-inflate@1.0.3: {}
tiny-invariant@1.3.3: {}
@@ -16395,6 +16641,13 @@ snapshots:
dependencies:
react: 18.3.1
use-intl@4.1.0(react@18.3.1):
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@schummar/icu-type-parser': 1.21.5
intl-messageformat: 10.7.16
react: 18.3.1
use-isomorphic-layout-effect@1.1.2(@types/react@18.3.3)(react@18.3.1):
dependencies:
react: 18.3.1
+4 -43
View File
@@ -1,46 +1,7 @@
<?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/api/feature</loc><lastmod>2025-06-04T09:40:19.516Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/pricing</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/privacy</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/terms</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker-zh</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/deploy</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/deploy-zh</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email-zh</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation-zh</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start-zh</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/chat</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-04T09:40:19.517Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/api/feature</loc><lastmod>2025-06-07T14:03:50.540Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-07T14:03:50.541Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-07T14:03:50.541Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-07T14:03:50.541Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
</urlset>
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -32,7 +32,8 @@
"**/*.tsx",
".next/types/**/*.ts",
".contentlayer/generated",
"contentlayer.config.ts"
"contentlayer.config.ts",
"next.config.mjs"
],
"exclude": ["node_modules"]
}