Improved user interface translations and clarity in Simplified

This commit is contained in:
oiov
2025-06-06 23:21:37 +08:00
parent ca35d96925
commit 7d629e9cd4
24 changed files with 588 additions and 214 deletions

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

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

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>

View File

@@ -132,7 +132,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" />
) : (
@@ -163,7 +163,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>
@@ -172,7 +172,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({
@@ -199,25 +199,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>
@@ -305,7 +305,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>
@@ -318,7 +318,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>

View File

@@ -1,6 +1,5 @@
import {
getScrapeStatsByTypeAndUserId,
getScrapeStatsByUserId,
getScrapeStatsByUserId1,
} from "@/lib/dto/scrape";

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

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>

View File

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

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>

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

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } 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";
@@ -48,6 +49,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);
@@ -148,14 +150,14 @@ export default function EmailList({
};
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 +170,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
@@ -204,7 +206,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>
@@ -216,13 +218,13 @@ export default function EmailList({
onClick={handleMarkSelectedAsRead}
className="w-full"
>
<span className="text-xs">Mask as read</span>
<span className="text-xs">{t("Mask as read")}</span>
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild disabled>
<Button variant="ghost" size="sm" className="w-full">
<span className="text-xs">Delete selected</span>
<span className="text-xs">{t("Delete selected")}</span>
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -298,7 +300,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 +323,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 +333,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 +348,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 +358,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 +368,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 +378,7 @@ export function EmptyInboxSection() {
target="_blank"
rel="noreferrer"
>
How to create emails with api?
{t("How to create emails with api?")}
</Link>
</li>
</ul>

View File

@@ -11,6 +11,7 @@ import {
Sparkles,
SquarePlus,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import useSWR from "swr";
@@ -67,6 +68,7 @@ export default function EmailSidebar({
setAdminModel,
}: EmailSidebarProps) {
const { isMobile } = useMediaQuery();
const t = useTranslations("Email");
const [isRefreshing, setIsRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -261,7 +263,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 +298,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 +310,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 +323,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 +346,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 +357,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 +376,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 +388,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 +424,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,7 +533,7 @@ 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
@@ -568,7 +574,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 +588,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 +628,7 @@ export default function EmailSidebar({
))
) : (
<Button className="w-full" variant="ghost">
No domains configured
{t("No domains configured")}
</Button>
)}
</SelectContent>
@@ -652,10 +658,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 +673,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 +703,7 @@ export default function EmailSidebar({
setEmailToDelete(null);
}}
>
Cancel
{t("Cancel")}
</Button>
<Button
variant="destructive"
@@ -710,7 +717,7 @@ export default function EmailSidebar({
}`
}
>
Delete
{t("Confirm Delete")}
</Button>
</div>
</div>

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>

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

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>

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>

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>

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>

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>

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"

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())}${

View File

@@ -1,5 +1,26 @@
{
"Common": {},
"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",
@@ -88,7 +109,35 @@
"Example": "E.g.",
"Time To Live": "Time To Live",
"Proxy": "Proxy",
"Proxy status": "DNS response is replaced by Cloudflare Anycast IP"
"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?"
},
"Components": {
"Dashboard": "Dashboard",
@@ -146,7 +195,10 @@
"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"
"Convert website content to text": "Convert website content to text",
"Emails": "Emails",
"Inbox": "Inbox",
"Users": "Users"
},
"Landing": {
"settings": "Settings",
@@ -187,6 +239,19 @@
"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",
@@ -219,5 +284,92 @@
"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",
"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"
}
}

View File

@@ -1,5 +1,26 @@
{
"Common": {},
"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": "管理短链",
@@ -88,7 +109,35 @@
"Example": "例如",
"Time To Live": "生效时间",
"Proxy": "代理记录",
"Proxy status": "DNS 响应被 Cloudflare Anycast IP 替代"
"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 密钥?"
},
"Components": {
"Dashboard": "用户面板",
@@ -146,7 +195,10 @@
"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 将网页内容转换为文本"
"Convert website content to text": "使用 API 将网页内容转换为文本",
"Emails": "邮箱",
"Inbox": "收件箱",
"Users": "用户"
},
"Landing": {
"settings": "设置",
@@ -187,6 +239,19 @@
"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": "控制台",
@@ -219,5 +284,92 @@
"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": "更多",
"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>"
}
}

File diff suppressed because one or more lines are too long