diff --git a/package-lock.json b/package-lock.json index ffc28b7..b776c5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.4.11", + "i18next": "^26.3.1", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.21.0", "marked": "^18.0.5", "radix-ui": "^1.6.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-i18next": "^17.0.8", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.1", "zod": "^4.4.3" @@ -35,6 +38,15 @@ "wrangler": "^4.103.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -3806,6 +3818,53 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", + "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -4318,6 +4377,33 @@ "react": "^19.2.7" } }, + "node_modules/react-i18next": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -4629,8 +4715,9 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4703,6 +4790,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -4871,6 +4967,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index aa5cd59..0179902 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.4.11", + "i18next": "^26.3.1", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.21.0", "marked": "^18.0.5", "radix-ui": "^1.6.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-i18next": "^17.0.8", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.1", "zod": "^4.4.3" diff --git a/src/App.tsx b/src/App.tsx index b4f66ad..04150bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import LoginPage from './routes/LoginPage'; import PrivateLinksPage from './routes/PrivateLinksPage'; import PublicLinksPage from './routes/PublicLinksPage'; @@ -14,7 +15,7 @@ import { resolveRouteForCurrentUser, type RouteId } from './lib/routing'; import LandingHero from './components/LandingHero'; const NAV_TABS: readonly { id: RouteId; label: string }[] = [ - { id: 'my-links', label: 'My Links' }, + { id: 'my-links', label: 'app.myLinks' }, ]; function readRouteFromHash(): RouteId { @@ -34,6 +35,7 @@ export function navigateToLinkDetail(id: string, scope: 'private' | 'public') { } export default function App() { + const { t } = useTranslation(); const [route, setRoute] = useState(() => readRouteFromHash()); const [linkDetailId, setLinkDetailId] = useState(null); const [linkDetailScope, setLinkDetailScope] = useState<'private' | 'public'>('private'); @@ -89,7 +91,7 @@ export default function App() { return (
- navigate('home')}>Heygo + navigate('home')}>{t('app.brand')} @@ -122,8 +124,8 @@ export default function App() { {route === 'admin' ? : null} {route === 'login' ? : null} {route === 'dev-login' ? : null} - {route === 'profile' ? : null} - {route === 'settings' ? : null} + {route === 'profile' ? : null} + {route === 'settings' ? : null} {route === 'terms' ? : null} {route === 'privacy' ? : null} {route === 'notifications' ? : null} @@ -145,17 +147,18 @@ export default function App() {
); } function PlaceholderPage({ title }: { title: string }) { + const { t } = useTranslation(); return (

{title}

-

{title} page is coming soon.

+

{t('app.placeholderTitle', { title })}

); } diff --git a/src/components/CopyPublicLinkDialog.tsx b/src/components/CopyPublicLinkDialog.tsx index 2609c2a..f854898 100644 --- a/src/components/CopyPublicLinkDialog.tsx +++ b/src/components/CopyPublicLinkDialog.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; interface CopyPublicLinkDialogProps { readonly conflictingAlias: string; @@ -13,6 +14,7 @@ export default function CopyPublicLinkDialog({ onSubmit, onClose, }: CopyPublicLinkDialogProps) { + const { t } = useTranslation(); const [alias, setAlias] = useState(suggestedAlias); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -21,12 +23,12 @@ export default function CopyPublicLinkDialog({ event.preventDefault(); const normalizedAlias = alias.trim(); if (!normalizedAlias) { - setError('Alias is required'); + setError(t('copyDialog.aliasRequired')); return; } if (normalizedAlias.toLowerCase() === conflictingAlias.toLowerCase()) { - setError('Please choose a different alias.'); + setError(t('copyDialog.aliasDifferent')); return; } @@ -42,19 +44,23 @@ export default function CopyPublicLinkDialog({ } return ( -
+
-

Alias already in use

+

{t('copyDialog.title')}

- You already have /{conflictingAlias} in My Links. Choose a different alias for this copy. + }} + />

- +
- This copy will be created in your private links. + {t('copyDialog.hint')} {alias.trim() ? /{alias.trim()} : null}
@@ -79,9 +85,9 @@ export default function CopyPublicLinkDialog({
- +
diff --git a/src/components/LandingHero.tsx b/src/components/LandingHero.tsx index 459e759..c691f3b 100644 --- a/src/components/LandingHero.tsx +++ b/src/components/LandingHero.tsx @@ -10,6 +10,7 @@ import { ArrowRight, Plus, Search, X } from 'lucide-react'; import { type Link, listPublicLinks } from '../lib/api'; import { safeLinkTargetUrl } from '../lib/url'; import { useCurrentUser } from '../lib/auth'; +import { useTranslation, Trans } from 'react-i18next'; const DEBOUNCE_MS = 220; const MAX_RESULTS = 8; @@ -31,6 +32,7 @@ function isExactMatch(link: Link, query: string): boolean { } export default function LandingHero() { + const { t } = useTranslation(); const { user } = useCurrentUser(); const [inputValue, setInputValue] = useState(''); const [state, setState] = useState(INITIAL_STATE); @@ -59,13 +61,13 @@ export default function LandingHero() { setState({ links: [], loading: false, - error: err instanceof Error ? err.message : '搜索失败', + error: err instanceof Error ? err.message : t('landing.searchFail'), query, }); setOpen(true); setActiveIndex(-1); } - }, []); + }, [t]); useEffect(() => { if (debounceRef.current) { @@ -201,7 +203,7 @@ export default function LandingHero() { const hasQuery = trimmedQuery.length > 0; const canCreate = hasQuery && !exactMatch && !state.loading; - const primaryLabel = exactMatch ? 'Go' : canCreate ? '创建个人链接' : 'Go'; + const primaryLabel = exactMatch ? t('landing.goLabel') : canCreate ? t('landing.createPersonalLink') : t('landing.goLabel'); const primaryDisabled = !exactMatch && !canCreate; const primaryVariant = exactMatch ? 'is-go' : canCreate ? 'is-create' : 'is-idle'; @@ -213,24 +215,23 @@ export default function LandingHero() {

Heygo - 黑狗 + {t('landing.brandSuffix')}

- 不记链接,也能到达。输入一个短链名称,
- 公开短链、私有链接、共享内容——一站直达。 + }} />

@@ -238,14 +239,14 @@ export default function LandingHero() {
setInputValue(e.target.value)} onKeyDown={handleKeyDown} @@ -257,7 +258,7 @@ export default function LandingHero() { autoCapitalize="off" spellCheck={false} enterKeyHint="go" - aria-label="搜索短链" + aria-label={t('landing.searchLabel')} aria-expanded={showDropdown} aria-autocomplete="list" aria-controls="hero-search-results" @@ -269,7 +270,7 @@ export default function LandingHero() { type="button" className="hero-search__clear" onClick={clearSearch} - aria-label="清除搜索" + aria-label={t('landing.clearLabel')} tabIndex={-1} >
{link.linkType === 'redirect' && link.targetUrl ? ( @@ -369,17 +374,17 @@ export default function LandingHero() { {link.targetUrl} ) : ( - 无效链接 + {t('landing.invalidLink')} )}

) : link.linkType === 'custom' ? (

- 自定义页面 + {t('landing.customPage')}

) : null}
- {link.clickCount.toLocaleString()} 次点击 + {t('landing.clicksCount', { count: link.clickCount.toLocaleString() })} {link.description ? ( {link.description} @@ -394,7 +399,7 @@ export default function LandingHero() {
- 试试: + {t('landing.suggestionsLabel')} {SUGGESTIONS.map((alias) => (