init
This commit is contained in:
commit
d2f36ea823
24 changed files with 3540 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
13
index.html
Normal file
13
index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>全译 W3C</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
39
package.json
Normal file
39
package.json
Normal file
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "wholetrans-w3c",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"md5": "^2.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@tailwindcss/postcss": "^4.0.6",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"postcss": "^8.5.2",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.0",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
2437
pnpm-lock.yaml
generated
Normal file
2437
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
519
src/App.tsx
Normal file
519
src/App.tsx
Normal file
|
@ -0,0 +1,519 @@
|
|||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { getSpecs, getSpecContributors, getAvatarInfo, getSpecTags, resolveTranslationUrl } from './data';
|
||||
import type { TranslationStatus, AcceptanceStatus, Contributor } from './data';
|
||||
import { ThemeToggle } from './components/ThemeToggle';
|
||||
import clsx from 'clsx';
|
||||
import { MdVerified, MdEmail } from 'react-icons/md';
|
||||
import { SiGithub, SiForgejo, SiBluesky, SiMatrix, SiCodeberg } from 'react-icons/si';
|
||||
import { PiFediverseLogo } from 'react-icons/pi';
|
||||
import { FiChevronUp, FiChevronDown } from 'react-icons/fi';
|
||||
import contacts from './data/contacts.json';
|
||||
|
||||
type SortField = 'originalTitle' | 'translatedTitle' | 'status' | 'acceptance' | null;
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const StatusBadge = ({ status }: { status: TranslationStatus }) => {
|
||||
const statusConfig = {
|
||||
'completed': { text: '已完成', className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
|
||||
'in-progress': { text: '进行中', className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
|
||||
'planned': { text: '计划中', className: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
|
||||
{config.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const AcceptanceBadge = ({ status }: { status: AcceptanceStatus }) => {
|
||||
const statusConfig = {
|
||||
'not-submitted': {
|
||||
text: '未提交',
|
||||
className: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'
|
||||
},
|
||||
'authed-available': {
|
||||
text: '被权威译本取代',
|
||||
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
},
|
||||
'submitted': {
|
||||
text: '已提交',
|
||||
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
},
|
||||
'in-review': {
|
||||
text: '审核中',
|
||||
className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
},
|
||||
'accepted': {
|
||||
text: '已接受',
|
||||
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
},
|
||||
'accepted-authed': {
|
||||
text: '授权翻译',
|
||||
className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
}
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
|
||||
{config.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Avatar = ({
|
||||
contributor,
|
||||
showName = false,
|
||||
style = {},
|
||||
className = ""
|
||||
}: {
|
||||
contributor: { id: string; name?: string; email?: string; avatarUrl?: string; homepage?: string };
|
||||
showName?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}) => {
|
||||
const avatarInfo = getAvatarInfo(contributor);
|
||||
|
||||
const content = avatarInfo.type === 'url' ? (
|
||||
<img
|
||||
src={avatarInfo.url}
|
||||
alt={contributor.name || contributor.id}
|
||||
className="w-6 h-6 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className={clsx(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium",
|
||||
avatarInfo.colors.light.bg,
|
||||
avatarInfo.colors.light.text,
|
||||
avatarInfo.colors.dark.bg,
|
||||
avatarInfo.colors.dark.text
|
||||
)}>
|
||||
{avatarInfo.char}
|
||||
</div>
|
||||
);
|
||||
|
||||
const containerClasses = clsx(
|
||||
"flex items-center gap-2",
|
||||
className,
|
||||
contributor.homepage ? "cursor-pointer hover:opacity-80" : ""
|
||||
);
|
||||
|
||||
const inner = (
|
||||
<div className={containerClasses} style={style}>
|
||||
{content}
|
||||
{showName && (
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{contributor.name || contributor.id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return contributor.homepage ? (
|
||||
<a
|
||||
href={contributor.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : inner;
|
||||
};
|
||||
|
||||
const PopupContent = ({
|
||||
contributors,
|
||||
anchorRect,
|
||||
}: {
|
||||
contributors: (Contributor & { primary?: boolean })[];
|
||||
anchorRect: DOMRect | null;
|
||||
}) => {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
if (!anchorRect) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={clsx(
|
||||
"fixed shadow-lg rounded-lg p-2",
|
||||
isDark ? "bg-gray-800" : "bg-white"
|
||||
)}
|
||||
style={{
|
||||
top: anchorRect.bottom + 4,
|
||||
left: anchorRect.left,
|
||||
zIndex: 9999,
|
||||
minWidth: `${contributors.length * 28}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{contributors.map(contributor => (
|
||||
<a
|
||||
key={contributor.id}
|
||||
href={contributor.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={clsx(
|
||||
"whitespace-nowrap py-1 px-2 rounded text-xs",
|
||||
isDark ? (
|
||||
"text-gray-300 hover:bg-gray-700"
|
||||
) : (
|
||||
"text-gray-700 hover:bg-gray-100"
|
||||
),
|
||||
contributor.homepage ? "cursor-pointer" : "cursor-default"
|
||||
)}
|
||||
>
|
||||
{contributor.name || contributor.id}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
const TranslatorAvatars = ({ contributors }: {
|
||||
contributors: (Contributor & { primary?: boolean })[]
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const primaryContributors = contributors.filter(c => c.primary);
|
||||
const otherContributors = contributors.filter(c => !c.primary);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setAnchorRect(rect);
|
||||
} else {
|
||||
setAnchorRect(null);
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 主要译者 */}
|
||||
{primaryContributors.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{primaryContributors.map((contributor) => (
|
||||
<Avatar
|
||||
key={contributor.id}
|
||||
contributor={contributor}
|
||||
showName={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 其他译者 */}
|
||||
{otherContributors.length > 0 && (
|
||||
<div className="relative h-6">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute"
|
||||
onMouseEnter={() => setIsExpanded(true)}
|
||||
onMouseLeave={() => setIsExpanded(false)}
|
||||
>
|
||||
<div className="flex">
|
||||
{otherContributors.map((contributor, idx) => (
|
||||
<Avatar
|
||||
key={contributor.id}
|
||||
contributor={contributor}
|
||||
style={{
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
transform: `translateX(${idx * (isExpanded ? 28 : 12)}px)`,
|
||||
zIndex: otherContributors.length - idx,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<PopupContent
|
||||
contributors={otherContributors}
|
||||
anchorRect={anchorRect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RepositoryLinks = ({ repository }: { repository: { forgejo?: string; github?: string } }) => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{repository.forgejo && (
|
||||
<a
|
||||
href={repository.forgejo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
title="Forgejo"
|
||||
>
|
||||
<SiForgejo className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
{repository.github && (
|
||||
<a
|
||||
href={repository.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
title="GitHub"
|
||||
>
|
||||
<SiGithub className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SortableHeaderProps {
|
||||
field: SortField;
|
||||
currentSort: SortField;
|
||||
direction: SortDirection;
|
||||
onSort: (field: SortField) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SortableHeader = ({ field, currentSort, direction, onSort, children }: SortableHeaderProps) => (
|
||||
<th
|
||||
onClick={() => onSort(field)}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
{currentSort === field && (
|
||||
direction === 'asc' ? <FiChevronUp /> : <FiChevronDown />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
|
||||
const Footer = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const iconComponents = {
|
||||
keyoxide: MdVerified,
|
||||
email: MdEmail,
|
||||
github: SiGithub,
|
||||
forgejo: SiForgejo,
|
||||
fediverse: PiFediverseLogo,
|
||||
bluesky: SiBluesky,
|
||||
matrix: SiMatrix,
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4 flex justify-between items-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
2024 - {currentYear} 全译 WholeTrans
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(contacts).map(([id, contact]) => {
|
||||
const Icon = iconComponents[id as keyof typeof iconComponents];
|
||||
return (
|
||||
<a
|
||||
key={id}
|
||||
href={contact.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
title={contact.label}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const specs = useMemo(() => {
|
||||
return getSpecs();
|
||||
}, []);
|
||||
|
||||
const filteredAndSortedSpecs = useMemo(() => {
|
||||
let result = [...specs];
|
||||
|
||||
// 搜索过滤
|
||||
if (searchTerm) {
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
result = result.filter(spec =>
|
||||
spec.originalTitle.toLowerCase().includes(lowerSearchTerm) ||
|
||||
spec.translatedTitle.toLowerCase().includes(lowerSearchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
// 按原文标题排序
|
||||
result.sort((a, b) => a.originalTitle.localeCompare(b.originalTitle));
|
||||
|
||||
return result;
|
||||
}, [specs, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex-1">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex justify-end py-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">全译 W3C</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 mb-4">
|
||||
为 W3C 规范、草案、工作组报告等文档提供翻译。
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<a
|
||||
href="mailto:hello@wholetrans.org"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
参与翻译
|
||||
</a>
|
||||
<a
|
||||
href="mailto:errata@wholetrans.org"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
纠错建议
|
||||
</a>
|
||||
<a
|
||||
href="https://wholetrans.org/about"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
关于全译
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索标题、标签、译者..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-8">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
原文
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
译文
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
翻译状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
接受状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
标签
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
译者
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Git 存储库
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredAndSortedSpecs.map((spec) => (
|
||||
<tr key={spec.originalTitle} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4">
|
||||
<a
|
||||
href={spec.originalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{spec.originalTitle}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{spec.translationUrl ? (
|
||||
<a
|
||||
href={resolveTranslationUrl(spec)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{spec.translatedTitle}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-900 dark:text-gray-100">
|
||||
{spec.translatedTitle}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge status={spec.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<AcceptanceBadge status={spec.acceptance} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{getSpecTags(spec).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<TranslatorAvatars contributors={getSpecContributors(spec)} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<RepositoryLinks repository={spec.repository} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
28
src/components/ThemeToggle.tsx
Normal file
28
src/components/ThemeToggle.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { FiMoon, FiSun } from 'react-icons/fi';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.localStorage.getItem('theme') as 'light' | 'dark' || 'light';
|
||||
}
|
||||
return 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className="fixed top-4 right-4 p-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100"
|
||||
aria-label="切换主题"
|
||||
>
|
||||
{theme === 'dark' ? <FiSun size={20} /> : <FiMoon size={20} />}
|
||||
</button>
|
||||
);
|
||||
}
|
65
src/data/avatar.ts
Normal file
65
src/data/avatar.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import md5 from 'md5';
|
||||
|
||||
// 头像背景颜色
|
||||
const AVATAR_COLORS = {
|
||||
light: [
|
||||
{ bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||
{ bg: 'bg-green-100', text: 'text-green-800' },
|
||||
{ bg: 'bg-purple-100', text: 'text-purple-800' },
|
||||
{ bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
||||
{ bg: 'bg-pink-100', text: 'text-pink-800' },
|
||||
{ bg: 'bg-indigo-100', text: 'text-indigo-800' },
|
||||
],
|
||||
dark: [
|
||||
{ bg: 'dark:bg-blue-900', text: 'dark:text-blue-100' },
|
||||
{ bg: 'dark:bg-green-900', text: 'dark:text-green-100' },
|
||||
{ bg: 'dark:bg-purple-900', text: 'dark:text-purple-100' },
|
||||
{ bg: 'dark:bg-yellow-900', text: 'dark:text-yellow-100' },
|
||||
{ bg: 'dark:bg-pink-900', text: 'dark:text-pink-100' },
|
||||
{ bg: 'dark:bg-indigo-900', text: 'dark:text-indigo-100' },
|
||||
]
|
||||
};
|
||||
|
||||
// 生成随机颜色组合
|
||||
export function getRandomAvatarColors() {
|
||||
const index = Math.floor(Math.random() * AVATAR_COLORS.light.length);
|
||||
return {
|
||||
light: AVATAR_COLORS.light[index],
|
||||
dark: AVATAR_COLORS.dark[index]
|
||||
};
|
||||
}
|
||||
|
||||
// 获取显示字符
|
||||
function getDisplayChar(contributor: { id: string; name?: string }) {
|
||||
if (contributor.name) {
|
||||
const firstChar = contributor.name.charAt(0);
|
||||
return firstChar.toUpperCase();
|
||||
}
|
||||
|
||||
const firstChar = contributor.id.charAt(0);
|
||||
return /[a-zA-Z]/.test(firstChar) ? firstChar.toUpperCase() : firstChar;
|
||||
}
|
||||
|
||||
// 生成头像URL或获取显示字符和颜色
|
||||
export function getAvatarInfo(contributor: { id: string; name?: string; email?: string; avatarUrl?: string }) {
|
||||
if (contributor.avatarUrl) {
|
||||
return { type: 'url' as const, url: contributor.avatarUrl };
|
||||
}
|
||||
|
||||
if (contributor.email) {
|
||||
const hash = md5(contributor.email.toLowerCase().trim());
|
||||
const libravatarUrl = `https://seccdn.libravatar.org/avatar/${hash}?d=404`;
|
||||
const gravatarUrl = `https://www.gravatar.com/avatar/${hash}?d=mp`;
|
||||
return {
|
||||
type: 'url' as const,
|
||||
url: `${libravatarUrl}&fallback=${encodeURIComponent(gravatarUrl)}`
|
||||
};
|
||||
}
|
||||
|
||||
// 生成字母头像
|
||||
return {
|
||||
type: 'char' as const,
|
||||
char: getDisplayChar(contributor),
|
||||
colors: getRandomAvatarColors()
|
||||
};
|
||||
}
|
30
src/data/contacts.json
Normal file
30
src/data/contacts.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"keyoxide": {
|
||||
"url": "https://keyoxide.org/7D7FEEA7EE4424F4BECE27A32F0718F74CCACC0F",
|
||||
"label": "Keyoxide"
|
||||
},
|
||||
"email": {
|
||||
"url": "mailto:hello@wholetrans.org",
|
||||
"label": "hello@wholetrans.org"
|
||||
},
|
||||
"github": {
|
||||
"url": "https://github.com/wholetrans",
|
||||
"label": "GitHub"
|
||||
},
|
||||
"forgejo": {
|
||||
"url": "https://git.owu.one/wholetrans",
|
||||
"label": "Forgejo"
|
||||
},
|
||||
"fediverse": {
|
||||
"url": "https://scg.owu.one/@wholetrans",
|
||||
"label": "Fediverse"
|
||||
},
|
||||
"matrix": {
|
||||
"url": "https://matrix.to/#/#wholetrans:mtx.owu.one",
|
||||
"label": "Matrix"
|
||||
},
|
||||
"bluesky": {
|
||||
"url": "https://bsky.app/profile/wholetrans.org",
|
||||
"label": "Bluesky"
|
||||
}
|
||||
}
|
10
src/data/contributors.json
Normal file
10
src/data/contributors.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"contributors": [
|
||||
{
|
||||
"id": "cdn0x12",
|
||||
"name": "CDN",
|
||||
"email": "info@cdn0x12.dev",
|
||||
"homepage": "https://cdn0x12.dev"
|
||||
}
|
||||
]
|
||||
}
|
63
src/data/index.ts
Normal file
63
src/data/index.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { Contributor, W3CSpec, Tag, TranslatorRef } from './types';
|
||||
import contributorsData from './contributors.json';
|
||||
import specsData from './specs.json';
|
||||
import tagsData from './tags.json';
|
||||
import { getAvatarInfo } from './avatar';
|
||||
|
||||
export type { Contributor, W3CSpec, TranslationStatus, AcceptanceStatus, Tag, TranslatorRef } from './types';
|
||||
|
||||
// 获取所有贡献者
|
||||
export function getContributors(): Contributor[] {
|
||||
return contributorsData.contributors;
|
||||
}
|
||||
|
||||
// 获取所有规范
|
||||
export function getSpecs(): W3CSpec[] {
|
||||
return specsData.specs;
|
||||
}
|
||||
|
||||
// 获取所有标签
|
||||
export function getTags(): Tag[] {
|
||||
return tagsData.tags;
|
||||
}
|
||||
|
||||
// 根据ID获取贡献者
|
||||
export function getContributorById(id: string): Contributor | undefined {
|
||||
return getContributors().find(c => c.id === id);
|
||||
}
|
||||
|
||||
// 根据ID获取标签
|
||||
export function getTagById(id: string): Tag | undefined {
|
||||
return getTags().find(t => t.id === id);
|
||||
}
|
||||
|
||||
// 获取规范的所有标签
|
||||
export function getSpecTags(spec: W3CSpec): Tag[] {
|
||||
return spec.tagIds
|
||||
.map(id => getTagById(id))
|
||||
.filter((t): t is Tag => t !== undefined);
|
||||
}
|
||||
|
||||
// 获取规范的所有贡献者信息
|
||||
export function getSpecContributors(spec: W3CSpec): (Contributor & { primary?: boolean })[] {
|
||||
return spec.translators
|
||||
.map(ref => {
|
||||
const contributor = getContributorById(ref.id);
|
||||
if (!contributor) return undefined;
|
||||
return { ...contributor, primary: ref.primary };
|
||||
})
|
||||
.filter((c): c is Contributor & { primary?: boolean } => c !== undefined);
|
||||
}
|
||||
|
||||
// 解析相对URL
|
||||
export function resolveTranslationUrl(spec: W3CSpec): string | undefined {
|
||||
const url = spec.translationUrl;
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
// 这里可以根据需要添加基础URL
|
||||
return `https://w3c.wholetrans.org${url}`;
|
||||
}
|
||||
|
||||
export { getAvatarInfo };
|
61
src/data/specs.json
Normal file
61
src/data/specs.json
Normal file
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"specs": [
|
||||
{
|
||||
"originalTitle": "ActivityPub",
|
||||
"translatedTitle": "ActivityPub",
|
||||
"originalUrl": "https://www.w3.org/TR/activitypub/",
|
||||
"translationUrl": "/activitypub/",
|
||||
"status": "completed",
|
||||
"acceptance": "accepted",
|
||||
"tagIds": ["social"],
|
||||
"repository": {
|
||||
"forgejo": "https://git.owu.one/wholetrans/w3c-activitypub",
|
||||
"github": "https://github.com/wholetrans/w3c-activitypub"
|
||||
},
|
||||
"translators": [
|
||||
{
|
||||
"id": "cdn0x12",
|
||||
"primary": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"originalTitle": "Activity Vocabulary",
|
||||
"translatedTitle": "行为术语",
|
||||
"originalUrl": "https://www.w3.org/TR/activitystreams-vocabulary/",
|
||||
"translationUrl": "/activitystreams-vocabulary/",
|
||||
"status": "planned",
|
||||
"acceptance": "not-submitted",
|
||||
"tagIds": ["social"],
|
||||
"repository": {
|
||||
"forgejo": "https://git.owu.one/wholetrans/w3c-activitystreams-vocabulary",
|
||||
"github": "https://github.com/wholetrans/w3c-activitystreams-vocabulary"
|
||||
},
|
||||
"translators": [
|
||||
{
|
||||
"id": "cdn0x12",
|
||||
"primary": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"originalTitle": "Activity Streams 2.0",
|
||||
"translatedTitle": "流式行为 2.0",
|
||||
"originalUrl": "https://www.w3.org/TR/activitystreams-core/",
|
||||
"translationUrl": "/activitystreams-core/",
|
||||
"status": "planned",
|
||||
"acceptance": "not-submitted",
|
||||
"tagIds": ["social"],
|
||||
"repository": {
|
||||
"forgejo": "https://git.owu.one/wholetrans/w3c-activitystreams-core",
|
||||
"github": "https://github.com/wholetrans/w3c-activitystreams-core"
|
||||
},
|
||||
"translators": [
|
||||
{
|
||||
"id": "cdn0x12",
|
||||
"primary": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
32
src/data/specs.ts
Normal file
32
src/data/specs.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import md5 from 'md5';
|
||||
|
||||
export interface Translator {
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl?: string; // 可选的自定义头像URL
|
||||
}
|
||||
|
||||
export interface W3CSpec {
|
||||
originalTitle: string;
|
||||
translatedTitle: string;
|
||||
status: 'not-started' | 'in-progress' | 'completed';
|
||||
tags: string[];
|
||||
repository: string;
|
||||
translators: Translator[];
|
||||
}
|
||||
|
||||
// 头像URL生成函数
|
||||
export function getAvatarUrl(email: string, customUrl?: string): string {
|
||||
if (customUrl) {
|
||||
return customUrl;
|
||||
}
|
||||
|
||||
const hash = md5(email.toLowerCase().trim());
|
||||
// 先尝试 Libravatar
|
||||
const libravatarUrl = `https://seccdn.libravatar.org/avatar/${hash}?d=404`;
|
||||
|
||||
// Gravatar 作为后备
|
||||
const gravatarUrl = `https://www.gravatar.com/avatar/${hash}?d=mp`;
|
||||
|
||||
return `${libravatarUrl}&fallback=${encodeURIComponent(gravatarUrl)}`;
|
||||
}
|
8
src/data/tags.json
Normal file
8
src/data/tags.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"tags": [
|
||||
{
|
||||
"id": "social",
|
||||
"name": "社交网络"
|
||||
}
|
||||
]
|
||||
}
|
44
src/data/types.ts
Normal file
44
src/data/types.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
export interface Contributor {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export type TranslationStatus = 'planned' | 'in-progress' | 'completed';
|
||||
export type AcceptanceStatus =
|
||||
| 'not-submitted' // 未提交
|
||||
| 'authed-available' // 已授权可翻译
|
||||
| 'submitted' // 已提交
|
||||
| 'in-review' // 审核中
|
||||
| 'accepted' // 已接受
|
||||
| 'accepted-authed'; // 已接受且已授权
|
||||
|
||||
export interface Repository {
|
||||
forgejo?: string;
|
||||
codeberg?: string;
|
||||
github?: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TranslatorRef {
|
||||
id: string;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface W3CSpec {
|
||||
originalTitle: string;
|
||||
translatedTitle: string;
|
||||
originalUrl: string;
|
||||
translationUrl?: string; // 可以是相对URL
|
||||
status: TranslationStatus;
|
||||
acceptance: AcceptanceStatus;
|
||||
tagIds: string[];
|
||||
repository: Repository;
|
||||
translators: TranslatorRef[];
|
||||
}
|
44
src/index.css
Normal file
44
src/index.css
Normal file
|
@ -0,0 +1,44 @@
|
|||
@import "tailwindcss";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
@layer utilities {
|
||||
/* 整个滚动条的样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent; /* Firefox */
|
||||
}
|
||||
|
||||
/* Webkit (Chrome, Safari, Edge) 浏览器的滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 暗色模式下的滚动条颜色 */
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) transparent; /* Firefox */
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
/* 鼠标悬停时的滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(75, 85, 99, 0.7);
|
||||
}
|
||||
}
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
12
tailwind.config.js
Normal file
12
tailwind.config.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
14
vite.config.ts
Normal file
14
vite.config.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue