feat: landing page i18n

This commit is contained in:
JOYCEQL
2024-12-30 12:58:13 +08:00
committed by qingchen
parent ed12aba86c
commit 9c4b007612
14 changed files with 266 additions and 50 deletions
+19 -7
View File
@@ -1,18 +1,30 @@
/** @type {import('next').NextConfig} */
import createNextIntlPlugin from "next-intl/plugin";
const nextConfig = {
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const config = {
typescript: {
ignoreBuildErrors: true,
ignoreBuildErrors: true
},
async redirects() {
return [
{
source: "/",
destination: "/en",
permanent: true
}
];
},
async rewrites() {
return [
{
source: "/generate-pdf",
destination:
"http://1255612844-0z3iovadu8.ap-chengdu.tencentscf.com/generate-pdf",
},
"http://1255612844-0z3iovadu8.ap-chengdu.tencentscf.com/generate-pdf"
}
];
},
}
};
export default nextConfig;
export default withNextIntl(config);
+27
View File
@@ -0,0 +1,27 @@
import { notFound } from "next/navigation";
import { NextIntlClientProvider, useMessages } from "next-intl";
import { locales } from "@/i18n/config";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default function LocaleLayout({
children,
params: { locale }
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const messages = useMessages();
if (!locales.includes(locale as any)) {
notFound();
}
return (
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
);
}
@@ -1,11 +1,14 @@
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import Header from "@/components/home/Header";
import { HeroAnimation } from "@/components/home/HeroAnimation";
import { FeaturesAnimation } from "@/components/home/FeaturesAnimation";
import FeaturesAnimation from "@/components/home/FeaturesAnimation";
import HeroAnimation from "@/components/home/HeroAnimation";
export default function LandingPage() {
const t = useTranslations("home");
return (
<div className="min-h-screen bg-background">
<Header />
@@ -15,11 +18,11 @@ export default function LandingPage() {
<div className="max-w-5xl mx-auto px-4">
<div className="text-center space-y-6">
<HeroAnimation>
<h1 className="text-5xl md:text-7xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary">
<h1 className="text-5xl md:text-7xl font-extrabold bg-clip-text text-primary ">
{t("hero.title")}
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mt-6">
{t("hero.subtitle")}
</p>
<div className="mt-12">
<Link href="/dashboard">
@@ -27,13 +30,13 @@ export default function LandingPage() {
size="lg"
className="rounded-full px-10 py-6 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
>
{t("hero.cta")}
</Button>
</Link>
</div>
</HeroAnimation>
<div className="mt-16 relative rounded-2xl overflow-hidden bg-white">
<div className="relative rounded-xl overflow-hidden bg-gradient-to-b from-gray-100 to-white dark:from-gray-900 dark:to-black p-2">
<Image
width={1280}
height={720}
@@ -55,13 +58,14 @@ export default function LandingPage() {
<div className="space-y-6">
<div className="inline-block px-4 py-2 bg-blue-50 dark:bg-blue-950 rounded-full">
<span className="text-blue-600 dark:text-blue-400 font-medium">
AI
{t("features.ai.badge")}
</span>
</div>
<h3 className="text-3xl font-bold"></h3>
<h3 className="text-3xl font-bold">
{t("features.ai.title")}
</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
{t("features.ai.description")}
</p>
</div>
</div>
@@ -96,14 +100,14 @@ export default function LandingPage() {
<div className="space-y-6">
<div className="inline-block px-4 py-2 bg-purple-50 dark:bg-purple-950 rounded-full">
<span className="text-purple-600 dark:text-purple-400 font-medium">
{t("features.storage.badge")}
</span>
</div>
<h3 className="text-3xl font-bold"></h3>
<h3 className="text-3xl font-bold">
{t("features.storage.title")}
</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
{t("features.storage.description")}
</p>
</div>
</div>
@@ -116,13 +120,14 @@ export default function LandingPage() {
<div className="space-y-6">
<div className="inline-block px-4 py-2 bg-green-50 dark:bg-green-950 rounded-full">
<span className="text-green-600 dark:text-green-400 font-medium">
{t("features.preview.badge")}
</span>
</div>
<h3 className="text-3xl font-bold"></h3>
<h3 className="text-3xl font-bold">
{t("features.preview.title")}
</h3>
<p className="text-gray-600 dark:text-gray-300 text-lg">
PDF
{t("features.preview.description")}
</p>
</div>
</div>
@@ -145,7 +150,7 @@ export default function LandingPage() {
<footer className="border-t py-8">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center text-sm text-muted-foreground">
<p>&#169; 2024 Magic Resume. All rights reserved.</p>
<p>{t("footer.copyright")}</p>
</div>
</div>
</footer>
+2 -2
View File
@@ -8,11 +8,11 @@ const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "魔法简历",
description: "极度自由的在线简历编辑器",
description: "极度自由的在线简历编辑器"
};
export default function RootLayout({
children,
children
}: Readonly<{
children: React.ReactNode;
}>) {
@@ -1,13 +1,10 @@
"use client";
import { motion } from "framer-motion";
import { PropsWithChildren } from "react";
import { motion } from "framer-motion";
interface FeaturesAnimationProps extends PropsWithChildren {
index: number;
}
export function FeaturesAnimation({ children, index }: FeaturesAnimationProps) {
const FeaturesAnimation = ({ children, index }: FeaturesAnimationProps) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -18,4 +15,5 @@ export function FeaturesAnimation({ children, index }: FeaturesAnimationProps) {
{children}
</motion.div>
);
}
};
export default FeaturesAnimation;
+21 -6
View File
@@ -1,20 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { motion } from "framer-motion";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import Logo from "@/components/shared/Logo";
import ThemeToggle from "@/components/shared/ThemeToggle";
import GitHubStars from "@/components/shared/GitHubStars";
import LanguageSwitch from "../shared/LanguageSwitch";
export default function Header() {
const [mounted, setMounted] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);
const t = useTranslations("home");
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const handleScroll = () => {
const currentScrollY = window.scrollY;
@@ -35,7 +44,11 @@ export default function Header() {
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [lastScrollY]);
}, [lastScrollY, mounted]);
if (!mounted) {
return null;
}
return (
<div className="fixed w-full z-50 flex justify-center">
@@ -44,19 +57,21 @@ export default function Header() {
initial={{ y: 0, opacity: 1 }}
animate={{
y: isVisible ? 0 : -100,
opacity: isVisible ? 1 : 0,
opacity: isVisible ? 1 : 0
}}
transition={{
duration: 0.2,
duration: 0.2
}}
>
<div className="mt-4 rounded-full bg-background/70 backdrop-blur-[8px] border border-border/50">
<div className="relative flex items-center justify-between h-12 px-6">
<div className="flex items-center space-x-2">
<Logo size={32} />
<span className="font-bold text-base">Magic Resume</span>
<span className="font-bold text-base">{t("header.title")}</span>
</div>
<div className="flex items-center space-x-2">
<LanguageSwitch />
<ThemeToggle>
<div className="w-8 h-8 relative cursor-pointer rounded-md hover:bg-accent/50 flex items-center justify-center">
<Sun className="h-[1.2rem] w-[1.2rem] absolute inset-0 m-auto rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
@@ -69,7 +84,7 @@ export default function Header() {
variant="default"
className="bg-primary hover:opacity-90 text-white h-8 text-sm rounded-full px-4"
>
使
{t("header.startButton")}
</Button>
</Link>
</div>
@@ -1,9 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { PropsWithChildren } from "react";
export function HeroAnimation({ children }: PropsWithChildren) {
import { motion } from "framer-motion";
const HeroAnimation = ({ children }: PropsWithChildren) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -13,4 +11,5 @@ export function HeroAnimation({ children }: PropsWithChildren) {
{children}
</motion.div>
);
}
};
export default HeroAnimation;
@@ -0,0 +1,57 @@
"use client";
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import { Languages } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { locales, localeNames } from "@/i18n/config";
export default function LanguageSwitch() {
const locale = useLocale();
const router = useRouter();
const handleLanguageChange = (newLocale: string) => {
const currentPath = window.location.pathname;
if (currentPath === `/${locale}`) {
router.push(`/${newLocale}`);
} else {
const newPath = currentPath.replace(`/${locale}/`, `/${newLocale}/`);
router.push(newPath);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 relative hover:bg-accent/50"
>
<Languages className="h-[1.2rem] w-[1.2rem]" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => handleLanguageChange(loc)}
className={locale === loc ? "bg-accent" : ""}
>
<span className="flex items-center gap-2">
{localeNames[loc]}
{locale === loc && (
<span className="text-xs text-muted-foreground"></span>
)}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,19 +1,27 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
const ThemeToggle = ({ children }: { children?: React.ReactNode }) => {
const { setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
+9
View File
@@ -0,0 +1,9 @@
export const locales = ["zh", "en"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "zh";
export const localeNames: Record<Locale, string> = {
zh: "中文",
en: "English"
};
+33
View File
@@ -0,0 +1,33 @@
{
"home": {
"header": {
"title": "Magic Resume",
"startButton": "Get Started"
},
"hero": {
"title": "Open Source, Privacy First",
"subtitle": "No registration required, all data stored locally.",
"cta": "Create Resume Now"
},
"features": {
"ai": {
"badge": "AI Smart Check",
"title": "Smart Detection, Professional Advice",
"description": "Built-in intelligent grammar check, automatically identifies inappropriate expressions, and provides professional modification suggestions to make your resume stand out."
},
"storage": {
"badge": "Local Storage",
"title": "Data Security, Privacy First",
"description": "All resume data is stored entirely on your local device, no need to worry about privacy leaks. Supports data export backup, ensuring your resume data is always available. No registration required to enjoy all features."
},
"preview": {
"badge": "Real-time Preview",
"title": "What You See Is What You Get",
"description": "Edit and preview simultaneously, see resume effects in real-time. Supports multiple professional templates, making your resume both beautiful and standardized. Quick PDF export for immediate resume submission."
}
},
"footer": {
"copyright": "© 2024 Magic Resume. All rights reserved."
}
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"home": {
"header": {
"title": "魔法简历",
"startButton": "开始使用"
},
"hero": {
"title": "开源免费,隐私优先",
"subtitle": "无需注册登录,数据完全存储在本地。",
"cta": "立即创建简历"
},
"features": {
"ai": {
"badge": "AI 智能纠错",
"title": "智能识别,专业建议",
"description": "内置智能语法检查,自动识别不恰当的表达,提供专业的修改建议,让您的简历更加出色。"
},
"storage": {
"badge": "本地存储",
"title": "数据安全,隐私优先",
"description": "所有简历数据完全存储在您的本地设备中,无需担心隐私泄露。支持数据导出备份,确保您的简历数据随时可用。无需注册登录,即可享受所有功能。"
},
"preview": {
"badge": "实时预览",
"title": "所见即所得",
"description": "边编辑边预览,实时查看简历效果。支持多种专业模板,让您的简历既美观又规范。快速导出PDF,随时投递简历。"
}
},
"footer": {
"copyright": "© 2024 Magic Resume. All rights reserved."
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ requestLocale }) => {
const locale = await requestLocale;
return {
messages: (await import(`./locales/${locale}.json`)).default
};
});
+12
View File
@@ -0,0 +1,12 @@
import createMiddleware from "next-intl/middleware";
import { locales, defaultLocale } from "./i18n/config";
export default createMiddleware({
locales,
defaultLocale,
localePrefix: "always"
});
export const config = {
matcher: ["/", "/(zh|en)"]
};