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