feat: accessibility enhancement
All checks were successful
Deploy / Deploy (push) Successful in 1m16s

closes #3
This commit is contained in:
CDN 2025-02-03 19:28:27 +08:00
parent 3e652d5e4e
commit 01c5131055
Signed by: CDN
GPG key ID: 0C656827F9F80080
31 changed files with 1121 additions and 58 deletions

View file

@ -1,19 +1,24 @@
import React from 'react';
import React, { Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Helmet } from 'react-helmet-async';
import { ThemeProvider } from './contexts/ThemeContext';
import Navbar from './components/Navbar';
import HomePage from './pages/HomePage';
import ProjectsPage from './pages/ProjectsPage';
import UpdatesPage from './pages/UpdatesPage';
import UpdateDetailPage from './pages/UpdateDetailPage';
import ContributorsPage from './pages/ContributorsPage';
import AboutPage from './pages/AboutPage';
import Breadcrumb from './components/Breadcrumb';
import LoadingSpinner from './components/LoadingSpinner';
import iconMap from './utils/iconMap';
import { useQueryClient } from '@tanstack/react-query';
// 懒加载路由组件
const HomePage = React.lazy(() => import('./pages/HomePage'));
const ProjectsPage = React.lazy(() => import('./pages/ProjectsPage'));
const UpdatesPage = React.lazy(() => import('./pages/UpdatesPage'));
const UpdateDetailPage = React.lazy(() => import('./pages/UpdateDetailPage'));
const ContributorsPage = React.lazy(() => import('./pages/ContributorsPage'));
const AboutPage = React.lazy(() => import('./pages/AboutPage'));
function App() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const socialLinks = t('social.links', { returnObjects: true });
@ -29,25 +34,51 @@ function App() {
};
}, [queryClient]);
// 构建 Schema.org 结构化数据
const schemaOrgWebsite = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'STARSET Mirror',
url: 'https://mirror.starset.fans',
description: t('site.description'),
inLanguage: ['zh-Hans', 'en', 'zh-Hant'],
potentialAction: {
'@type': 'SearchAction',
target: 'https://mirror.starset.fans/search?q={search_term_string}',
'query-input': 'required name=search_term_string'
}
};
return (
<ThemeProvider>
<Router>
<div className="min-h-screen bg-white dark:bg-gray-900 transition-colors">
<Helmet>
<script type="application/ld+json">
{JSON.stringify(schemaOrgWebsite)}
</script>
</Helmet>
<Navbar />
<main className="pt-14">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/updates" element={<UpdatesPage />} />
<Route path="/updates/:id" element={<UpdateDetailPage />} />
<Route path="/contributors" element={<ContributorsPage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
<Breadcrumb />
<main className="pt-14" role="main" aria-label={t('aria.mainContent')}>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/updates" element={<UpdatesPage />} />
<Route path="/updates/:id" element={<UpdateDetailPage />} />
<Route path="/contributors" element={<ContributorsPage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
</Suspense>
</main>
<footer className="bg-gray-900 dark:bg-gray-950 text-white py-6">
<footer className="bg-gray-900 dark:bg-gray-950 text-white py-6" role="contentinfo">
<div className="container mx-auto px-4">
<div className="flex flex-col items-center">
<p className="text-gray-400 mb-4">{t('footer.copyright')}</p>
<p className="text-gray-400 mb-4"> {t('footer.copyright')}</p>
<div className="flex space-x-6">
{socialLinks.map((link: any) => {
const Icon = iconMap[link.icon as keyof typeof iconMap];
@ -57,10 +88,11 @@ function App() {
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="transition-colors"
className={link.color}
aria-label={link.name}
title={link.name}
>
<Icon className={`h-5 w-5 ${link.color}`} />
<Icon className="h-6 w-6" />
</a>
);
})}

View file

@ -0,0 +1,70 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const Breadcrumb: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
const pathnames = location.pathname.split('/').filter(x => x);
// 如果是首页,不显示面包屑
if (pathnames.length === 0) {
return null;
}
return (
<nav className="bg-gray-50 dark:bg-gray-800 py-3 px-4" aria-label={t('aria.breadcrumb')}>
<ol className="list-none p-0 inline-flex">
<li className="flex items-center">
<Link
to="/"
className="text-primary hover:text-primary-dark transition-colors"
aria-label={t('nav.home')}
>
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
</Link>
</li>
{pathnames.map((name, index) => {
const routeTo = `/${pathnames.slice(0, index + 1).join('/')}`;
const isLast = index === pathnames.length - 1;
return (
<li key={name} className="flex items-center">
<svg
className="w-4 h-4 mx-2 text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
{isLast ? (
<span className="text-gray-600 dark:text-gray-300" aria-current="page">
{t(`nav.${name}`)}
</span>
) : (
<Link
to={routeTo}
className="text-primary hover:text-primary-dark transition-colors"
>
{t(`nav.${name}`)}
</Link>
)}
</li>
);
})}
</ol>
</nav>
);
};
export default Breadcrumb;

View file

@ -0,0 +1,15 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
const LoadingSpinner: React.FC = () => {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center min-h-[200px]" role="status">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent" />
<span className="sr-only">{t('common.loading')}</span>
</div>
);
};
export default LoadingSpinner;

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Book, Menu, X } from 'lucide-react';
import { Menu, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import LanguageSwitcher from './LanguageSwitcher';
import ThemeSwitcher from './ThemeSwitcher';
@ -28,7 +28,7 @@ const Navbar = () => {
<div className="flex justify-between items-center h-14">
<div className="flex items-center">
<Link to="/" className="flex items-center">
<Book className="h-6 w-6 text-blue-600 dark:text-blue-400" />
<img src="/favicon.png" alt="Logo" className="h-6 w-6" />
<span className="ml-2 text-lg font-bold text-gray-900 dark:text-white">{t('brand.name')}</span>
</Link>
</div>

View file

@ -1,5 +1,6 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { HelmetProvider } from 'react-helmet-async';
import App from './App.tsx';
import './i18n';
import './index.css';
@ -15,8 +16,10 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
<HelmetProvider>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</HelmetProvider>
</StrictMode>
);

View file

@ -1,11 +1,94 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import About from '../components/About';
const AboutPage = () => {
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
const pageTitle = t('about.meta.title');
const pageDescription = t('about.meta.description');
// 构建页面级别的结构化数据
const schemaOrg = {
'@context': 'https://schema.org',
'@type': 'AboutPage',
name: pageTitle,
description: pageDescription,
url: 'https://mirror.starset.fans/about',
inLanguage: currentLang,
isPartOf: {
'@type': 'WebSite',
name: 'STARSET Mirror',
url: 'https://mirror.starset.fans'
},
breadcrumb: {
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
item: {
'@id': 'https://mirror.starset.fans',
name: t('nav.home')
}
},
{
'@type': 'ListItem',
position: 2,
item: {
'@id': 'https://mirror.starset.fans/projects',
name: t('nav.projects')
}
},
{
'@type': 'ListItem',
position: 3,
item: {
'@id': 'https://mirror.starset.fans/updates',
name: t('nav.updates')
}
},
{
'@type': 'ListItem',
position: 4,
item: {
'@id': 'https://mirror.starset.fans/contributors',
name: t('nav.contributors')
}
},
{
'@type': 'ListItem',
position: 5,
item: {
'@id': 'https://mirror.starset.fans/about',
name: t('nav.about')
}
}
]
}
};
return (
<div className="py-20">
<About />
</div>
<>
<Helmet>
<html lang={currentLang} />
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="website" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<script type="application/ld+json">
{JSON.stringify(schemaOrg)}
</script>
</Helmet>
<div className="py-20">
<About />
</div>
</>
);
};

View file

@ -1,11 +1,94 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import Contributors from '../components/Contributors';
const ContributorsPage = () => {
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
const pageTitle = t('contributors.meta.title');
const pageDescription = t('contributors.meta.description');
// 构建页面级别的结构化数据
const schemaOrg = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: pageTitle,
description: pageDescription,
url: 'https://mirror.starset.fans/contributors',
inLanguage: currentLang,
isPartOf: {
'@type': 'WebSite',
name: 'STARSET Mirror',
url: 'https://mirror.starset.fans'
},
breadcrumb: {
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
item: {
'@id': 'https://mirror.starset.fans',
name: t('nav.home')
}
},
{
'@type': 'ListItem',
position: 2,
item: {
'@id': 'https://mirror.starset.fans/projects',
name: t('nav.projects')
}
},
{
'@type': 'ListItem',
position: 3,
item: {
'@id': 'https://mirror.starset.fans/updates',
name: t('nav.updates')
}
},
{
'@type': 'ListItem',
position: 4,
item: {
'@id': 'https://mirror.starset.fans/contributors',
name: t('nav.contributors')
}
},
{
'@type': 'ListItem',
position: 5,
item: {
'@id': 'https://mirror.starset.fans/about',
name: t('nav.about')
}
}
]
}
};
return (
<div className="pb-20">
<Contributors />
</div>
<>
<Helmet>
<html lang={currentLang} />
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="website" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<script type="application/ld+json">
{JSON.stringify(schemaOrg)}
</script>
</Helmet>
<div className="pb-20">
<Contributors />
</div>
</>
);
};

View file

@ -1,11 +1,93 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import Hero from '../components/Hero';
const HomePage = () => {
const HomePage: React.FC = () => {
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
const pageTitle = t('home.meta.title');
const pageDescription = t('home.meta.description');
// 构建页面级别的结构化数据
const schemaOrg = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: pageTitle,
description: pageDescription,
url: 'https://mirror.starset.fans',
inLanguage: currentLang,
isPartOf: {
'@type': 'WebSite',
name: 'STARSET Mirror',
url: 'https://mirror.starset.fans'
},
breadcrumb: {
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
item: {
'@id': 'https://mirror.starset.fans',
name: t('nav.home')
}
},
{
'@type': 'ListItem',
position: 2,
item: {
'@id': 'https://mirror.starset.fans/projects',
name: t('nav.projects')
}
},
{
'@type': 'ListItem',
position: 3,
item: {
'@id': 'https://mirror.starset.fans/updates',
name: t('nav.updates')
}
},
{
'@type': 'ListItem',
position: 4,
item: {
'@id': 'https://mirror.starset.fans/contributors',
name: t('nav.contributors')
}
},
{
'@type': 'ListItem',
position: 5,
item: {
'@id': 'https://mirror.starset.fans/about',
name: t('nav.about')
}
}
]
}
};
return (
<div>
<>
<Helmet>
<html lang={currentLang} />
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="website" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<script type="application/ld+json">
{JSON.stringify(schemaOrg)}
</script>
</Helmet>
<Hero />
</div>
</>
);
};

View file

@ -1,11 +1,94 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import Projects from '../components/Projects';
const ProjectsPage = () => {
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
const pageTitle = t('projects.meta.title');
const pageDescription = t('projects.meta.description');
// 构建页面级别的结构化数据
const schemaOrg = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: pageTitle,
description: pageDescription,
url: 'https://mirror.starset.fans/projects',
inLanguage: currentLang,
isPartOf: {
'@type': 'WebSite',
name: 'STARSET Mirror',
url: 'https://mirror.starset.fans'
},
breadcrumb: {
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
item: {
'@id': 'https://mirror.starset.fans',
name: t('nav.home')
}
},
{
'@type': 'ListItem',
position: 2,
item: {
'@id': 'https://mirror.starset.fans/projects',
name: t('nav.projects')
}
},
{
'@type': 'ListItem',
position: 3,
item: {
'@id': 'https://mirror.starset.fans/updates',
name: t('nav.updates')
}
},
{
'@type': 'ListItem',
position: 4,
item: {
'@id': 'https://mirror.starset.fans/contributors',
name: t('nav.contributors')
}
},
{
'@type': 'ListItem',
position: 5,
item: {
'@id': 'https://mirror.starset.fans/about',
name: t('nav.about')
}
}
]
}
};
return (
<div className="py-20 bg-gray-50 dark:bg-gray-900 transition-colors">
<Projects />
</div>
<>
<Helmet>
<html lang={currentLang} />
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="website" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<script type="application/ld+json">
{JSON.stringify(schemaOrg)}
</script>
</Helmet>
<div className="py-20 bg-gray-50 dark:bg-gray-900 transition-colors">
<Projects />
</div>
</>
);
};

View file

@ -1,11 +1,94 @@
import React from 'react';
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import Timeline from '../components/Timeline';
const UpdatesPage = () => {
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
const pageTitle = t('updates.meta.title');
const pageDescription = t('updates.meta.description');
// 构建页面级别的结构化数据
const schemaOrg = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: pageTitle,
description: pageDescription,
url: 'https://mirror.starset.fans/updates',
inLanguage: currentLang,
isPartOf: {
'@type': 'WebSite',
name: 'STARSET Mirror',
url: 'https://mirror.starset.fans'
},
breadcrumb: {
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
item: {
'@id': 'https://mirror.starset.fans',
name: t('nav.home')
}
},
{
'@type': 'ListItem',
position: 2,
item: {
'@id': 'https://mirror.starset.fans/projects',
name: t('nav.projects')
}
},
{
'@type': 'ListItem',
position: 3,
item: {
'@id': 'https://mirror.starset.fans/updates',
name: t('nav.updates')
}
},
{
'@type': 'ListItem',
position: 4,
item: {
'@id': 'https://mirror.starset.fans/contributors',
name: t('nav.contributors')
}
},
{
'@type': 'ListItem',
position: 5,
item: {
'@id': 'https://mirror.starset.fans/about',
name: t('nav.about')
}
}
]
}
};
return (
<div className="py-20">
<Timeline />
</div>
<>
<Helmet>
<html lang={currentLang} />
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="website" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<script type="application/ld+json">
{JSON.stringify(schemaOrg)}
</script>
</Helmet>
<div className="py-20">
<Timeline />
</div>
</>
);
};