mirror of
https://github.com/beilunyang/moemail.git
synced 2026-04-22 23:07:25 +08:00
feat: add internationalization support with next-intl
This commit is contained in:
@@ -52,6 +52,7 @@
|
||||
- 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知
|
||||
- 🛡️ **权限系统**:支持基于角色的权限控制系统
|
||||
- 🔑 **OpenAPI**:支持通过 API Key 访问 OpenAPI
|
||||
- 🌍 **多语言支持**:支持中文和英文界面,可自由切换
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -64,6 +65,7 @@
|
||||
- **邮件处理**: [Cloudflare Email Workers](https://developers.cloudflare.com/email-routing/)
|
||||
- **类型安全**: [TypeScript](https://www.typescriptlang.org/)
|
||||
- **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||
- **国际化**: [next-intl](https://next-intl-docs.vercel.app/) 支持多语言
|
||||
|
||||
## 本地运行
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextIntlClientProvider } from "next-intl"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { i18n, type Locale } from "@/i18n/config"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { FloatMenu } from "@/components/float-menu"
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { zpix } from "../fonts"
|
||||
import "../globals.css"
|
||||
import { Providers } from "../providers"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#826DD9',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
async function getMessages(locale: Locale) {
|
||||
try {
|
||||
const common = (await import(`@/i18n/messages/${locale}/common.json`)).default
|
||||
const home = (await import(`@/i18n/messages/${locale}/home.json`)).default
|
||||
const auth = (await import(`@/i18n/messages/${locale}/auth.json`)).default
|
||||
const metadata = (await import(`@/i18n/messages/${locale}/metadata.json`)).default
|
||||
const emails = (await import(`@/i18n/messages/${locale}/emails.json`)).default
|
||||
const profile = (await import(`@/i18n/messages/${locale}/profile.json`)).default
|
||||
return { common, home, auth, metadata, emails, profile }
|
||||
} catch (error) {
|
||||
console.error(`Failed to load messages for locale ${locale}:`, error)
|
||||
return { common: {}, home: {}, auth: {}, metadata: {}, emails: {}, profile: {} }
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const t = await getTranslations({ locale, namespace: "metadata" })
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://moemail.app"
|
||||
|
||||
// Generate hreflang links for all supported locales
|
||||
const languages: Record<string, string> = {}
|
||||
i18n.locales.forEach((loc) => {
|
||||
languages[loc] = `${baseUrl}/${loc}`
|
||||
})
|
||||
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: t("keywords"),
|
||||
authors: [{ name: "SoftMoe Studio" }],
|
||||
creator: "SoftMoe Studio",
|
||||
publisher: "SoftMoe Studio",
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: locale === "zh-CN" ? "zh_CN" : locale,
|
||||
url: `${baseUrl}/${locale}`,
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
siteName: "MoeMail",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${baseUrl}/${locale}`,
|
||||
languages,
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
icons: [
|
||||
{ rel: 'apple-touch-icon', url: '/icons/icon-192x192.png' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
if (!i18n.locales.includes(locale)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const messages = await getMessages(locale)
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="application-name" content="MoeMail" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoeMail" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
zpix.variable,
|
||||
"font-zpix min-h-screen antialiased",
|
||||
"bg-background text-foreground",
|
||||
"transition-colors duration-300"
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange={false}
|
||||
storageKey="temp-mail-theme"
|
||||
>
|
||||
<Providers>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
<FloatMenu />
|
||||
</NextIntlClientProvider>
|
||||
</Providers>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { LoginForm } from "@/components/auth/login-form"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { Locale } from "@/i18n/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export default async function LoginPage() {
|
||||
export default async function LoginPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const session = await auth()
|
||||
|
||||
if (session?.user) {
|
||||
redirect("/")
|
||||
redirect(`/${locale}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -16,4 +23,5 @@ export default async function LoginPage() {
|
||||
<LoginForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,21 @@ import { auth } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import type { Locale } from "@/i18n/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export default async function MoePage() {
|
||||
export default async function MoePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/")
|
||||
redirect(`/${locale}`)
|
||||
}
|
||||
|
||||
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_EMAIL)
|
||||
@@ -28,4 +35,5 @@ export default async function MoePage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,20 @@ import { auth } from "@/lib/auth"
|
||||
import { Shield, Mail, Clock } from "lucide-react"
|
||||
import { ActionButton } from "@/components/home/action-button"
|
||||
import { FeatureCard } from "@/components/home/feature-card"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import type { Locale } from "@/i18n/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export default async function Home() {
|
||||
export default async function Home({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const session = await auth()
|
||||
const t = await getTranslations({ locale, namespace: "home" })
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 h-screen">
|
||||
@@ -21,29 +30,29 @@ export default async function Home() {
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-wider">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
|
||||
MoeMail
|
||||
{t("title")}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 tracking-wide">
|
||||
萌萌哒临时邮箱服务
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 px-4 sm:px-0">
|
||||
<FeatureCard
|
||||
icon={<Shield className="w-5 h-5" />}
|
||||
title="隐私保护"
|
||||
description="保护您的真实邮箱地址"
|
||||
title={t("features.privacy.title")}
|
||||
description={t("features.privacy.description")}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Mail className="w-5 h-5" />}
|
||||
title="即时收件"
|
||||
description="实时接收邮件通知"
|
||||
title={t("features.instant.title")}
|
||||
description={t("features.instant.description")}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Clock className="w-5 h-5" />}
|
||||
title="自动过期"
|
||||
description="到期自动失效"
|
||||
title={t("features.expiry.title")}
|
||||
description={t("features.expiry.description")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -57,3 +66,4 @@ export default async function Home() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,21 @@ import { Header } from "@/components/layout/header"
|
||||
import { ProfileCard } from "@/components/profile/profile-card"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { Locale } from "@/i18n/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export default async function ProfilePage() {
|
||||
export default async function ProfilePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale: localeFromParams } = await params
|
||||
const locale = localeFromParams as Locale
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/")
|
||||
redirect(`/${locale}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -22,4 +29,5 @@ export default async function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -34,25 +35,26 @@ export function LoginForm() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations("auth.loginForm")
|
||||
|
||||
const validateLoginForm = () => {
|
||||
const newErrors: FormErrors = {}
|
||||
if (!username) newErrors.username = "请输入用户名"
|
||||
if (!password) newErrors.password = "请输入密码"
|
||||
if (username.includes('@')) newErrors.username = "用户名不能包含 @ 符号"
|
||||
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
|
||||
if (!username) newErrors.username = t("errors.usernameRequired")
|
||||
if (!password) newErrors.password = t("errors.passwordRequired")
|
||||
if (username.includes('@')) newErrors.username = t("errors.usernameInvalid")
|
||||
if (password && password.length < 8) newErrors.password = t("errors.passwordTooShort")
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const validateRegisterForm = () => {
|
||||
const newErrors: FormErrors = {}
|
||||
if (!username) newErrors.username = "请输入用户名"
|
||||
if (!password) newErrors.password = "请输入密码"
|
||||
if (username.includes('@')) newErrors.username = "用户名不能包含 @ 符号"
|
||||
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
|
||||
if (!confirmPassword) newErrors.confirmPassword = "请确认密码"
|
||||
if (password !== confirmPassword) newErrors.confirmPassword = "两次输入的密码不一致"
|
||||
if (!username) newErrors.username = t("errors.usernameRequired")
|
||||
if (!password) newErrors.password = t("errors.passwordRequired")
|
||||
if (username.includes('@')) newErrors.username = t("errors.usernameInvalid")
|
||||
if (password && password.length < 8) newErrors.password = t("errors.passwordTooShort")
|
||||
if (!confirmPassword) newErrors.confirmPassword = t("errors.confirmPasswordRequired")
|
||||
if (password !== confirmPassword) newErrors.confirmPassword = t("errors.passwordMismatch")
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
@@ -70,8 +72,8 @@ export function LoginForm() {
|
||||
|
||||
if (result?.error) {
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: "用户名或密码错误",
|
||||
title: t("toast.loginFailed"),
|
||||
description: t("toast.loginFailedDesc"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
@@ -81,8 +83,8 @@ export function LoginForm() {
|
||||
window.location.href = "/"
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
title: t("toast.loginFailed"),
|
||||
description: error instanceof Error ? error.message : t("toast.registerFailedDesc"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
@@ -104,8 +106,8 @@ export function LoginForm() {
|
||||
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: "注册失败",
|
||||
description: data.error || "请稍后重试",
|
||||
title: t("toast.registerFailed"),
|
||||
description: data.error || t("toast.registerFailedDesc"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
@@ -121,8 +123,8 @@ export function LoginForm() {
|
||||
|
||||
if (result?.error) {
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: "自动登录失败,请手动登录",
|
||||
title: t("toast.loginFailed"),
|
||||
description: t("toast.autoLoginFailed"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
@@ -132,8 +134,8 @@ export function LoginForm() {
|
||||
window.location.href = "/"
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "注册失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
title: t("toast.registerFailed"),
|
||||
description: error instanceof Error ? error.message : t("toast.registerFailedDesc"),
|
||||
variant: "destructive",
|
||||
})
|
||||
setLoading(false)
|
||||
@@ -155,17 +157,17 @@ export function LoginForm() {
|
||||
<Card className="w-[95%] max-w-lg border-2 border-primary/20">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="text-2xl text-center bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
|
||||
欢迎使用 MoeMail
|
||||
{t("title")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
萌萌哒临时邮箱服务 (。・∀・)ノ
|
||||
{t("subtitle")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6">
|
||||
<Tabs defaultValue="login" className="w-full" onValueChange={clearForm}>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="login">登录</TabsTrigger>
|
||||
<TabsTrigger value="register">注册</TabsTrigger>
|
||||
<TabsTrigger value="login">{t("tabs.login")}</TabsTrigger>
|
||||
<TabsTrigger value="register">{t("tabs.register")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="min-h-[220px]">
|
||||
<TabsContent value="login" className="space-y-4 mt-0">
|
||||
@@ -180,7 +182,7 @@ export function LoginForm() {
|
||||
"h-9 pl-9 pr-3",
|
||||
errors.username && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
placeholder="用户名"
|
||||
placeholder={t("fields.username")}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
@@ -204,7 +206,7 @@ export function LoginForm() {
|
||||
errors.password && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
placeholder={t("fields.password")}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
@@ -226,7 +228,7 @@ export function LoginForm() {
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
登录
|
||||
{t("actions.login")}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
@@ -235,7 +237,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
或者
|
||||
{t("actions.or")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,7 +248,7 @@ export function LoginForm() {
|
||||
onClick={handleGithubLogin}
|
||||
>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
使用 GitHub 账号登录
|
||||
{t("actions.githubLogin")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -262,7 +264,7 @@ export function LoginForm() {
|
||||
"h-9 pl-9 pr-3",
|
||||
errors.username && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
placeholder="用户名"
|
||||
placeholder={t("fields.username")}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
@@ -286,7 +288,7 @@ export function LoginForm() {
|
||||
errors.password && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
placeholder={t("fields.password")}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
@@ -310,7 +312,7 @@ export function LoginForm() {
|
||||
errors.confirmPassword && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
type="password"
|
||||
placeholder="确认密码"
|
||||
placeholder={t("fields.confirmPassword")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value)
|
||||
@@ -332,7 +334,7 @@ export function LoginForm() {
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
注册
|
||||
{t("actions.register")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Image from "next/image"
|
||||
import { signOut, useSession } from "next-auth/react"
|
||||
import { LogIn } from "lucide-react"
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -14,7 +15,9 @@ interface SignButtonProps {
|
||||
|
||||
export function SignButton({ size = "default" }: SignButtonProps) {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const { data: session, status } = useSession()
|
||||
const t = useTranslations("auth.signButton")
|
||||
const loading = status === "loading"
|
||||
|
||||
if (loading) {
|
||||
@@ -23,9 +26,9 @@ export function SignButton({ size = "default" }: SignButtonProps) {
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<Button onClick={() => router.push('/login')} className={cn("gap-2", size === "lg" ? "px-8" : "")} size={size}>
|
||||
<Button onClick={() => router.push(`/${locale}/login`)} className={cn("gap-2", size === "lg" ? "px-8" : "")} size={size}>
|
||||
<LogIn className={size === "lg" ? "w-5 h-5" : "w-4 h-4"} />
|
||||
登录/注册
|
||||
{t("login")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -33,13 +36,13 @@ export function SignButton({ size = "default" }: SignButtonProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/profile"
|
||||
href={`/${locale}/profile`}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{session.user.image && (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
alt={session.user.name || "用户头像"}
|
||||
alt={session.user.name || t("userAvatar")}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full"
|
||||
@@ -47,8 +50,8 @@ export function SignButton({ size = "default" }: SignButtonProps) {
|
||||
)}
|
||||
<span className="text-sm">{session.user.name}</span>
|
||||
</Link>
|
||||
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline" className={cn("flex-shrink-0", size === "lg" ? "px-8" : "")} size={size}>
|
||||
登出
|
||||
<Button onClick={() => signOut({ callbackUrl: `/${locale}` })} variant="outline" className={cn("flex-shrink-0", size === "lg" ? "px-8" : "")} size={size}>
|
||||
{t("logout")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
@@ -19,7 +20,10 @@ interface CreateDialogProps {
|
||||
}
|
||||
|
||||
export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
const { config } = useConfig()
|
||||
const { config } = useConfig()
|
||||
const t = useTranslations("emails.create")
|
||||
const tList = useTranslations("emails.list")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [emailName, setEmailName] = useState("")
|
||||
@@ -37,8 +41,8 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
const createEmail = async () => {
|
||||
if (!emailName.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "请输入邮箱名",
|
||||
title: tList("error"),
|
||||
description: t("namePlaceholder"),
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
@@ -59,7 +63,7 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: "错误",
|
||||
title: tList("error"),
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
@@ -67,16 +71,16 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "已创建新的临时邮箱"
|
||||
title: tList("success"),
|
||||
description: t("success")
|
||||
})
|
||||
onEmailCreated()
|
||||
setOpen(false)
|
||||
setEmailName("")
|
||||
} catch {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "创建邮箱失败",
|
||||
title: tList("error"),
|
||||
description: t("failed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -95,19 +99,19 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
创建新邮箱
|
||||
{t("title")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建新的临时邮箱</DialogTitle>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={emailName}
|
||||
onChange={(e) => setEmailName(e.target.value)}
|
||||
placeholder="输入邮箱名"
|
||||
placeholder={t("namePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
{(config?.emailDomainsArray?.length ?? 0) > 1 && (
|
||||
@@ -133,25 +137,28 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Label className="shrink-0 text-muted-foreground">过期时间</Label>
|
||||
<Label className="shrink-0 text-muted-foreground">{t("expiryTime")}</Label>
|
||||
<RadioGroup
|
||||
value={expiryTime}
|
||||
onValueChange={setExpiryTime}
|
||||
className="flex gap-6"
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((option) => (
|
||||
<div key={option.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={option.value.toString()} id={option.value.toString()} />
|
||||
<Label htmlFor={option.value.toString()} className="cursor-pointer text-sm">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
{EXPIRY_OPTIONS.map((option, index) => {
|
||||
const labels = [t("oneHour"), t("oneDay"), t("threeDays"), t("permanent")]
|
||||
return (
|
||||
<div key={option.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={option.value.toString()} id={option.value.toString()} />
|
||||
<Label htmlFor={option.value.toString()} className="cursor-pointer text-sm">
|
||||
{labels[index]}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">完整邮箱地址将为:</span>
|
||||
<span className="shrink-0">{t("domain")}:</span>
|
||||
{emailName ? (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate">{`${emailName}@${currentDomain}`}</span>
|
||||
@@ -169,10 +176,10 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
取消
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={createEmail} disabled={loading}>
|
||||
{loading ? "创建中..." : "创建"}
|
||||
{loading ? t("creating") : t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CreateDialog } from "./create-dialog"
|
||||
import { Mail, RefreshCw, Trash2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -45,6 +46,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
const { data: session } = useSession()
|
||||
const { config } = useConfig()
|
||||
const { role } = useUserRole()
|
||||
const t = useTranslations("emails.list")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [emails, setEmails] = useState<Email[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
@@ -125,7 +128,7 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: "错误",
|
||||
title: t("error"),
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
@@ -136,8 +139,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
setTotal(prev => prev - 1)
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "邮箱已删除"
|
||||
title: t("success"),
|
||||
description: t("deleteSuccess")
|
||||
})
|
||||
|
||||
if (selectedEmailId === email.id) {
|
||||
@@ -145,8 +148,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除邮箱失败",
|
||||
title: t("error"),
|
||||
description: t("deleteFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -172,9 +175,9 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{role === ROLES.EMPEROR ? (
|
||||
`${total}/∞ 个邮箱`
|
||||
t("emailCountUnlimited", { count: total })
|
||||
) : (
|
||||
`${total}/${config?.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱`
|
||||
t("emailCount", { count: total, max: config?.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS })
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -183,7 +186,7 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
|
||||
<div className="flex-1 overflow-auto p-2" onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="text-center text-sm text-gray-500">加载中...</div>
|
||||
<div className="text-center text-sm text-gray-500">{t("loading")}</div>
|
||||
) : emails.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{emails.map(email => (
|
||||
@@ -200,9 +203,9 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
<div className="font-medium truncate">{email.address}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(email.expiresAt).getFullYear() === 9999 ? (
|
||||
"永久有效"
|
||||
t("permanent")
|
||||
) : (
|
||||
`过期时间: ${new Date(email.expiresAt).toLocaleString()}`
|
||||
`${t("expiresAt")}: ${new Date(email.expiresAt).toLocaleString()}`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,13 +224,13 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="text-center text-sm text-gray-500 py-2">
|
||||
加载更多...
|
||||
{t("loadingMore")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
还没有邮箱,创建一个吧!
|
||||
{t("noEmails")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -236,18 +239,18 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||
<AlertDialog open={!!emailToDelete} onOpenChange={() => setEmailToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除邮箱 {emailToDelete?.address} 吗?此操作将同时删除该邮箱中的所有邮件,且不可恢复。
|
||||
{t("deleteDescription", { email: emailToDelete?.address })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
onClick={() => emailToDelete && handleDelete(emailToDelete)}
|
||||
>
|
||||
删除
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Send, Inbox } from "lucide-react"
|
||||
import { Tabs, SlidingTabsList, SlidingTabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { MessageList } from "./message-list"
|
||||
@@ -17,6 +18,7 @@ interface MessageListContainerProps {
|
||||
}
|
||||
|
||||
export function MessageListContainer({ email, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListContainerProps) {
|
||||
const t = useTranslations("emails.messages")
|
||||
const [activeTab, setActiveTab] = useState<'received' | 'sent'>('received')
|
||||
const { canSend: canSendEmails } = useSendPermission()
|
||||
|
||||
@@ -33,11 +35,11 @@ export function MessageListContainer({ email, onMessageSelect, selectedMessageId
|
||||
<SlidingTabsList>
|
||||
<SlidingTabsTrigger value="received">
|
||||
<Inbox className="h-4 w-4" />
|
||||
收件箱
|
||||
{t("received")}
|
||||
</SlidingTabsTrigger>
|
||||
<SlidingTabsTrigger value="sent">
|
||||
<Send className="h-4 w-4" />
|
||||
已发送
|
||||
{t("sent")}
|
||||
</SlidingTabsTrigger>
|
||||
</SlidingTabsList>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {Mail, Calendar, RefreshCw, Trash2} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -47,6 +48,9 @@ interface MessageResponse {
|
||||
}
|
||||
|
||||
export function MessageList({ email, messageType, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListProps) {
|
||||
const t = useTranslations("emails.messages")
|
||||
const tList = useTranslations("emails.list")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
@@ -149,7 +153,7 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: "错误",
|
||||
title: tList("error"),
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
@@ -160,8 +164,8 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
|
||||
setTotal(prev => prev - 1)
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "邮件已删除"
|
||||
title: tList("success"),
|
||||
description: tList("deleteSuccess")
|
||||
})
|
||||
|
||||
if (selectedMessageId === message.id) {
|
||||
@@ -169,8 +173,8 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "删除邮件失败",
|
||||
title: tList("error"),
|
||||
description: tList("deleteFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -215,13 +219,13 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{total > 0 ? `${total} 封邮件` : "暂无邮件"}
|
||||
{total > 0 ? `${total} ${t("noMessages")}` : t("noMessages")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">加载中...</div>
|
||||
<div className="p-4 text-center text-sm text-gray-500">{t("loading")}</div>
|
||||
) : messages.length > 0 ? (
|
||||
<div className="divide-y divide-primary/10">
|
||||
{messages.map(message => (
|
||||
@@ -263,13 +267,13 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="text-center text-sm text-gray-500 py-2">
|
||||
加载更多...
|
||||
{t("loadingMore")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
{messageType === 'sent' ? '暂无发送的邮件' : '暂无收到的邮件'}
|
||||
{t("noMessages")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -277,18 +281,18 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
|
||||
<AlertDialog open={!!messageToDelete} onOpenChange={() => setMessageToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogTitle>{tList("deleteConfirm")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除邮件 {messageToDelete?.subject} 吗?
|
||||
{tList("deleteDescription", { email: messageToDelete?.subject })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
onClick={() => messageToDelete && handleDelete(messageToDelete)}
|
||||
>
|
||||
删除
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@@ -28,6 +29,8 @@ interface MessageViewProps {
|
||||
type ViewMode = "html" | "text"
|
||||
|
||||
export function MessageView({ emailId, messageId, messageType = 'received' }: MessageViewProps) {
|
||||
const t = useTranslations("emails.messageView")
|
||||
const tList = useTranslations("emails.list")
|
||||
const [message, setMessage] = useState<Message | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -48,10 +51,10 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
const errorMessage = (errorData as { error?: string }).error || '获取邮件详情失败'
|
||||
const errorMessage = (errorData as { error?: string }).error || t("loadError")
|
||||
setError(errorMessage)
|
||||
toast({
|
||||
title: "错误",
|
||||
title: tList("error"),
|
||||
description: errorMessage,
|
||||
variant: "destructive"
|
||||
})
|
||||
@@ -64,10 +67,10 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
|
||||
setViewMode("text")
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = "网络错误,请稍后重试"
|
||||
const errorMessage = t("networkError")
|
||||
setError(errorMessage)
|
||||
toast({
|
||||
title: "错误",
|
||||
title: tList("error"),
|
||||
description: errorMessage,
|
||||
variant: "destructive"
|
||||
})
|
||||
@@ -78,7 +81,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
|
||||
}
|
||||
|
||||
fetchMessage()
|
||||
}, [emailId, messageId, messageType, toast])
|
||||
}, [emailId, messageId, messageType, toast, t, tList])
|
||||
|
||||
const updateIframeContent = () => {
|
||||
if (viewMode === "html" && message?.html && iframeRef.current) {
|
||||
@@ -182,7 +185,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
|
||||
<span className="ml-2 text-sm text-gray-500">加载邮件详情...</span>
|
||||
<span className="ml-2 text-sm text-gray-500">{t("loading")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +198,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
点击重试
|
||||
{t("retry")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -209,12 +212,12 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
|
||||
<h3 className="text-base font-bold">{message.subject}</h3>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
{message.from_address && (
|
||||
<p>发件人:{message.from_address}</p>
|
||||
<p>{t("from")}: {message.from_address}</p>
|
||||
)}
|
||||
{message.to_address && (
|
||||
<p>收件人:{message.to_address}</p>
|
||||
<p>{t("to")}: {message.to_address}</p>
|
||||
)}
|
||||
<p>时间:{new Date(message.sent_at || message.received_at || 0).toLocaleString()}</p>
|
||||
<p>{t("time")}: {new Date(message.sent_at || message.received_at || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +234,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
|
||||
htmlFor="html"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
HTML 格式
|
||||
{t("htmlFormat")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -240,7 +243,7 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
|
||||
htmlFor="text"
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
纯文本格式
|
||||
{t("textFormat")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
@@ -21,6 +22,9 @@ interface SendDialogProps {
|
||||
}
|
||||
|
||||
export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogProps) {
|
||||
const t = useTranslations("emails.send")
|
||||
const tList = useTranslations("emails.list")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [to, setTo] = useState("")
|
||||
@@ -31,8 +35,8 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
|
||||
const handleSend = async () => {
|
||||
if (!to.trim() || !subject.trim() || !content.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "收件人、主题和内容都是必填项",
|
||||
title: tList("error"),
|
||||
description: t("toPlaceholder") + ", " + t("subjectPlaceholder") + ", " + t("contentPlaceholder"),
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
@@ -49,7 +53,7 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: "错误",
|
||||
title: tList("error"),
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
@@ -57,8 +61,8 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "邮件已发送"
|
||||
title: tList("success"),
|
||||
description: t("success")
|
||||
})
|
||||
setOpen(false)
|
||||
setTo("")
|
||||
@@ -69,8 +73,8 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
|
||||
|
||||
} catch {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "发送邮件失败",
|
||||
title: tList("error"),
|
||||
description: t("failed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -90,46 +94,46 @@ export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogPr
|
||||
className="h-8 gap-2 hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">发送邮件</span>
|
||||
<span className="hidden sm:inline">{t("title")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DialogTrigger>
|
||||
<TooltipContent className="sm:hidden">
|
||||
<p>使用此邮箱发送新邮件</p>
|
||||
<p>{t("title")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送新邮件</DialogTitle>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
发件人: {fromAddress}
|
||||
{t("from")}: {fromAddress}
|
||||
</div>
|
||||
<Input
|
||||
value={to}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTo(e.target.value)}
|
||||
placeholder="收件人邮箱地址"
|
||||
placeholder={t("toPlaceholder")}
|
||||
/>
|
||||
<Input
|
||||
value={subject}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSubject(e.target.value)}
|
||||
placeholder="邮件主题"
|
||||
placeholder={t("subjectPlaceholder")}
|
||||
/>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value)}
|
||||
placeholder="邮件内容"
|
||||
placeholder={t("contentPlaceholder")}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
取消
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSend} disabled={loading}>
|
||||
{loading ? "发送中..." : "发送"}
|
||||
{loading ? t("sending") : t("send")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { EmailList } from "./email-list"
|
||||
import { MessageListContainer } from "./message-list-container"
|
||||
import { MessageView } from "./message-view"
|
||||
@@ -16,6 +17,7 @@ interface Email {
|
||||
}
|
||||
|
||||
export function ThreeColumnLayout() {
|
||||
const t = useTranslations("emails.layout")
|
||||
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null)
|
||||
const [selectedMessageType, setSelectedMessageType] = useState<'received' | 'sent'>('received')
|
||||
@@ -55,7 +57,7 @@ export function ThreeColumnLayout() {
|
||||
<div className="hidden lg:grid grid-cols-12 gap-4 h-full min-h-0">
|
||||
<div className={cn("col-span-3", columnClass)}>
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>我的邮箱</h2>
|
||||
<h2 className={titleClass}>{t("myEmails")}</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailList
|
||||
@@ -88,7 +90,7 @@ export function ThreeColumnLayout() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"选择邮箱查看消息"
|
||||
t("selectEmail")
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -107,7 +109,7 @@ export function ThreeColumnLayout() {
|
||||
<div className={cn("col-span-5", columnClass)}>
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>
|
||||
{selectedMessageId ? "邮件内容" : "选择邮件查看详情"}
|
||||
{selectedMessageId ? t("messageContent") : t("selectMessage")}
|
||||
</h2>
|
||||
</div>
|
||||
{selectedEmail && selectedMessageId && (
|
||||
@@ -129,7 +131,7 @@ export function ThreeColumnLayout() {
|
||||
{mobileView === "list" && (
|
||||
<>
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>我的邮箱</h2>
|
||||
<h2 className={titleClass}>{t("myEmails")}</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailList
|
||||
@@ -151,7 +153,7 @@ export function ThreeColumnLayout() {
|
||||
}}
|
||||
className="text-sm text-primary shrink-0"
|
||||
>
|
||||
← 返回邮箱列表
|
||||
{t("backToEmailList")}
|
||||
</button>
|
||||
<div className="flex-1 flex justify-between items-center gap-2 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -187,9 +189,9 @@ export function ThreeColumnLayout() {
|
||||
onClick={() => setSelectedMessageId(null)}
|
||||
className="text-sm text-primary"
|
||||
>
|
||||
← 返回消息列表
|
||||
{t("backToMessageList")}
|
||||
</button>
|
||||
<span className="text-sm font-medium">邮件内容</span>
|
||||
<span className="text-sm font-medium">{t("messageContent")}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageView
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Github } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
export function FloatMenu() {
|
||||
const t = useTranslations("common")
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6">
|
||||
<TooltipProvider>
|
||||
@@ -24,12 +27,12 @@ export function FloatMenu() {
|
||||
<Github
|
||||
className="w-4 h-4 transition-all duration-300 text-primary group-hover:scale-110"
|
||||
/>
|
||||
<span className="sr-only">获取网站源代码</span>
|
||||
<span className="sr-only">{t("github")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm">
|
||||
<p>获取网站源代码</p>
|
||||
<p>{t("github")}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Mail } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import { SignButton } from "../auth/sign-button"
|
||||
|
||||
interface ActionButtonProps {
|
||||
@@ -11,16 +12,18 @@ interface ActionButtonProps {
|
||||
|
||||
export function ActionButton({ isLoggedIn }: ActionButtonProps) {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const t = useTranslations("home")
|
||||
|
||||
if (isLoggedIn) {
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => router.push("/moe")}
|
||||
onClick={() => router.push(`/${locale}/moe`)}
|
||||
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
进入邮箱
|
||||
{t("actions.enterMailbox")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SignButton } from "@/components/auth/sign-button"
|
||||
import { ThemeToggle } from "@/components/theme/theme-toggle"
|
||||
import { LanguageSwitcher } from "@/components/layout/language-switcher"
|
||||
import { Logo } from "@/components/ui/logo"
|
||||
|
||||
export function Header() {
|
||||
@@ -9,6 +10,7 @@ export function Header() {
|
||||
<div className="h-full flex items-center justify-between">
|
||||
<Logo />
|
||||
<div className="flex items-center gap-4">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<SignButton />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { i18n } from "@/i18n/config"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Languages } from "lucide-react"
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const t = useTranslations("common")
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const switchLocale = (newLocale: string) => {
|
||||
if (newLocale === locale) return
|
||||
|
||||
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
|
||||
|
||||
const segments = pathname.split("/")
|
||||
if (i18n.locales.includes(segments[1] as any)) {
|
||||
segments[1] = newLocale
|
||||
} else {
|
||||
segments.splice(1, 0, newLocale)
|
||||
}
|
||||
const newPath = segments.join("/")
|
||||
|
||||
router.push(newPath)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Languages className="h-5 w-5" />
|
||||
<span className="sr-only">{t("lang.switch")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => switchLocale("en")}
|
||||
className={locale === "en" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("lang.en")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => switchLocale("zh-CN")}
|
||||
className={locale === "zh-CN" ? "bg-accent" : ""}
|
||||
>
|
||||
{t("lang.zhCN")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
|
||||
export function NoPermissionDialog() {
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const t = useTranslations("emails.noPermission")
|
||||
const { config } = useConfig()
|
||||
|
||||
return (
|
||||
@@ -12,18 +16,18 @@ export function NoPermissionDialog() {
|
||||
<div className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[90%] max-w-md">
|
||||
<div className="bg-background border-2 border-primary/20 rounded-lg p-6 md:p-12 shadow-lg">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-xl md:text-2xl font-bold">权限不足</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">你没有权限访问此页面,请联系网站管理员</p>
|
||||
<h1 className="text-xl md:text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">{t("description")}</p>
|
||||
{
|
||||
config?.adminContact && (
|
||||
<p className="text-sm md:text-base text-muted-foreground">管理员联系方式:{config.adminContact}</p>
|
||||
<p className="text-sm md:text-base text-muted-foreground">{t("adminContact")}:{config.adminContact}</p>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
onClick={() => router.push("/")}
|
||||
onClick={() => router.push(`/${locale}`)}
|
||||
className="mt-4 w-full md:w-auto"
|
||||
>
|
||||
返回首页
|
||||
{t("backToHome")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Key, Plus, Loader2, Copy, Trash2, ChevronDown, ChevronUp } from "lucide-react"
|
||||
@@ -32,6 +33,10 @@ type ApiKey = {
|
||||
}
|
||||
|
||||
export function ApiKeyPanel() {
|
||||
const t = useTranslations("profile.apiKey")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const tNoPermission = useTranslations("emails.noPermission")
|
||||
const tMessages = useTranslations("emails.messages")
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
@@ -47,14 +52,14 @@ export function ApiKeyPanel() {
|
||||
const fetchApiKeys = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/api-keys")
|
||||
if (!res.ok) throw new Error("获取 API Keys 失败")
|
||||
if (!res.ok) throw new Error(t("createFailed"))
|
||||
const data = await res.json() as { apiKeys: ApiKey[] }
|
||||
setApiKeys(data.apiKeys)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: "获取失败",
|
||||
description: "获取 API Keys 列表失败",
|
||||
title: t("createFailed"),
|
||||
description: t("createFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -81,15 +86,15 @@ export function ApiKeyPanel() {
|
||||
body: JSON.stringify({ name: newKeyName })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("创建 API Key 失败")
|
||||
if (!res.ok) throw new Error(t("createFailed"))
|
||||
|
||||
const data = await res.json() as { key: string }
|
||||
setNewKey(data.key)
|
||||
fetchApiKeys()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "创建失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
title: t("createFailed"),
|
||||
description: error instanceof Error ? error.message : t("createFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
setCreateDialogOpen(false)
|
||||
@@ -112,7 +117,7 @@ export function ApiKeyPanel() {
|
||||
body: JSON.stringify({ enabled })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("更新失败")
|
||||
if (!res.ok) throw new Error(t("createFailed"))
|
||||
|
||||
setApiKeys(keys =>
|
||||
keys.map(key =>
|
||||
@@ -122,8 +127,8 @@ export function ApiKeyPanel() {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: "更新失败",
|
||||
description: "更新 API Key 状态失败",
|
||||
title: t("createFailed"),
|
||||
description: t("createFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
@@ -135,18 +140,18 @@ export function ApiKeyPanel() {
|
||||
method: "DELETE"
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("删除失败")
|
||||
if (!res.ok) throw new Error(t("deleteFailed"))
|
||||
|
||||
setApiKeys(keys => keys.filter(key => key.id !== id))
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "API Key 已删除"
|
||||
title: t("deleteSuccess"),
|
||||
description: t("deleteSuccess")
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: "删除 API Key 失败",
|
||||
title: t("deleteFailed"),
|
||||
description: t("deleteFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
@@ -157,7 +162,7 @@ export function ApiKeyPanel() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
{
|
||||
canManageApiKey && (
|
||||
@@ -165,17 +170,17 @@ export function ApiKeyPanel() {
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
创建 API Key
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{newKey ? "API Key 创建成功" : "创建新的 API Key"}
|
||||
{newKey ? t("createSuccess") : t("create")}
|
||||
</DialogTitle>
|
||||
{newKey && (
|
||||
<DialogDescription className="text-destructive">
|
||||
请立即保存此密钥,它只会显示一次且无法恢复
|
||||
{t("description")}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
@@ -183,18 +188,18 @@ export function ApiKeyPanel() {
|
||||
{!newKey ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称</Label>
|
||||
<Label>{t("name")}</Label>
|
||||
<Input
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="为你的 API Key 起个名字"
|
||||
placeholder={t("namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<Label>{t("key")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newKey}
|
||||
@@ -220,7 +225,7 @@ export function ApiKeyPanel() {
|
||||
onClick={handleDialogClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{newKey ? "完成" : "取消"}
|
||||
{newKey ? tCommon("ok") : tCommon("cancel")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
{!newKey && (
|
||||
@@ -231,7 +236,7 @@ export function ApiKeyPanel() {
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"创建"
|
||||
t("create")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -245,11 +250,11 @@ export function ApiKeyPanel() {
|
||||
{
|
||||
!canManageApiKey ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<p>需要公爵或更高权限才能管理 API Key</p>
|
||||
<p className="mt-2">请联系网站管理员升级您的角色</p>
|
||||
<p>{tNoPermission("needPermission")}</p>
|
||||
<p className="mt-2">{tNoPermission("contactAdmin")}</p>
|
||||
{
|
||||
config?.adminContact && (
|
||||
<p className="mt-2">管理员联系方式:{config.adminContact}</p>
|
||||
<p className="mt-2">{tNoPermission("adminContact")}: {config.adminContact}</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -261,7 +266,7 @@ export function ApiKeyPanel() {
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
<p className="text-sm text-muted-foreground">{tMessages("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
@@ -270,9 +275,9 @@ export function ApiKeyPanel() {
|
||||
<Key className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">没有 API Keys</h3>
|
||||
<h3 className="text-lg font-medium">{t("noKeys")}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
点击上方的创建 "API Key" 按钮来创建你的第一个 API Key
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,7 +291,7 @@ export function ApiKeyPanel() {
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{key.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
创建于 {new Date(key.createdAt).toLocaleString()}
|
||||
{t("createdAt")}: {new Date(key.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -312,14 +317,14 @@ export function ApiKeyPanel() {
|
||||
onClick={() => setShowExamples(!showExamples)}
|
||||
>
|
||||
{showExamples ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
查看使用文档
|
||||
{t("viewDocs")}
|
||||
</button>
|
||||
|
||||
{showExamples && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">获取系统配置</div>
|
||||
<div className="text-sm font-medium">{t("docs.getConfig")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -339,7 +344,7 @@ export function ApiKeyPanel() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">生成临时邮箱</div>
|
||||
<div className="text-sm font-medium">{t("docs.generateEmail")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -371,7 +376,7 @@ export function ApiKeyPanel() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">获取邮箱列表</div>
|
||||
<div className="text-sm font-medium">{t("docs.getEmails")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -391,7 +396,7 @@ export function ApiKeyPanel() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">获取邮件列表</div>
|
||||
<div className="text-sm font-medium">{t("docs.getMessages")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -411,7 +416,7 @@ export function ApiKeyPanel() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">获取单封邮件</div>
|
||||
<div className="text-sm font-medium">{t("docs.getMessage")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -430,16 +435,16 @@ export function ApiKeyPanel() {
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground mt-4">
|
||||
<p>注意:</p>
|
||||
<p>{t("docs.notes")}</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>请将 YOUR_API_KEY 替换为你的实际 API Key</li>
|
||||
<li>/api/config 接口可获取系统配置,包括可用的邮箱域名列表</li>
|
||||
<li>emailId 是邮箱的唯一标识符</li>
|
||||
<li>messageId 是邮件的唯一标识符</li>
|
||||
<li>expiryTime 是邮箱的有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)</li>
|
||||
<li>domain 是邮箱域名,可通过 /api/config 接口获取可用域名列表</li>
|
||||
<li>cursor 用于分页,从上一次请求的响应中获取 nextCursor</li>
|
||||
<li>所有请求都需要包含 X-API-Key 请求头</li>
|
||||
<li>{t("docs.note1")}</li>
|
||||
<li>{t("docs.note2")}</li>
|
||||
<li>{t("docs.note3")}</li>
|
||||
<li>{t("docs.note4")}</li>
|
||||
<li>{t("docs.note5")}</li>
|
||||
<li>{t("docs.note6")}</li>
|
||||
<li>{t("docs.note7")}</li>
|
||||
<li>{t("docs.note8")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Zap, Eye, EyeOff } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
@@ -19,6 +20,10 @@ interface EmailServiceConfig {
|
||||
}
|
||||
|
||||
export function EmailServiceConfig() {
|
||||
const t = useTranslations("profile.emailService")
|
||||
const tCard = useTranslations("profile.card")
|
||||
const tSend = useTranslations("emails.send")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const [config, setConfig] = useState<EmailServiceConfig>({
|
||||
enabled: false,
|
||||
apiKey: "",
|
||||
@@ -64,17 +69,17 @@ export function EmailServiceConfig() {
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json() as { error: string }
|
||||
throw new Error(error.error || "保存失败")
|
||||
throw new Error(error.error || t("saveFailed"))
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "Resend 发件服务配置已更新",
|
||||
title: t("saveSuccess"),
|
||||
description: t("saveSuccess"),
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
title: t("saveFailed"),
|
||||
description: error instanceof Error ? error.message : t("saveFailed"),
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
@@ -86,17 +91,17 @@ export function EmailServiceConfig() {
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Zap className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Resend 发件服务配置</h2>
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled" className="text-sm font-medium">
|
||||
启用 Resend 发件服务
|
||||
{t("enable")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后将使用 Resend 发送邮件
|
||||
{t("enableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -112,7 +117,7 @@ export function EmailServiceConfig() {
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey" className="text-sm font-medium">
|
||||
Resend API Key
|
||||
{t("apiKey")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -120,7 +125,7 @@ export function EmailServiceConfig() {
|
||||
type={showToken ? "text" : "password"}
|
||||
value={config.apiKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig((prev: EmailServiceConfig) => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder="输入 Resend API Key"
|
||||
placeholder={t("apiKeyPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -140,33 +145,33 @@ export function EmailServiceConfig() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
允许使用发件功能的角色
|
||||
{t("roleLimits")}
|
||||
</Label>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg text-sm">
|
||||
<p className="font-semibold text-blue-900 mb-3 flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
固定权限规则
|
||||
{t("fixedRoleLimits")}
|
||||
</p>
|
||||
<div className="space-y-2 text-blue-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
|
||||
<span><strong>Emperor (皇帝)</strong> - 可以无限发件,不受任何限制</span>
|
||||
<span><strong>{tCard("roles.EMPEROR")}</strong> - {t("emperorLimit")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full"></div>
|
||||
<span><strong>Civilian (平民)</strong> - 永远不能发件</span>
|
||||
<span><strong>{tCard("roles.CIVILIAN")}</strong> - {t("civilianLimit")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
|
||||
<p className="text-sm font-medium text-gray-900">可配置的角色权限</p>
|
||||
<p className="text-sm font-medium text-gray-900">{t("configRoleLabel")}</p>
|
||||
</div>
|
||||
{[
|
||||
{ value: "duke", label: "Duke (公爵)", key: "duke" as const },
|
||||
{ value: "knight", label: "Knight (骑士)", key: "knight" as const }
|
||||
{ value: "duke", label: tCard("roles.DUKE"), key: "duke" as const },
|
||||
{ value: "knight", label: tCard("roles.KNIGHT"), key: "knight" as const }
|
||||
].map((role) => {
|
||||
const isDisabled = config.roleLimits[role.key] === -1
|
||||
const isEnabled = !isDisabled
|
||||
@@ -208,13 +213,13 @@ export function EmailServiceConfig() {
|
||||
{role.label}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isEnabled ? '已启用发件权限' : '未启用发件权限'}
|
||||
{isEnabled ? t("enabled") : t("disabled")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-right">
|
||||
<Label className="text-xs font-medium text-gray-600 block mb-1">每日限制</Label>
|
||||
<Label className="text-xs font-medium text-gray-600 block mb-1">{t("dailyLimit")}</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
@@ -233,9 +238,9 @@ export function EmailServiceConfig() {
|
||||
placeholder="0"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">封/天</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{tSend("dailyLimitUnit")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">0 = 无限制</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">0 = {t("unlimited")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +258,7 @@ export function EmailServiceConfig() {
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? "保存中..." : "保存配置"}
|
||||
{loading ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { User } from "next-auth"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
import Image from "next/image"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { signOut } from "next-auth/react"
|
||||
@@ -19,13 +20,18 @@ interface ProfileCardProps {
|
||||
}
|
||||
|
||||
const roleConfigs = {
|
||||
emperor: { name: '皇帝', icon: Crown },
|
||||
duke: { name: '公爵', icon: Gem },
|
||||
knight: { name: '骑士', icon: Sword },
|
||||
civilian: { name: '平民', icon: User2 },
|
||||
emperor: { key: 'EMPEROR', icon: Crown },
|
||||
duke: { key: 'DUKE', icon: Gem },
|
||||
knight: { key: 'KNIGHT', icon: Sword },
|
||||
civilian: { key: 'CIVILIAN', icon: User2 },
|
||||
} as const
|
||||
|
||||
export function ProfileCard({ user }: ProfileCardProps) {
|
||||
const t = useTranslations("profile.card")
|
||||
const tAuth = useTranslations("auth.signButton")
|
||||
const tWebhook = useTranslations("profile.webhook")
|
||||
const tNav = useTranslations("common.nav")
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const { checkPermission } = useRolePermission()
|
||||
const canManageWebhook = checkPermission(PERMISSIONS.MANAGE_WEBHOOK)
|
||||
@@ -40,7 +46,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
{user.image && (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.name || "用户头像"}
|
||||
alt={user.name || tAuth("userAvatar")}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-full ring-2 ring-primary/20"
|
||||
@@ -55,14 +61,14 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
// 先简单实现,后续再完善
|
||||
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full flex-shrink-0">
|
||||
<Github className="w-3 h-3" />
|
||||
已关联
|
||||
{tAuth("linked")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate mt-1">
|
||||
{
|
||||
user.email ? user.email : `用户名: ${user.username}`
|
||||
user.email ? user.email : `${t("name")}: ${user.username}`
|
||||
}
|
||||
</p>
|
||||
{user.roles && (
|
||||
@@ -70,14 +76,15 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
{user.roles.map(({ name }) => {
|
||||
const roleConfig = roleConfigs[name as keyof typeof roleConfigs]
|
||||
const Icon = roleConfig.icon
|
||||
const roleName = t(`roles.${roleConfig.key}` as any)
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"
|
||||
title={roleConfig.name}
|
||||
title={roleName}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{roleConfig.name}
|
||||
{roleName}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -91,7 +98,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Webhook 配置</h2>
|
||||
<h2 className="text-lg font-semibold">{tWebhook("title")}</h2>
|
||||
</div>
|
||||
<WebhookConfig />
|
||||
</div>
|
||||
@@ -104,18 +111,18 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 px-1">
|
||||
<Button
|
||||
onClick={() => router.push("/moe")}
|
||||
onClick={() => router.push(`/${locale}/moe`)}
|
||||
className="gap-2 flex-1"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
返回邮箱
|
||||
{tNav("backToMailbox")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
onClick={() => signOut({ callbackUrl: `/${locale}` })}
|
||||
className="flex-1"
|
||||
>
|
||||
退出登录
|
||||
{tAuth("logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Gem, Sword, User2, Loader2 } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -20,19 +21,21 @@ const roleIcons = {
|
||||
[ROLES.CIVILIAN]: User2,
|
||||
} as const
|
||||
|
||||
const roleNames = {
|
||||
[ROLES.DUKE]: "公爵",
|
||||
[ROLES.KNIGHT]: "骑士",
|
||||
[ROLES.CIVILIAN]: "平民",
|
||||
} as const
|
||||
|
||||
type RoleWithoutEmperor = Exclude<Role, typeof ROLES.EMPEROR>
|
||||
|
||||
export function PromotePanel() {
|
||||
const t = useTranslations("profile.promote")
|
||||
const tCard = useTranslations("profile.card")
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [targetRole, setTargetRole] = useState<RoleWithoutEmperor>(ROLES.KNIGHT)
|
||||
const { toast } = useToast()
|
||||
|
||||
const roleNames = {
|
||||
[ROLES.DUKE]: tCard("roles.DUKE"),
|
||||
[ROLES.KNIGHT]: tCard("roles.KNIGHT"),
|
||||
[ROLES.CIVILIAN]: tCard("roles.CIVILIAN"),
|
||||
} as const
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!searchText) return
|
||||
@@ -59,8 +62,8 @@ export function PromotePanel() {
|
||||
|
||||
if (!data.user) {
|
||||
toast({
|
||||
title: "未找到用户",
|
||||
description: "请确认用户名或邮箱地址是否正确",
|
||||
title: t("noUsers"),
|
||||
description: t("searchPlaceholder"),
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
@@ -68,8 +71,8 @@ export function PromotePanel() {
|
||||
|
||||
if (data.user.role === targetRole) {
|
||||
toast({
|
||||
title: `用户已是${roleNames[targetRole]}`,
|
||||
description: "无需重复设置",
|
||||
title: t("updateSuccess"),
|
||||
description: t("updateSuccess"),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -85,18 +88,18 @@ export function PromotePanel() {
|
||||
|
||||
if (!promoteRes.ok) {
|
||||
const error = await promoteRes.json() as { error: string }
|
||||
throw new Error(error.error || "设置失败")
|
||||
throw new Error(error.error || t("updateFailed"))
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "设置成功",
|
||||
description: `已将用户 ${data.user.username || data.user.email} 设为${roleNames[targetRole]}`,
|
||||
title: t("updateSuccess"),
|
||||
description: `${data.user.username || data.user.email} - ${roleNames[targetRole]}`,
|
||||
})
|
||||
setSearchText("")
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "设置失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
title: t("updateFailed"),
|
||||
description: error instanceof Error ? error.message : t("updateFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -110,7 +113,7 @@ export function PromotePanel() {
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">角色管理</h2>
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -119,7 +122,7 @@ export function PromotePanel() {
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="输入用户名或邮箱"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<Select value={targetRole} onValueChange={(value) => setTargetRole(value as RoleWithoutEmperor)}>
|
||||
@@ -130,19 +133,19 @@ export function PromotePanel() {
|
||||
<SelectItem value={ROLES.DUKE}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Gem className="w-4 h-4" />
|
||||
公爵
|
||||
{roleNames[ROLES.DUKE]}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value={ROLES.KNIGHT}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sword className="w-4 h-4" />
|
||||
骑士
|
||||
{roleNames[ROLES.KNIGHT]}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value={ROLES.CIVILIAN}>
|
||||
<div className="flex items-center gap-2">
|
||||
<User2 className="w-4 h-4" />
|
||||
平民
|
||||
{roleNames[ROLES.CIVILIAN]}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -157,7 +160,7 @@ export function PromotePanel() {
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
`设为${roleNames[targetRole]}`
|
||||
`${t("promote")} ${roleNames[targetRole]}`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@@ -16,6 +17,10 @@ import {
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
export function WebhookConfig() {
|
||||
const t = useTranslations("profile.webhook")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
const tMessages = useTranslations("emails.messages")
|
||||
const tApiKey = useTranslations("profile.apiKey")
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [url, setUrl] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -42,7 +47,7 @@ export function WebhookConfig() {
|
||||
<Loader2 className="w-6 h-6 text-primary animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
<p className="text-sm text-muted-foreground">{tMessages("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -60,16 +65,16 @@ export function WebhookConfig() {
|
||||
body: JSON.stringify({ url, enabled })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save")
|
||||
if (!res.ok) throw new Error(t("saveFailed"))
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "Webhook 配置已更新"
|
||||
title: t("saveSuccess"),
|
||||
description: t("saveSuccess")
|
||||
})
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: "请稍后重试",
|
||||
title: t("saveFailed"),
|
||||
description: t("saveFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -88,16 +93,16 @@ export function WebhookConfig() {
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("测试失败")
|
||||
if (!res.ok) throw new Error(t("testFailed"))
|
||||
|
||||
toast({
|
||||
title: "测试成功",
|
||||
description: "Webhook 调用成功,请检查目标服务器是否收到请求"
|
||||
title: t("testSuccess"),
|
||||
description: t("testSuccess")
|
||||
})
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "测试失败",
|
||||
description: "请检查 URL 是否正确且可访问",
|
||||
title: t("testFailed"),
|
||||
description: t("testFailed"),
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
@@ -109,9 +114,9 @@ export function WebhookConfig() {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>启用 Webhook</Label>
|
||||
<Label>{t("enable")}</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
当收到新邮件时通知指定的 URL
|
||||
{t("description")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -123,11 +128,11 @@ export function WebhookConfig() {
|
||||
{enabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-url">Webhook URL</Label>
|
||||
<Label htmlFor="webhook-url">{t("url")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="webhook-url"
|
||||
placeholder="https://example.com/webhook"
|
||||
placeholder={t("urlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
type="url"
|
||||
@@ -137,7 +142,7 @@ export function WebhookConfig() {
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"保存"
|
||||
tCommon("save")
|
||||
)}
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
@@ -157,13 +162,13 @@ export function WebhookConfig() {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>发送测试消息到此 Webhook</p>
|
||||
<p>{t("test")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
我们会向此 URL 发送 POST 请求,包含新邮件的相关信息
|
||||
{t("description2")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -174,26 +179,26 @@ export function WebhookConfig() {
|
||||
onClick={() => setShowDocs(!showDocs)}
|
||||
>
|
||||
{showDocs ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
查看数据格式说明
|
||||
{t("description3")}
|
||||
</button>
|
||||
|
||||
{showDocs && (
|
||||
<div className="rounded-md bg-muted p-4 text-sm space-y-3">
|
||||
<p>当收到新邮件时,我们会向配置的 URL 发送 POST 请求,请求头包含:</p>
|
||||
<p>{t("docs.intro")}</p>
|
||||
<pre className="bg-background p-2 rounded text-xs">
|
||||
Content-Type: application/json{'\n'}
|
||||
X-Webhook-Event: new_message
|
||||
</pre>
|
||||
|
||||
<p>请求体示例:</p>
|
||||
<p>{t("docs.exampleBody")}</p>
|
||||
<pre className="bg-background p-2 rounded text-xs overflow-auto">
|
||||
{`{
|
||||
"emailId": "email-uuid",
|
||||
"messageId": "message-uuid",
|
||||
"fromAddress": "sender@example.com",
|
||||
"subject": "邮件主题",
|
||||
"content": "邮件文本内容",
|
||||
"html": "邮件HTML内容",
|
||||
"subject": "${t("docs.subject")}",
|
||||
"content": "${t("docs.content")}",
|
||||
"html": "${t("docs.html")}",
|
||||
"receivedAt": "2024-01-01T12:00:00.000Z",
|
||||
"toAddress": "your-email@${window.location.host}"
|
||||
}`}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Settings } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export function WebsiteConfigPanel() {
|
||||
const t = useTranslations("profile.website")
|
||||
const tCard = useTranslations("profile.card")
|
||||
const [defaultRole, setDefaultRole] = useState<string>("")
|
||||
const [emailDomains, setEmailDomains] = useState<string>("")
|
||||
const [adminContact, setAdminContact] = useState<string>("")
|
||||
@@ -58,16 +61,16 @@ export function WebsiteConfigPanel() {
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("保存失败")
|
||||
if (!res.ok) throw new Error(t("saveFailed"))
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "网站设置已更新",
|
||||
title: t("saveSuccess"),
|
||||
description: t("saveSuccess"),
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
title: t("saveFailed"),
|
||||
description: error instanceof Error ? error.message : t("saveFailed"),
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
@@ -79,48 +82,48 @@ export function WebsiteConfigPanel() {
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Settings className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">网站设置</h2>
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">新用户默认角色:</span>
|
||||
<span className="text-sm">{t("defaultRole")}:</span>
|
||||
<Select value={defaultRole} onValueChange={setDefaultRole}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROLES.DUKE}>公爵</SelectItem>
|
||||
<SelectItem value={ROLES.KNIGHT}>骑士</SelectItem>
|
||||
<SelectItem value={ROLES.CIVILIAN}>平民</SelectItem>
|
||||
<SelectItem value={ROLES.DUKE}>{tCard("roles.DUKE")}</SelectItem>
|
||||
<SelectItem value={ROLES.KNIGHT}>{tCard("roles.KNIGHT")}</SelectItem>
|
||||
<SelectItem value={ROLES.CIVILIAN}>{tCard("roles.CIVILIAN")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">邮箱域名:</span>
|
||||
<span className="text-sm">{t("emailDomains")}:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={emailDomains}
|
||||
onChange={(e) => setEmailDomains(e.target.value)}
|
||||
placeholder="多个域名用逗号分隔,如: moemail.app,bitibiti.com"
|
||||
placeholder={t("emailDomainsPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">管理员联系方式:</span>
|
||||
<span className="text-sm">{t("adminContact")}:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={adminContact}
|
||||
onChange={(e) => setAdminContact(e.target.value)}
|
||||
placeholder="如: 微信号、邮箱等"
|
||||
placeholder={t("adminContactPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">最大邮箱数量:</span>
|
||||
<span className="text-sm">{t("maxEmails")}:</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="number"
|
||||
@@ -128,7 +131,7 @@ export function WebsiteConfigPanel() {
|
||||
max="100"
|
||||
value={maxEmails}
|
||||
onChange={(e) => setMaxEmails(e.target.value)}
|
||||
placeholder={`默认为 ${EMAIL_CONFIG.MAX_ACTIVE_EMAILS}`}
|
||||
placeholder={`${EMAIL_CONFIG.MAX_ACTIVE_EMAILS}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +141,7 @@ export function WebsiteConfigPanel() {
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
保存
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const locales = ['en', 'zh-CN'] as const
|
||||
export type Locale = typeof locales[number]
|
||||
|
||||
export const defaultLocale: Locale = 'en'
|
||||
|
||||
export const i18n = {
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: 'as-needed',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"signButton": {
|
||||
"login": "Login / Sign Up",
|
||||
"logout": "Logout",
|
||||
"userAvatar": "User Avatar",
|
||||
"linked": "Linked"
|
||||
},
|
||||
"loginForm": {
|
||||
"title": "Welcome to MoeMail",
|
||||
"subtitle": "Cute Temporary Email Service (。・∀・)ノ",
|
||||
"tabs": {
|
||||
"login": "Login",
|
||||
"register": "Sign Up"
|
||||
},
|
||||
"fields": {
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password"
|
||||
},
|
||||
"actions": {
|
||||
"login": "Login",
|
||||
"register": "Sign Up",
|
||||
"or": "OR",
|
||||
"githubLogin": "Login with GitHub"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "Please enter username",
|
||||
"passwordRequired": "Please enter password",
|
||||
"confirmPasswordRequired": "Please confirm password",
|
||||
"usernameInvalid": "Username cannot contain @ symbol",
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
},
|
||||
"toast": {
|
||||
"loginFailed": "Login Failed",
|
||||
"loginFailedDesc": "Incorrect username or password",
|
||||
"registerFailed": "Registration Failed",
|
||||
"registerFailedDesc": "Please try again later",
|
||||
"autoLoginFailed": "Auto-login failed, please login manually"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"lang": {
|
||||
"en": "English",
|
||||
"zhCN": "简体中文",
|
||||
"switch": "Switch Language"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"login": "Login",
|
||||
"profile": "Profile",
|
||||
"logout": "Logout",
|
||||
"backToMailbox": "Back to Mailbox"
|
||||
},
|
||||
"github": "Get Source Code"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"noPermission": {
|
||||
"title": "Insufficient Permission",
|
||||
"description": "You don't have permission to access this page. Please contact the website administrator.",
|
||||
"adminContact": "Admin Contact",
|
||||
"backToHome": "Back to Home"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "My Emails",
|
||||
"selectEmail": "Select an email to view messages",
|
||||
"messageContent": "Message Content",
|
||||
"selectMessage": "Select a message to view details",
|
||||
"backToEmailList": "← Back to Email List",
|
||||
"backToMessageList": "← Back to Message List"
|
||||
},
|
||||
"list": {
|
||||
"emailCount": "{count}/{max} emails",
|
||||
"emailCountUnlimited": "{count}/∞ emails",
|
||||
"loading": "Loading...",
|
||||
"loadingMore": "Loading more...",
|
||||
"noEmails": "No emails yet, create one!",
|
||||
"expiresAt": "Expires",
|
||||
"permanent": "Permanent",
|
||||
"deleteConfirm": "Confirm Delete",
|
||||
"deleteDescription": "Are you sure you want to delete {email}? This will also delete all messages in this mailbox and cannot be undone.",
|
||||
"deleteSuccess": "Email deleted successfully",
|
||||
"deleteFailed": "Failed to delete email",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Email",
|
||||
"name": "Email Prefix",
|
||||
"namePlaceholder": "Leave empty for random generation",
|
||||
"domain": "Domain",
|
||||
"domainPlaceholder": "Select a domain",
|
||||
"expiryTime": "Validity Period",
|
||||
"oneHour": "1 Hour",
|
||||
"oneDay": "1 Day",
|
||||
"threeDays": "3 Days",
|
||||
"permanent": "Permanent",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
"success": "Email created successfully",
|
||||
"failed": "Failed to create email"
|
||||
},
|
||||
"messages": {
|
||||
"received": "Received",
|
||||
"sent": "Sent",
|
||||
"noMessages": "No messages yet",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"subject": "Subject",
|
||||
"date": "Date",
|
||||
"loading": "Loading...",
|
||||
"loadingMore": "Loading more..."
|
||||
},
|
||||
"send": {
|
||||
"title": "Send Email",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"toPlaceholder": "Recipient email address",
|
||||
"subject": "Subject",
|
||||
"subjectPlaceholder": "Email subject",
|
||||
"content": "Content",
|
||||
"contentPlaceholder": "Email content (supports HTML)",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"success": "Email sent successfully",
|
||||
"failed": "Failed to send email",
|
||||
"dailyLimitReached": "Daily sending limit reached",
|
||||
"dailyLimit": "Daily Limit: {count}/{max}",
|
||||
"dailyLimitUnit": "emails/day"
|
||||
},
|
||||
"messageView": {
|
||||
"loading": "Loading message details...",
|
||||
"loadError": "Failed to load message details",
|
||||
"networkError": "Network error, please try again later",
|
||||
"retry": "Click to retry",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"time": "Time",
|
||||
"htmlFormat": "HTML Format",
|
||||
"textFormat": "Plain Text Format"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"title": "MoeMail",
|
||||
"subtitle": "Cute Temporary Email Service",
|
||||
"features": {
|
||||
"privacy": {
|
||||
"title": "Privacy Protection",
|
||||
"description": "Protect your real email address"
|
||||
},
|
||||
"instant": {
|
||||
"title": "Instant Delivery",
|
||||
"description": "Receive emails in real-time"
|
||||
},
|
||||
"expiry": {
|
||||
"title": "Auto Expiry",
|
||||
"description": "Automatically expires when due"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"enterMailbox": "Enter Mailbox",
|
||||
"getStarted": "Get Started"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "MoeMail - Cute Temporary Email Service",
|
||||
"description": "Secure, fast, and disposable temporary email addresses. Protect your privacy and stay away from spam. Instant delivery with automatic expiration.",
|
||||
"keywords": "temporary email, disposable email, anonymous email, privacy protection, spam filter, instant delivery, auto expiry, secure email, verification email, temporary account, cute email, email service, privacy security, MoeMail"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"title": "Profile",
|
||||
"card": {
|
||||
"title": "User Information",
|
||||
"name": "Name",
|
||||
"role": "Role",
|
||||
"roles": {
|
||||
"EMPEROR": "Emperor",
|
||||
"DUKE": "Duke",
|
||||
"KNIGHT": "Knight",
|
||||
"CIVILIAN": "Civilian"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key Management",
|
||||
"description": "Create and manage API keys for accessing OpenAPI",
|
||||
"create": "Create API Key",
|
||||
"name": "Key Name",
|
||||
"namePlaceholder": "Enter key name",
|
||||
"key": "API Key",
|
||||
"createdAt": "Created At",
|
||||
"copy": "Copy",
|
||||
"delete": "Delete",
|
||||
"noKeys": "No API keys yet",
|
||||
"createSuccess": "API key created successfully",
|
||||
"createFailed": "Failed to create API key",
|
||||
"deleteConfirm": "Confirm Delete",
|
||||
"deleteDescription": "Are you sure you want to delete API key {name}? This action cannot be undone.",
|
||||
"deleteSuccess": "API key deleted successfully",
|
||||
"deleteFailed": "Failed to delete API key",
|
||||
"viewDocs": "View Documentation",
|
||||
"docs": {
|
||||
"getConfig": "Get System Config",
|
||||
"generateEmail": "Generate Temp Email",
|
||||
"getEmails": "Get Email List",
|
||||
"getMessages": "Get Message List",
|
||||
"getMessage": "Get Single Message",
|
||||
"notes": "Notes:",
|
||||
"note1": "Replace YOUR_API_KEY with your actual API Key",
|
||||
"note2": "/api/config endpoint provides system configuration including available email domains",
|
||||
"note3": "emailId is the unique identifier for an email",
|
||||
"note4": "messageId is the unique identifier for a message",
|
||||
"note5": "expiryTime is the validity period in milliseconds: 3600000 (1 hour), 86400000 (1 day), 604800000 (7 days), 0 (permanent)",
|
||||
"note6": "domain is the email domain, get available domains from /api/config endpoint",
|
||||
"note7": "cursor is for pagination, get nextCursor from previous response",
|
||||
"note8": "All requests require X-API-Key header"
|
||||
}
|
||||
},
|
||||
"emailService": {
|
||||
"title": "Resend Email Service Configuration",
|
||||
"configRoleLabel": "Configurable Role Permissions",
|
||||
"enable": "Enable Email Service",
|
||||
"fixedRoleLimits": "Fixed Role Limits",
|
||||
"emperorLimit": "Emperor can send unlimited emails without any restrictions",
|
||||
"civilianLimit": "Cannot send emails",
|
||||
"enableDescription": "When enabled, emails will be sent using Resend",
|
||||
"apiKey": "Resend API Key",
|
||||
"apiKeyPlaceholder": "Enter Resend API Key",
|
||||
"dailyLimit": "Daily Limit",
|
||||
"roleLimits": "Roles allowed to use sending feature",
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"unlimited": "Unlimited",
|
||||
"disabled": "Sending permission not enabled",
|
||||
"enabled": "Sending permission enabled"
|
||||
},
|
||||
"webhook": {
|
||||
"title": "Webhook Configuration",
|
||||
"description": "Notify specified URL when new emails arrive",
|
||||
"description2": "We will send a POST request to this URL with information about the new email",
|
||||
"description3": "View data format documentation",
|
||||
"enable": "Enable Webhook",
|
||||
"url": "Webhook URL",
|
||||
"urlPlaceholder": "Enter webhook URL",
|
||||
"test": "Test",
|
||||
"testing": "Testing...",
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"testSuccess": "Webhook test successful",
|
||||
"testFailed": "Webhook test failed",
|
||||
"docs": {
|
||||
"intro": "When a new email is received, we will send a POST request to the configured URL with the following headers:",
|
||||
"exampleBody": "Request body example:",
|
||||
"subject": "Email Subject",
|
||||
"content": "Email text content",
|
||||
"html": "Email HTML content"
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"title": "Website Configuration",
|
||||
"description": "Configure website settings (Emperor only)",
|
||||
"defaultRole": "Default Role for New Users",
|
||||
"emailDomains": "Email Domains",
|
||||
"emailDomainsPlaceholder": "Separate multiple domains with commas",
|
||||
"adminContact": "Admin Contact",
|
||||
"adminContactPlaceholder": "Email or other contact method",
|
||||
"maxEmails": "Max Emails per User",
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration"
|
||||
},
|
||||
"promote": {
|
||||
"title": "Role Management",
|
||||
"description": "Manage user roles (Emperor only)",
|
||||
"search": "Search Users",
|
||||
"searchPlaceholder": "Enter username or email",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"actions": "Actions",
|
||||
"promote": "Set as",
|
||||
"noUsers": "No users found",
|
||||
"loading": "Loading...",
|
||||
"updateSuccess": "User role updated successfully",
|
||||
"updateFailed": "Failed to update user role"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"signButton": {
|
||||
"login": "登录/注册",
|
||||
"logout": "登出",
|
||||
"userAvatar": "用户头像",
|
||||
"linked": "已关联"
|
||||
},
|
||||
"loginForm": {
|
||||
"title": "欢迎使用 MoeMail",
|
||||
"subtitle": "萌萌哒临时邮箱服务 (。・∀・)ノ",
|
||||
"tabs": {
|
||||
"login": "登录",
|
||||
"register": "注册"
|
||||
},
|
||||
"fields": {
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码"
|
||||
},
|
||||
"actions": {
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"or": "或者",
|
||||
"githubLogin": "使用 GitHub 账号登录"
|
||||
},
|
||||
"errors": {
|
||||
"usernameRequired": "请输入用户名",
|
||||
"passwordRequired": "请输入密码",
|
||||
"confirmPasswordRequired": "请确认密码",
|
||||
"usernameInvalid": "用户名不能包含 @ 符号",
|
||||
"passwordTooShort": "密码长度必须大于等于8位",
|
||||
"passwordMismatch": "两次输入的密码不一致"
|
||||
},
|
||||
"toast": {
|
||||
"loginFailed": "登录失败",
|
||||
"loginFailedDesc": "用户名或密码错误",
|
||||
"registerFailed": "注册失败",
|
||||
"registerFailedDesc": "请稍后重试",
|
||||
"autoLoginFailed": "自动登录失败,请手动登录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "MoeMail"
|
||||
},
|
||||
"lang": {
|
||||
"en": "English",
|
||||
"zhCN": "简体中文",
|
||||
"switch": "切换语言"
|
||||
},
|
||||
"actions": {
|
||||
"ok": "确定",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"delete": "删除"
|
||||
},
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"login": "登录",
|
||||
"profile": "个人中心",
|
||||
"logout": "退出登录",
|
||||
"backToMailbox": "返回邮箱"
|
||||
},
|
||||
"github": "获取网站源代码"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"noPermission": {
|
||||
"title": "权限不足",
|
||||
"description": "你没有权限访问此页面,请联系网站管理员",
|
||||
"adminContact": "管理员联系方式",
|
||||
"backToHome": "返回首页"
|
||||
},
|
||||
"layout": {
|
||||
"myEmails": "我的邮箱",
|
||||
"selectEmail": "选择邮箱查看消息",
|
||||
"messageContent": "邮件内容",
|
||||
"selectMessage": "选择邮件查看详情",
|
||||
"backToEmailList": "← 返回邮箱列表",
|
||||
"backToMessageList": "← 返回消息列表"
|
||||
},
|
||||
"list": {
|
||||
"emailCount": "{count}/{max} 个邮箱",
|
||||
"emailCountUnlimited": "{count}/∞ 个邮箱",
|
||||
"loading": "加载中...",
|
||||
"loadingMore": "加载更多...",
|
||||
"noEmails": "还没有邮箱,创建一个吧!",
|
||||
"expiresAt": "过期时间",
|
||||
"permanent": "永久有效",
|
||||
"deleteConfirm": "确认删除",
|
||||
"deleteDescription": "确定要删除邮箱 {email} 吗?此操作将同时删除该邮箱中的所有邮件,且不可恢复。",
|
||||
"deleteSuccess": "邮箱已删除",
|
||||
"deleteFailed": "删除邮箱失败",
|
||||
"error": "错误",
|
||||
"success": "成功"
|
||||
},
|
||||
"create": {
|
||||
"title": "创建邮箱",
|
||||
"name": "邮箱前缀",
|
||||
"namePlaceholder": "留空则随机生成",
|
||||
"domain": "域名",
|
||||
"domainPlaceholder": "选择域名",
|
||||
"expiryTime": "有效期",
|
||||
"oneHour": "1 小时",
|
||||
"oneDay": "1 天",
|
||||
"threeDays": "3 天",
|
||||
"permanent": "永久",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
"success": "邮箱创建成功",
|
||||
"failed": "创建邮箱失败"
|
||||
},
|
||||
"messages": {
|
||||
"received": "收件箱",
|
||||
"sent": "已发送",
|
||||
"noMessages": "暂无邮件",
|
||||
"from": "发件人",
|
||||
"to": "收件人",
|
||||
"subject": "主题",
|
||||
"date": "日期",
|
||||
"loading": "加载中...",
|
||||
"loadingMore": "加载更多..."
|
||||
},
|
||||
"send": {
|
||||
"title": "发送邮件",
|
||||
"from": "发件人",
|
||||
"to": "收件人",
|
||||
"toPlaceholder": "收件人邮箱地址",
|
||||
"subject": "主题",
|
||||
"subjectPlaceholder": "邮件主题",
|
||||
"content": "内容",
|
||||
"contentPlaceholder": "邮件内容(支持 HTML)",
|
||||
"send": "发送",
|
||||
"sending": "发送中...",
|
||||
"success": "邮件发送成功",
|
||||
"failed": "发送邮件失败",
|
||||
"dailyLimitReached": "已达每日发送上限",
|
||||
"dailyLimit": "每日限额:{count}/{max}",
|
||||
"dailyLimitUnit": "封/天"
|
||||
},
|
||||
"messageView": {
|
||||
"loading": "加载邮件详情...",
|
||||
"loadError": "获取邮件详情失败",
|
||||
"networkError": "网络错误,请稍后重试",
|
||||
"retry": "点击重试",
|
||||
"from": "发件人",
|
||||
"to": "收件人",
|
||||
"time": "时间",
|
||||
"htmlFormat": "HTML 格式",
|
||||
"textFormat": "纯文本格式"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"title": "MoeMail",
|
||||
"subtitle": "萌萌哒临时邮箱服务",
|
||||
"features": {
|
||||
"privacy": {
|
||||
"title": "隐私保护",
|
||||
"description": "保护您的真实邮箱地址"
|
||||
},
|
||||
"instant": {
|
||||
"title": "即时收件",
|
||||
"description": "实时接收邮件通知"
|
||||
},
|
||||
"expiry": {
|
||||
"title": "自动过期",
|
||||
"description": "到期自动失效"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"enterMailbox": "进入邮箱",
|
||||
"getStarted": "开始使用"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "MoeMail - 萌萌哒临时邮箱服务",
|
||||
"description": "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
|
||||
"keywords": "临时邮箱, 一次性邮箱, 匿名邮箱, 隐私保护, 垃圾邮件过滤, 即时收件, 自动过期, 安全邮箱, 注册验证, 临时账号, 萌系邮箱, 电子邮件, 隐私安全, 邮件服务, MoeMail"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"title": "个人中心",
|
||||
"card": {
|
||||
"title": "用户信息",
|
||||
"name": "用户名",
|
||||
"role": "角色",
|
||||
"roles": {
|
||||
"EMPEROR": "皇帝",
|
||||
"DUKE": "公爵",
|
||||
"KNIGHT": "骑士",
|
||||
"CIVILIAN": "平民"
|
||||
}
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API Key 管理",
|
||||
"description": "创建和管理用于访问 OpenAPI 的 API 密钥",
|
||||
"create": "创建 API Key",
|
||||
"name": "密钥名称",
|
||||
"namePlaceholder": "输入密钥名称",
|
||||
"key": "API Key",
|
||||
"createdAt": "创建时间",
|
||||
"copy": "复制",
|
||||
"delete": "删除",
|
||||
"noKeys": "暂无 API Key",
|
||||
"createSuccess": "API Key 创建成功",
|
||||
"createFailed": "创建 API Key 失败",
|
||||
"deleteConfirm": "确认删除",
|
||||
"deleteDescription": "确定要删除 API Key {name} 吗?此操作不可恢复。",
|
||||
"deleteSuccess": "API Key 已删除",
|
||||
"deleteFailed": "删除 API Key 失败",
|
||||
"viewDocs": "查看使用文档",
|
||||
"docs": {
|
||||
"getConfig": "获取系统配置",
|
||||
"generateEmail": "生成临时邮箱",
|
||||
"getEmails": "获取邮箱列表",
|
||||
"getMessages": "获取邮件列表",
|
||||
"getMessage": "获取单封邮件",
|
||||
"notes": "注意:",
|
||||
"note1": "请将 YOUR_API_KEY 替换为你的实际 API Key",
|
||||
"note2": "/api/config 接口可获取系统配置,包括可用的邮箱域名列表",
|
||||
"note3": "emailId 是邮箱的唯一标识符",
|
||||
"note4": "messageId 是邮件的唯一标识符",
|
||||
"note5": "expiryTime 是邮箱的有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)",
|
||||
"note6": "domain 是邮箱域名,可通过 /api/config 接口获取可用域名列表",
|
||||
"note7": "cursor 用于分页,从上一次请求的响应中获取 nextCursor",
|
||||
"note8": "所有请求都需要包含 X-API-Key 请求头"
|
||||
}
|
||||
},
|
||||
"emailService": {
|
||||
"title": "Resend 发件服务配置",
|
||||
"configRoleLabel": "可配置的角色权限",
|
||||
"enable": "启用邮件服务",
|
||||
"fixedRoleLimits": "固定权限规则",
|
||||
"emperorLimit": "皇帝可以无限发件,不受任何限制",
|
||||
"civilianLimit": "永远不能发件",
|
||||
"enableDescription": "开启后将使用 Resend 发送邮件",
|
||||
"apiKey": "Resend API Key",
|
||||
"apiKeyPlaceholder": "输入 Resend API Key",
|
||||
"dailyLimit": "每日限额",
|
||||
"roleLimits": "允许使用发件功能的角色",
|
||||
"save": "保存配置",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "配置保存成功",
|
||||
"saveFailed": "保存配置失败",
|
||||
"unlimited": "无限制",
|
||||
"disabled": "未启用发件权限",
|
||||
"enabled": "已启用发件权限"
|
||||
},
|
||||
"webhook": {
|
||||
"title": "Webhook 配置",
|
||||
"description": "当收到新邮件时通知指定的 URL",
|
||||
"description2": "我们会向此 URL 发送 POST 请求,包含新邮件的相关信息",
|
||||
"description3": "查看数据格式说明",
|
||||
"enable": "启用 Webhook",
|
||||
"url": "Webhook URL",
|
||||
"urlPlaceholder": "输入 webhook URL",
|
||||
"test": "测试",
|
||||
"testing": "测试中...",
|
||||
"save": "保存配置",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "配置保存成功",
|
||||
"saveFailed": "保存配置失败",
|
||||
"testSuccess": "Webhook 测试成功",
|
||||
"testFailed": "Webhook 测试失败",
|
||||
"docs": {
|
||||
"intro": "当收到新邮件时,我们会向配置的 URL 发送 POST 请求,请求头包含:",
|
||||
"exampleBody": "请求体示例:",
|
||||
"subject": "邮件主题",
|
||||
"content": "邮件文本内容",
|
||||
"html": "邮件HTML内容"
|
||||
}
|
||||
},
|
||||
"website": {
|
||||
"title": "网站配置",
|
||||
"description": "配置网站设置(仅皇帝可用)",
|
||||
"defaultRole": "新用户默认角色",
|
||||
"emailDomains": "邮箱域名",
|
||||
"emailDomainsPlaceholder": "多个域名用逗号分隔",
|
||||
"adminContact": "管理员联系方式",
|
||||
"adminContactPlaceholder": "邮箱或其他联系方式",
|
||||
"maxEmails": "每个用户最大邮箱数",
|
||||
"save": "保存配置",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "配置保存成功",
|
||||
"saveFailed": "保存配置失败"
|
||||
},
|
||||
"promote": {
|
||||
"title": "角色管理",
|
||||
"description": "管理用户角色(仅皇帝可用)",
|
||||
"search": "搜索用户",
|
||||
"searchPlaceholder": "输入用户名或邮箱",
|
||||
"username": "用户名",
|
||||
"email": "邮箱",
|
||||
"role": "角色",
|
||||
"actions": "操作",
|
||||
"promote": "设为",
|
||||
"noUsers": "未找到用户",
|
||||
"loading": "加载中...",
|
||||
"updateSuccess": "用户角色更新成功",
|
||||
"updateFailed": "更新用户角色失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {getRequestConfig} from 'next-intl/server'
|
||||
import {i18n} from '@/i18n/config'
|
||||
|
||||
export default getRequestConfig(async ({locale}) => {
|
||||
const safeLocale = (i18n.locales.includes(locale as any) ? locale : i18n.defaultLocale) as string
|
||||
try {
|
||||
const common = (await import(`@/i18n/messages/${safeLocale}/common.json`)).default
|
||||
const home = (await import(`@/i18n/messages/${safeLocale}/home.json`)).default
|
||||
const auth = (await import(`@/i18n/messages/${safeLocale}/auth.json`)).default
|
||||
const metadata = (await import(`@/i18n/messages/${safeLocale}/metadata.json`)).default
|
||||
const emails = (await import(`@/i18n/messages/${safeLocale}/emails.json`)).default
|
||||
const profile = (await import(`@/i18n/messages/${safeLocale}/profile.json`)).default
|
||||
return {locale: safeLocale, messages: {common, home, auth, metadata, emails, profile}}
|
||||
} catch {
|
||||
return {locale: safeLocale, messages: {common: {}, home: {}, auth: {}, metadata: {}, emails: {}, profile: {}}}
|
||||
}
|
||||
})
|
||||
|
||||
+1
-101
@@ -1,107 +1,7 @@
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { zpix } from "./fonts"
|
||||
import "./globals.css"
|
||||
import { Providers } from "./providers"
|
||||
import { FloatMenu } from "@/components/float-menu"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MoeMail - 萌萌哒临时邮箱服务",
|
||||
description: "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
|
||||
keywords: [
|
||||
"临时邮箱",
|
||||
"一次性邮箱",
|
||||
"匿名邮箱",
|
||||
"隐私保护",
|
||||
"垃圾邮件过滤",
|
||||
"即时收件",
|
||||
"自动过期",
|
||||
"安全邮箱",
|
||||
"注册验证",
|
||||
"临时账号",
|
||||
"萌系邮箱",
|
||||
"电子邮件",
|
||||
"隐私安全",
|
||||
"邮件服务",
|
||||
"MoeMail"
|
||||
].join(", "),
|
||||
authors: [{ name: "SoftMoe Studio" }],
|
||||
creator: "SoftMoe Studio",
|
||||
publisher: "SoftMoe Studio",
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "zh_CN",
|
||||
url: "https://moemail.app",
|
||||
title: "MoeMail - 萌萌哒临时邮箱服务",
|
||||
description: "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
|
||||
siteName: "MoeMail",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "MoeMail - 萌萌哒临时邮箱服务",
|
||||
description: "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
icons: [
|
||||
{ rel: 'apple-touch-icon', url: '/icons/icon-192x192.png' },
|
||||
],
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#826DD9',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="application-name" content="MoeMail" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoeMail" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
zpix.variable,
|
||||
"font-zpix min-h-screen antialiased",
|
||||
"bg-background text-foreground",
|
||||
"transition-colors duration-300"
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange={false}
|
||||
storageKey="temp-mail-theme"
|
||||
>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
<Toaster />
|
||||
<FloatMenu />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
return children
|
||||
}
|
||||
|
||||
+47
-28
@@ -1,5 +1,6 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { NextResponse } from "next/server"
|
||||
import { i18n } from "@/i18n/config"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
import { Permission } from "@/lib/permissions"
|
||||
@@ -14,40 +15,57 @@ const API_PERMISSIONS: Record<string, Permission> = {
|
||||
}
|
||||
|
||||
export async function middleware(request: Request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
const url = new URL(request.url)
|
||||
const pathname = url.pathname
|
||||
|
||||
// API Key 认证
|
||||
request.headers.delete("X-User-Id")
|
||||
const apiKey = request.headers.get("X-API-Key")
|
||||
if (apiKey) {
|
||||
return handleApiKeyAuth(apiKey, pathname)
|
||||
}
|
||||
if (pathname.startsWith('/api')) {
|
||||
if (pathname.startsWith('/api/auth')) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Session 认证
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "未授权" },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
request.headers.delete("X-User-Id")
|
||||
const apiKey = request.headers.get("X-API-Key")
|
||||
if (apiKey) {
|
||||
return handleApiKeyAuth(apiKey, pathname)
|
||||
}
|
||||
|
||||
if (pathname === '/api/config' && request.method === 'GET') {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "未授权" },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
if (pathname === '/api/config' && request.method === 'GET') {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
for (const [route, permission] of Object.entries(API_PERMISSIONS)) {
|
||||
if (pathname.startsWith(route)) {
|
||||
const hasAccess = await checkPermission(permission)
|
||||
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "权限不足" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
for (const [route, permission] of Object.entries(API_PERMISSIONS)) {
|
||||
if (pathname.startsWith(route)) {
|
||||
const hasAccess = await checkPermission(permission)
|
||||
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "权限不足" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
// Pages: 语言前缀
|
||||
const segments = pathname.split('/')
|
||||
const maybeLocale = segments[1]
|
||||
const hasLocalePrefix = i18n.locales.includes(maybeLocale as any)
|
||||
if (!hasLocalePrefix) {
|
||||
const cookieLocale = request.headers.get('Cookie')?.match(/NEXT_LOCALE=([^;]+)/)?.[1]
|
||||
const targetLocale = (cookieLocale && i18n.locales.includes(cookieLocale as any)) ? cookieLocale : i18n.defaultLocale
|
||||
const redirectURL = new URL(`/${targetLocale}${pathname}${url.search}`, request.url)
|
||||
return NextResponse.redirect(redirectURL)
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
@@ -55,6 +73,7 @@ export async function middleware(request: Request) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next|.*\\..*).*)', // all pages excluding static assets
|
||||
'/api/emails/:path*',
|
||||
'/api/webhook/:path*',
|
||||
'/api/roles/:path*',
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { i18n } from "./app/i18n/config"
|
||||
|
||||
export default {
|
||||
locales: i18n.locales,
|
||||
defaultLocale: i18n.defaultLocale,
|
||||
localePrefix: i18n.localePrefix,
|
||||
}
|
||||
|
||||
|
||||
+10
-5
@@ -1,5 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import withPWA from 'next-pwa'
|
||||
import createNextIntlPlugin from 'next-intl/plugin'
|
||||
import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev';
|
||||
|
||||
async function setup() {
|
||||
@@ -10,7 +10,9 @@ async function setup() {
|
||||
|
||||
setup()
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
const withNextIntl = createNextIntlPlugin('./app/i18n/request.ts')
|
||||
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
@@ -21,10 +23,13 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default withPWA({
|
||||
const withPWAConfigured = withPWA({
|
||||
dest: 'public',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
// @ts-expect-error "ignore the error"
|
||||
})(nextConfig);
|
||||
}) as any
|
||||
|
||||
const configWithPWA = withPWAConfigured(nextConfig as any) as any
|
||||
|
||||
export default withNextIntl(configWithPWA)
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"nanoid": "^5.0.6",
|
||||
"next": "^15.1.5",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"next-intl": "^4.3.12",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"postal-mime": "^2.3.2",
|
||||
|
||||
Generated
+109
@@ -68,6 +68,9 @@ importers:
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.25
|
||||
version: 5.0.0-beta.25(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
||||
next-intl:
|
||||
specifier: ^4.3.12
|
||||
version: 4.3.12(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.7.2)
|
||||
next-pwa:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0(@babel/core@7.26.0)(esbuild@0.17.19)(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(webpack@5.97.1(esbuild@0.17.19))
|
||||
@@ -1382,6 +1385,24 @@ packages:
|
||||
'@floating-ui/utils@0.2.8':
|
||||
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
|
||||
|
||||
'@formatjs/ecma402-abstract@2.3.6':
|
||||
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
|
||||
|
||||
'@formatjs/fast-memoize@2.2.7':
|
||||
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
|
||||
|
||||
'@formatjs/icu-messageformat-parser@2.11.4':
|
||||
resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==}
|
||||
|
||||
'@formatjs/icu-skeleton-parser@1.8.16':
|
||||
resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==}
|
||||
|
||||
'@formatjs/intl-localematcher@0.5.10':
|
||||
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
|
||||
|
||||
'@formatjs/intl-localematcher@0.6.2':
|
||||
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
|
||||
|
||||
'@humanwhocodes/config-array@0.13.0':
|
||||
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
@@ -2325,6 +2346,9 @@ packages:
|
||||
'@rushstack/eslint-patch@1.10.4':
|
||||
resolution: {integrity: sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==}
|
||||
|
||||
'@schummar/icu-type-parser@1.21.5':
|
||||
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
|
||||
|
||||
'@sinclair/typebox@0.25.24':
|
||||
resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==}
|
||||
|
||||
@@ -3072,6 +3096,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -4048,6 +4075,9 @@ packages:
|
||||
resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
intl-messageformat@10.7.18:
|
||||
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
|
||||
|
||||
invariant@2.2.4:
|
||||
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
|
||||
|
||||
@@ -4486,6 +4516,10 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
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==}
|
||||
|
||||
@@ -4505,6 +4539,16 @@ packages:
|
||||
nodemailer:
|
||||
optional: true
|
||||
|
||||
next-intl@4.3.12:
|
||||
resolution: {integrity: sha512-yAmrQ3yx0zpNva/knniDvam3jT2d01Lv2aRgRxUIDL9zm9O4AsDjWbDIxX13t5RNf0KVnKkxH+iRcqEAmWecPg==}
|
||||
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:
|
||||
@@ -5598,6 +5642,11 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-intl@4.3.12:
|
||||
resolution: {integrity: sha512-RxW2/D17irlDOJOzClKl+kWA7ReGLpo/A8f/LF7w1kIxO6mPKVh422JJ/pDCcvtYFCI4aPtn1AXUfELKbM+7tg==}
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
|
||||
|
||||
use-sidecar@1.1.2:
|
||||
resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6953,6 +7002,36 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.8': {}
|
||||
|
||||
'@formatjs/ecma402-abstract@2.3.6':
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 2.2.7
|
||||
'@formatjs/intl-localematcher': 0.6.2
|
||||
decimal.js: 10.6.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@formatjs/fast-memoize@2.2.7':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@formatjs/icu-messageformat-parser@2.11.4':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.6
|
||||
'@formatjs/icu-skeleton-parser': 1.8.16
|
||||
tslib: 2.8.1
|
||||
|
||||
'@formatjs/icu-skeleton-parser@1.8.16':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@formatjs/intl-localematcher@0.5.10':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@formatjs/intl-localematcher@0.6.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@humanwhocodes/config-array@0.13.0':
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
@@ -7803,6 +7882,8 @@ snapshots:
|
||||
|
||||
'@rushstack/eslint-patch@1.10.4': {}
|
||||
|
||||
'@schummar/icu-type-parser@1.21.5': {}
|
||||
|
||||
'@sinclair/typebox@0.25.24': {}
|
||||
|
||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||
@@ -8715,6 +8796,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
@@ -9813,6 +9896,13 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.0.6
|
||||
|
||||
intl-messageformat@10.7.18:
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.6
|
||||
'@formatjs/fast-memoize': 2.2.7
|
||||
'@formatjs/icu-messageformat-parser': 2.11.4
|
||||
tslib: 2.8.1
|
||||
|
||||
invariant@2.2.4:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -10219,6 +10309,8 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next-auth@5.0.0-beta.25(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
|
||||
@@ -10227,6 +10319,16 @@ snapshots:
|
||||
next: 15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
react: 19.0.0
|
||||
|
||||
next-intl@4.3.12(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.7.2):
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.5.10
|
||||
negotiator: 1.0.0
|
||||
next: 15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
react: 19.0.0
|
||||
use-intl: 4.3.12(react@19.0.0)
|
||||
optionalDependencies:
|
||||
typescript: 5.7.2
|
||||
|
||||
next-pwa@5.6.0(@babel/core@7.26.0)(esbuild@0.17.19)(next@15.5.4(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(webpack@5.97.1(esbuild@0.17.19)):
|
||||
dependencies:
|
||||
babel-loader: 8.4.1(@babel/core@7.26.0)(webpack@5.97.1(esbuild@0.17.19))
|
||||
@@ -11370,6 +11472,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.0
|
||||
|
||||
use-intl@4.3.12(react@19.0.0):
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 2.2.7
|
||||
'@schummar/icu-type-parser': 1.21.5
|
||||
intl-messageformat: 10.7.18
|
||||
react: 19.0.0
|
||||
|
||||
use-sidecar@1.1.2(@types/react@19.0.0)(react@19.0.0):
|
||||
dependencies:
|
||||
detect-node-es: 1.1.0
|
||||
|
||||
Reference in New Issue
Block a user