mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: landing page i18n
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>© 2024 Magic Resume. All rights reserved.</p>
|
||||
<p>{t("footer.copyright")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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)"]
|
||||
};
|
||||
Reference in New Issue
Block a user