feat: add i18n support with Chinese/English translations

This commit is contained in:
Hermes Agent
2026-06-21 11:00:19 +10:00
parent d5cd66245e
commit 3eb439c769
25 changed files with 1355 additions and 401 deletions
+106 -1
View File
@@ -13,11 +13,14 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.4.11", "dompurify": "^3.4.11",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"marked": "^18.0.5", "marked": "^18.0.5",
"radix-ui": "^1.6.0", "radix-ui": "^1.6.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -35,6 +38,15 @@
"wrangler": "^4.103.0" "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": { "node_modules/@cloudflare/kv-asset-handler": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", "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==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "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": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -4318,6 +4377,33 @@
"react": "^19.2.7" "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": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -4629,8 +4715,9 @@
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "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": { "node_modules/vite": {
"version": "8.0.16", "version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+3
View File
@@ -26,11 +26,14 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.4.11", "dompurify": "^3.4.11",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"marked": "^18.0.5", "marked": "^18.0.5",
"radix-ui": "^1.6.0", "radix-ui": "^1.6.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"zod": "^4.4.3" "zod": "^4.4.3"
+10 -7
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LoginPage from './routes/LoginPage'; import LoginPage from './routes/LoginPage';
import PrivateLinksPage from './routes/PrivateLinksPage'; import PrivateLinksPage from './routes/PrivateLinksPage';
import PublicLinksPage from './routes/PublicLinksPage'; import PublicLinksPage from './routes/PublicLinksPage';
@@ -14,7 +15,7 @@ import { resolveRouteForCurrentUser, type RouteId } from './lib/routing';
import LandingHero from './components/LandingHero'; import LandingHero from './components/LandingHero';
const NAV_TABS: readonly { id: RouteId; label: string }[] = [ const NAV_TABS: readonly { id: RouteId; label: string }[] = [
{ id: 'my-links', label: 'My Links' }, { id: 'my-links', label: 'app.myLinks' },
]; ];
function readRouteFromHash(): RouteId { function readRouteFromHash(): RouteId {
@@ -34,6 +35,7 @@ export function navigateToLinkDetail(id: string, scope: 'private' | 'public') {
} }
export default function App() { export default function App() {
const { t } = useTranslation();
const [route, setRoute] = useState<RouteId>(() => readRouteFromHash()); const [route, setRoute] = useState<RouteId>(() => readRouteFromHash());
const [linkDetailId, setLinkDetailId] = useState<string | null>(null); const [linkDetailId, setLinkDetailId] = useState<string | null>(null);
const [linkDetailScope, setLinkDetailScope] = useState<'private' | 'public'>('private'); const [linkDetailScope, setLinkDetailScope] = useState<'private' | 'public'>('private');
@@ -89,7 +91,7 @@ export default function App() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="app-bar"> <header className="app-bar">
<a className="brand" href="#/" onClick={() => navigate('home')}>Heygo</a> <a className="brand" href="#/" onClick={() => navigate('home')}>{t('app.brand')}</a>
<nav className="tabs" aria-label="Primary"> <nav className="tabs" aria-label="Primary">
{user && navTabs.map((entry) => ( {user && navTabs.map((entry) => (
<a <a
@@ -99,7 +101,7 @@ export default function App() {
aria-current={route === entry.id ? 'page' : undefined} aria-current={route === entry.id ? 'page' : undefined}
onClick={() => navigate(entry.id)} onClick={() => navigate(entry.id)}
> >
{entry.label} {t(entry.label)}
</a> </a>
))} ))}
</nav> </nav>
@@ -122,8 +124,8 @@ export default function App() {
{route === 'admin' ? <AdminReviewPage /> : null} {route === 'admin' ? <AdminReviewPage /> : null}
{route === 'login' ? <LoginPage /> : null} {route === 'login' ? <LoginPage /> : null}
{route === 'dev-login' ? <DevLoginPage /> : null} {route === 'dev-login' ? <DevLoginPage /> : null}
{route === 'profile' ? <PlaceholderPage title="Profile" /> : null} {route === 'profile' ? <PlaceholderPage title={t('app.profile')} /> : null}
{route === 'settings' ? <PlaceholderPage title="Settings" /> : null} {route === 'settings' ? <PlaceholderPage title={t('app.settings')} /> : null}
{route === 'terms' ? <LegalPage kind="terms" /> : null} {route === 'terms' ? <LegalPage kind="terms" /> : null}
{route === 'privacy' ? <LegalPage kind="privacy" /> : null} {route === 'privacy' ? <LegalPage kind="privacy" /> : null}
{route === 'notifications' ? <NotificationsPage onMarkedViewed={handleMarkedViewed} /> : null} {route === 'notifications' ? <NotificationsPage onMarkedViewed={handleMarkedViewed} /> : null}
@@ -145,17 +147,18 @@ export default function App() {
</main> </main>
<footer className="app-footer"> <footer className="app-footer">
<span>Heygo · <a href="/api/health">API status</a></span> <span>{t('app.brand')} · <a href="/api/health">{t('app.apiStatus')}</a></span>
</footer> </footer>
</div> </div>
); );
} }
function PlaceholderPage({ title }: { title: string }) { function PlaceholderPage({ title }: { title: string }) {
const { t } = useTranslation();
return ( return (
<section className="panel"> <section className="panel">
<h1>{title}</h1> <h1>{title}</h1>
<p className="muted">{title} page is coming soon.</p> <p className="muted">{t('app.placeholderTitle', { title })}</p>
</section> </section>
); );
} }
+16 -10
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
interface CopyPublicLinkDialogProps { interface CopyPublicLinkDialogProps {
readonly conflictingAlias: string; readonly conflictingAlias: string;
@@ -13,6 +14,7 @@ export default function CopyPublicLinkDialog({
onSubmit, onSubmit,
onClose, onClose,
}: CopyPublicLinkDialogProps) { }: CopyPublicLinkDialogProps) {
const { t } = useTranslation();
const [alias, setAlias] = useState(suggestedAlias); const [alias, setAlias] = useState(suggestedAlias);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -21,12 +23,12 @@ export default function CopyPublicLinkDialog({
event.preventDefault(); event.preventDefault();
const normalizedAlias = alias.trim(); const normalizedAlias = alias.trim();
if (!normalizedAlias) { if (!normalizedAlias) {
setError('Alias is required'); setError(t('copyDialog.aliasRequired'));
return; return;
} }
if (normalizedAlias.toLowerCase() === conflictingAlias.toLowerCase()) { if (normalizedAlias.toLowerCase() === conflictingAlias.toLowerCase()) {
setError('Please choose a different alias.'); setError(t('copyDialog.aliasDifferent'));
return; return;
} }
@@ -42,19 +44,23 @@ export default function CopyPublicLinkDialog({
} }
return ( return (
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Choose a different alias"> <div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label={t('copyDialog.dialogLabel')}>
<form className="dialog-card link-form" onSubmit={handleSubmit}> <form className="dialog-card link-form" onSubmit={handleSubmit}>
<div className="dialog-header"> <div className="dialog-header">
<h2>Alias already in use</h2> <h2>{t('copyDialog.title')}</h2>
<button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button> <button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button>
</div> </div>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
You already have <span className="alias-pill">/{conflictingAlias}</span> in My Links. Choose a different alias for this copy. <Trans
i18nKey="copyDialog.description"
values={{ alias: conflictingAlias }}
components={{ aliasPill: <span className="alias-pill" /> }}
/>
</p> </p>
<div className="field"> <div className="field">
<label htmlFor="copy-link-alias">Private alias</label> <label htmlFor="copy-link-alias">{t('copyDialog.fieldLabel')}</label>
<div className="alias-input"> <div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">/</span> <span className="alias-input__prefix" aria-hidden="true">/</span>
<input <input
@@ -65,12 +71,12 @@ export default function CopyPublicLinkDialog({
required required
maxLength={100} maxLength={100}
autoComplete="off" autoComplete="off"
placeholder="my-link-copy" placeholder={t('copyDialog.aliasPlaceholder')}
disabled={submitting} disabled={submitting}
/> />
</div> </div>
<small className="hint alias-preview"> <small className="hint alias-preview">
<span>This copy will be created in your private links.</span> <span>{t('copyDialog.hint')}</span>
{alias.trim() ? <span className="alias-pill">/{alias.trim()}</span> : null} {alias.trim() ? <span className="alias-pill">/{alias.trim()}</span> : null}
</small> </small>
</div> </div>
@@ -79,9 +85,9 @@ export default function CopyPublicLinkDialog({
<div className="form-actions"> <div className="form-actions">
<button type="submit" disabled={submitting || !alias.trim()}> <button type="submit" disabled={submitting || !alias.trim()}>
{submitting ? 'Creating' : 'Create link'} {submitting ? t('copyDialog.creating') : t('copyDialog.createLink')}
</button> </button>
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button> <button type="button" onClick={onClose} disabled={submitting}>{t('copyDialog.cancel')}</button>
</div> </div>
</form> </form>
</div> </div>
+30 -25
View File
@@ -10,6 +10,7 @@ import { ArrowRight, Plus, Search, X } from 'lucide-react';
import { type Link, listPublicLinks } from '../lib/api'; import { type Link, listPublicLinks } from '../lib/api';
import { safeLinkTargetUrl } from '../lib/url'; import { safeLinkTargetUrl } from '../lib/url';
import { useCurrentUser } from '../lib/auth'; import { useCurrentUser } from '../lib/auth';
import { useTranslation, Trans } from 'react-i18next';
const DEBOUNCE_MS = 220; const DEBOUNCE_MS = 220;
const MAX_RESULTS = 8; const MAX_RESULTS = 8;
@@ -31,6 +32,7 @@ function isExactMatch(link: Link, query: string): boolean {
} }
export default function LandingHero() { export default function LandingHero() {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [state, setState] = useState<SearchState>(INITIAL_STATE); const [state, setState] = useState<SearchState>(INITIAL_STATE);
@@ -59,13 +61,13 @@ export default function LandingHero() {
setState({ setState({
links: [], links: [],
loading: false, loading: false,
error: err instanceof Error ? err.message : '搜索失败', error: err instanceof Error ? err.message : t('landing.searchFail'),
query, query,
}); });
setOpen(true); setOpen(true);
setActiveIndex(-1); setActiveIndex(-1);
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
if (debounceRef.current) { if (debounceRef.current) {
@@ -201,7 +203,7 @@ export default function LandingHero() {
const hasQuery = trimmedQuery.length > 0; const hasQuery = trimmedQuery.length > 0;
const canCreate = hasQuery && !exactMatch && !state.loading; 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 primaryDisabled = !exactMatch && !canCreate;
const primaryVariant = exactMatch ? 'is-go' : canCreate ? 'is-create' : 'is-idle'; const primaryVariant = exactMatch ? 'is-go' : canCreate ? 'is-create' : 'is-idle';
@@ -213,24 +215,23 @@ export default function LandingHero() {
<div className="hero-content"> <div className="hero-content">
<div className="hero-eyebrow"> <div className="hero-eyebrow">
<span className="hero-eyebrow__dot" aria-hidden="true" /> <span className="hero-eyebrow__dot" aria-hidden="true" />
<span></span> <span>{t('landing.eyebrow')}</span>
</div> </div>
<h1 className="hero-title"> <h1 className="hero-title">
<span className="hero-brand">Heygo</span> <span className="hero-brand">Heygo</span>
<span className="hero-suffix"></span> <span className="hero-suffix">{t('landing.brandSuffix')}</span>
</h1> </h1>
<p className="hero-description"> <p className="hero-description">
<br /> <Trans i18nKey="landing.description" components={{ 1: <br /> }} />
</p> </p>
<div className="hero-features" aria-hidden="true"> <div className="hero-features" aria-hidden="true">
<div className="feature-pill"><span className="feature-dot" /></div> <div className="feature-pill"><span className="feature-dot" />{t('landing.featurePublic')}</div>
<div className="feature-pill"><span className="feature-dot" /></div> <div className="feature-pill"><span className="feature-dot" />{t('landing.featurePrivate')}</div>
<div className="feature-pill"><span className="feature-dot" /></div> <div className="feature-pill"><span className="feature-dot" />{t('landing.featureShared')}</div>
<div className="feature-pill"><span className="feature-dot" /></div> <div className="feature-pill"><span className="feature-dot" />{t('landing.featureCrossPlatform')}</div>
</div> </div>
</div> </div>
@@ -238,14 +239,14 @@ export default function LandingHero() {
<div className="hero-search__shell"> <div className="hero-search__shell">
<div className="hero-search__prefix"> <div className="hero-search__prefix">
<Search className="hero-search__prefix-icon" size={16} strokeWidth={2.4} aria-hidden="true" /> <Search className="hero-search__prefix-icon" size={16} strokeWidth={2.4} aria-hidden="true" />
<span className="hero-search__prefix-text">heygo.cc/</span> <span className="hero-search__prefix-text">{t('landing.searchPrefix')}</span>
</div> </div>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
className="hero-search__field" className="hero-search__field"
placeholder="输入短链名称,例如 canva" placeholder={t('landing.searchPlaceholder')}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -257,7 +258,7 @@ export default function LandingHero() {
autoCapitalize="off" autoCapitalize="off"
spellCheck={false} spellCheck={false}
enterKeyHint="go" enterKeyHint="go"
aria-label="搜索短链" aria-label={t('landing.searchLabel')}
aria-expanded={showDropdown} aria-expanded={showDropdown}
aria-autocomplete="list" aria-autocomplete="list"
aria-controls="hero-search-results" aria-controls="hero-search-results"
@@ -269,7 +270,7 @@ export default function LandingHero() {
type="button" type="button"
className="hero-search__clear" className="hero-search__clear"
onClick={clearSearch} onClick={clearSearch}
aria-label="清除搜索" aria-label={t('landing.clearLabel')}
tabIndex={-1} tabIndex={-1}
> >
<X size={16} strokeWidth={2.5} aria-hidden="true" /> <X size={16} strokeWidth={2.5} aria-hidden="true" />
@@ -281,7 +282,7 @@ export default function LandingHero() {
className={`hero-search__action ${primaryVariant}`} className={`hero-search__action ${primaryVariant}`}
onClick={handlePrimaryAction} onClick={handlePrimaryAction}
disabled={primaryDisabled} disabled={primaryDisabled}
aria-label={exactMatch ? `跳转到 /${exactMatch.alias}` : primaryLabel} aria-label={exactMatch ? t('landing.goToAlias', { alias: exactMatch.alias }) : primaryLabel}
> >
{exactMatch ? ( {exactMatch ? (
<> <>
@@ -307,7 +308,7 @@ export default function LandingHero() {
{showLoading ? ( {showLoading ? (
<div className="hero-search__status"> <div className="hero-search__status">
<span className="hero-search__spinner" aria-hidden="true" /> <span className="hero-search__spinner" aria-hidden="true" />
<span></span> <span>{t('landing.searching')}</span>
</div> </div>
) : null} ) : null}
@@ -320,10 +321,14 @@ export default function LandingHero() {
{showNoResults ? ( {showNoResults ? (
<div className="hero-search__empty"> <div className="hero-search__empty">
<div className="hero-search__empty-title"> <div className="hero-search__empty-title">
<code>{trimmedQuery}</code> <Trans
i18nKey="landing.noResultsTitle"
values={{ query: trimmedQuery }}
components={{ 1: <code /> }}
/>
</div> </div>
<div className="hero-search__empty-hint"> <div className="hero-search__empty-hint">
{t('landing.noResultsHint')}
</div> </div>
<button <button
type="button" type="button"
@@ -331,7 +336,7 @@ export default function LandingHero() {
onClick={handleCreate} onClick={handleCreate}
> >
<Plus size={15} strokeWidth={2.6} aria-hidden="true" /> <Plus size={15} strokeWidth={2.6} aria-hidden="true" />
{t('landing.createPersonalLink')}
</button> </button>
</div> </div>
) : null} ) : null}
@@ -359,7 +364,7 @@ export default function LandingHero() {
{link.alias} {link.alias}
</span> </span>
{exact ? ( {exact ? (
<span className="hero-search__exact-badge"></span> <span className="hero-search__exact-badge">{t('landing.exactMatch')}</span>
) : null} ) : null}
</div> </div>
{link.linkType === 'redirect' && link.targetUrl ? ( {link.linkType === 'redirect' && link.targetUrl ? (
@@ -369,17 +374,17 @@ export default function LandingHero() {
{link.targetUrl} {link.targetUrl}
</span> </span>
) : ( ) : (
<span className="muted"></span> <span className="muted">{t('landing.invalidLink')}</span>
)} )}
</p> </p>
) : link.linkType === 'custom' ? ( ) : link.linkType === 'custom' ? (
<p className="hero-search__result-url"> <p className="hero-search__result-url">
<span className="muted"></span> <span className="muted">{t('landing.customPage')}</span>
</p> </p>
) : null} ) : null}
<div className="hero-search__result-meta"> <div className="hero-search__result-meta">
<span className="hero-search__clicks"> <span className="hero-search__clicks">
{link.clickCount.toLocaleString()} {t('landing.clicksCount', { count: link.clickCount.toLocaleString() })}
</span> </span>
{link.description ? ( {link.description ? (
<span className="hero-search__result-desc">{link.description}</span> <span className="hero-search__result-desc">{link.description}</span>
@@ -394,7 +399,7 @@ export default function LandingHero() {
</div> </div>
<div className="hero-suggestions"> <div className="hero-suggestions">
<span className="hero-suggestions__label"></span> <span className="hero-suggestions__label">{t('landing.suggestionsLabel')}</span>
{SUGGESTIONS.map((alias) => ( {SUGGESTIONS.map((alias) => (
<button <button
key={alias} key={alias}
+26 -18
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import type { Link, LinkInput, LinkType } from '../lib/api'; import type { Link, LinkInput, LinkType } from '../lib/api';
interface LinkFormProps { interface LinkFormProps {
@@ -39,6 +40,7 @@ export default function LinkForm({
submitLabel, submitLabel,
disabled = false, disabled = false,
}: LinkFormProps) { }: LinkFormProps) {
const { t } = useTranslation();
const [input, setInput] = useState<LinkInput>(() => toFormInput(initial)); const [input, setInput] = useState<LinkInput>(() => toFormInput(initial));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -79,9 +81,9 @@ export default function LinkForm({
} }
return ( return (
<form className="link-form" onSubmit={handleSubmit} aria-label={initial ? 'Edit link' : 'Create link'}> <form className="link-form" onSubmit={handleSubmit} aria-label={initial ? t('linkForm.editTitle') : t('linkForm.createTitle')}>
<div className="field"> <div className="field">
<label htmlFor="link-alias">Alias</label> <label htmlFor="link-alias">{t('linkForm.fieldAlias')}</label>
<div className="alias-input"> <div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">#</span> <span className="alias-input__prefix" aria-hidden="true">#</span>
<input <input
@@ -92,18 +94,18 @@ export default function LinkForm({
required required
maxLength={100} maxLength={100}
autoComplete="off" autoComplete="off"
placeholder="my-link" placeholder={t('linkForm.aliasPlaceholder')}
disabled={submitting || disabled} disabled={submitting || disabled}
/> />
</div> </div>
<small className="hint alias-preview"> <small className="hint alias-preview">
<span>Letters, numbers, hyphens, underscores. Case-insensitive.</span> <span>{t('linkForm.aliasHint')}</span>
{input.alias.trim() ? <span className="alias-pill">#{input.alias.trim()}</span> : null} {input.alias.trim() ? <span className="alias-pill">#{input.alias.trim()}</span> : null}
</small> </small>
</div> </div>
<fieldset className="field" disabled={submitting || disabled}> <fieldset className="field" disabled={submitting || disabled}>
<legend>Link type</legend> <legend>{t('linkForm.fieldLinkType')}</legend>
<label className="radio"> <label className="radio">
<input <input
type="radio" type="radio"
@@ -112,7 +114,7 @@ export default function LinkForm({
checked={linkType === 'redirect'} checked={linkType === 'redirect'}
onChange={() => update('linkType', 'redirect')} onChange={() => update('linkType', 'redirect')}
/> />
Redirect URL {t('linkForm.typeRedirect')}
</label> </label>
<label className="radio"> <label className="radio">
<input <input
@@ -122,13 +124,13 @@ export default function LinkForm({
checked={linkType === 'custom'} checked={linkType === 'custom'}
onChange={() => update('linkType', 'custom')} onChange={() => update('linkType', 'custom')}
/> />
Custom markdown page {t('linkForm.typeCustom')}
</label> </label>
</fieldset> </fieldset>
{linkType === 'redirect' ? ( {linkType === 'redirect' ? (
<div className="field"> <div className="field">
<label htmlFor="link-target">Target URL</label> <label htmlFor="link-target">{t('linkForm.fieldTargetUrl')}</label>
<input <input
id="link-target" id="link-target"
name="targetUrl" name="targetUrl"
@@ -136,18 +138,24 @@ export default function LinkForm({
value={input.targetUrl ?? ''} value={input.targetUrl ?? ''}
onChange={(e) => update('targetUrl', e.target.value)} onChange={(e) => update('targetUrl', e.target.value)}
required required
placeholder="https://example.com/search?q={query}" placeholder={t('linkForm.targetPlaceholder')}
disabled={submitting || disabled} disabled={submitting || disabled}
/> />
<small className="hint"> <small className="hint">
Supports parameterized templates. Use <code>{'{query}'}</code> placeholders, e.g. <Trans
<code> https://example.com/search?q={'{query}'} </code> i18nKey="linkForm.targetHint"
then visit <code>/alias/foo?bar=baz</code> to substitute. values={{ query: '{query}' }}
components={{
1: <code />,
3: <code />,
5: <code />,
}}
/>
</small> </small>
</div> </div>
) : ( ) : (
<div className="field"> <div className="field">
<label htmlFor="link-content">Markdown content</label> <label htmlFor="link-content">{t('linkForm.fieldContent')}</label>
<textarea <textarea
id="link-content" id="link-content"
name="contentMarkdown" name="contentMarkdown"
@@ -155,21 +163,21 @@ export default function LinkForm({
onChange={(e) => update('contentMarkdown', e.target.value)} onChange={(e) => update('contentMarkdown', e.target.value)}
required required
rows={6} rows={6}
placeholder="# Hello world" placeholder={t('linkForm.contentPlaceholder')}
disabled={submitting || disabled} disabled={submitting || disabled}
/> />
</div> </div>
)} )}
<div className="field"> <div className="field">
<label htmlFor="link-description">Description (optional)</label> <label htmlFor="link-description">{t('linkForm.fieldDescription')}</label>
<input <input
id="link-description" id="link-description"
name="description" name="description"
value={input.description ?? ''} value={input.description ?? ''}
onChange={(e) => update('description', e.target.value)} onChange={(e) => update('description', e.target.value)}
maxLength={500} maxLength={500}
placeholder="What this link is for" placeholder={t('linkForm.descriptionPlaceholder')}
disabled={submitting || disabled} disabled={submitting || disabled}
/> />
</div> </div>
@@ -180,11 +188,11 @@ export default function LinkForm({
<div className="form-actions"> <div className="form-actions">
<button type="submit" disabled={submitting || disabled}> <button type="submit" disabled={submitting || disabled}>
{submitting ? 'Saving' : (submitLabel ?? 'Save')} {submitting ? t('linkForm.saving') : (submitLabel ?? t('linkForm.save'))}
</button> </button>
{onCancel ? ( {onCancel ? (
<button type="button" onClick={onCancel} disabled={submitting || disabled}> <button type="button" onClick={onCancel} disabled={submitting || disabled}>
Cancel {t('linkForm.cancel')}
</button> </button>
) : null} ) : null}
</div> </div>
+19 -11
View File
@@ -9,6 +9,7 @@ import {
} from 'react'; } from 'react';
import { type Link, type LinkScope, listPrivateLinks, listPublicLinks } from '../lib/api'; import { type Link, type LinkScope, listPrivateLinks, listPublicLinks } from '../lib/api';
import { safeLinkTargetUrl } from '../lib/url'; import { safeLinkTargetUrl } from '../lib/url';
import { useTranslation, Trans } from 'react-i18next';
const DEBOUNCE_MS = 220; const DEBOUNCE_MS = 220;
const MAX_RESULTS = 12; const MAX_RESULTS = 12;
@@ -44,9 +45,12 @@ function navigateToLinkDetail(link: Pick<Link, 'id' | 'scope'>) {
export default function LinkSearchBar({ export default function LinkSearchBar({
scope, scope,
placeholder = 'Search by alias…', placeholder,
onSelect, onSelect,
}: LinkSearchBarProps) { }: LinkSearchBarProps) {
const { t } = useTranslation();
const resolvedPlaceholder =
placeholder ?? (scope === 'private' ? t('privateLinks.searchPlaceholder') : t('publicLinks.searchPlaceholder'));
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [state, setState] = useState<SearchState>(INITIAL_STATE); const [state, setState] = useState<SearchState>(INITIAL_STATE);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -78,7 +82,7 @@ export default function LinkSearchBar({
setState({ setState({
links: [], links: [],
loading: false, loading: false,
error: err instanceof Error ? err.message : 'Search failed', error: err instanceof Error ? err.message : t('landing.searchFail'),
query, query,
}); });
setOpen(true); setOpen(true);
@@ -227,7 +231,7 @@ export default function LinkSearchBar({
ref={inputRef} ref={inputRef}
type="search" type="search"
className="link-search__input" className="link-search__input"
placeholder={placeholder} placeholder={resolvedPlaceholder}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -236,7 +240,7 @@ export default function LinkSearchBar({
autoCapitalize="off" autoCapitalize="off"
spellCheck={false} spellCheck={false}
enterKeyHint="go" enterKeyHint="go"
aria-label="Search links by alias" aria-label={t('landing.searchLabel')}
aria-expanded={showDropdown} aria-expanded={showDropdown}
aria-autocomplete="list" aria-autocomplete="list"
aria-controls="link-search-results" aria-controls="link-search-results"
@@ -247,7 +251,7 @@ export default function LinkSearchBar({
type="button" type="button"
className="link-search__clear" className="link-search__clear"
onClick={clearSearch} onClick={clearSearch}
aria-label="Clear search" aria-label={t('landing.clearLabel')}
tabIndex={-1} tabIndex={-1}
> >
<XIcon aria-hidden="true" size={16} strokeWidth={2.5} /> <XIcon aria-hidden="true" size={16} strokeWidth={2.5} />
@@ -264,7 +268,7 @@ export default function LinkSearchBar({
{showLoading ? ( {showLoading ? (
<div className="link-search__status"> <div className="link-search__status">
<span className="link-search__spinner" aria-hidden="true" /> <span className="link-search__spinner" aria-hidden="true" />
<span>Searching</span> <span>{t('landing.searching')}</span>
</div> </div>
) : null} ) : null}
@@ -276,7 +280,11 @@ export default function LinkSearchBar({
{showNoResults ? ( {showNoResults ? (
<div className="link-search__status"> <div className="link-search__status">
No links matching <code>{trimmedQuery}</code> <Trans
i18nKey="landing.noResultsTitle"
values={{ query: trimmedQuery }}
components={{ 1: <code /> }}
/>
</div> </div>
) : null} ) : null}
@@ -309,7 +317,7 @@ export default function LinkSearchBar({
/{link.alias} /{link.alias}
</a> </a>
{exact ? ( {exact ? (
<span className="link-search__exact-badge">Exact match</span> <span className="link-search__exact-badge">{t('landing.exactMatch')}</span>
) : null} ) : null}
</div> </div>
{link.linkType === 'redirect' && link.targetUrl ? ( {link.linkType === 'redirect' && link.targetUrl ? (
@@ -319,17 +327,17 @@ export default function LinkSearchBar({
{link.targetUrl} {link.targetUrl}
</span> </span>
) : ( ) : (
<span className="muted">Invalid target</span> <span className="muted">{t('linkTable.invalidTarget')}</span>
)} )}
</p> </p>
) : link.linkType === 'custom' ? ( ) : link.linkType === 'custom' ? (
<p className="link-search__result-url"> <p className="link-search__result-url">
<span className="muted">Custom page</span> <span className="muted">{t('landing.customPage')}</span>
</p> </p>
) : null} ) : null}
<div className="link-search__result-meta"> <div className="link-search__result-meta">
<span className="link-search__clicks"> <span className="link-search__clicks">
{link.clickCount.toLocaleString()} click{link.clickCount !== 1 ? 's' : ''} {t('landing.clicksCount', { count: link.clickCount.toLocaleString() })}
</span> </span>
</div> </div>
</div> </div>
+50 -41
View File
@@ -1,5 +1,6 @@
import { CodeIcon, LinkIcon } from 'lucide-react'; import { CodeIcon, LinkIcon } from 'lucide-react';
import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type SyntheticEvent } from 'react'; import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { Link } from '../lib/api'; import type { Link } from '../lib/api';
import { safeLinkTargetUrl } from '../lib/url'; import { safeLinkTargetUrl } from '../lib/url';
@@ -10,12 +11,12 @@ type ViewStyle = 'list' | 'cards';
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
const VIEW_QUERY_PARAM = 'view'; const VIEW_QUERY_PARAM = 'view';
const SORT_LABELS: Record<SortField, string> = { const SORT_FIELD_KEYS: Record<SortField, string> = {
alias: 'Alias', alias: 'linkTable.colAlias',
linkType: 'Type', linkType: 'linkTable.colType',
targetUrl: 'Target', targetUrl: 'linkTable.colTarget',
clickCount: 'Clicks', clickCount: 'linkTable.colClicks',
updatedAt: 'Updated', updatedAt: 'linkTable.colUpdated',
}; };
interface LinkTableProps { interface LinkTableProps {
@@ -85,13 +86,14 @@ function handleRowKeyDown(event: ReactKeyboardEvent<HTMLElement>, link: Pick<Lin
} }
function LinkTypeBadge({ linkType, className }: { linkType: Link['linkType']; className?: string }) { function LinkTypeBadge({ linkType, className }: { linkType: Link['linkType']; className?: string }) {
const { t } = useTranslation();
const isCustom = linkType === 'custom'; const isCustom = linkType === 'custom';
const Icon = isCustom ? CodeIcon : LinkIcon; const Icon = isCustom ? CodeIcon : LinkIcon;
return ( return (
<span className={`link-type-badge ${isCustom ? 'is-custom' : 'is-redirect'}${className ? ` ${className}` : ''}`}> <span className={`link-type-badge ${isCustom ? 'is-custom' : 'is-redirect'}${className ? ` ${className}` : ''}`}>
<Icon aria-hidden="true" size={14} strokeWidth={2.2} /> <Icon aria-hidden="true" size={14} strokeWidth={2.2} />
<span>{isCustom ? 'Custom' : 'Redirect'}</span> <span>{isCustom ? t('linkTable.typeCustom') : t('linkTable.typeRedirect')}</span>
</span> </span>
); );
} }
@@ -100,7 +102,7 @@ export default function LinkTable({
links, links,
loading = false, loading = false,
error = null, error = null,
emptyMessage = 'No links yet.', emptyMessage,
selectable = false, selectable = false,
selectedIds, selectedIds,
onToggleSelect, onToggleSelect,
@@ -109,6 +111,7 @@ export default function LinkTable({
onDelete, onDelete,
actions = true, actions = true,
}: LinkTableProps) { }: LinkTableProps) {
const { t } = useTranslation();
const [sortField, setSortField] = useState<SortField>('clickCount'); const [sortField, setSortField] = useState<SortField>('clickCount');
const [sortDir, setSortDir] = useState<SortDir>('desc'); const [sortDir, setSortDir] = useState<SortDir>('desc');
const [viewStyle, setViewStyle] = useState<ViewStyle>(() => readViewStyleFromHash()); const [viewStyle, setViewStyle] = useState<ViewStyle>(() => readViewStyleFromHash());
@@ -148,9 +151,9 @@ export default function LinkTable({
setVisibleCount(PAGE_SIZE); setVisibleCount(PAGE_SIZE);
} }
if (loading) return <p className="table-status" aria-busy="true">Loading links</p>; if (loading) return <p className="table-status" aria-busy="true">{t('linkTable.loading')}</p>;
if (error) return <p className="table-error" role="alert">{error}</p>; if (error) return <p className="table-error" role="alert">{error}</p>;
if (links.length === 0) return <p className="table-empty">{emptyMessage}</p>; if (links.length === 0) return <p className="table-empty">{emptyMessage ?? t('linkTable.empty')}</p>;
const allIds = links.map((l) => l.id); const allIds = links.map((l) => l.id);
const allSelected = selectable && selectedIds != null && allIds.length > 0 && allIds.every((id) => selectedIds.has(id)); const allSelected = selectable && selectedIds != null && allIds.length > 0 && allIds.every((id) => selectedIds.has(id));
@@ -159,35 +162,45 @@ export default function LinkTable({
const remaining = sorted.length - visibleCount; const remaining = sorted.length - visibleCount;
const showActionColumn = actions && (onEdit != null || onDelete != null); const showActionColumn = actions && (onEdit != null || onDelete != null);
function columnHeader(field: SortField): string {
switch (field) {
case 'alias': return t('linkTable.colAlias');
case 'linkType': return t('linkTable.colType');
case 'targetUrl': return t('linkTable.colTarget');
case 'clickCount': return t('linkTable.colClicks');
case 'updatedAt': return t('linkTable.colUpdated');
}
}
return ( return (
<div> <div>
{/* Toolbar */} {/* Toolbar */}
<div className="link-table-toolbar"> <div className="link-table-toolbar">
<span className="muted" style={{ fontSize: '0.8125rem' }}> <span className="muted" style={{ fontSize: '0.8125rem' }}>
{links.length} link{links.length !== 1 ? 's' : ''} {t('linkTable.linksCount', { count: links.length })}
{visibleCount < links.length ? ` · showing ${visibleCount}` : ''} {visibleCount < links.length ? t('linkTable.showing', { count: visibleCount }) : ''}
</span> </span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
{viewStyle === 'cards' && ( {viewStyle === 'cards' && (
<> <>
<label style={{ fontSize: '0.8rem', color: 'var(--muted)', fontWeight: 500, whiteSpace: 'nowrap' }}>Sort</label> <label style={{ fontSize: '0.8rem', color: 'var(--muted)', fontWeight: 500, whiteSpace: 'nowrap' }}>{t('linkTable.sort')}</label>
<select <select
value={sortField} value={sortField}
onChange={(e) => { setSortField(e.target.value as SortField); setVisibleCount(PAGE_SIZE); }} onChange={(e) => { setSortField(e.target.value as SortField); setVisibleCount(PAGE_SIZE); }}
style={{ fontSize: '0.8125rem', padding: '0.2rem 0.5rem', border: '1px solid var(--border-strong)', borderRadius: '6px', background: 'var(--surface)', color: 'var(--text)' }} style={{ fontSize: '0.8125rem', padding: '0.2rem 0.5rem', border: '1px solid var(--border-strong)', borderRadius: '6px', background: 'var(--surface)', color: 'var(--text)' }}
> >
{(Object.keys(SORT_LABELS) as SortField[]).map((f) => ( {(Object.keys(SORT_FIELD_KEYS) as SortField[]).map((f) => (
<option key={f} value={f}>{SORT_LABELS[f]}</option> <option key={f} value={f}>{columnHeader(f)}</option>
))} ))}
</select> </select>
<button <button
type="button" type="button"
className="link-action" className="link-action"
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))} onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Sort ascending' : 'Sort descending'} title={sortDir === 'asc' ? t('linkTable.sortAscTitle') : t('linkTable.sortDescTitle')}
style={{ padding: '0.2rem 0.5rem', fontFamily: 'inherit' }} style={{ padding: '0.2rem 0.5rem', fontFamily: 'inherit' }}
> >
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'} {sortDir === 'asc' ? t('linkTable.sortAsc') : t('linkTable.sortDesc')}
</button> </button>
<div style={{ width: '1px', height: '1.25rem', background: 'var(--border)', margin: '0 0.15rem' }} /> <div style={{ width: '1px', height: '1.25rem', background: 'var(--border)', margin: '0 0.15rem' }} />
</> </>
@@ -196,17 +209,17 @@ export default function LinkTable({
type="button" type="button"
className={`tab-button${viewStyle === 'list' ? ' is-active' : ''}`} className={`tab-button${viewStyle === 'list' ? ' is-active' : ''}`}
onClick={() => setViewStyle('list')} onClick={() => setViewStyle('list')}
title="List view" title={t('linkTable.listViewTitle')}
> >
List {t('linkTable.listView')}
</button> </button>
<button <button
type="button" type="button"
className={`tab-button${viewStyle === 'cards' ? ' is-active' : ''}`} className={`tab-button${viewStyle === 'cards' ? ' is-active' : ''}`}
onClick={() => setViewStyle('cards')} onClick={() => setViewStyle('cards')}
title="Card view" title={t('linkTable.cardViewTitle')}
> >
Cards {t('linkTable.cardView')}
</button> </button>
</div> </div>
</div> </div>
@@ -220,7 +233,7 @@ export default function LinkTable({
<th scope="col" className="col-select"> <th scope="col" className="col-select">
<input <input
type="checkbox" type="checkbox"
aria-label={allSelected ? 'Deselect all links' : 'Select all links'} aria-label={allSelected ? t('linkTable.deselectAllLabel') : t('linkTable.selectAllLabel')}
checked={allSelected} checked={allSelected}
onChange={() => onSelectAll?.(allSelected ? [] : allIds)} onChange={() => onSelectAll?.(allSelected ? [] : allIds)}
/> />
@@ -228,15 +241,11 @@ export default function LinkTable({
) : null} ) : null}
{(['alias', 'linkType', 'targetUrl', 'clickCount', 'updatedAt'] as SortField[]).map((field) => ( {(['alias', 'linkType', 'targetUrl', 'clickCount', 'updatedAt'] as SortField[]).map((field) => (
<th key={field} scope="col" className="sortable-th" onClick={() => handleSort(field)}> <th key={field} scope="col" className="sortable-th" onClick={() => handleSort(field)}>
{field === 'alias' ? 'Alias' {columnHeader(field)}
: field === 'linkType' ? 'Type'
: field === 'targetUrl' ? 'Target / content'
: field === 'clickCount' ? 'Clicks'
: 'Updated'}
<SortIndicator field={field} sortField={sortField} sortDir={sortDir} /> <SortIndicator field={field} sortField={sortField} sortDir={sortDir} />
</th> </th>
))} ))}
{showActionColumn ? <th scope="col" className="col-actions">Actions</th> : null} {showActionColumn ? <th scope="col" className="col-actions">{t('linkTable.colActions')}</th> : null}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -252,13 +261,13 @@ export default function LinkTable({
onKeyDown={(event) => handleRowKeyDown(event, link)} onKeyDown={(event) => handleRowKeyDown(event, link)}
tabIndex={0} tabIndex={0}
role="link" role="link"
aria-label={`Open details for ${link.alias}`} aria-label={t('linkTable.openDetailsLabel', { alias: link.alias })}
> >
{selectable ? ( {selectable ? (
<td className="col-select"> <td className="col-select">
<input <input
type="checkbox" type="checkbox"
aria-label={`Select ${link.alias}`} aria-label={t('linkTable.selectLinkLabel', { alias: link.alias })}
checked={selected} checked={selected}
onClick={stopRowNavigation} onClick={stopRowNavigation}
onChange={() => onToggleSelect?.(link.id)} onChange={() => onToggleSelect?.(link.id)}
@@ -284,7 +293,7 @@ export default function LinkTable({
link.targetUrl ? ( link.targetUrl ? (
safeUrl safeUrl
? <span className="truncate" title={link.targetUrl}>{link.targetUrl}</span> ? <span className="truncate" title={link.targetUrl}>{link.targetUrl}</span>
: <span className="muted" title="Target URL is not a valid http(s) link">Invalid target</span> : <span className="muted" title="Target URL is not a valid http(s) link">{t('linkTable.invalidTarget')}</span>
) : <span className="muted"></span> ) : <span className="muted"></span>
) : <span className="muted">markdown</span>} ) : <span className="muted">markdown</span>}
</td> </td>
@@ -303,7 +312,7 @@ export default function LinkTable({
onEdit(link); onEdit(link);
}} }}
> >
Edit {t('linkTable.edit')}
</button> </button>
) : null} ) : null}
{actions && onDelete ? ( {actions && onDelete ? (
@@ -315,7 +324,7 @@ export default function LinkTable({
onDelete(link); onDelete(link);
}} }}
> >
Delete {t('linkTable.delete')}
</button> </button>
) : null} ) : null}
</td> </td>
@@ -342,13 +351,13 @@ export default function LinkTable({
onKeyDown={(event) => handleRowKeyDown(event, link)} onKeyDown={(event) => handleRowKeyDown(event, link)}
tabIndex={0} tabIndex={0}
role="link" role="link"
aria-label={`Open details for ${link.alias}`} aria-label={t('linkTable.openDetailsLabel', { alias: link.alias })}
> >
<div className="link-card-header"> <div className="link-card-header">
{selectable ? ( {selectable ? (
<input <input
type="checkbox" type="checkbox"
aria-label={`Select ${link.alias}`} aria-label={t('linkTable.selectLinkLabel', { alias: link.alias })}
checked={selected} checked={selected}
onClick={stopRowNavigation} onClick={stopRowNavigation}
onChange={() => onToggleSelect?.(link.id)} onChange={() => onToggleSelect?.(link.id)}
@@ -375,7 +384,7 @@ export default function LinkTable({
</p> </p>
) : null} ) : null}
<div className="link-card-meta"> <div className="link-card-meta">
<span style={{ fontWeight: 600, color: 'var(--accent-text)' }}>{link.clickCount.toLocaleString()} clicks</span> <span style={{ fontWeight: 600, color: 'var(--accent-text)' }}>{t('linkTable.clicks', { count: link.clickCount.toLocaleString() })}</span>
<time className="muted" dateTime={link.updatedAt} style={{ fontSize: '0.75rem' }}> <time className="muted" dateTime={link.updatedAt} style={{ fontSize: '0.75rem' }}>
{new Date(link.updatedAt).toLocaleDateString()} {new Date(link.updatedAt).toLocaleDateString()}
</time> </time>
@@ -391,7 +400,7 @@ export default function LinkTable({
onEdit(link); onEdit(link);
}} }}
> >
Edit {t('linkTable.edit')}
</button> </button>
) : null} ) : null}
{actions && onDelete ? ( {actions && onDelete ? (
@@ -403,7 +412,7 @@ export default function LinkTable({
onDelete(link); onDelete(link);
}} }}
> >
Delete {t('linkTable.delete')}
</button> </button>
) : null} ) : null}
</div> </div>
@@ -423,11 +432,11 @@ export default function LinkTable({
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)} onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
style={{ padding: '0.5rem 2rem', fontSize: '0.875rem' }} style={{ padding: '0.5rem 2rem', fontSize: '0.875rem' }}
> >
Load {Math.min(PAGE_SIZE, remaining)} more {t('linkTable.loadMore', { count: Math.min(PAGE_SIZE, remaining) })}
<span className="muted" style={{ marginLeft: '0.4rem', fontWeight: 400 }}>({remaining} remaining)</span> <span className="muted" style={{ marginLeft: '0.4rem', fontWeight: 400 }}>{t('linkTable.remaining', { count: remaining })}</span>
</button> </button>
</div> </div>
)} )}
</div> </div>
); );
} }
+18 -16
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { PencilLineIcon } from 'lucide-react'; import { PencilLineIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { import {
ApiClientError, ApiClientError,
type PromotionSubmission, type PromotionSubmission,
@@ -9,11 +10,11 @@ import {
} from '../lib/api'; } from '../lib/api';
import ResubmitDialog from './ResubmitDialog'; import ResubmitDialog from './ResubmitDialog';
const STATUS_LABELS: Record<SubmissionStatus, string> = { const STATUS_LABEL_KEYS: Record<SubmissionStatus, string> = {
pending: 'Pending', pending: 'mySubmissions.statusPending',
approved: 'Approved', approved: 'mySubmissions.statusApproved',
rejected: 'Rejected', rejected: 'mySubmissions.statusRejected',
needs_changes: 'Needs changes', needs_changes: 'mySubmissions.statusNeedsChanges',
}; };
interface MySubmissionsListProps { interface MySubmissionsListProps {
@@ -21,6 +22,7 @@ interface MySubmissionsListProps {
} }
export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProps) { export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProps) {
const { t } = useTranslation();
const [submissions, setSubmissions] = useState<PromotionSubmission[]>([]); const [submissions, setSubmissions] = useState<PromotionSubmission[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -62,18 +64,18 @@ export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProp
{error ? <p className="form-error" role="alert">{error}</p> : null} {error ? <p className="form-error" role="alert">{error}</p> : null}
{loading ? ( {loading ? (
<p className="table-status" aria-busy="true">Loading submissions</p> <p className="table-status" aria-busy="true">{t('mySubmissions.loading')}</p>
) : submissions.length === 0 ? ( ) : submissions.length === 0 ? (
<p className="table-empty">You have not submitted any links for promotion yet.</p> <p className="table-empty">{t('mySubmissions.empty')}</p>
) : ( ) : (
<table className="link-table review-table"> <table className="link-table review-table">
<thead> <thead>
<tr> <tr>
<th scope="col">Proposed alias</th> <th scope="col">{t('mySubmissions.colProposedAlias')}</th>
<th scope="col">Private link</th> <th scope="col">{t('mySubmissions.colPrivateLink')}</th>
<th scope="col">Submitted</th> <th scope="col">{t('mySubmissions.colSubmitted')}</th>
<th scope="col">Status</th> <th scope="col">{t('mySubmissions.colStatus')}</th>
<th scope="col" className="col-actions">Actions</th> <th scope="col" className="col-actions">{t('mySubmissions.colActions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -83,7 +85,7 @@ export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProp
<span className="alias-pill">#{submission.proposedAlias}</span> <span className="alias-pill">#{submission.proposedAlias}</span>
{submission.note ? <small className="row-description">{submission.note}</small> : null} {submission.note ? <small className="row-description">{submission.note}</small> : null}
{submission.rejectionReason ? ( {submission.rejectionReason ? (
<small className="row-description review-reason">Reason: {submission.rejectionReason}</small> <small className="row-description review-reason">{t('mySubmissions.reason', { reason: submission.rejectionReason })}</small>
) : null} ) : null}
</td> </td>
<td> <td>
@@ -98,7 +100,7 @@ export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProp
<time dateTime={submission.createdAt}>{new Date(submission.createdAt).toLocaleString()}</time> <time dateTime={submission.createdAt}>{new Date(submission.createdAt).toLocaleString()}</time>
</td> </td>
<td> <td>
<span className={`status-badge status-${submission.status}`}>{STATUS_LABELS[submission.status]}</span> <span className={`status-badge status-${submission.status}`}>{t(STATUS_LABEL_KEYS[submission.status])}</span>
</td> </td>
<td className="col-actions"> <td className="col-actions">
{submission.status === 'needs_changes' ? ( {submission.status === 'needs_changes' ? (
@@ -108,7 +110,7 @@ export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProp
onClick={() => setResubmitting(submission)} onClick={() => setResubmitting(submission)}
> >
<PencilLineIcon aria-hidden="true" size={14} /> <PencilLineIcon aria-hidden="true" size={14} />
<span>Edit &amp; resubmit</span> <span>{t('mySubmissions.editResubmit')}</span>
</button> </button>
) : ( ) : (
<span className="muted"></span> <span className="muted"></span>
@@ -129,4 +131,4 @@ export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProp
) : null} ) : null}
</div> </div>
); );
} }
+23 -17
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import type { Link } from '../lib/api'; import type { Link } from '../lib/api';
interface PromotionDialogProps { interface PromotionDialogProps {
@@ -15,6 +16,7 @@ function defaultAlias(link: Link): string {
} }
export default function PromotionDialog({ links, onSubmit, onClose }: PromotionDialogProps) { export default function PromotionDialog({ links, onSubmit, onClose }: PromotionDialogProps) {
const { t } = useTranslation();
const [selectedLinkId, setSelectedLinkId] = useState<string>(() => links[0]?.id ?? ''); const [selectedLinkId, setSelectedLinkId] = useState<string>(() => links[0]?.id ?? '');
const [proposedAlias, setProposedAlias] = useState<string>(() => links[0]?.alias ?? ''); const [proposedAlias, setProposedAlias] = useState<string>(() => links[0]?.alias ?? '');
const [note, setNote] = useState<string>(EMPTY_NOTE); const [note, setNote] = useState<string>(EMPTY_NOTE);
@@ -56,12 +58,12 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
if (links.length === 0) { if (links.length === 0) {
return ( return (
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Promote private link"> <div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label={t('promotionDialog.dialogLabel')}>
<div className="dialog-card"> <div className="dialog-card">
<h2>Promote to public</h2> <h2>{t('promotionDialog.emptyTitle')}</h2>
<p className="muted">Select at least one private link to promote.</p> <p className="muted">{t('promotionDialog.emptyDescription')}</p>
<div className="form-actions"> <div className="form-actions">
<button type="button" onClick={onClose}>Close</button> <button type="button" onClick={onClose}>{t('promotionDialog.close')}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -69,16 +71,16 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
} }
return ( return (
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Promote private link"> <div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label={t('promotionDialog.dialogLabel')}>
<form className="dialog-card link-form" onSubmit={handleSubmit}> <form className="dialog-card link-form" onSubmit={handleSubmit}>
<div className="dialog-header"> <div className="dialog-header">
<h2>Promote to public directory</h2> <h2>{t('promotionDialog.title')}</h2>
<button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button> <button type="button" className="dialog-close" aria-label={t('promotionDialog.closeLabel')} onClick={onClose} disabled={submitting}>×</button>
</div> </div>
{!single ? ( {!single ? (
<div className="field"> <div className="field">
<label htmlFor="promotion-link">Private link</label> <label htmlFor="promotion-link">{t('promotionDialog.fieldPrivateLink')}</label>
<select <select
id="promotion-link" id="promotion-link"
value={selectedLinkId} value={selectedLinkId}
@@ -89,17 +91,21 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
<option key={link.id} value={link.id}>#{link.alias}</option> <option key={link.id} value={link.id}>#{link.alias}</option>
))} ))}
</select> </select>
<small className="hint">Choose which private link to submit for promotion.</small> <small className="hint">{t('promotionDialog.linkHint')}</small>
</div> </div>
) : ( ) : (
<p className="muted"> <p className="muted">
Promoting <span className="alias-pill">#{links[0].alias}</span> <Trans
i18nKey="promotionDialog.promotingLabel"
values={{ alias: links[0].alias }}
components={{ 0: <span className="alias-pill" /> }}
/>
{links[0].targetUrl ? <> <span className="truncate">{links[0].targetUrl}</span></> : null} {links[0].targetUrl ? <> <span className="truncate">{links[0].targetUrl}</span></> : null}
</p> </p>
)} )}
<div className="field"> <div className="field">
<label htmlFor="promotion-alias">Proposed public alias</label> <label htmlFor="promotion-alias">{t('promotionDialog.fieldAlias')}</label>
<div className="alias-input"> <div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">#</span> <span className="alias-input__prefix" aria-hidden="true">#</span>
<input <input
@@ -110,18 +116,18 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
required required
maxLength={100} maxLength={100}
autoComplete="off" autoComplete="off"
placeholder="public-alias" placeholder={t('promotionDialog.aliasPlaceholder')}
disabled={submitting} disabled={submitting}
/> />
</div> </div>
<small className="hint alias-preview"> <small className="hint alias-preview">
<span>The alias reviewers will publish this link under.</span> <span>{t('promotionDialog.aliasHint')}</span>
{proposedAlias.trim() ? <span className="alias-pill">#{proposedAlias.trim()}</span> : null} {proposedAlias.trim() ? <span className="alias-pill">#{proposedAlias.trim()}</span> : null}
</small> </small>
</div> </div>
<div className="field"> <div className="field">
<label htmlFor="promotion-note">Note for reviewers (optional)</label> <label htmlFor="promotion-note">{t('promotionDialog.fieldNote')}</label>
<textarea <textarea
id="promotion-note" id="promotion-note"
name="note" name="note"
@@ -129,7 +135,7 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
rows={3} rows={3}
maxLength={1000} maxLength={1000}
placeholder="Why should this be public?" placeholder={t('promotionDialog.notePlaceholder')}
disabled={submitting} disabled={submitting}
/> />
</div> </div>
@@ -138,9 +144,9 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
<div className="form-actions"> <div className="form-actions">
<button type="submit" disabled={submitting || !proposedAlias.trim()}> <button type="submit" disabled={submitting || !proposedAlias.trim()}>
{submitting ? 'Submitting' : 'Submit for review'} {submitting ? t('promotionDialog.submitting') : t('promotionDialog.submitForReview')}
</button> </button>
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button> <button type="button" onClick={onClose} disabled={submitting}>{t('promotionDialog.cancel')}</button>
</div> </div>
</form> </form>
</div> </div>
+8 -6
View File
@@ -1,17 +1,18 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface ProviderInfo { interface ProviderInfo {
readonly id: string; readonly id: string;
readonly label: string; readonly labelKey: string;
readonly loadingLabel: string; readonly loadingKey: string;
readonly href: string; readonly href: string;
} }
const PROVIDERS: readonly ProviderInfo[] = [ const PROVIDERS: readonly ProviderInfo[] = [
{ {
id: 'google', id: 'google',
label: 'Continue with Google', labelKey: 'login.continueWithGoogle',
loadingLabel: 'Opening Google...', loadingKey: 'login.openingGoogle',
href: '/api/auth/google', href: '/api/auth/google',
}, },
]; ];
@@ -21,6 +22,7 @@ interface ProviderButtonsProps {
} }
export default function ProviderButtons({ disabled = false }: ProviderButtonsProps) { export default function ProviderButtons({ disabled = false }: ProviderButtonsProps) {
const { t } = useTranslation();
const [loadingProvider, setLoadingProvider] = useState<string | null>(null); const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
const isBusy = disabled || loadingProvider !== null; const isBusy = disabled || loadingProvider !== null;
@@ -51,12 +53,12 @@ export default function ProviderButtons({ disabled = false }: ProviderButtonsPro
<span className="provider-button__icon">G</span> <span className="provider-button__icon">G</span>
) : null} ) : null}
<span className="provider-button__label"> <span className="provider-button__label">
{loadingProvider === provider.id ? provider.loadingLabel : provider.label} {loadingProvider === provider.id ? t(provider.loadingKey) : t(provider.labelKey)}
</span> </span>
</a> </a>
))} ))}
<span className="sr-only" role="status" aria-live="polite"> <span className="sr-only" role="status" aria-live="polite">
{loadingProvider ? 'Opening Google sign in' : ''} {loadingProvider ? t('login.openingGoogleSr') : ''}
</span> </span>
</div> </div>
); );
+14 -12
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PromotionSubmission } from '../lib/api'; import type { PromotionSubmission } from '../lib/api';
interface ResubmitDialogProps { interface ResubmitDialogProps {
@@ -8,6 +9,7 @@ interface ResubmitDialogProps {
} }
export default function ResubmitDialog({ submission, onSubmit, onClose }: ResubmitDialogProps) { export default function ResubmitDialog({ submission, onSubmit, onClose }: ResubmitDialogProps) {
const { t } = useTranslation();
const [proposedAlias, setProposedAlias] = useState<string>(submission.proposedAlias); const [proposedAlias, setProposedAlias] = useState<string>(submission.proposedAlias);
const [note, setNote] = useState<string>(submission.note ?? ''); const [note, setNote] = useState<string>(submission.note ?? '');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -34,11 +36,11 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
} }
return ( return (
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Resubmit promotion"> <div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label={t('resubmitDialog.dialogLabel')}>
<form className="dialog-card link-form" onSubmit={handleSubmit}> <form className="dialog-card link-form" onSubmit={handleSubmit}>
<div className="dialog-header"> <div className="dialog-header">
<h2>Edit &amp; resubmit</h2> <h2>{t('resubmitDialog.title')}</h2>
<button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button> <button type="button" className="dialog-close" aria-label={t('resubmitDialog.closeLabel')} onClick={onClose} disabled={submitting}>×</button>
</div> </div>
<p className="muted"> <p className="muted">
@@ -48,13 +50,13 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
{submission.rejectionReason ? ( {submission.rejectionReason ? (
<div className="resubmit-reason" role="note"> <div className="resubmit-reason" role="note">
<strong>Requested changes:</strong> <strong>{t('resubmitDialog.requestedChanges')}</strong>
<p>{submission.rejectionReason}</p> <p>{submission.rejectionReason}</p>
</div> </div>
) : null} ) : null}
<div className="field"> <div className="field">
<label htmlFor="resubmit-alias">Proposed public alias</label> <label htmlFor="resubmit-alias">{t('resubmitDialog.fieldAlias')}</label>
<div className="alias-input"> <div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">#</span> <span className="alias-input__prefix" aria-hidden="true">#</span>
<input <input
@@ -65,18 +67,18 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
required required
maxLength={100} maxLength={100}
autoComplete="off" autoComplete="off"
placeholder="public-alias" placeholder={t('resubmitDialog.aliasPlaceholder')}
disabled={submitting} disabled={submitting}
/> />
</div> </div>
<small className="hint alias-preview"> <small className="hint alias-preview">
<span>The alias reviewers will publish this link under.</span> <span>{t('resubmitDialog.aliasHint')}</span>
{proposedAlias.trim() ? <span className="alias-pill">#{proposedAlias.trim()}</span> : null} {proposedAlias.trim() ? <span className="alias-pill">#{proposedAlias.trim()}</span> : null}
</small> </small>
</div> </div>
<div className="field"> <div className="field">
<label htmlFor="resubmit-note">Note for reviewers (optional)</label> <label htmlFor="resubmit-note">{t('resubmitDialog.fieldNote')}</label>
<textarea <textarea
id="resubmit-note" id="resubmit-note"
name="note" name="note"
@@ -84,7 +86,7 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
rows={3} rows={3}
maxLength={1000} maxLength={1000}
placeholder="What did you change?" placeholder={t('resubmitDialog.notePlaceholder')}
disabled={submitting} disabled={submitting}
/> />
</div> </div>
@@ -93,11 +95,11 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
<div className="form-actions"> <div className="form-actions">
<button type="submit" disabled={submitting || !proposedAlias.trim()}> <button type="submit" disabled={submitting || !proposedAlias.trim()}>
{submitting ? 'Submitting' : 'Resubmit for review'} {submitting ? t('resubmitDialog.submitting') : t('resubmitDialog.resubmitForReview')}
</button> </button>
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button> <button type="button" onClick={onClose} disabled={submitting}>{t('resubmitDialog.cancel')}</button>
</div> </div>
</form> </form>
</div> </div>
); );
} }
+14 -11
View File
@@ -1,4 +1,5 @@
import type { CurrentUser } from '../lib/auth'; import type { CurrentUser } from '../lib/auth';
import { useTranslation } from 'react-i18next';
interface UserMenuProps { interface UserMenuProps {
user: CurrentUser | null; user: CurrentUser | null;
@@ -8,23 +9,25 @@ interface UserMenuProps {
} }
export default function UserMenu({ user, loading, unreadCount, onLogout }: UserMenuProps) { export default function UserMenu({ user, loading, unreadCount, onLogout }: UserMenuProps) {
const { t } = useTranslation();
if (loading) { if (loading) {
return <div className="user-menu-loading" aria-label="Loading user" />; return <div className="user-menu-loading" aria-label={t('common.loadingUser')} />;
} }
if (!user) { if (!user) {
return ( return (
<div className="user-menu"> <div className="user-menu">
<a className="signin-button" href="#/login">Sign In</a> <a className="signin-button" href="#/login">{t('app.signIn')}</a>
</div> </div>
); );
} }
const displayName = user.name || user.email?.split('@')[0] || 'User'; const displayName = user.name || user.email?.split('@')[0] || t('common.usersName');
const hasUnread = unreadCount > 0; const hasUnread = unreadCount > 0;
const unreadLabel = hasUnread const unreadLabel = hasUnread
? `${unreadCount} unread notification${unreadCount === 1 ? '' : 's'}` ? t('app.unreadNotifications', { count: unreadCount })
: 'Notifications'; : t('app.notifications');
return ( return (
<div className="user-menu"> <div className="user-menu">
@@ -42,24 +45,24 @@ export default function UserMenu({ user, loading, unreadCount, onLogout }: UserM
) : null} ) : null}
</span> </span>
<span className="user-name">{displayName}</span> <span className="user-name">{displayName}</span>
{user.role === 'admin' && <span className="admin-badge">admin</span>} {user.role === 'admin' && <span className="admin-badge">{t('common.adminBadge')}</span>}
<svg className="chevron" width="12" height="12" viewBox="0 0 12 12" fill="none"> <svg className="chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/> <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg> </svg>
</div> </div>
<div className="user-dropdown" role="menu"> <div className="user-dropdown" role="menu">
<a href="#/notifications" className="dropdown-item dropdown-item--with-badge" role="menuitem"> <a href="#/notifications" className="dropdown-item dropdown-item--with-badge" role="menuitem">
<span>Notifications</span> <span>{t('app.notifications')}</span>
{hasUnread ? <span className="dropdown-badge" aria-label={unreadLabel}>{unreadCount}</span> : null} {hasUnread ? <span className="dropdown-badge" aria-label={unreadLabel}>{unreadCount}</span> : null}
</a> </a>
<a href="#/profile" className="dropdown-item" role="menuitem">Profile</a> <a href="#/profile" className="dropdown-item" role="menuitem">{t('app.profile')}</a>
<a href="#/settings" className="dropdown-item" role="menuitem">Settings</a> <a href="#/settings" className="dropdown-item" role="menuitem">{t('app.settings')}</a>
{user.role === 'admin' && ( {user.role === 'admin' && (
<a href="#/admin" className="dropdown-item" role="menuitem">Admin Settings</a> <a href="#/admin" className="dropdown-item" role="menuitem">{t('app.adminSettings')}</a>
)} )}
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<button type="button" className="dropdown-item dropdown-item--danger" onClick={onLogout}> <button type="button" className="dropdown-item dropdown-item--danger" onClick={onLogout}>
Logout {t('app.logout')}
</button> </button>
</div> </div>
</div> </div>
+26
View File
@@ -0,0 +1,26 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
import zh from './locales/zh.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
zh: { translation: zh },
},
fallbackLng: 'zh',
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'heygo_lang',
},
interpolation: {
escapeValue: false,
},
});
export default i18n;
+4
View File
@@ -0,0 +1,4 @@
declare module '*.json' {
const value: Record<string, unknown>;
export default value;
}
+373
View File
@@ -0,0 +1,373 @@
{
"app": {
"brand": "Heygo",
"myLinks": "My Links",
"signIn": "Sign In",
"profile": "Profile",
"settings": "Settings",
"adminSettings": "Admin Settings",
"logout": "Logout",
"notifications": "Notifications",
"unreadNotifications": "{{count}} unread notification",
"unreadNotifications_plural": "{{count}} unread notifications",
"apiStatus": "API status",
"footerText": "Heygo · API status",
"placeholderTitle": "{{title}} page is coming soon."
},
"login": {
"title": "Sign in or create an account",
"legal": "By logging in, you agree to our <1>Terms of Service</1> and <2>Privacy Policy</2>.",
"continueWithGoogle": "Continue with Google",
"openingGoogle": "Opening Google...",
"openingGoogleSr": "Opening Google sign in"
},
"landing": {
"eyebrow": "One word, any link",
"brandSuffix": "Go",
"description": "No links to remember. Type a shortlink name, <1/>and reach public links, private links, and shared content — all in one place.",
"featurePublic": "Public links",
"featurePrivate": "Private links",
"featureShared": "Shared content",
"featureCrossPlatform": "Cross-platform",
"searchPrefix": "heygo.cc/",
"searchPlaceholder": "Enter a shortlink name, e.g. canva",
"searchLabel": "Search shortlinks",
"clearLabel": "Clear search",
"goLabel": "Go",
"createPersonalLink": "Create personal link",
"exactMatch": "Exact match",
"invalidLink": "Invalid link",
"customPage": "Custom page",
"clicksCount": "{{count}} clicks",
"searchFail": "Search failed",
"searching": "Searching…",
"noResultsTitle": "No results for <1>{{query}}</1>",
"noResultsHint": "Is this your own shortlink? Create a personal link that's yours.",
"suggestionsLabel": "Try:",
"goToAlias": "Go to /{{alias}}"
},
"publicLinks": {
"title": "Public Links",
"subtitle": "Short links that go!",
"searchPlaceholder": "Search public links by alias…",
"empty": "No public links yet."
},
"privateLinks": {
"title": "My Links",
"subtitle": "Your personal shortlinks. Only visible to you.",
"submissionsTitle": "My Submissions",
"submissionsSubtitle": "Links you have submitted for public promotion.",
"newLink": "New link",
"closeForm": "Close form",
"mySubmissions": "My Submissions",
"backToMyLinks": "← Back to My Links",
"selected": "{{count}} selected.",
"promoteToPublic": "Promote to public",
"clear": "Clear",
"create": "Create",
"createLink": "Create link",
"editLink": "Edit <1>{{alias}}</1>",
"saveChanges": "Save changes",
"searchPlaceholder": "Search my links by alias…",
"empty": "You have no private links yet. Create one to get started.",
"deleteConfirm": "Delete the link \"{{alias}}\"? This cannot be undone."
},
"linkDetail": {
"loading": "Loading…",
"back": "← Back",
"linkDetails": "Link Details",
"go": "Go",
"copyLink": "Copy link",
"copying": "Copying…",
"copyToMyLinks": "Copy to My Links",
"propose": "Propose",
"edit": "Edit",
"delete": "Delete",
"deleting": "Deleting…",
"saving": "Saving…",
"editTitle": "Edit link",
"cancel": "Cancel",
"fieldAlias": "Alias",
"fieldType": "Type",
"fieldTargetUrl": "Target URL",
"fieldContent": "Markdown content",
"fieldDescription": "Description",
"aliasReadonly": "Alias cant be changed after creation.",
"customPage": "Custom page",
"redirect": "Redirect",
"customMarkdownPage": "Custom markdown page",
"redirectUrl": "Redirect URL",
"totalClicks": "Total clicks",
"created": "Created",
"updated": "Updated",
"description": "Description",
"targetUrl": "Target URL",
"content": "Content",
"templateParams": "Template parameters",
"templateDesc": "This redirect link has parameterized placeholders. Fill in values to preview the resolved URL.",
"resolvedUrl": "Resolved URL",
"open": "Open",
"stats": "Stats",
"history": "History",
"clickStatistics": "Click Statistics",
"changeHistory": "Change History",
"period3m": "3 Months",
"period6m": "6 Months",
"period1y": "1 Year",
"periodAll": "All Time",
"noClickData": "No click data for this period.",
"noChanges": "No changes recorded yet.",
"publicLink": "Public link",
"privateLink": "Private link",
"deleteConfirm": "Delete the link \"{{alias}}\"? This cannot be undone.",
"shortLinkCopied": "Short link copied",
"couldNotCopy": "Could not copy link",
"proposedSubmitted": "Submitted for review — admins will be notified.",
"privateCopySaved": "Private copy saved",
"copyInMyLinks": "/{{alias}} is now in My Links",
"copyDetail": "You can edit it here or keep browsing.",
"dismissNotice": "Dismiss notice",
"editAliasInfo": "Supports <1>{{param}}</1> placeholders, e.g. <3>https://example.com/?q={{query}}</3>.",
"editMarkdownInfo": "Markdown is rendered as HTML with safety guardrails for visitors.",
"editTargetPlaceholder": "https://example.com/search?q={query}",
"editContentPlaceholder": "# Hello world",
"editDescriptionPlaceholder": "What this link is for",
"changeTypeCreated": "Link created",
"changeTypeCloned": "Cloned from public link",
"changeTypeUrlChanged": "URL changed",
"changeTypeAliasChanged": "Alias changed",
"changeTypeDescChanged": "Description changed",
"changeTypeContentChanged": "Content changed",
"changeTypeTypeChanged": "Type changed",
"linkId": "Link ID",
"alias": "Alias",
"url": "URL",
"was": "Was: {{value}}",
"now": "Now: {{value}}",
"loadFailed": "Failed to load link details",
"saveChanges": "Save changes",
"failedCopy": "Failed to copy link",
"failedSave": "Failed to save link",
"failedDelete": "Failed to delete link",
"failedPromote": "Failed to submit promotion",
"clicksChartLabel": "Clicks"
},
"linkForm": {
"createTitle": "Create link",
"editTitle": "Edit link",
"fieldAlias": "Alias",
"aliasHint": "Letters, numbers, hyphens, underscores. Case-insensitive.",
"fieldLinkType": "Link type",
"typeRedirect": "Redirect URL",
"typeCustom": "Custom markdown page",
"fieldTargetUrl": "Target URL",
"targetPlaceholder": "https://example.com/search?q={query}",
"targetHint": "Supports parameterized templates. Use <1>{{query}}</1> placeholders, e.g.<3> https://example.com/search?q={{query}} </3>then visit <5>/alias/foo?bar=baz</5> to substitute.",
"fieldContent": "Markdown content",
"contentPlaceholder": "# Hello world",
"fieldDescription": "Description (optional)",
"descriptionPlaceholder": "What this link is for",
"saving": "Saving…",
"save": "Save",
"cancel": "Cancel",
"aliasPlaceholder": "my-link"
},
"linkTable": {
"loading": "Loading links…",
"empty": "No links yet.",
"sort": "Sort",
"sortAsc": "↑ Asc",
"sortDesc": "↓ Desc",
"sortAscTitle": "Sort ascending",
"sortDescTitle": "Sort descending",
"listView": "☰ List",
"cardView": "⊞ Cards",
"listViewTitle": "List view",
"cardViewTitle": "Card view",
"colAlias": "Alias",
"colType": "Type",
"colTarget": "Target / content",
"colClicks": "Clicks",
"colUpdated": "Updated",
"colActions": "Actions",
"edit": "Edit",
"delete": "Delete",
"selectAllLabel": "Select all links",
"deselectAllLabel": "Deselect all links",
"selectLinkLabel": "Select {{alias}}",
"openDetailsLabel": "Open details for {{alias}}",
"linksCount": "{{count}} link",
"linksCount_plural": "{{count}} links",
"showing": " · showing {{count}}",
"loadMore": "Load {{count}} more",
"remaining": "({{count}} remaining)",
"invalidTarget": "Invalid target",
"typeCustom": "Custom",
"typeRedirect": "Redirect",
"clicks": "{{count}} clicks"
},
"adminReview": {
"title": "Admin",
"subtitle": "Manage public links and review promotion submissions.",
"publicLinks": "Public Links",
"submissions": "Submissions",
"refresh": "Refresh",
"closeForm": "Close form",
"newPublicLink": "New public link",
"createPublicLink": "Create public link",
"create": "Create",
"saveChanges": "Save changes",
"editLink": "Edit <1>{{alias}}</1>",
"emptyPublicLinks": "No public links yet.",
"emptySubmissions": "No {{status}} submissions.",
"statusPending": "Pending",
"statusApproved": "Approved",
"statusRejected": "Rejected",
"statusNeedsChanges": "Needs changes",
"colProposedAlias": "Proposed alias",
"colPrivateLink": "Private link",
"colSubmitted": "Submitted",
"colStatus": "Status",
"colActions": "Actions",
"actionApprove": "Approve",
"actionReject": "Reject",
"actionNeedsChanges": "Needs changes",
"actionConfirm": "Confirm",
"actionCancel": "Cancel",
"reasonPlaceholder": "Reason (optional)",
"reasonLabel": "Reason",
"reason": "Reason: {{reason}}",
"loadingSubmissions": "Loading submissions…",
"deleteConfirm": "Delete public link \"{{alias}}\"? This cannot be undone."
},
"notifications": {
"title": "Notifications",
"subtitle": "Updates about your promotion proposals.",
"refresh": "Refresh",
"deleting": "Deleting…",
"deleteSelected": "Delete selected",
"clear": "Clear",
"selected": "{{count}} selected.",
"loading": "Loading notifications…",
"empty": "No notifications yet.",
"deselectAll": "Deselect all notifications",
"selectAll": "Select all notifications",
"notificationsCount": "{{count}} notification",
"notificationsCount_plural": "{{count}} notifications",
"delete": "Delete",
"deleteConfirm": "Delete \"{{title}}\"?",
"deleteSelectedConfirm": "Delete {{count}} notification?",
"deleteSelectedConfirm_plural": "Delete {{count}} notifications?",
"notificationDeleted": "Notification deleted",
"notificationsDeleted": "{{count}} notification deleted",
"notificationsDeleted_plural": "{{count}} notifications deleted",
"kindApproved": "Approved",
"kindRejected": "Rejected",
"kindNeedsChanges": "Needs changes",
"proposed": "Proposed: <1>#{{alias}}</1>",
"privateLink": "Private link: <1>#{{alias}}</1>",
"viewedAt": "Viewed {{datetime}}",
"unreadLabel": "Unread",
"selectItem": "Select {{title}}",
"deleteItem": "Delete {{title}}",
"loadFailed": "Failed to load notifications",
"deleteFailed": "Failed to delete notification",
"deleteManyFailed": "Failed to delete notifications"
},
"legal": {
"brand": "Heygo",
"termsOfService": "Terms of Service",
"privacyPolicy": "Privacy Policy",
"lastUpdated": "Last updated: {{date}}",
"termsSection1Title": "1. Using Heygo",
"termsSection1": "Heygo lets you create, manage, and share shortlinks. You are responsible for the links, descriptions, and content you create or submit.",
"termsSection2Title": "2. Accounts",
"termsSection2": "You must use accurate account information and keep access to your account secure. Activity from your signed-in account is treated as your activity.",
"termsSection3Title": "3. Acceptable use",
"termsSection3": "Do not use Heygo for unlawful, harmful, deceptive, abusive, infringing, or security-compromising content. We may remove links or restrict access when needed to protect users or the service.",
"termsSection4Title": "4. Service changes",
"termsSection4": "Heygo is provided as available. Features may change, pause, or stop, and we are not liable for indirect losses or unavailable links.",
"termsSection5Title": "5. Contact",
"termsSection5": "Questions about these terms can be sent to the service owner or administrator.",
"privacySection1Title": "1. Information we collect",
"privacySection1": "We store account details from sign-in providers, such as your email, name, profile image, provider account ID, session records, and links you create.",
"privacySection2Title": "2. How we use information",
"privacySection2": "We use this information to sign you in, manage your links, protect private links, review public-link submissions, and operate the service.",
"privacySection3Title": "3. Cookies and sessions",
"privacySection3": "Heygo uses secure session cookies to keep you signed in and to authorize private or admin actions.",
"privacySection4Title": "4. Sharing",
"privacySection4": "We do not sell personal information. Public links and approved public submissions may be visible to anyone with access to the public site.",
"privacySection5Title": "5. Retention and deletion",
"privacySection5": "We keep account, session, and link records as needed to provide the service. Contact the service owner or administrator to request deletion."
},
"copyDialog": {
"title": "Alias already in use",
"description": "You already have <1>{{alias}}</1> in My Links. Choose a different alias for this copy.",
"fieldLabel": "Private alias",
"hint": "This copy will be created in your private links.",
"creating": "Creating…",
"createLink": "Create link",
"cancel": "Cancel",
"aliasRequired": "Alias is required",
"aliasDifferent": "Please choose a different alias.",
"aliasPlaceholder": "my-link-copy",
"dialogLabel": "Choose a different alias",
"closeLabel": "Close"
},
"promotionDialog": {
"title": "Promote to public directory",
"emptyTitle": "Promote to public",
"promotingLabel": "Promoting <0>#{{alias}}</0>",
"emptyDescription": "Select at least one private link to promote.",
"fieldPrivateLink": "Private link",
"fieldAlias": "Proposed public alias",
"fieldNote": "Note for reviewers (optional)",
"notePlaceholder": "Why should this be public?",
"notePlaceholderResubmit": "What did you change?",
"aliasHint": "The alias reviewers will publish this link under.",
"linkHint": "Choose which private link to submit for promotion.",
"submitting": "Submitting…",
"submitForReview": "Submit for review",
"resubmitForReview": "Resubmit for review",
"cancel": "Cancel",
"close": "Close",
"aliasPlaceholder": "public-alias",
"dialogLabel": "Promote private link",
"closeLabel": "Close"
},
"resubmitDialog": {
"title": "Edit & resubmit",
"requestedChanges": "Requested changes:",
"fieldAlias": "Proposed public alias",
"fieldNote": "Note for reviewers (optional)",
"notePlaceholder": "What did you change?",
"aliasHint": "The alias reviewers will publish this link under.",
"submitting": "Submitting…",
"resubmitForReview": "Resubmit for review",
"cancel": "Cancel",
"aliasPlaceholder": "public-alias",
"dialogLabel": "Resubmit promotion",
"closeLabel": "Close"
},
"mySubmissions": {
"loading": "Loading submissions…",
"empty": "You have not submitted any links for promotion yet.",
"colProposedAlias": "Proposed alias",
"colPrivateLink": "Private link",
"colSubmitted": "Submitted",
"colStatus": "Status",
"colActions": "Actions",
"editResubmit": "Edit & resubmit",
"statusPending": "Pending",
"statusApproved": "Approved",
"statusRejected": "Rejected",
"statusNeedsChanges": "Needs changes",
"reason": "Reason: {{reason}}"
},
"common": {
"loadingUser": "Loading user",
"usersName": "User",
"adminBadge": "admin",
"unsavedChanges": "You have unsaved changes."
}
}
+361
View File
@@ -0,0 +1,361 @@
{
"app": {
"brand": "Heygo",
"myLinks": "我的链接",
"signIn": "登录",
"profile": "个人资料",
"settings": "设置",
"adminSettings": "管理后台",
"logout": "退出登录",
"notifications": "通知",
"unreadNotifications": "{{count}} 条未读通知",
"apiStatus": "API 状态",
"footerText": "Heygo · API 状态",
"placeholderTitle": "{{title}}页面即将上线"
},
"login": {
"title": "登录或创建账号",
"legal": "登录即表示您同意我们的<1>服务条款</1>和<2>隐私政策</1>。",
"continueWithGoogle": "使用 Google 登录",
"openingGoogle": "正在打开 Google...",
"openingGoogleSr": "正在打开 Google 登录"
},
"landing": {
"eyebrow": "一个词,直达任何链接",
"brandSuffix": "黑狗",
"description": "不记链接,也能到达。输入一个短链名称,<1/>公开短链、私有链接、共享内容——一站直达。",
"featurePublic": "公开短链",
"featurePrivate": "私有链接",
"featureShared": "共享内容",
"featureCrossPlatform": "跨平台",
"searchPrefix": "heygo.cc/",
"searchPlaceholder": "输入短链名称,例如 canva",
"searchLabel": "搜索短链",
"clearLabel": "清除搜索",
"goLabel": "转到",
"createPersonalLink": "创建个人链接",
"exactMatch": "精确匹配",
"invalidLink": "无效链接",
"customPage": "自定义页面",
"clicksCount": "{{count}} 次点击",
"searchFail": "搜索失败",
"searching": "搜索中…",
"noResultsTitle": "没有找到 <1>{{query}}</1>",
"noResultsHint": "这是你的专属短链吗?创建一个属于你的个人链接。",
"suggestionsLabel": "试试:",
"goToAlias": "跳转到 /{{alias}}"
},
"publicLinks": {
"title": "公开链接",
"subtitle": "共享短链,人人可访问!",
"searchPlaceholder": "搜索公开链接…",
"empty": "还没有公开链接。"
},
"privateLinks": {
"title": "我的链接",
"subtitle": "你的个人短链,仅自己可见。",
"submissionsTitle": "我的提交",
"submissionsSubtitle": "你提交申请公开展示的链接。",
"newLink": "新建链接",
"closeForm": "关闭表单",
"mySubmissions": "我的提交",
"backToMyLinks": "← 返回我的链接",
"selected": "已选 {{count}} 条。",
"promoteToPublic": "申请公开展示",
"clear": "清除",
"create": "创建",
"createLink": "创建链接",
"editLink": "编辑 <1>{{alias}}</1>",
"saveChanges": "保存修改",
"searchPlaceholder": "搜索我的链接…",
"empty": "还没有私人链接,创建一个开始吧。",
"deleteConfirm": "确定删除链接 \"{{alias}}\"?此操作不可恢复。"
},
"linkDetail": {
"loading": "加载中…",
"back": "← 返回",
"linkDetails": "链接详情",
"go": "打开",
"copyLink": "复制链接",
"copying": "复制中…",
"copyToMyLinks": "复制到我的链接",
"propose": "申请公开",
"edit": "编辑",
"delete": "删除",
"deleting": "删除中…",
"saving": "保存中…",
"editTitle": "编辑链接",
"cancel": "取消",
"fieldAlias": "短链名称",
"fieldType": "类型",
"fieldTargetUrl": "目标 URL",
"fieldContent": "Markdown 内容",
"fieldDescription": "描述",
"aliasReadonly": "短链名称创建后不可修改。",
"customPage": "自定义页面",
"redirect": "跳转",
"customMarkdownPage": "自定义 Markdown 页面",
"redirectUrl": "跳转 URL",
"totalClicks": "总点击量",
"created": "创建时间",
"updated": "更新时间",
"description": "描述",
"targetUrl": "目标 URL",
"content": "内容",
"templateParams": "模板参数",
"templateDesc": "此跳转链接包含参数化占位符。填写数值可预览解析后的 URL。",
"resolvedUrl": "解析后的 URL",
"open": "打开",
"stats": "📊 统计",
"history": "📋 变更记录",
"clickStatistics": "点击统计",
"changeHistory": "变更历史",
"period3m": "近3个月",
"period6m": "近6个月",
"period1y": "近1年",
"periodAll": "全部",
"noClickData": "该时间段暂无点击数据。",
"noChanges": "暂无变更记录。",
"publicLink": "公开链接",
"privateLink": "私有链接",
"deleteConfirm": "确定删除链接 \"{{alias}}\"?此操作不可恢复。",
"shortLinkCopied": "短链已复制",
"couldNotCopy": "无法复制链接",
"proposedSubmitted": "已提交审核——管理员将收到通知。",
"privateCopySaved": "私有副本已保存",
"copyInMyLinks": "/{{alias}} 现在已在我的链接中",
"copyDetail": "您可以在此编辑或继续浏览。",
"dismissNotice": "关闭提示",
"editAliasInfo": "支持 <1>{{param}}</1> 占位符,例如 <3>https://example.com/?q={{query}}</3>。",
"editMarkdownInfo": "Markdown 会渲染为带安全防护的 HTML。",
"editTargetPlaceholder": "https://example.com/search?q={query}",
"editContentPlaceholder": "# 你好世界",
"editDescriptionPlaceholder": "此链接的用途说明",
"changeTypeCreated": "链接已创建",
"changeTypeCloned": "从公开链接克隆",
"changeTypeUrlChanged": "URL 已变更",
"changeTypeAliasChanged": "短链名称已变更",
"changeTypeDescChanged": "描述已变更",
"changeTypeContentChanged": "内容已变更",
"changeTypeTypeChanged": "类型已变更",
"linkId": "链接 ID",
"alias": "短链名称",
"url": "URL",
"was": "之前: {{value}}",
"now": "现在: {{value}}"
},
"linkForm": {
"createTitle": "创建链接",
"editTitle": "编辑链接",
"fieldAlias": "短链名称",
"aliasHint": "字母、数字、连字符、下划线。不区分大小写。",
"fieldLinkType": "链接类型",
"typeRedirect": "跳转 URL",
"typeCustom": "自定义 Markdown 页面",
"fieldTargetUrl": "目标 URL",
"targetPlaceholder": "https://example.com/search?q={query}",
"targetHint": "支持参数化模板。使用 <1>{{query}}</1> 占位符,例如 <3>https://example.com/search?q={{query}}</3>,然后访问 <5>/alias/foo?bar=baz</5> 完成替换。",
"fieldContent": "Markdown 内容",
"contentPlaceholder": "# 你好世界",
"fieldDescription": "描述(可选)",
"descriptionPlaceholder": "此链接的用途说明",
"saving": "保存中…",
"save": "保存",
"cancel": "取消",
"aliasPlaceholder": "my-link"
},
"linkTable": {
"loading": "加载链接中…",
"empty": "暂无链接。",
"sort": "排序",
"sortAsc": "↑ 升序",
"sortDesc": "↓ 降序",
"sortAscTitle": "升序排列",
"sortDescTitle": "降序排列",
"listView": "☰ 列表",
"cardView": "⊞ 卡片",
"listViewTitle": "列表视图",
"cardViewTitle": "卡片视图",
"colAlias": "短链名称",
"colType": "类型",
"colTarget": "目标 / 内容",
"colClicks": "点击",
"colUpdated": "更新时间",
"colActions": "操作",
"edit": "编辑",
"delete": "删除",
"selectAllLabel": "全选所有链接",
"deselectAllLabel": "取消全选",
"selectLinkLabel": "选择 {{alias}}",
"openDetailsLabel": "查看 {{alias}} 详情",
"linksCount": "共 {{count}} 条链接",
"showing": " · 显示 {{count}} 条",
"loadMore": "再加载 {{count}} 条",
"remaining": "(剩余 {{count}} 条)",
"invalidTarget": "无效目标",
"typeCustom": "自定义",
"typeRedirect": "跳转",
"clicks": "{{count}} 次点击"
},
"adminReview": {
"title": "管理后台",
"subtitle": "管理公开链接和审核推广申请。",
"publicLinks": "公开链接",
"submissions": "审核申请",
"refresh": "刷新",
"closeForm": "关闭表单",
"newPublicLink": "新建公开链接",
"createPublicLink": "创建公开链接",
"create": "创建",
"saveChanges": "保存修改",
"editLink": "编辑 <1>{{alias}}</1>",
"emptyPublicLinks": "暂无公开链接。",
"emptySubmissions": "暂无{{status}}的申请。",
"statusPending": "待审核",
"statusApproved": "已通过",
"statusRejected": "已拒绝",
"statusNeedsChanges": "需修改",
"colProposedAlias": "申请的短链名称",
"colPrivateLink": "私有链接",
"colSubmitted": "提交时间",
"colStatus": "状态",
"colActions": "操作",
"actionApprove": "通过",
"actionReject": "拒绝",
"actionNeedsChanges": "要求修改",
"actionConfirm": "确认",
"actionCancel": "取消",
"reasonPlaceholder": "原因(可选)",
"reasonLabel": "原因",
"reason": "原因:{{reason}}",
"loadingSubmissions": "加载申请中…",
"deleteConfirm": "确定删除公开链接 \"{{alias}}\"?此操作不可恢复。"
},
"notifications": {
"title": "通知",
"subtitle": "你的推广申请动态更新。",
"refresh": "刷新",
"deleting": "删除中…",
"deleteSelected": "删除选中",
"clear": "清除",
"selected": "已选 {{count}} 条。",
"loading": "加载通知中…",
"empty": "暂无通知。",
"deselectAll": "取消全选通知",
"selectAll": "全选通知",
"notificationsCount": "共 {{count}} 条通知",
"delete": "删除",
"deleteConfirm": "删除 \"{{title}}\"",
"deleteSelectedConfirm": "确定删除 {{count}} 条通知?",
"notificationDeleted": "通知已删除",
"notificationsDeleted": "已删除 {{count}} 条通知",
"kindApproved": "已通过",
"kindRejected": "已拒绝",
"kindNeedsChanges": "需修改",
"proposed": "申请的短链: <1>#{{alias}}</1>",
"privateLink": "私有链接: <1>#{{alias}}</1>",
"viewedAt": "查看于 {{datetime}}",
"unreadLabel": "未读",
"selectItem": "选择 {{title}}",
"deleteItem": "删除 {{title}}",
"loadFailed": "加载通知失败",
"deleteFailed": "删除通知失败",
"deleteManyFailed": "删除通知失败"
},
"legal": {
"brand": "Heygo",
"termsOfService": "服务条款",
"privacyPolicy": "隐私政策",
"lastUpdated": "最后更新: {{date}}",
"termsSection1Title": "1. 使用 Heygo",
"termsSection1": "Heygo 允许您创建、管理和分享短链。您对自己创建或提交的链接、描述和内容负责。",
"termsSection2Title": "2. 账号",
"termsSection2": "您必须使用准确的账号信息并确保账号安全。您已登录账号下的活动将被视为您本人的行为。",
"termsSection3Title": "3. 可接受的使用",
"termsSection3": "请勿将 Heygo 用于违法、有害、欺诈、滥用、侵权或危害安全的内容。为保护用户或服务,我们可能会删除链接或限制访问。",
"termsSection4Title": "4. 服务变更",
"termsSection4": "Heygo 按现有状态提供服务。功能可能会变更、暂停或停止,我们不对间接损失或链接不可用承担责任。",
"termsSection5Title": "5. 联系我们",
"termsSection5": "关于这些条款的问题可发送给服务所有者或管理员。",
"privacySection1Title": "1. 我们收集的信息",
"privacySection1": "我们会存储来自登录服务商的账号信息,例如您的邮箱、姓名、头像、服务商账号ID、会话记录以及您创建的链接。",
"privacySection2Title": "2. 我们如何使用信息",
"privacySection2": "我们使用这些信息来为您登录、管理链接、保护私有链接、审核公开链接申请以及运营服务。",
"privacySection3Title": "3. Cookie 和会话",
"privacySection3": "Heygo 使用安全的会话 Cookie 来保持您的登录状态并授权私有或管理操作。",
"privacySection4Title": "4. 信息分享",
"privacySection4": "我们不会出售个人信息。公开链接和已批准的公开申请可能对任何可访问公开网站的用户可见。",
"privacySection5Title": "5. 保留与删除",
"privacySection5": "我们在提供服务的必要范围内保留账号、会话和链接记录。如需删除,请联系服务所有者或管理员。"
},
"copyDialog": {
"title": "短链名称已被使用",
"description": "您已在\"我的链接\"中拥有 <1>{{alias}}</1>。请为此副本选择其他名称。",
"fieldLabel": "私有短链名称",
"hint": "此副本将在您的私有链接中创建。",
"creating": "创建中…",
"createLink": "创建链接",
"cancel": "取消",
"aliasRequired": "短链名称不能为空",
"aliasDifferent": "请选择不同的短链名称。",
"aliasPlaceholder": "my-link-copy",
"dialogLabel": "选择其他短链名称",
"closeLabel": "关闭"
},
"promotionDialog": {
"title": "申请公开展示",
"emptyTitle": "申请公开",
"promotingLabel": "正在申请公开 <0>#{{alias}}</0>",
"emptyDescription": "请至少选择一条私有链接来申请公开。",
"fieldPrivateLink": "私有链接",
"fieldAlias": "申请的公开短链名称",
"fieldNote": "审核备注(可选)",
"notePlaceholder": "为什么这个链接应该公开?",
"notePlaceholderResubmit": "你做了哪些修改?",
"aliasHint": "审核员将以此名称发布此链接。",
"linkHint": "选择要提交申请的私有链接。",
"submitting": "提交中…",
"submitForReview": "提交审核",
"resubmitForReview": "重新提交审核",
"cancel": "取消",
"close": "关闭",
"aliasPlaceholder": "public-alias",
"dialogLabel": "申请公开展示",
"closeLabel": "关闭"
},
"resubmitDialog": {
"title": "编辑后重新提交",
"requestedChanges": "要求的修改:",
"fieldAlias": "申请的公开短链名称",
"fieldNote": "审核备注(可选)",
"notePlaceholder": "你做了哪些修改?",
"aliasHint": "审核员将以此名称发布此链接。",
"submitting": "提交中…",
"resubmitForReview": "重新提交审核",
"cancel": "取消",
"aliasPlaceholder": "public-alias",
"dialogLabel": "重新提交申请",
"closeLabel": "关闭"
},
"mySubmissions": {
"loading": "加载提交记录中…",
"empty": "您还没有提交过公开展示申请。",
"colProposedAlias": "申请的短链名称",
"colPrivateLink": "私有链接",
"colSubmitted": "提交时间",
"colStatus": "状态",
"colActions": "操作",
"editResubmit": "编辑后重新提交",
"statusPending": "待审核",
"statusApproved": "已通过",
"statusRejected": "已拒绝",
"statusNeedsChanges": "需修改",
"reason": "原因:{{reason}}"
},
"common": {
"loadingUser": "加载用户中",
"usersName": "用户",
"adminBadge": "管理员",
"unsavedChanges": "有未保存的更改。"
}
}
+1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App'; import App from './App';
import './styles.css'; import './styles.css';
import './i18n';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
+46 -36
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import LinkForm from '../components/LinkForm'; import LinkForm from '../components/LinkForm';
import LinkTable from '../components/LinkTable'; import LinkTable from '../components/LinkTable';
import { import {
@@ -22,11 +23,11 @@ type AdminTab = 'public-links' | 'submissions';
const STATUS_FILTERS: readonly AdminSubmissionFilter[] = ['pending', 'needs_changes', 'approved', 'rejected']; const STATUS_FILTERS: readonly AdminSubmissionFilter[] = ['pending', 'needs_changes', 'approved', 'rejected'];
const STATUS_LABELS: Record<SubmissionStatus, string> = { const STATUS_I18N_KEYS: Record<SubmissionStatus, string> = {
pending: 'Pending', pending: 'adminReview.statusPending',
approved: 'Approved', approved: 'adminReview.statusApproved',
rejected: 'Rejected', rejected: 'adminReview.statusRejected',
needs_changes: 'Needs changes', needs_changes: 'adminReview.statusNeedsChanges',
}; };
interface PendingAction { interface PendingAction {
@@ -35,14 +36,15 @@ interface PendingAction {
} }
export default function AdminReviewPage() { export default function AdminReviewPage() {
const { t } = useTranslation();
const [adminTab, setAdminTab] = useState<AdminTab>('public-links'); const [adminTab, setAdminTab] = useState<AdminTab>('public-links');
return ( return (
<section className="panel"> <section className="panel">
<header className="panel-header"> <header className="panel-header">
<div> <div>
<h1>Admin</h1> <h1>{t('adminReview.title')}</h1>
<p className="muted">Manage public links and review promotion submissions.</p> <p className="muted">{t('adminReview.subtitle')}</p>
</div> </div>
<div className="panel-actions"> <div className="panel-actions">
<button <button
@@ -50,14 +52,14 @@ export default function AdminReviewPage() {
className={`tab-button${adminTab === 'public-links' ? ' is-active' : ''}`} className={`tab-button${adminTab === 'public-links' ? ' is-active' : ''}`}
onClick={() => setAdminTab('public-links')} onClick={() => setAdminTab('public-links')}
> >
Public Links {t('adminReview.publicLinks')}
</button> </button>
<button <button
type="button" type="button"
className={`tab-button${adminTab === 'submissions' ? ' is-active' : ''}`} className={`tab-button${adminTab === 'submissions' ? ' is-active' : ''}`}
onClick={() => setAdminTab('submissions')} onClick={() => setAdminTab('submissions')}
> >
Submissions {t('adminReview.submissions')}
</button> </button>
</div> </div>
</header> </header>
@@ -68,6 +70,7 @@ export default function AdminReviewPage() {
} }
function PublicLinksAdmin() { function PublicLinksAdmin() {
const { t } = useTranslation();
const [links, setLinks] = useState<Link[]>([]); const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -111,7 +114,7 @@ function PublicLinksAdmin() {
} }
async function handleDelete(link: Link) { async function handleDelete(link: Link) {
if (!window.confirm(`Delete public link "${link.alias}"? This cannot be undone.`)) return; if (!window.confirm(t('adminReview.deleteConfirm', { alias: link.alias }))) return;
setError(null); setError(null);
try { try {
await deletePublicLink(link.id); await deletePublicLink(link.id);
@@ -124,9 +127,9 @@ function PublicLinksAdmin() {
return ( return (
<div> <div>
<div className="panel-actions" style={{ marginBottom: '1rem' }}> <div className="panel-actions" style={{ marginBottom: '1rem' }}>
<button type="button" onClick={() => void refresh()} disabled={loading}>Refresh</button> <button type="button" onClick={() => void refresh()} disabled={loading}>{t('adminReview.refresh')}</button>
<button type="button" onClick={() => { setEditing(null); setShowCreate((v) => !v); }}> <button type="button" onClick={() => { setEditing(null); setShowCreate((v) => !v); }}>
{showCreate ? 'Close form' : 'New public link'} {showCreate ? t('adminReview.closeForm') : t('adminReview.newPublicLink')}
</button> </button>
</div> </div>
@@ -134,22 +137,28 @@ function PublicLinksAdmin() {
{showCreate ? ( {showCreate ? (
<div className="form-card"> <div className="form-card">
<h2>Create public link</h2> <h2>{t('adminReview.createPublicLink')}</h2>
<LinkForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} submitLabel="Create" /> <LinkForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} submitLabel={t('adminReview.create')} />
</div> </div>
) : null} ) : null}
{editing ? ( {editing ? (
<div className="form-card"> <div className="form-card">
<h2>Edit <code>{editing.alias}</code></h2> <h2>
<LinkForm initial={editing} onSubmit={handleUpdate} onCancel={() => setEditing(null)} submitLabel="Save changes" /> <Trans
i18nKey="adminReview.editLink"
values={{ alias: editing.alias }}
components={{ 1: <code /> }}
/>
</h2>
<LinkForm initial={editing} onSubmit={handleUpdate} onCancel={() => setEditing(null)} submitLabel={t('adminReview.saveChanges')} />
</div> </div>
) : null} ) : null}
<LinkTable <LinkTable
links={links} links={links}
loading={loading} loading={loading}
emptyMessage="No public links yet." emptyMessage={t('adminReview.emptyPublicLinks')}
onEdit={(link) => { setShowCreate(false); setEditing(link); }} onEdit={(link) => { setShowCreate(false); setEditing(link); }}
onDelete={handleDelete} onDelete={handleDelete}
/> />
@@ -158,6 +167,7 @@ function PublicLinksAdmin() {
} }
function SubmissionsAdmin() { function SubmissionsAdmin() {
const { t } = useTranslation();
const [status, setStatus] = useState<AdminSubmissionFilter>('pending'); const [status, setStatus] = useState<AdminSubmissionFilter>('pending');
const [submissions, setSubmissions] = useState<PromotionSubmission[]>([]); const [submissions, setSubmissions] = useState<PromotionSubmission[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -220,27 +230,27 @@ function SubmissionsAdmin() {
className={`tab-button${status === filter ? ' is-active' : ''}`} className={`tab-button${status === filter ? ' is-active' : ''}`}
onClick={() => setStatus(filter)} onClick={() => setStatus(filter)}
> >
{STATUS_LABELS[filter]} {t(STATUS_I18N_KEYS[filter])}
</button> </button>
))} ))}
<button type="button" onClick={() => void refresh(status)} disabled={loading}>Refresh</button> <button type="button" onClick={() => void refresh(status)} disabled={loading}>{t('adminReview.refresh')}</button>
</div> </div>
{error ? <p className="form-error" role="alert">{error}</p> : null} {error ? <p className="form-error" role="alert">{error}</p> : null}
{loading ? ( {loading ? (
<p className="table-status" aria-busy="true">Loading submissions</p> <p className="table-status" aria-busy="true">{t('adminReview.loadingSubmissions')}</p>
) : submissions.length === 0 ? ( ) : submissions.length === 0 ? (
<p className="table-empty">No {STATUS_LABELS[status].toLowerCase()} submissions.</p> <p className="table-empty">{t('adminReview.emptySubmissions', { status: t(STATUS_I18N_KEYS[status]).toLowerCase() })}</p>
) : ( ) : (
<table className="link-table review-table"> <table className="link-table review-table">
<thead> <thead>
<tr> <tr>
<th scope="col">Proposed alias</th> <th scope="col">{t('adminReview.colProposedAlias')}</th>
<th scope="col">Private link</th> <th scope="col">{t('adminReview.colPrivateLink')}</th>
<th scope="col">Submitted</th> <th scope="col">{t('adminReview.colSubmitted')}</th>
<th scope="col">Status</th> <th scope="col">{t('adminReview.colStatus')}</th>
<th scope="col" className="col-actions">Actions</th> <th scope="col" className="col-actions">{t('adminReview.colActions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -250,7 +260,7 @@ function SubmissionsAdmin() {
<span className="alias-pill">#{submission.proposedAlias}</span> <span className="alias-pill">#{submission.proposedAlias}</span>
{submission.note ? <small className="row-description">{submission.note}</small> : null} {submission.note ? <small className="row-description">{submission.note}</small> : null}
{submission.rejectionReason ? ( {submission.rejectionReason ? (
<small className="row-description review-reason">Reason: {submission.rejectionReason}</small> <small className="row-description review-reason">{t('adminReview.reason', { reason: submission.rejectionReason })}</small>
) : null} ) : null}
</td> </td>
<td> <td>
@@ -265,34 +275,34 @@ function SubmissionsAdmin() {
<time dateTime={submission.createdAt}>{new Date(submission.createdAt).toLocaleString()}</time> <time dateTime={submission.createdAt}>{new Date(submission.createdAt).toLocaleString()}</time>
</td> </td>
<td> <td>
<span className={`status-badge status-${submission.status}`}>{STATUS_LABELS[submission.status]}</span> <span className={`status-badge status-${submission.status}`}>{t(STATUS_I18N_KEYS[submission.status])}</span>
</td> </td>
<td className="col-actions"> <td className="col-actions">
{pending?.id === submission.id ? ( {pending?.id === submission.id ? (
<div className="inline-review"> <div className="inline-review">
<input <input
type="text" type="text"
aria-label="Reason" aria-label={t('adminReview.reasonLabel')}
placeholder="Reason (optional)" placeholder={t('adminReview.reasonPlaceholder')}
value={reason} value={reason}
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
disabled={loading} disabled={loading}
/> />
<button type="button" className="link-action link-action--confirm" onClick={() => void confirmAction()} disabled={loading}> <button type="button" className="link-action link-action--confirm" onClick={() => void confirmAction()} disabled={loading}>
Confirm {t('adminReview.actionConfirm')}
</button> </button>
<button type="button" className="link-action" onClick={cancelAction} disabled={loading}>Cancel</button> <button type="button" className="link-action" onClick={cancelAction} disabled={loading}>{t('adminReview.actionCancel')}</button>
</div> </div>
) : submission.status === 'pending' || submission.status === 'needs_changes' ? ( ) : submission.status === 'pending' || submission.status === 'needs_changes' ? (
<> <>
<button type="button" className="link-action link-action--approve" onClick={() => startAction(submission.id, 'approve')} disabled={loading}> <button type="button" className="link-action link-action--approve" onClick={() => startAction(submission.id, 'approve')} disabled={loading}>
Approve {t('adminReview.actionApprove')}
</button> </button>
<button type="button" className="link-action link-action--danger" onClick={() => startAction(submission.id, 'reject')} disabled={loading}> <button type="button" className="link-action link-action--danger" onClick={() => startAction(submission.id, 'reject')} disabled={loading}>
Reject {t('adminReview.actionReject')}
</button> </button>
<button type="button" className="link-action" onClick={() => startAction(submission.id, 'needs-changes')} disabled={loading}> <button type="button" className="link-action" onClick={() => startAction(submission.id, 'needs-changes')} disabled={loading}>
Needs changes {t('adminReview.actionNeedsChanges')}
</button> </button>
</> </>
) : ( ) : (
@@ -306,4 +316,4 @@ function SubmissionsAdmin() {
)} )}
</div> </div>
); );
} }
+31 -54
View File
@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
type LegalPageProps = { type LegalPageProps = {
kind: 'terms' | 'privacy'; kind: 'terms' | 'privacy';
}; };
@@ -5,13 +7,14 @@ type LegalPageProps = {
const updated = 'June 20, 2026'; const updated = 'June 20, 2026';
export default function LegalPage({ kind }: LegalPageProps) { export default function LegalPage({ kind }: LegalPageProps) {
const { t } = useTranslation();
const isTerms = kind === 'terms'; const isTerms = kind === 'terms';
return ( return (
<article className="panel legal-page"> <article className="panel legal-page">
<p className="legal-kicker">Heygo</p> <p className="legal-kicker">{t('legal.brand')}</p>
<h1>{isTerms ? 'Terms of Service' : 'Privacy Policy'}</h1> <h1>{isTerms ? t('legal.termsOfService') : t('legal.privacyPolicy')}</h1>
<p className="muted">Last updated: {updated}</p> <p className="muted">{t('legal.lastUpdated', { date: updated })}</p>
{isTerms ? <TermsContent /> : <PrivacyContent />} {isTerms ? <TermsContent /> : <PrivacyContent />}
</article> </article>
@@ -19,73 +22,47 @@ export default function LegalPage({ kind }: LegalPageProps) {
} }
function TermsContent() { function TermsContent() {
const { t } = useTranslation();
return ( return (
<> <>
<h2>1. Using Heygo</h2> <h2>{t('legal.terms.section1.title')}</h2>
<p> <p>{t('legal.terms.section1.body')}</p>
Heygo lets you create, manage, and share shortlinks. You are responsible for
the links, descriptions, and content you create or submit.
</p>
<h2>2. Accounts</h2> <h2>{t('legal.terms.section2.title')}</h2>
<p> <p>{t('legal.terms.section2.body')}</p>
You must use accurate account information and keep access to your account
secure. Activity from your signed-in account is treated as your activity.
</p>
<h2>3. Acceptable use</h2> <h2>{t('legal.terms.section3.title')}</h2>
<p> <p>{t('legal.terms.section3.body')}</p>
Do not use Heygo for unlawful, harmful, deceptive, abusive, infringing, or
security-compromising content. We may remove links or restrict access when
needed to protect users or the service.
</p>
<h2>4. Service changes</h2> <h2>{t('legal.terms.section4.title')}</h2>
<p> <p>{t('legal.terms.section4.body')}</p>
Heygo is provided as available. Features may change, pause, or stop, and we
are not liable for indirect losses or unavailable links.
</p>
<h2>5. Contact</h2> <h2>{t('legal.terms.section5.title')}</h2>
<p> <p>{t('legal.terms.section5.body')}</p>
Questions about these terms can be sent to the service owner or administrator.
</p>
</> </>
); );
} }
function PrivacyContent() { function PrivacyContent() {
const { t } = useTranslation();
return ( return (
<> <>
<h2>1. Information we collect</h2> <h2>{t('legal.privacy.section1.title')}</h2>
<p> <p>{t('legal.privacy.section1.body')}</p>
We store account details from sign-in providers, such as your email, name,
profile image, provider account ID, session records, and links you create.
</p>
<h2>2. How we use information</h2> <h2>{t('legal.privacy.section2.title')}</h2>
<p> <p>{t('legal.privacy.section2.body')}</p>
We use this information to sign you in, manage your links, protect private
links, review public-link submissions, and operate the service.
</p>
<h2>3. Cookies and sessions</h2> <h2>{t('legal.privacy.section3.title')}</h2>
<p> <p>{t('legal.privacy.section3.body')}</p>
Heygo uses secure session cookies to keep you signed in and to authorize
private or admin actions.
</p>
<h2>4. Sharing</h2> <h2>{t('legal.privacy.section4.title')}</h2>
<p> <p>{t('legal.privacy.section4.body')}</p>
We do not sell personal information. Public links and approved public
submissions may be visible to anyone with access to the public site.
</p>
<h2>5. Retention and deletion</h2> <h2>{t('legal.privacy.section5.title')}</h2>
<p> <p>{t('legal.privacy.section5.body')}</p>
We keep account, session, and link records as needed to provide the service.
Contact the service owner or administrator to request deletion.
</p>
</> </>
); );
} }
+87 -81
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Trans, useTranslation } from 'react-i18next';
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import { CheckIcon, CopyIcon, ExternalLinkIcon, GlobeIcon, LockIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from 'lucide-react'; import { CheckIcon, CopyIcon, ExternalLinkIcon, GlobeIcon, LockIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from 'lucide-react';
import CopyPublicLinkDialog from '../components/CopyPublicLinkDialog'; import CopyPublicLinkDialog from '../components/CopyPublicLinkDialog';
@@ -46,20 +47,20 @@ type CopySuccessNotice = {
}; };
const CHANGE_TYPE_LABELS: Record<string, string> = { const CHANGE_TYPE_LABELS: Record<string, string> = {
created: 'Link created', created: 'linkDetail.changeTypeCreated',
cloned_from_public: 'Cloned from public link', cloned_from_public: 'linkDetail.changeTypeCloned',
url_changed: 'URL changed', url_changed: 'linkDetail.changeTypeUrlChanged',
alias_changed: 'Alias changed', alias_changed: 'linkDetail.changeTypeAliasChanged',
description_changed: 'Description changed', description_changed: 'linkDetail.changeTypeDescChanged',
content_changed: 'Content changed', content_changed: 'linkDetail.changeTypeContentChanged',
type_changed: 'Type changed', type_changed: 'linkDetail.changeTypeTypeChanged',
}; };
const PERIODS: { id: StatsPeriod; label: string }[] = [ const PERIODS: { id: StatsPeriod; labelKey: string }[] = [
{ id: '3m', label: '3 Months' }, { id: '3m', labelKey: 'linkDetail.period3m' },
{ id: '6m', label: '6 Months' }, { id: '6m', labelKey: 'linkDetail.period6m' },
{ id: '1y', label: '1 Year' }, { id: '1y', labelKey: 'linkDetail.period1y' },
{ id: 'all', label: 'All Time' }, { id: 'all', labelKey: 'linkDetail.periodAll' },
]; ];
const COPY_SUCCESS_NOTICE_KEY = 'heygo.copy-success-notice'; const COPY_SUCCESS_NOTICE_KEY = 'heygo.copy-success-notice';
@@ -90,6 +91,7 @@ function setEditInHash(edit: boolean) {
} }
export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBaseUrls, onBack }: LinkDetailPageProps) { export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBaseUrls, onBack }: LinkDetailPageProps) {
const { t } = useTranslation();
const [link, setLink] = useState<Link | null>(null); const [link, setLink] = useState<Link | null>(null);
const [stats, setStats] = useState<DayCount[]>([]); const [stats, setStats] = useState<DayCount[]>([]);
const [history, setHistory] = useState<ChangeLogEntry[]>([]); const [history, setHistory] = useState<ChangeLogEntry[]>([]);
@@ -125,7 +127,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
setLink(data.link); setLink(data.link);
}) })
.catch((err) => { .catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to load link details'); setError(err instanceof Error ? err.message : t('linkDetail.loadFailed'));
}) })
.finally(() => { .finally(() => {
setLoadingLink(false); setLoadingLink(false);
@@ -275,7 +277,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
return date.toLocaleDateString('en-AU', { month: 'short', day: 'numeric' }); return date.toLocaleDateString('en-AU', { month: 'short', day: 'numeric' });
}), }),
datasets: [{ datasets: [{
label: 'Clicks', label: t('linkDetail.clicksChartLabel'),
data: stats.map((stat) => stat.count), data: stats.map((stat) => stat.count),
backgroundColor: 'rgba(99,102,241,0.08)', backgroundColor: 'rgba(99,102,241,0.08)',
borderColor: 'rgba(99,102,241,1)', borderColor: 'rgba(99,102,241,1)',
@@ -321,7 +323,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
chartInstance.current?.destroy(); chartInstance.current?.destroy();
chartInstance.current = null; chartInstance.current = null;
}; };
}, [stats]); }, [stats, t]);
const templateParams = useMemo( const templateParams = useMemo(
() => (link?.linkType === 'redirect' && link.targetUrl ? parseTemplateParameters(link.targetUrl) : []), () => (link?.linkType === 'redirect' && link.targetUrl ? parseTemplateParameters(link.targetUrl) : []),
@@ -336,7 +338,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
if (loadingLink) { if (loadingLink) {
return ( return (
<section className="panel" style={{ maxWidth: 960 }}> <section className="panel" style={{ maxWidth: 960 }}>
<p className="table-status" aria-busy="true">Loading</p> <p className="table-status" aria-busy="true">{t('linkDetail.loading')}</p>
</section> </section>
); );
} }
@@ -345,7 +347,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
return ( return (
<section className="panel" style={{ maxWidth: 960 }}> <section className="panel" style={{ maxWidth: 960 }}>
<p className="form-error" role="alert">{error}</p> <p className="form-error" role="alert">{error}</p>
<button type="button" onClick={onBack} style={{ marginTop: '1rem' }}> Back</button> <button type="button" onClick={onBack} style={{ marginTop: '1rem' }}>{t('linkDetail.back')}</button>
</section> </section>
); );
} }
@@ -398,7 +400,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
if (err instanceof ApiClientError && err.status === 409) { if (err instanceof ApiClientError && err.status === 409) {
setShowCopyDialog(true); setShowCopyDialog(true);
} else { } else {
setCopyError(err instanceof Error ? err.message : 'Failed to copy link'); setCopyError(err instanceof Error ? err.message : t('linkDetail.failedCopy'));
} }
} finally { } finally {
setCopying(false); setCopying(false);
@@ -412,20 +414,20 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
try { try {
await navigator.clipboard.writeText(buildShortLinkUrl(link, linkBaseUrls)); await navigator.clipboard.writeText(buildShortLinkUrl(link, linkBaseUrls));
setShortLinkNotice('Short link copied'); setShortLinkNotice(t('linkDetail.shortLinkCopied'));
} catch { } catch {
setShortLinkNotice('Could not copy link'); setShortLinkNotice(t('linkDetail.couldNotCopy'));
} }
} }
async function handlePropose(input: { privateLinkId: string; proposedAlias: string; note?: string }) { async function handlePropose(input: { privateLinkId: string; proposedAlias: string; note?: string }) {
try { try {
await promoteLink(input); await promoteLink(input);
setShowPropose(false); setShowPropose(false);
setProposeNotice('Submitted for review — admins will be notified.'); setProposeNotice(t('linkDetail.proposedSubmitted'));
} catch (err) { } catch (err) {
throw new Error(err instanceof ApiClientError ? err.message : 'Failed to submit promotion'); throw new Error(err instanceof ApiClientError ? err.message : t('linkDetail.failedPromote'));
} }
} }
function startEdit() { function startEdit() {
@@ -466,7 +468,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
setEditing(false); setEditing(false);
setEditInput(null); setEditInput(null);
} catch (err) { } catch (err) {
setEditError(err instanceof ApiClientError ? err.message : 'Failed to save link'); setEditError(err instanceof ApiClientError ? err.message : t('linkDetail.failedSave'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -474,7 +476,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
async function handleDeleteLink() { async function handleDeleteLink() {
if (!link || deleting) return; if (!link || deleting) return;
if (!window.confirm(`Delete the link "${link.alias}"? This cannot be undone.`)) return; if (!window.confirm(t('linkDetail.deleteConfirm', { alias: link.alias }))) return;
setDeleting(true); setDeleting(true);
try { try {
if (linkScope === 'private') { if (linkScope === 'private') {
@@ -484,7 +486,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
} }
onBack(); onBack();
} catch (err) { } catch (err) {
setEditError(err instanceof ApiClientError ? err.message : 'Failed to delete link'); setEditError(err instanceof ApiClientError ? err.message : t('linkDetail.failedDelete'));
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@@ -500,7 +502,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
) : null} ) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<button type="button" className="link-action" onClick={onBack} style={{ flexShrink: 0 }}> Back</button> <button type="button" className="link-action" onClick={onBack} style={{ flexShrink: 0 }}>{t('linkDetail.back')}</button>
{link ? <span className="alias-pill" style={{ fontSize: '1rem' }}>/{link.alias}</span> : null} {link ? <span className="alias-pill" style={{ fontSize: '1rem' }}>/{link.alias}</span> : null}
</div> </div>
@@ -509,7 +511,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<div className="detail-panel__title-group"> <div className="detail-panel__title-group">
<h1 className="detail-panel__title"> <h1 className="detail-panel__title">
Link Details {t('linkDetail.linkDetails')}
</h1> </h1>
<DetailSectionStatusIcon scope={link.scope} /> <DetailSectionStatusIcon scope={link.scope} />
</div> </div>
@@ -521,15 +523,15 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
onClick={() => window.open(buildGoLinkUrl(link.id), '_blank', 'noopener,noreferrer')} onClick={() => window.open(buildGoLinkUrl(link.id), '_blank', 'noopener,noreferrer')}
> >
<ExternalLinkIcon aria-hidden="true" size={14} /> <ExternalLinkIcon aria-hidden="true" size={14} />
<span>Go</span> <span>{t('linkDetail.go')}</span>
</button> </button>
<button type="button" className="link-action detail-copy-button" onClick={() => void handleCopyShortLink()}> <button type="button" className="link-action detail-copy-button" onClick={() => void handleCopyShortLink()}>
<CopyIcon aria-hidden="true" size={14} /> <CopyIcon aria-hidden="true" size={14} />
<span>Copy link</span> <span>{t('linkDetail.copyLink')}</span>
</button> </button>
{canCopyToPrivate ? ( {canCopyToPrivate ? (
<button type="button" className="link-action" onClick={() => void handleCopyClick()} disabled={copying}> <button type="button" className="link-action" onClick={() => void handleCopyClick()} disabled={copying}>
{copying ? 'Copying' : 'Copy to My Links'} {copying ? t('linkDetail.copying') : t('linkDetail.copyToMyLinks')}
</button> </button>
) : null} ) : null}
{canPropose ? ( {canPropose ? (
@@ -539,7 +541,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
onClick={() => setShowPropose(true)} onClick={() => setShowPropose(true)}
> >
<MegaphoneIcon aria-hidden="true" size={14} /> <MegaphoneIcon aria-hidden="true" size={14} />
<span>Propose</span> <span>{t('linkDetail.propose')}</span>
</button> </button>
) : null} ) : null}
{canEdit ? ( {canEdit ? (
@@ -550,7 +552,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
disabled={editing || saving} disabled={editing || saving}
> >
<PencilIcon aria-hidden="true" size={14} /> <PencilIcon aria-hidden="true" size={14} />
<span>Edit</span> <span>{t('linkDetail.edit')}</span>
</button> </button>
) : null} ) : null}
{canDelete ? ( {canDelete ? (
@@ -561,7 +563,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
disabled={deleting || saving} disabled={deleting || saving}
> >
<Trash2Icon aria-hidden="true" size={14} /> <Trash2Icon aria-hidden="true" size={14} />
<span>{deleting ? 'Deleting' : 'Delete'}</span> <span>{deleting ? t('linkDetail.deleting') : t('linkDetail.delete')}</span>
</button> </button>
) : null} ) : null}
</div> </div>
@@ -583,58 +585,62 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
{editError ? <p className="form-error" role="alert" style={{ marginTop: 0 }}>{editError}</p> : null} {editError ? <p className="form-error" role="alert" style={{ marginTop: 0 }}>{editError}</p> : null}
{editing && editInput ? ( {editing && editInput ? (
<div className="form-card detail-edit-form link-form"> <div className="form-card detail-edit-form link-form">
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem' }}>Edit link</h2> <h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem' }}>{t('linkDetail.editTitle')}</h2>
<div className="field"> <div className="field">
<label>Alias</label> <label>{t('linkDetail.fieldAlias')}</label>
<div className="alias-input"> <div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">/</span> <span className="alias-input__prefix" aria-hidden="true">/</span>
<input value={link.alias} disabled readOnly /> <input value={link.alias} disabled readOnly />
</div> </div>
<small className="hint">Alias cant be changed after creation.</small> <small className="hint">{t('linkDetail.aliasReadonly')}</small>
</div> </div>
<div className="field"> <div className="field">
<label>Type</label> <label>{t('linkDetail.fieldType')}</label>
<input value={link.linkType === 'custom' ? 'Custom markdown page' : 'Redirect URL'} disabled readOnly /> <input value={link.linkType === 'custom' ? t('linkDetail.customMarkdownPage') : t('linkDetail.redirectUrl')} disabled readOnly />
</div> </div>
{editInput.linkType === 'redirect' ? ( {editInput.linkType === 'redirect' ? (
<div className="field"> <div className="field">
<label htmlFor="edit-target">Target URL</label> <label htmlFor="edit-target">{t('linkDetail.fieldTargetUrl')}</label>
<input <input
id="edit-target" id="edit-target"
type="url" type="url"
value={editInput.targetUrl ?? ''} value={editInput.targetUrl ?? ''}
onChange={(e) => updateEdit('targetUrl', e.target.value)} onChange={(e) => updateEdit('targetUrl', e.target.value)}
required required
placeholder="https://example.com/search?q={query}" placeholder={t('linkDetail.editTargetPlaceholder')}
disabled={saving} disabled={saving}
/> />
<small className="hint"> <small className="hint">
Supports <code>{'{param}'}</code> placeholders, e.g. <code>https://example.com/?q={'{query}'}</code>. <Trans
i18nKey="linkDetail.editAliasInfo"
components={{ 1: <code />, 3: <code /> }}
values={{ param: '{param}', query: '{query}' }}
/>
</small> </small>
</div> </div>
) : ( ) : (
<div className="field"> <div className="field">
<label htmlFor="edit-content">Markdown content</label> <label htmlFor="edit-content">{t('linkDetail.fieldContent')}</label>
<MarkdownEditor <MarkdownEditor
value={editInput.contentMarkdown ?? ''} value={editInput.contentMarkdown ?? ''}
onChange={(v) => updateEdit('contentMarkdown', v)} onChange={(v) => updateEdit('contentMarkdown', v)}
placeholder="# Hello world" placeholder={t('linkDetail.editContentPlaceholder')}
disabled={saving} disabled={saving}
/> />
<small className="hint">Markdown is rendered as HTML with safety guardrails for visitors.</small> <small className="hint">{t('linkDetail.editMarkdownInfo')}</small>
</div> </div>
)} )}
<div className="field"> <div className="field">
<label htmlFor="edit-description">Description</label> <label htmlFor="edit-description">{t('linkDetail.fieldDescription')}</label>
<input <input
id="edit-description" id="edit-description"
value={editInput.description ?? ''} value={editInput.description ?? ''}
onChange={(e) => updateEdit('description', e.target.value)} onChange={(e) => updateEdit('description', e.target.value)}
maxLength={500} maxLength={500}
placeholder="What this link is for" placeholder={t('linkDetail.editDescriptionPlaceholder')}
disabled={saving} disabled={saving}
/> />
</div> </div>
@@ -643,25 +649,25 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
<div className="form-actions"> <div className="form-actions">
<button type="button" onClick={() => void handleSaveEdit()} disabled={saving || (editInput.linkType === 'redirect' && !editInput.targetUrl?.trim())}> <button type="button" onClick={() => void handleSaveEdit()} disabled={saving || (editInput.linkType === 'redirect' && !editInput.targetUrl?.trim())}>
{saving ? 'Saving' : 'Save changes'} {saving ? t('linkDetail.saving') : t('linkDetail.saveChanges')}
</button> </button>
<button type="button" onClick={() => { setEditInHash(false); setEditing(false); setEditInput(null); setEditError(null); }} disabled={saving}> <button type="button" onClick={() => { setEditInHash(false); setEditing(false); setEditInput(null); setEditError(null); }} disabled={saving}>
Cancel {t('linkDetail.cancel')}
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<dl style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem 1.5rem' }}> <dl style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem 1.5rem' }}>
<MetaItem label="Alias"><span className="alias-pill">/{link.alias}</span></MetaItem> <MetaItem label={t('linkDetail.fieldAlias')}><span className="alias-pill">/{link.alias}</span></MetaItem>
<MetaItem label="Type">{link.linkType === 'custom' ? 'Custom page' : 'Redirect'}</MetaItem> <MetaItem label={t('linkDetail.fieldType')}>{link.linkType === 'custom' ? t('linkDetail.customPage') : t('linkDetail.redirect')}</MetaItem>
<MetaItem label="Total clicks"> <MetaItem label={t('linkDetail.totalClicks')}>
<strong style={{ color: 'var(--accent)', fontSize: '1.1rem' }}>{link.clickCount.toLocaleString()}</strong> <strong style={{ color: 'var(--accent)', fontSize: '1.1rem' }}>{link.clickCount.toLocaleString()}</strong>
</MetaItem> </MetaItem>
<MetaItem label="Created">{new Date(link.createdAt).toLocaleString()}</MetaItem> <MetaItem label={t('linkDetail.created')}>{new Date(link.createdAt).toLocaleString()}</MetaItem>
<MetaItem label="Updated">{new Date(link.updatedAt).toLocaleString()}</MetaItem> <MetaItem label={t('linkDetail.updated')}>{new Date(link.updatedAt).toLocaleString()}</MetaItem>
{link.description ? <MetaItem label="Description" wide>{link.description}</MetaItem> : null} {link.description ? <MetaItem label={t('linkDetail.description')} wide>{link.description}</MetaItem> : null}
{link.linkType === 'redirect' && link.targetUrl ? ( {link.linkType === 'redirect' && link.targetUrl ? (
<MetaItem label="Target URL" wide> <MetaItem label={t('linkDetail.targetUrl')} wide>
{safeUrl ? ( {safeUrl ? (
<a <a
href={safeUrl} href={safeUrl}
@@ -685,7 +691,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
{!editing && link?.linkType === 'custom' && link.contentMarkdown ? ( {!editing && link?.linkType === 'custom' && link.contentMarkdown ? (
<section className="panel" style={{ padding: '1.5rem' }}> <section className="panel" style={{ padding: '1.5rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem', letterSpacing: '-0.01em' }}> <h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem', letterSpacing: '-0.01em' }}>
Content {t('linkDetail.content')}
</h2> </h2>
<MarkdownContent markdown={link.contentMarkdown} /> <MarkdownContent markdown={link.contentMarkdown} />
</section> </section>
@@ -694,10 +700,10 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
{!editing && isTemplated && link?.targetUrl ? ( {!editing && isTemplated && link?.targetUrl ? (
<section className="panel template-preview" style={{ padding: '1.5rem' }}> <section className="panel template-preview" style={{ padding: '1.5rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 0.5rem', letterSpacing: '-0.01em' }}> <h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 0.5rem', letterSpacing: '-0.01em' }}>
Template parameters {t('linkDetail.templateParams')}
</h2> </h2>
<p className="muted" style={{ marginTop: 0, fontSize: '0.85rem' }}> <p className="muted" style={{ marginTop: 0, fontSize: '0.85rem' }}>
This redirect link has parameterized placeholders. Fill in values to preview the resolved URL. {t('linkDetail.templateDesc')}
</p> </p>
{templateParams.length > 0 ? ( {templateParams.length > 0 ? (
<div className="template-params-grid"> <div className="template-params-grid">
@@ -715,7 +721,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
</div> </div>
) : null} ) : null}
<div className="template-resolved"> <div className="template-resolved">
<span className="muted" style={{ fontSize: '0.75rem' }}>Resolved URL</span> <span className="muted" style={{ fontSize: '0.75rem' }}>{t('linkDetail.resolvedUrl')}</span>
<code className="template-resolved__url">{resolvedTemplateUrl ?? link.targetUrl}</code> <code className="template-resolved__url">{resolvedTemplateUrl ?? link.targetUrl}</code>
<a <a
className="link-action detail-copy-button" className="link-action detail-copy-button"
@@ -724,7 +730,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
rel="noreferrer noopener" rel="noreferrer noopener"
> >
<ExternalLinkIcon aria-hidden="true" size={14} /> <ExternalLinkIcon aria-hidden="true" size={14} />
<span>Open</span> <span>{t('linkDetail.open')}</span>
</a> </a>
</div> </div>
</section> </section>
@@ -732,10 +738,10 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
<div className="detail-floating-actions"> <div className="detail-floating-actions">
<button type="button" className={`link-action${showStats ? ' link-action--confirm' : ''}`} onClick={() => setShowStats(v => !v)}> <button type="button" className={`link-action${showStats ? ' link-action--confirm' : ''}`} onClick={() => setShowStats(v => !v)}>
📊 Stats 📊 {t('linkDetail.stats')}
</button> </button>
<button type="button" className={`link-action${showHistory ? ' link-action--confirm' : ''}`} onClick={() => setShowHistory(v => !v)}> <button type="button" className={`link-action${showHistory ? ' link-action--confirm' : ''}`} onClick={() => setShowHistory(v => !v)}>
📋 History 📋 {t('linkDetail.history')}
</button> </button>
</div> </div>
@@ -743,7 +749,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
<section className="panel" style={{ padding: '1.5rem' }}> <section className="panel" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem', flexWrap: 'wrap', gap: '0.5rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem', flexWrap: 'wrap', gap: '0.5rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: 0, letterSpacing: '-0.01em' }}> <h2 style={{ fontSize: '1rem', fontWeight: 600, margin: 0, letterSpacing: '-0.01em' }}>
Click Statistics {t('linkDetail.clickStatistics')}
</h2> </h2>
<div style={{ display: 'flex', gap: '0.35rem' }}> <div style={{ display: 'flex', gap: '0.35rem' }}>
{PERIODS.map((entry) => ( {PERIODS.map((entry) => (
@@ -753,9 +759,9 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
className={`tab-button${period === entry.id ? ' is-active' : ''}`} className={`tab-button${period === entry.id ? ' is-active' : ''}`}
onClick={() => setPeriod(entry.id)} onClick={() => setPeriod(entry.id)}
disabled={loadingStats} disabled={loadingStats}
> >
{entry.label} {t(entry.labelKey)}
</button> </button>
))} ))}
</div> </div>
</div> </div>
@@ -765,16 +771,16 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
className="table-status" className="table-status"
aria-busy="true" aria-busy="true"
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', padding: 0 }} style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', padding: 0 }}
> >
Loading {t('linkDetail.loading')}
</p> </p>
) : stats.length === 0 ? ( ) : stats.length === 0 ? (
<p <p
className="table-empty" className="table-empty"
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', padding: 0 }} style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', padding: 0 }}
> >
No click data for this period. {t('linkDetail.noClickData')}
</p> </p>
) : null} ) : null}
<canvas ref={chartRef} style={{ display: stats.length === 0 || loadingStats ? 'none' : 'block' }} /> <canvas ref={chartRef} style={{ display: stats.length === 0 || loadingStats ? 'none' : 'block' }} />
</div> </div>
@@ -784,12 +790,12 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
{showHistory ? ( {showHistory ? (
<section className="panel" style={{ padding: '1.5rem' }}> <section className="panel" style={{ padding: '1.5rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem', letterSpacing: '-0.01em' }}> <h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem', letterSpacing: '-0.01em' }}>
Change History {t('linkDetail.changeHistory')}
</h2> </h2>
{loadingHistory ? ( {loadingHistory ? (
<p className="table-status" aria-busy="true">Loading</p> <p className="table-status" aria-busy="true">{t('linkDetail.loading')}</p>
) : history.length === 0 ? ( ) : history.length === 0 ? (
<p className="table-empty">No changes recorded yet.</p> <p className="table-empty">{t('linkDetail.noChanges')}</p>
) : ( ) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: '0' }}> <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: '0' }}>
{history.map((entry) => ( {history.map((entry) => (
+10 -4
View File
@@ -1,15 +1,21 @@
import { Trans, useTranslation } from 'react-i18next';
import ProviderButtons from '../components/ProviderButtons'; import ProviderButtons from '../components/ProviderButtons';
export default function LoginPage() { export default function LoginPage() {
const { t } = useTranslation();
return ( return (
<section className="auth-page" aria-labelledby="login-title"> <section className="auth-page" aria-labelledby="login-title">
<div className="auth-mark" aria-hidden="true">H</div> <div className="auth-mark" aria-hidden="true">H</div>
<h1 id="login-title">Sign in or create an account</h1> <h1 id="login-title">{t('login.title')}</h1>
<ProviderButtons /> <ProviderButtons />
<p className="auth-legal"> <p className="auth-legal">
By logging in, you agree to our{' '} <Trans
<a href="#/terms">Terms of Service</a> and{' '} i18nKey="login.legal"
<a href="#/privacy">Privacy Policy</a>. components={{
1: <a href="#/terms" />,
2: <a href="#/privacy" />,
}}
/>
</p> </p>
</section> </section>
); );
+47 -31
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { CheckIcon, Trash2Icon } from 'lucide-react'; import { CheckIcon, Trash2Icon } from 'lucide-react';
import { useTranslation, Trans } from 'react-i18next';
import { import {
ApiClientError, ApiClientError,
type Notification, type Notification,
@@ -8,10 +9,10 @@ import {
listNotifications, listNotifications,
} from '../lib/api'; } from '../lib/api';
const KIND_LABELS: Record<string, string> = { const KIND_LABEL_KEYS: Record<string, string> = {
promotion_approved: 'Approved', promotion_approved: 'notifications.kindApproved',
promotion_rejected: 'Rejected', promotion_rejected: 'notifications.kindRejected',
promotion_needs_changes: 'Needs changes', promotion_needs_changes: 'notifications.kindNeedsChanges',
}; };
interface NotificationsPageProps { interface NotificationsPageProps {
@@ -19,6 +20,7 @@ interface NotificationsPageProps {
} }
export default function NotificationsPage({ onMarkedViewed }: NotificationsPageProps) { export default function NotificationsPage({ onMarkedViewed }: NotificationsPageProps) {
const { t } = useTranslation();
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -35,11 +37,11 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
const result = await listNotifications(); const result = await listNotifications();
setNotifications(result.notifications ?? []); setNotifications(result.notifications ?? []);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load notifications'); setError(err instanceof Error ? err.message : t('notifications.loadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
@@ -78,7 +80,7 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
} }
async function handleDeleteOne(notification: Notification) { async function handleDeleteOne(notification: Notification) {
if (!window.confirm(`Delete "${notification.title}"?`)) { if (!window.confirm(t('notifications.deleteConfirm', { title: notification.title }))) {
return; return;
} }
setError(null); setError(null);
@@ -90,9 +92,9 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
next.delete(notification.id); next.delete(notification.id);
return next; return next;
}); });
setNotice('Notification deleted'); setNotice(t('notifications.notificationDeleted'));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete notification'); setError(err instanceof Error ? err.message : t('notifications.deleteFailed'));
} }
} }
@@ -100,7 +102,7 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
if (selected.size === 0 || deleting) { if (selected.size === 0 || deleting) {
return; return;
} }
if (!window.confirm(`Delete ${selected.size} notification${selected.size === 1 ? '' : 's'}?`)) { if (!window.confirm(t('notifications.deleteSelectedConfirm', { count: selected.size }))) {
return; return;
} }
setError(null); setError(null);
@@ -110,9 +112,9 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
await deleteNotifications(ids); await deleteNotifications(ids);
setNotifications((prev) => prev.filter((n) => !selected.has(n.id))); setNotifications((prev) => prev.filter((n) => !selected.has(n.id)));
setSelected(new Set()); setSelected(new Set());
setNotice(`${ids.length} notification${ids.length === 1 ? '' : 's'} deleted`); setNotice(t('notifications.notificationsDeleted', { count: ids.length }));
} catch (err) { } catch (err) {
setError(err instanceof ApiClientError ? err.message : 'Failed to delete notifications'); setError(err instanceof ApiClientError ? err.message : t('notifications.deleteManyFailed'));
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@@ -125,11 +127,11 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
<section className="panel"> <section className="panel">
<header className="panel-header"> <header className="panel-header">
<div> <div>
<h1>Notifications</h1> <h1>{t('notifications.title')}</h1>
<p className="muted">Updates about your promotion proposals.</p> <p className="muted">{t('notifications.subtitle')}</p>
</div> </div>
<div className="panel-actions"> <div className="panel-actions">
<button type="button" onClick={() => void refresh()} disabled={loading}>Refresh</button> <button type="button" onClick={() => void refresh()} disabled={loading}>{t('notifications.refresh')}</button>
</div> </div>
</header> </header>
@@ -137,16 +139,16 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
{selected.size > 0 ? ( {selected.size > 0 ? (
<div className="selection-bar" role="status"> <div className="selection-bar" role="status">
<span>{selected.size} selected.</span> <span>{t('notifications.selected', { count: selected.size })}</span>
<button <button
type="button" type="button"
className="link-action link-action--danger" className="link-action link-action--danger"
onClick={() => void handleDeleteSelected()} onClick={() => void handleDeleteSelected()}
disabled={deleting} disabled={deleting}
> >
{deleting ? 'Deleting' : 'Delete selected'} {deleting ? t('notifications.deleting') : t('notifications.deleteSelected')}
</button> </button>
<button type="button" className="link-action" onClick={() => setSelected(new Set())}>Clear</button> <button type="button" className="link-action" onClick={() => setSelected(new Set())}>{t('notifications.clear')}</button>
</div> </div>
) : null} ) : null}
@@ -158,20 +160,20 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
) : null} ) : null}
{loading ? ( {loading ? (
<p className="table-status" aria-busy="true">Loading notifications</p> <p className="table-status" aria-busy="true">{t('notifications.loading')}</p>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<p className="table-empty">No notifications yet.</p> <p className="table-empty">{t('notifications.empty')}</p>
) : ( ) : (
<div className="notifications-list"> <div className="notifications-list">
<div className="notifications-list__select-all"> <div className="notifications-list__select-all">
<label> <label>
<input <input
type="checkbox" type="checkbox"
aria-label={allSelected ? 'Deselect all notifications' : 'Select all notifications'} aria-label={allSelected ? t('notifications.deselectAll') : t('notifications.selectAll')}
checked={allSelected} checked={allSelected}
onChange={() => selectAll(allSelected ? [] : allIds)} onChange={() => selectAll(allSelected ? [] : allIds)}
/> />
<span className="muted">{notifications.length} notification{notifications.length === 1 ? '' : 's'}</span> <span className="muted">{t('notifications.notificationsCount', { count: notifications.length })}</span>
</label> </label>
</div> </div>
<ul> <ul>
@@ -202,7 +204,9 @@ function NotificationItem({
onToggleSelect: () => void; onToggleSelect: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const kindLabel = KIND_LABELS[notification.kind] ?? notification.kind; const { t } = useTranslation();
const kindLabelKey = KIND_LABEL_KEYS[notification.kind];
const kindLabel = kindLabelKey ? t(kindLabelKey) : notification.kind;
const kindClassName = `notification-kind notification-kind--${notification.kind}`; const kindClassName = `notification-kind notification-kind--${notification.kind}`;
return ( return (
@@ -210,14 +214,14 @@ function NotificationItem({
<div className="notification-item__select"> <div className="notification-item__select">
<input <input
type="checkbox" type="checkbox"
aria-label={`Select ${notification.title}`} aria-label={t('notifications.selectItem', { title: notification.title })}
checked={selected} checked={selected}
onChange={onToggleSelect} onChange={onToggleSelect}
/> />
</div> </div>
<div className="notification-item__body"> <div className="notification-item__body">
<div className="notification-item__header"> <div className="notification-item__header">
{notification.status === 'unread' ? <span className="notification-unread-dot" aria-label="Unread" /> : null} {notification.status === 'unread' ? <span className="notification-unread-dot" aria-label={t('notifications.unreadLabel')} /> : null}
<span className={kindClassName}>{kindLabel}</span> <span className={kindClassName}>{kindLabel}</span>
<h3 className="notification-item__title">{notification.title}</h3> <h3 className="notification-item__title">{notification.title}</h3>
</div> </div>
@@ -225,13 +229,25 @@ function NotificationItem({
<div className="notification-item__details"> <div className="notification-item__details">
<time dateTime={notification.createdAt}>{new Date(notification.createdAt).toLocaleString()}</time> <time dateTime={notification.createdAt}>{new Date(notification.createdAt).toLocaleString()}</time>
{notification.proposedAlias ? ( {notification.proposedAlias ? (
<span className="detail-chip">Proposed: <span className="alias-pill">#{notification.proposedAlias}</span></span> <span className="detail-chip">
<Trans
i18nKey="notifications.proposed"
values={{ alias: notification.proposedAlias }}
components={{ 1: <span className="alias-pill" /> }}
/>
</span>
) : null} ) : null}
{notification.privateLinkAlias ? ( {notification.privateLinkAlias ? (
<span className="detail-chip">Private link: <span className="alias-pill">#{notification.privateLinkAlias}</span></span> <span className="detail-chip">
<Trans
i18nKey="notifications.privateLink"
values={{ alias: notification.privateLinkAlias }}
components={{ 1: <span className="alias-pill" /> }}
/>
</span>
) : null} ) : null}
{notification.viewedAt ? ( {notification.viewedAt ? (
<span className="muted">Viewed {new Date(notification.viewedAt).toLocaleString()}</span> <span className="muted">{t('notifications.viewedAt', { datetime: new Date(notification.viewedAt).toLocaleString() })}</span>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -240,12 +256,12 @@ function NotificationItem({
type="button" type="button"
className="link-action link-action--danger" className="link-action link-action--danger"
onClick={onDelete} onClick={onDelete}
aria-label={`Delete ${notification.title}`} aria-label={t('notifications.deleteItem', { title: notification.title })}
> >
<Trash2Icon aria-hidden="true" size={14} /> <Trash2Icon aria-hidden="true" size={14} />
<span>Delete</span> <span>{t('notifications.delete')}</span>
</button> </button>
</div> </div>
</li> </li>
); );
} }
+26 -16
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { InboxIcon, PlusIcon } from 'lucide-react'; import { InboxIcon, PlusIcon } from 'lucide-react';
import { useTranslation, Trans } from 'react-i18next';
import LinkForm from '../components/LinkForm'; import LinkForm from '../components/LinkForm';
import LinkSearchBar from '../components/LinkSearchBar'; import LinkSearchBar from '../components/LinkSearchBar';
import LinkTable from '../components/LinkTable'; import LinkTable from '../components/LinkTable';
@@ -19,6 +20,7 @@ import {
type MyLinksView = 'links' | 'submissions'; type MyLinksView = 'links' | 'submissions';
export default function PrivateLinksPage() { export default function PrivateLinksPage() {
const { t } = useTranslation();
const [view, setView] = useState<MyLinksView>('links'); const [view, setView] = useState<MyLinksView>('links');
const [links, setLinks] = useState<Link[]>([]); const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -93,7 +95,7 @@ export default function PrivateLinksPage() {
} }
async function handleDelete(link: Link) { async function handleDelete(link: Link) {
if (!window.confirm(`Delete the link "${link.alias}"? This cannot be undone.`)) { if (!window.confirm(t('privateLinks.deleteConfirm', { alias: link.alias }))) {
return; return;
} }
try { try {
@@ -125,11 +127,11 @@ export default function PrivateLinksPage() {
<section className="panel"> <section className="panel">
<header className="panel-header"> <header className="panel-header">
<div> <div>
<h1>{view === 'links' ? 'My Links' : 'My Submissions'}</h1> <h1>{view === 'links' ? t('privateLinks.title') : t('privateLinks.submissionsTitle')}</h1>
<p className="muted"> <p className="muted">
{view === 'links' {view === 'links'
? 'Your personal shortlinks. Only visible to you.' ? t('privateLinks.subtitle')
: 'Links you have submitted for public promotion.'} : t('privateLinks.submissionsSubtitle')}
</p> </p>
</div> </div>
<div className="panel-actions"> <div className="panel-actions">
@@ -141,7 +143,7 @@ export default function PrivateLinksPage() {
onClick={() => { setEditing(null); setShowCreate((v) => !v); }} onClick={() => { setEditing(null); setShowCreate((v) => !v); }}
> >
<PlusIcon aria-hidden="true" size={16} /> <PlusIcon aria-hidden="true" size={16} />
<span>{showCreate ? 'Close form' : 'New link'}</span> <span>{showCreate ? t('privateLinks.closeForm') : t('privateLinks.newLink')}</span>
</button> </button>
<button <button
type="button" type="button"
@@ -149,7 +151,7 @@ export default function PrivateLinksPage() {
onClick={() => setView('submissions')} onClick={() => setView('submissions')}
> >
<InboxIcon aria-hidden="true" size={16} /> <InboxIcon aria-hidden="true" size={16} />
<span>My Submissions</span> <span>{t('privateLinks.mySubmissions')}</span>
</button> </button>
</> </>
) : ( ) : (
@@ -158,7 +160,7 @@ export default function PrivateLinksPage() {
className="link-action" className="link-action"
onClick={() => setView('links')} onClick={() => setView('links')}
> >
Back to My Links {t('privateLinks.backToMyLinks')}
</button> </button>
)} )}
</div> </div>
@@ -168,15 +170,17 @@ export default function PrivateLinksPage() {
<> <>
{selected.size > 0 ? ( {selected.size > 0 ? (
<div className="selection-bar" role="status"> <div className="selection-bar" role="status">
<span>{selected.size} selected.</span> <span>{t('privateLinks.selected', { count: selected.size })}</span>
<button <button
type="button" type="button"
className="link-action link-action--approve" className="link-action link-action--approve"
onClick={() => { setPromoteError(null); setShowPromote(true); }} onClick={() => { setPromoteError(null); setShowPromote(true); }}
> >
Promote to public {t('privateLinks.promoteToPublic')}
</button>
<button type="button" className="link-action" onClick={() => setSelected(new Set())}>
{t('privateLinks.clear')}
</button> </button>
<button type="button" className="link-action" onClick={() => setSelected(new Set())}>Clear</button>
</div> </div>
) : null} ) : null}
@@ -192,34 +196,40 @@ export default function PrivateLinksPage() {
{showCreate ? ( {showCreate ? (
<div className="form-card"> <div className="form-card">
<h2>Create link</h2> <h2>{t('privateLinks.createLink')}</h2>
<LinkForm <LinkForm
onSubmit={handleCreate} onSubmit={handleCreate}
onCancel={() => setShowCreate(false)} onCancel={() => setShowCreate(false)}
submitLabel="Create" submitLabel={t('privateLinks.create')}
/> />
</div> </div>
) : null} ) : null}
{editing ? ( {editing ? (
<div className="form-card"> <div className="form-card">
<h2>Edit <code>{editing.alias}</code></h2> <h2>
<Trans
i18nKey="privateLinks.editLink"
values={{ alias: editing.alias }}
components={{ 1: <code /> }}
/>
</h2>
<LinkForm <LinkForm
initial={editing} initial={editing}
onSubmit={handleUpdate} onSubmit={handleUpdate}
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
submitLabel="Save changes" submitLabel={t('privateLinks.saveChanges')}
/> />
</div> </div>
) : null} ) : null}
<LinkSearchBar scope="private" placeholder="Search my links by alias…" /> <LinkSearchBar scope="private" placeholder={t('privateLinks.searchPlaceholder')} />
<LinkTable <LinkTable
links={links} links={links}
loading={loading} loading={loading}
error={error} error={error}
emptyMessage="You have no private links yet. Create one to get started." emptyMessage={t('privateLinks.empty')}
selectable selectable
selectedIds={selected} selectedIds={selected}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
+6 -4
View File
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LinkSearchBar from '../components/LinkSearchBar'; import LinkSearchBar from '../components/LinkSearchBar';
import LinkTable from '../components/LinkTable'; import LinkTable from '../components/LinkTable';
import { type Link, listPublicLinks } from '../lib/api'; import { type Link, listPublicLinks } from '../lib/api';
export default function PublicLinksPage() { export default function PublicLinksPage() {
const { t } = useTranslation();
const [links, setLinks] = useState<Link[]>([]); const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -29,18 +31,18 @@ export default function PublicLinksPage() {
<section className="panel"> <section className="panel">
<header className="panel-header"> <header className="panel-header">
<div> <div>
<h1>Public Links</h1> <h1>{t('publicLinks.title')}</h1>
<p className="muted">Short links that go!</p> <p className="muted">{t('publicLinks.subtitle')}</p>
</div> </div>
</header> </header>
<LinkSearchBar scope="public" placeholder="Search public links by alias…" /> <LinkSearchBar scope="public" placeholder={t('publicLinks.searchPlaceholder')} />
<LinkTable <LinkTable
links={links} links={links}
loading={loading} loading={loading}
error={error} error={error}
emptyMessage="No public links yet." emptyMessage={t('publicLinks.empty')}
actions={false} actions={false}
/> />
</section> </section>