feat(i18n): implement internationalization support for en and zh
Add i18n infrastructure with translation files and utility functions Update components to use translations and language switching Create localized pages for en and zh languages Add language detection and path localization utilities
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import { personalInfo } from "@/lib/data";
|
||||
import { useTranslations, type Lang } from "@/i18n/utils";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function Footer() {
|
||||
interface FooterProps {
|
||||
lang: Lang;
|
||||
}
|
||||
|
||||
export default function Footer({ lang }: FooterProps) {
|
||||
const t = useTranslations(lang);
|
||||
return (
|
||||
<footer className="border-t border-purple-500/10 py-6 bg-gradient-to-b from-background to-muted/20 backdrop-blur-sm">
|
||||
<div className="container max-w-4xl mx-auto px-6 md:px-4">
|
||||
@@ -16,8 +22,7 @@ export default function Footer() {
|
||||
className="text-sm text-muted-foreground text-center md:text-left"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
>
|
||||
© {new Date().getFullYear()} {personalInfo.name}. All rights
|
||||
reserved. ✨
|
||||
© {new Date().getFullYear()} {personalInfo.name}. {t('footer.rights')} ✨
|
||||
</motion.p>
|
||||
<motion.p
|
||||
className="text-sm text-muted-foreground mt-2 md:mt-0 text-center md:text-left"
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import ThemeToggle from "./ui/theme-toggle";
|
||||
import LanguageSwitcher from "./LanguageSwitcher"; // Added import for LanguageSwitcher
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { useTranslations, getLocalizedPath, type Lang } from "@/i18n/utils";
|
||||
import { personalInfo } from "@/lib/data";
|
||||
import { useState } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function GlassHeader() {
|
||||
interface GlassHeaderProps {
|
||||
lang: Lang;
|
||||
}
|
||||
|
||||
export default function GlassHeader({ lang }: GlassHeaderProps) {
|
||||
const t = useTranslations(lang);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
|
||||
@@ -15,7 +21,7 @@ export default function GlassHeader() {
|
||||
<div className="container max-w-4xl mx-auto p-4 flex justify-between items-center">
|
||||
<motion.a
|
||||
className="flex items-center text-lg font-medium"
|
||||
href="/"
|
||||
href={getLocalizedPath('/', lang)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
@@ -24,23 +30,25 @@ export default function GlassHeader() {
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
|
||||
{["experience", "skills", "projects", "awards", "education"].map(
|
||||
{[
|
||||
{ key: 'nav.experience', icon: '💼 ', sectionId: 'experience' },
|
||||
{ key: 'nav.skills', icon: '🛠️ ', sectionId: 'skills' },
|
||||
{ key: 'nav.projects', icon: '🚀 ', sectionId: 'projects' },
|
||||
{ key: 'nav.awards', icon: '🏆 ', sectionId: 'awards' },
|
||||
{ key: 'nav.education', icon: '🎓 ', sectionId: 'education' },
|
||||
].map(
|
||||
(item, index) => (
|
||||
<motion.a
|
||||
key={item}
|
||||
href={`#${item}`}
|
||||
key={item.key} // Changed from item to item.key
|
||||
href={`#${item.sectionId}`}
|
||||
className="transition-colors hover:text-foreground/80 text-foreground/60"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.1 }}
|
||||
whileHover={{ y: -2 }}
|
||||
>
|
||||
{item === "experience" && "💼 "}
|
||||
{item === "skills" && "🛠️ "}
|
||||
{item === "projects" && "🚀 "}
|
||||
{item === "awards" && "🏆 "}
|
||||
{item === "education" && "🎓 "}
|
||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
||||
{item.icon}
|
||||
{t(item.key as any) /* Type assertion needed if UiKeys is strict */}
|
||||
</motion.a>
|
||||
)
|
||||
)}
|
||||
@@ -48,7 +56,7 @@ export default function GlassHeader() {
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Language Switcher added here */}
|
||||
<LanguageSwitcher />
|
||||
<LanguageSwitcher lang={lang} />
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
@@ -74,23 +82,25 @@ export default function GlassHeader() {
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<nav className="flex flex-col space-y-4 text-sm font-medium">
|
||||
{["experience", "skills", "projects", "awards", "education"].map(
|
||||
{[
|
||||
{ key: 'nav.experience', icon: '💼 ', sectionId: 'experience' },
|
||||
{ key: 'nav.skills', icon: '🛠️ ', sectionId: 'skills' },
|
||||
{ key: 'nav.projects', icon: '🚀 ', sectionId: 'projects' },
|
||||
{ key: 'nav.awards', icon: '🏆 ', sectionId: 'awards' },
|
||||
{ key: 'nav.education', icon: '🎓 ', sectionId: 'education' },
|
||||
].map(
|
||||
(item, index) => (
|
||||
<motion.a
|
||||
key={item}
|
||||
href={`#${item}`}
|
||||
key={item.key} // Changed from item to item.key
|
||||
href={`#${item.sectionId}`}
|
||||
className="transition-colors hover:text-foreground/80 text-foreground/60 py-2"
|
||||
onClick={toggleMenu}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.1 }}
|
||||
>
|
||||
{item === "experience" && "💼 "}
|
||||
{item === "skills" && "🛠️ "}
|
||||
{item === "projects" && "🚀 "}
|
||||
{item === "awards" && "🏆 "}
|
||||
{item === "education" && "🎓 "}
|
||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
||||
{item.icon}
|
||||
{t(item.key as any) /* Type assertion needed if UiKeys is strict */}
|
||||
</motion.a>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getLangFromUrl, getLocalizedPath, type Lang } from "@/i18n/utils";
|
||||
import { languages as i18nLanguages, defaultLang } from "@/i18n/ui";
|
||||
import { Languages, Check } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
const languages = [
|
||||
{ code: "en", name: "English", icon: "🇬🇧" }, // Added icon for English
|
||||
{ code: "zh", name: "中文", icon: "🇨🇳" }, // Added icon for Chinese
|
||||
];
|
||||
const availableLanguages = Object.entries(i18nLanguages).map(([code, name]) => ({
|
||||
code: code as Lang,
|
||||
name,
|
||||
// You can add icons here if you have a mapping or a more complex structure in ui.ts
|
||||
icon: code === 'en' ? '🇬🇧' : code === 'zh' ? '🇨🇳' : '🌐'
|
||||
}));
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
interface LanguageSwitcherProps {
|
||||
lang: Lang;
|
||||
}
|
||||
|
||||
export default function LanguageSwitcher({ lang: initialLang }: LanguageSwitcherProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// TODO: Implement actual language switching logic, e.g., using a context or a library
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(() => {
|
||||
return availableLanguages.find(l => l.code === initialLang) || availableLanguages.find(l => l.code === defaultLang) || availableLanguages[0];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const currentLangObject = availableLanguages.find(l => l.code === initialLang);
|
||||
if (currentLangObject && currentLangObject.code !== selectedLanguage.code) {
|
||||
setSelectedLanguage(currentLangObject);
|
||||
}
|
||||
}, [initialLang, selectedLanguage.code]);
|
||||
|
||||
const toggleOpen = () => setIsOpen(!isOpen);
|
||||
|
||||
const handleSelectLanguage = (lang: typeof languages[0]) => {
|
||||
const handleSelectLanguage = (lang: typeof availableLanguages[0]) => {
|
||||
setSelectedLanguage(lang);
|
||||
setIsOpen(false);
|
||||
// Here you would typically call a function to change the language of the application
|
||||
console.log(`Language changed to: ${lang.name}`);
|
||||
if (typeof window !== 'undefined') {
|
||||
const currentPath = window.location.pathname.replace(/\/en|\/zh/, ''); // Remove lang prefix
|
||||
const newPath = getLocalizedPath(currentPath || '/', lang.code);
|
||||
window.location.href = newPath;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -41,7 +60,7 @@ export default function LanguageSwitcher() {
|
||||
className="absolute right-0 mt-2 w-40 origin-top-right rounded-md bg-popover text-popover-foreground shadow-md focus:outline-none z-50 border border-border/20"
|
||||
>
|
||||
<div className="p-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
|
||||
{languages.map((lang) => (
|
||||
{availableLanguages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleSelectLanguage(lang)}
|
||||
|
||||
32
src/i18n/ui.ts
Normal file
32
src/i18n/ui.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/i18n/ui.ts
|
||||
export const languages = {
|
||||
en: 'English',
|
||||
zh: '简体中文',
|
||||
} as const;
|
||||
|
||||
export const defaultLang = 'en';
|
||||
|
||||
export const ui = {
|
||||
en: {
|
||||
'nav.home': 'Home',
|
||||
'nav.projects': 'Projects',
|
||||
'nav.experience': 'Experience',
|
||||
'nav.skills': 'Skills',
|
||||
'nav.awards': 'Awards',
|
||||
'nav.education': 'Education',
|
||||
'footer.rights': 'All rights reserved.',
|
||||
'site.title': 'My Portfolio',
|
||||
// 根据您的项目实际情况添加更多翻译键值对
|
||||
},
|
||||
zh: {
|
||||
'nav.home': '首页',
|
||||
'nav.projects': '项目经历',
|
||||
'nav.experience': '工作经历',
|
||||
'nav.skills': '专业技能',
|
||||
'nav.awards': '奖项荣誉',
|
||||
'nav.education': '教育背景',
|
||||
'footer.rights': '版权所有。',
|
||||
'site.title': '我的作品集',
|
||||
// 根据您的项目实际情况添加更多翻译键值对
|
||||
},
|
||||
} as const;
|
||||
41
src/i18n/utils.ts
Normal file
41
src/i18n/utils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// src/i18n/utils.ts
|
||||
import { ui, defaultLang, languages } from './ui';
|
||||
|
||||
export type Lang = keyof typeof languages;
|
||||
export type UiKeys = keyof typeof ui[typeof defaultLang];
|
||||
|
||||
export function getLangFromUrl(url: URL): Lang {
|
||||
const [, lang] = url.pathname.split('/');
|
||||
if (lang in languages) return lang as Lang;
|
||||
return defaultLang;
|
||||
}
|
||||
|
||||
export function useTranslations(lang: Lang | undefined) {
|
||||
const currentLang = lang || defaultLang;
|
||||
return function t(key: UiKeys, ...args: any[]): string {
|
||||
let translation: string = ui[currentLang][key] || ui[defaultLang][key];
|
||||
if (args.length > 0) {
|
||||
args.forEach((arg, index) => {
|
||||
translation = translation.replace(`{${index}}`, arg);
|
||||
});
|
||||
}
|
||||
return translation;
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalizedPath(path: string, lang: Lang | undefined): string {
|
||||
const currentLang = lang || defaultLang;
|
||||
const basePath = import.meta.env.BASE_URL === '/' ? '' : import.meta.env.BASE_URL;
|
||||
|
||||
// If the current language is the default language, do not add a language prefix.
|
||||
if (currentLang === defaultLang) {
|
||||
const fullPath = `${basePath}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
return fullPath.replace(/\/\/+/g, '/');
|
||||
}
|
||||
|
||||
// Otherwise, add the language prefix.
|
||||
const langPrefix = `/${currentLang}`;
|
||||
const fullPath = `${basePath}${langPrefix}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
// Remove any double slashes that might occur.
|
||||
return fullPath.replace(/\/\/+/g, '/');
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
---
|
||||
import { type Lang, useTranslations } from "@/i18n/utils";
|
||||
import "../styles/global.css";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
lang: Lang;
|
||||
}
|
||||
|
||||
const { title = "Rishikesh S - Portfolio", description = "My Portfolio" } =
|
||||
const { title = "Rishikesh S - Portfolio", description = "My Portfolio", lang } =
|
||||
Astro.props;
|
||||
const t = useTranslations(lang);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="description" content={description} />
|
||||
<title>{title}</title>
|
||||
<title>{title}{t('site.title') ? ` | ${t('site.title')}` : ''}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import { getLangFromUrl } from "@/i18n/utils";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import GlassHeader from "@/components/GlassHeader";
|
||||
import HeroSection from "@/components/HeroSection";
|
||||
@@ -8,10 +9,12 @@ import ProjectsSection from "@/components/ProjectsSection";
|
||||
import AwardsSection from "@/components/AwardsSection";
|
||||
import EducationSection from "@/components/EducationSection";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<GlassHeader client:only="react" />
|
||||
<Layout title="Home" lang={lang}>
|
||||
<GlassHeader lang={lang} client:only="react" />
|
||||
<main class="min-h-screen">
|
||||
<HeroSection client:only="react" />
|
||||
<ExperienceSection client:only="react" />
|
||||
@@ -20,5 +23,5 @@ import Footer from "@/components/Footer";
|
||||
<AwardsSection client:only="react" />
|
||||
<EducationSection client:only="react" />
|
||||
</main>
|
||||
<Footer client:only="react" />
|
||||
<Footer lang={lang} client:only="react" />
|
||||
</Layout>
|
||||
|
||||
27
src/pages/zh/index.astro
Normal file
27
src/pages/zh/index.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import { getLangFromUrl } from "@/i18n/utils";
|
||||
import Layout from "@/layouts/Layout.astro";
|
||||
import GlassHeader from "@/components/GlassHeader";
|
||||
import HeroSection from "@/components/HeroSection";
|
||||
import ExperienceSection from "@/components/ExperienceSection";
|
||||
import SkillsSection from "@/components/SkillsSection";
|
||||
import ProjectsSection from "@/components/ProjectsSection";
|
||||
import AwardsSection from "@/components/AwardsSection";
|
||||
import EducationSection from "@/components/EducationSection";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
---
|
||||
|
||||
<Layout title="首页" lang={lang}>
|
||||
<GlassHeader lang={lang} client:only="react" />
|
||||
<main class="min-h-screen">
|
||||
<HeroSection client:only="react" />
|
||||
<ExperienceSection client:only="react" />
|
||||
<SkillsSection client:only="react" />
|
||||
<ProjectsSection client:only="react" />
|
||||
<AwardsSection client:only="react" />
|
||||
<EducationSection client:only="react" />
|
||||
</main>
|
||||
<Footer lang={lang} client:only="react" />
|
||||
</Layout>
|
||||
Reference in New Issue
Block a user