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",
"clsx": "^2.1.1",
"dompurify": "^3.4.11",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.21.0",
"marked": "^18.0.5",
"radix-ui": "^1.6.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"zod": "^4.4.3"
@@ -35,6 +38,15 @@
"wrangler": "^4.103.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@cloudflare/kv-asset-handler": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
@@ -3806,6 +3818,53 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peer": true,
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -4318,6 +4377,33 @@
"react": "^19.2.7"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -4629,8 +4715,9 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4703,6 +4790,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
@@ -4871,6 +4967,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+3
View File
@@ -26,11 +26,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.11",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.21.0",
"marked": "^18.0.5",
"radix-ui": "^1.6.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"zod": "^4.4.3"
+10 -7
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LoginPage from './routes/LoginPage';
import PrivateLinksPage from './routes/PrivateLinksPage';
import PublicLinksPage from './routes/PublicLinksPage';
@@ -14,7 +15,7 @@ import { resolveRouteForCurrentUser, type RouteId } from './lib/routing';
import LandingHero from './components/LandingHero';
const NAV_TABS: readonly { id: RouteId; label: string }[] = [
{ id: 'my-links', label: 'My Links' },
{ id: 'my-links', label: 'app.myLinks' },
];
function readRouteFromHash(): RouteId {
@@ -34,6 +35,7 @@ export function navigateToLinkDetail(id: string, scope: 'private' | 'public') {
}
export default function App() {
const { t } = useTranslation();
const [route, setRoute] = useState<RouteId>(() => readRouteFromHash());
const [linkDetailId, setLinkDetailId] = useState<string | null>(null);
const [linkDetailScope, setLinkDetailScope] = useState<'private' | 'public'>('private');
@@ -89,7 +91,7 @@ export default function App() {
return (
<div className="app-shell">
<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">
{user && navTabs.map((entry) => (
<a
@@ -99,7 +101,7 @@ export default function App() {
aria-current={route === entry.id ? 'page' : undefined}
onClick={() => navigate(entry.id)}
>
{entry.label}
{t(entry.label)}
</a>
))}
</nav>
@@ -122,8 +124,8 @@ export default function App() {
{route === 'admin' ? <AdminReviewPage /> : null}
{route === 'login' ? <LoginPage /> : null}
{route === 'dev-login' ? <DevLoginPage /> : null}
{route === 'profile' ? <PlaceholderPage title="Profile" /> : null}
{route === 'settings' ? <PlaceholderPage title="Settings" /> : null}
{route === 'profile' ? <PlaceholderPage title={t('app.profile')} /> : null}
{route === 'settings' ? <PlaceholderPage title={t('app.settings')} /> : null}
{route === 'terms' ? <LegalPage kind="terms" /> : null}
{route === 'privacy' ? <LegalPage kind="privacy" /> : null}
{route === 'notifications' ? <NotificationsPage onMarkedViewed={handleMarkedViewed} /> : null}
@@ -145,17 +147,18 @@ export default function App() {
</main>
<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>
</div>
);
}
function PlaceholderPage({ title }: { title: string }) {
const { t } = useTranslation();
return (
<section className="panel">
<h1>{title}</h1>
<p className="muted">{title} page is coming soon.</p>
<p className="muted">{t('app.placeholderTitle', { title })}</p>
</section>
);
}
+16 -10
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
interface CopyPublicLinkDialogProps {
readonly conflictingAlias: string;
@@ -13,6 +14,7 @@ export default function CopyPublicLinkDialog({
onSubmit,
onClose,
}: CopyPublicLinkDialogProps) {
const { t } = useTranslation();
const [alias, setAlias] = useState(suggestedAlias);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -21,12 +23,12 @@ export default function CopyPublicLinkDialog({
event.preventDefault();
const normalizedAlias = alias.trim();
if (!normalizedAlias) {
setError('Alias is required');
setError(t('copyDialog.aliasRequired'));
return;
}
if (normalizedAlias.toLowerCase() === conflictingAlias.toLowerCase()) {
setError('Please choose a different alias.');
setError(t('copyDialog.aliasDifferent'));
return;
}
@@ -42,19 +44,23 @@ export default function CopyPublicLinkDialog({
}
return (
<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}>
<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>
</div>
<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>
<div className="field">
<label htmlFor="copy-link-alias">Private alias</label>
<label htmlFor="copy-link-alias">{t('copyDialog.fieldLabel')}</label>
<div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">/</span>
<input
@@ -65,12 +71,12 @@ export default function CopyPublicLinkDialog({
required
maxLength={100}
autoComplete="off"
placeholder="my-link-copy"
placeholder={t('copyDialog.aliasPlaceholder')}
disabled={submitting}
/>
</div>
<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}
</small>
</div>
@@ -79,9 +85,9 @@ export default function CopyPublicLinkDialog({
<div className="form-actions">
<button type="submit" disabled={submitting || !alias.trim()}>
{submitting ? 'Creating' : 'Create link'}
{submitting ? t('copyDialog.creating') : t('copyDialog.createLink')}
</button>
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button>
<button type="button" onClick={onClose} disabled={submitting}>{t('copyDialog.cancel')}</button>
</div>
</form>
</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 { safeLinkTargetUrl } from '../lib/url';
import { useCurrentUser } from '../lib/auth';
import { useTranslation, Trans } from 'react-i18next';
const DEBOUNCE_MS = 220;
const MAX_RESULTS = 8;
@@ -31,6 +32,7 @@ function isExactMatch(link: Link, query: string): boolean {
}
export default function LandingHero() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const [inputValue, setInputValue] = useState('');
const [state, setState] = useState<SearchState>(INITIAL_STATE);
@@ -59,13 +61,13 @@ export default function LandingHero() {
setState({
links: [],
loading: false,
error: err instanceof Error ? err.message : '搜索失败',
error: err instanceof Error ? err.message : t('landing.searchFail'),
query,
});
setOpen(true);
setActiveIndex(-1);
}
}, []);
}, [t]);
useEffect(() => {
if (debounceRef.current) {
@@ -201,7 +203,7 @@ export default function LandingHero() {
const hasQuery = trimmedQuery.length > 0;
const canCreate = hasQuery && !exactMatch && !state.loading;
const primaryLabel = exactMatch ? 'Go' : canCreate ? '创建个人链接' : 'Go';
const primaryLabel = exactMatch ? t('landing.goLabel') : canCreate ? t('landing.createPersonalLink') : t('landing.goLabel');
const primaryDisabled = !exactMatch && !canCreate;
const primaryVariant = exactMatch ? 'is-go' : canCreate ? 'is-create' : 'is-idle';
@@ -213,24 +215,23 @@ export default function LandingHero() {
<div className="hero-content">
<div className="hero-eyebrow">
<span className="hero-eyebrow__dot" aria-hidden="true" />
<span></span>
<span>{t('landing.eyebrow')}</span>
</div>
<h1 className="hero-title">
<span className="hero-brand">Heygo</span>
<span className="hero-suffix"></span>
<span className="hero-suffix">{t('landing.brandSuffix')}</span>
</h1>
<p className="hero-description">
<br />
<Trans i18nKey="landing.description" components={{ 1: <br /> }} />
</p>
<div className="hero-features" aria-hidden="true">
<div className="feature-pill"><span className="feature-dot" /></div>
<div className="feature-pill"><span className="feature-dot" /></div>
<div className="feature-pill"><span className="feature-dot" /></div>
<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" />{t('landing.featurePrivate')}</div>
<div className="feature-pill"><span className="feature-dot" />{t('landing.featureShared')}</div>
<div className="feature-pill"><span className="feature-dot" />{t('landing.featureCrossPlatform')}</div>
</div>
</div>
@@ -238,14 +239,14 @@ export default function LandingHero() {
<div className="hero-search__shell">
<div className="hero-search__prefix">
<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>
<input
ref={inputRef}
type="text"
className="hero-search__field"
placeholder="输入短链名称,例如 canva"
placeholder={t('landing.searchPlaceholder')}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
@@ -257,7 +258,7 @@ export default function LandingHero() {
autoCapitalize="off"
spellCheck={false}
enterKeyHint="go"
aria-label="搜索短链"
aria-label={t('landing.searchLabel')}
aria-expanded={showDropdown}
aria-autocomplete="list"
aria-controls="hero-search-results"
@@ -269,7 +270,7 @@ export default function LandingHero() {
type="button"
className="hero-search__clear"
onClick={clearSearch}
aria-label="清除搜索"
aria-label={t('landing.clearLabel')}
tabIndex={-1}
>
<X size={16} strokeWidth={2.5} aria-hidden="true" />
@@ -281,7 +282,7 @@ export default function LandingHero() {
className={`hero-search__action ${primaryVariant}`}
onClick={handlePrimaryAction}
disabled={primaryDisabled}
aria-label={exactMatch ? `跳转到 /${exactMatch.alias}` : primaryLabel}
aria-label={exactMatch ? t('landing.goToAlias', { alias: exactMatch.alias }) : primaryLabel}
>
{exactMatch ? (
<>
@@ -307,7 +308,7 @@ export default function LandingHero() {
{showLoading ? (
<div className="hero-search__status">
<span className="hero-search__spinner" aria-hidden="true" />
<span></span>
<span>{t('landing.searching')}</span>
</div>
) : null}
@@ -320,10 +321,14 @@ export default function LandingHero() {
{showNoResults ? (
<div className="hero-search__empty">
<div className="hero-search__empty-title">
<code>{trimmedQuery}</code>
<Trans
i18nKey="landing.noResultsTitle"
values={{ query: trimmedQuery }}
components={{ 1: <code /> }}
/>
</div>
<div className="hero-search__empty-hint">
{t('landing.noResultsHint')}
</div>
<button
type="button"
@@ -331,7 +336,7 @@ export default function LandingHero() {
onClick={handleCreate}
>
<Plus size={15} strokeWidth={2.6} aria-hidden="true" />
{t('landing.createPersonalLink')}
</button>
</div>
) : null}
@@ -359,7 +364,7 @@ export default function LandingHero() {
{link.alias}
</span>
{exact ? (
<span className="hero-search__exact-badge"></span>
<span className="hero-search__exact-badge">{t('landing.exactMatch')}</span>
) : null}
</div>
{link.linkType === 'redirect' && link.targetUrl ? (
@@ -369,17 +374,17 @@ export default function LandingHero() {
{link.targetUrl}
</span>
) : (
<span className="muted"></span>
<span className="muted">{t('landing.invalidLink')}</span>
)}
</p>
) : link.linkType === 'custom' ? (
<p className="hero-search__result-url">
<span className="muted"></span>
<span className="muted">{t('landing.customPage')}</span>
</p>
) : null}
<div className="hero-search__result-meta">
<span className="hero-search__clicks">
{link.clickCount.toLocaleString()}
{t('landing.clicksCount', { count: link.clickCount.toLocaleString() })}
</span>
{link.description ? (
<span className="hero-search__result-desc">{link.description}</span>
@@ -394,7 +399,7 @@ export default function LandingHero() {
</div>
<div className="hero-suggestions">
<span className="hero-suggestions__label"></span>
<span className="hero-suggestions__label">{t('landing.suggestionsLabel')}</span>
{SUGGESTIONS.map((alias) => (
<button
key={alias}
+26 -18
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import type { Link, LinkInput, LinkType } from '../lib/api';
interface LinkFormProps {
@@ -39,6 +40,7 @@ export default function LinkForm({
submitLabel,
disabled = false,
}: LinkFormProps) {
const { t } = useTranslation();
const [input, setInput] = useState<LinkInput>(() => toFormInput(initial));
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -79,9 +81,9 @@ export default function LinkForm({
}
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">
<label htmlFor="link-alias">Alias</label>
<label htmlFor="link-alias">{t('linkForm.fieldAlias')}</label>
<div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">#</span>
<input
@@ -92,18 +94,18 @@ export default function LinkForm({
required
maxLength={100}
autoComplete="off"
placeholder="my-link"
placeholder={t('linkForm.aliasPlaceholder')}
disabled={submitting || disabled}
/>
</div>
<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}
</small>
</div>
<fieldset className="field" disabled={submitting || disabled}>
<legend>Link type</legend>
<legend>{t('linkForm.fieldLinkType')}</legend>
<label className="radio">
<input
type="radio"
@@ -112,7 +114,7 @@ export default function LinkForm({
checked={linkType === 'redirect'}
onChange={() => update('linkType', 'redirect')}
/>
Redirect URL
{t('linkForm.typeRedirect')}
</label>
<label className="radio">
<input
@@ -122,13 +124,13 @@ export default function LinkForm({
checked={linkType === 'custom'}
onChange={() => update('linkType', 'custom')}
/>
Custom markdown page
{t('linkForm.typeCustom')}
</label>
</fieldset>
{linkType === 'redirect' ? (
<div className="field">
<label htmlFor="link-target">Target URL</label>
<label htmlFor="link-target">{t('linkForm.fieldTargetUrl')}</label>
<input
id="link-target"
name="targetUrl"
@@ -136,18 +138,24 @@ export default function LinkForm({
value={input.targetUrl ?? ''}
onChange={(e) => update('targetUrl', e.target.value)}
required
placeholder="https://example.com/search?q={query}"
placeholder={t('linkForm.targetPlaceholder')}
disabled={submitting || disabled}
/>
<small className="hint">
Supports parameterized templates. Use <code>{'{query}'}</code> placeholders, e.g.
<code> https://example.com/search?q={'{query}'} </code>
then visit <code>/alias/foo?bar=baz</code> to substitute.
<Trans
i18nKey="linkForm.targetHint"
values={{ query: '{query}' }}
components={{
1: <code />,
3: <code />,
5: <code />,
}}
/>
</small>
</div>
) : (
<div className="field">
<label htmlFor="link-content">Markdown content</label>
<label htmlFor="link-content">{t('linkForm.fieldContent')}</label>
<textarea
id="link-content"
name="contentMarkdown"
@@ -155,21 +163,21 @@ export default function LinkForm({
onChange={(e) => update('contentMarkdown', e.target.value)}
required
rows={6}
placeholder="# Hello world"
placeholder={t('linkForm.contentPlaceholder')}
disabled={submitting || disabled}
/>
</div>
)}
<div className="field">
<label htmlFor="link-description">Description (optional)</label>
<label htmlFor="link-description">{t('linkForm.fieldDescription')}</label>
<input
id="link-description"
name="description"
value={input.description ?? ''}
onChange={(e) => update('description', e.target.value)}
maxLength={500}
placeholder="What this link is for"
placeholder={t('linkForm.descriptionPlaceholder')}
disabled={submitting || disabled}
/>
</div>
@@ -180,11 +188,11 @@ export default function LinkForm({
<div className="form-actions">
<button type="submit" disabled={submitting || disabled}>
{submitting ? 'Saving' : (submitLabel ?? 'Save')}
{submitting ? t('linkForm.saving') : (submitLabel ?? t('linkForm.save'))}
</button>
{onCancel ? (
<button type="button" onClick={onCancel} disabled={submitting || disabled}>
Cancel
{t('linkForm.cancel')}
</button>
) : null}
</div>
+19 -11
View File
@@ -9,6 +9,7 @@ import {
} from 'react';
import { type Link, type LinkScope, listPrivateLinks, listPublicLinks } from '../lib/api';
import { safeLinkTargetUrl } from '../lib/url';
import { useTranslation, Trans } from 'react-i18next';
const DEBOUNCE_MS = 220;
const MAX_RESULTS = 12;
@@ -44,9 +45,12 @@ function navigateToLinkDetail(link: Pick<Link, 'id' | 'scope'>) {
export default function LinkSearchBar({
scope,
placeholder = 'Search by alias…',
placeholder,
onSelect,
}: LinkSearchBarProps) {
const { t } = useTranslation();
const resolvedPlaceholder =
placeholder ?? (scope === 'private' ? t('privateLinks.searchPlaceholder') : t('publicLinks.searchPlaceholder'));
const [inputValue, setInputValue] = useState('');
const [state, setState] = useState<SearchState>(INITIAL_STATE);
const [open, setOpen] = useState(false);
@@ -78,7 +82,7 @@ export default function LinkSearchBar({
setState({
links: [],
loading: false,
error: err instanceof Error ? err.message : 'Search failed',
error: err instanceof Error ? err.message : t('landing.searchFail'),
query,
});
setOpen(true);
@@ -227,7 +231,7 @@ export default function LinkSearchBar({
ref={inputRef}
type="search"
className="link-search__input"
placeholder={placeholder}
placeholder={resolvedPlaceholder}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
@@ -236,7 +240,7 @@ export default function LinkSearchBar({
autoCapitalize="off"
spellCheck={false}
enterKeyHint="go"
aria-label="Search links by alias"
aria-label={t('landing.searchLabel')}
aria-expanded={showDropdown}
aria-autocomplete="list"
aria-controls="link-search-results"
@@ -247,7 +251,7 @@ export default function LinkSearchBar({
type="button"
className="link-search__clear"
onClick={clearSearch}
aria-label="Clear search"
aria-label={t('landing.clearLabel')}
tabIndex={-1}
>
<XIcon aria-hidden="true" size={16} strokeWidth={2.5} />
@@ -264,7 +268,7 @@ export default function LinkSearchBar({
{showLoading ? (
<div className="link-search__status">
<span className="link-search__spinner" aria-hidden="true" />
<span>Searching</span>
<span>{t('landing.searching')}</span>
</div>
) : null}
@@ -276,7 +280,11 @@ export default function LinkSearchBar({
{showNoResults ? (
<div className="link-search__status">
No links matching <code>{trimmedQuery}</code>
<Trans
i18nKey="landing.noResultsTitle"
values={{ query: trimmedQuery }}
components={{ 1: <code /> }}
/>
</div>
) : null}
@@ -309,7 +317,7 @@ export default function LinkSearchBar({
/{link.alias}
</a>
{exact ? (
<span className="link-search__exact-badge">Exact match</span>
<span className="link-search__exact-badge">{t('landing.exactMatch')}</span>
) : null}
</div>
{link.linkType === 'redirect' && link.targetUrl ? (
@@ -319,17 +327,17 @@ export default function LinkSearchBar({
{link.targetUrl}
</span>
) : (
<span className="muted">Invalid target</span>
<span className="muted">{t('linkTable.invalidTarget')}</span>
)}
</p>
) : link.linkType === 'custom' ? (
<p className="link-search__result-url">
<span className="muted">Custom page</span>
<span className="muted">{t('landing.customPage')}</span>
</p>
) : null}
<div className="link-search__result-meta">
<span className="link-search__clicks">
{link.clickCount.toLocaleString()} click{link.clickCount !== 1 ? 's' : ''}
{t('landing.clicksCount', { count: link.clickCount.toLocaleString() })}
</span>
</div>
</div>
+50 -41
View File
@@ -1,5 +1,6 @@
import { CodeIcon, LinkIcon } from 'lucide-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 { safeLinkTargetUrl } from '../lib/url';
@@ -10,12 +11,12 @@ type ViewStyle = 'list' | 'cards';
const PAGE_SIZE = 100;
const VIEW_QUERY_PARAM = 'view';
const SORT_LABELS: Record<SortField, string> = {
alias: 'Alias',
linkType: 'Type',
targetUrl: 'Target',
clickCount: 'Clicks',
updatedAt: 'Updated',
const SORT_FIELD_KEYS: Record<SortField, string> = {
alias: 'linkTable.colAlias',
linkType: 'linkTable.colType',
targetUrl: 'linkTable.colTarget',
clickCount: 'linkTable.colClicks',
updatedAt: 'linkTable.colUpdated',
};
interface LinkTableProps {
@@ -85,13 +86,14 @@ function handleRowKeyDown(event: ReactKeyboardEvent<HTMLElement>, link: Pick<Lin
}
function LinkTypeBadge({ linkType, className }: { linkType: Link['linkType']; className?: string }) {
const { t } = useTranslation();
const isCustom = linkType === 'custom';
const Icon = isCustom ? CodeIcon : LinkIcon;
return (
<span className={`link-type-badge ${isCustom ? 'is-custom' : 'is-redirect'}${className ? ` ${className}` : ''}`}>
<Icon aria-hidden="true" size={14} strokeWidth={2.2} />
<span>{isCustom ? 'Custom' : 'Redirect'}</span>
<span>{isCustom ? t('linkTable.typeCustom') : t('linkTable.typeRedirect')}</span>
</span>
);
}
@@ -100,7 +102,7 @@ export default function LinkTable({
links,
loading = false,
error = null,
emptyMessage = 'No links yet.',
emptyMessage,
selectable = false,
selectedIds,
onToggleSelect,
@@ -109,6 +111,7 @@ export default function LinkTable({
onDelete,
actions = true,
}: LinkTableProps) {
const { t } = useTranslation();
const [sortField, setSortField] = useState<SortField>('clickCount');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [viewStyle, setViewStyle] = useState<ViewStyle>(() => readViewStyleFromHash());
@@ -148,9 +151,9 @@ export default function LinkTable({
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 (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 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 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 (
<div>
{/* Toolbar */}
<div className="link-table-toolbar">
<span className="muted" style={{ fontSize: '0.8125rem' }}>
{links.length} link{links.length !== 1 ? 's' : ''}
{visibleCount < links.length ? ` · showing ${visibleCount}` : ''}
{t('linkTable.linksCount', { count: links.length })}
{visibleCount < links.length ? t('linkTable.showing', { count: visibleCount }) : ''}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
{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
value={sortField}
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)' }}
>
{(Object.keys(SORT_LABELS) as SortField[]).map((f) => (
<option key={f} value={f}>{SORT_LABELS[f]}</option>
{(Object.keys(SORT_FIELD_KEYS) as SortField[]).map((f) => (
<option key={f} value={f}>{columnHeader(f)}</option>
))}
</select>
<button
type="button"
className="link-action"
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' }}
>
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
{sortDir === 'asc' ? t('linkTable.sortAsc') : t('linkTable.sortDesc')}
</button>
<div style={{ width: '1px', height: '1.25rem', background: 'var(--border)', margin: '0 0.15rem' }} />
</>
@@ -196,17 +209,17 @@ export default function LinkTable({
type="button"
className={`tab-button${viewStyle === 'list' ? ' is-active' : ''}`}
onClick={() => setViewStyle('list')}
title="List view"
title={t('linkTable.listViewTitle')}
>
List
{t('linkTable.listView')}
</button>
<button
type="button"
className={`tab-button${viewStyle === 'cards' ? ' is-active' : ''}`}
onClick={() => setViewStyle('cards')}
title="Card view"
title={t('linkTable.cardViewTitle')}
>
Cards
{t('linkTable.cardView')}
</button>
</div>
</div>
@@ -220,7 +233,7 @@ export default function LinkTable({
<th scope="col" className="col-select">
<input
type="checkbox"
aria-label={allSelected ? 'Deselect all links' : 'Select all links'}
aria-label={allSelected ? t('linkTable.deselectAllLabel') : t('linkTable.selectAllLabel')}
checked={allSelected}
onChange={() => onSelectAll?.(allSelected ? [] : allIds)}
/>
@@ -228,15 +241,11 @@ export default function LinkTable({
) : null}
{(['alias', 'linkType', 'targetUrl', 'clickCount', 'updatedAt'] as SortField[]).map((field) => (
<th key={field} scope="col" className="sortable-th" onClick={() => handleSort(field)}>
{field === 'alias' ? 'Alias'
: field === 'linkType' ? 'Type'
: field === 'targetUrl' ? 'Target / content'
: field === 'clickCount' ? 'Clicks'
: 'Updated'}
{columnHeader(field)}
<SortIndicator field={field} sortField={sortField} sortDir={sortDir} />
</th>
))}
{showActionColumn ? <th scope="col" className="col-actions">Actions</th> : null}
{showActionColumn ? <th scope="col" className="col-actions">{t('linkTable.colActions')}</th> : null}
</tr>
</thead>
<tbody>
@@ -252,13 +261,13 @@ export default function LinkTable({
onKeyDown={(event) => handleRowKeyDown(event, link)}
tabIndex={0}
role="link"
aria-label={`Open details for ${link.alias}`}
aria-label={t('linkTable.openDetailsLabel', { alias: link.alias })}
>
{selectable ? (
<td className="col-select">
<input
type="checkbox"
aria-label={`Select ${link.alias}`}
aria-label={t('linkTable.selectLinkLabel', { alias: link.alias })}
checked={selected}
onClick={stopRowNavigation}
onChange={() => onToggleSelect?.(link.id)}
@@ -284,7 +293,7 @@ export default function LinkTable({
link.targetUrl ? (
safeUrl
? <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">markdown</span>}
</td>
@@ -303,7 +312,7 @@ export default function LinkTable({
onEdit(link);
}}
>
Edit
{t('linkTable.edit')}
</button>
) : null}
{actions && onDelete ? (
@@ -315,7 +324,7 @@ export default function LinkTable({
onDelete(link);
}}
>
Delete
{t('linkTable.delete')}
</button>
) : null}
</td>
@@ -342,13 +351,13 @@ export default function LinkTable({
onKeyDown={(event) => handleRowKeyDown(event, link)}
tabIndex={0}
role="link"
aria-label={`Open details for ${link.alias}`}
aria-label={t('linkTable.openDetailsLabel', { alias: link.alias })}
>
<div className="link-card-header">
{selectable ? (
<input
type="checkbox"
aria-label={`Select ${link.alias}`}
aria-label={t('linkTable.selectLinkLabel', { alias: link.alias })}
checked={selected}
onClick={stopRowNavigation}
onChange={() => onToggleSelect?.(link.id)}
@@ -375,7 +384,7 @@ export default function LinkTable({
</p>
) : null}
<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' }}>
{new Date(link.updatedAt).toLocaleDateString()}
</time>
@@ -391,7 +400,7 @@ export default function LinkTable({
onEdit(link);
}}
>
Edit
{t('linkTable.edit')}
</button>
) : null}
{actions && onDelete ? (
@@ -403,7 +412,7 @@ export default function LinkTable({
onDelete(link);
}}
>
Delete
{t('linkTable.delete')}
</button>
) : null}
</div>
@@ -423,11 +432,11 @@ export default function LinkTable({
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
style={{ padding: '0.5rem 2rem', fontSize: '0.875rem' }}
>
Load {Math.min(PAGE_SIZE, remaining)} more
<span className="muted" style={{ marginLeft: '0.4rem', fontWeight: 400 }}>({remaining} remaining)</span>
{t('linkTable.loadMore', { count: Math.min(PAGE_SIZE, remaining) })}
<span className="muted" style={{ marginLeft: '0.4rem', fontWeight: 400 }}>{t('linkTable.remaining', { count: remaining })}</span>
</button>
</div>
)}
</div>
);
}
}
+18 -16
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { PencilLineIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
ApiClientError,
type PromotionSubmission,
@@ -9,11 +10,11 @@ import {
} from '../lib/api';
import ResubmitDialog from './ResubmitDialog';
const STATUS_LABELS: Record<SubmissionStatus, string> = {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
needs_changes: 'Needs changes',
const STATUS_LABEL_KEYS: Record<SubmissionStatus, string> = {
pending: 'mySubmissions.statusPending',
approved: 'mySubmissions.statusApproved',
rejected: 'mySubmissions.statusRejected',
needs_changes: 'mySubmissions.statusNeedsChanges',
};
interface MySubmissionsListProps {
@@ -21,6 +22,7 @@ interface MySubmissionsListProps {
}
export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProps) {
const { t } = useTranslation();
const [submissions, setSubmissions] = useState<PromotionSubmission[]>([]);
const [loading, setLoading] = useState(true);
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}
{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 ? (
<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">
<thead>
<tr>
<th scope="col">Proposed alias</th>
<th scope="col">Private link</th>
<th scope="col">Submitted</th>
<th scope="col">Status</th>
<th scope="col" className="col-actions">Actions</th>
<th scope="col">{t('mySubmissions.colProposedAlias')}</th>
<th scope="col">{t('mySubmissions.colPrivateLink')}</th>
<th scope="col">{t('mySubmissions.colSubmitted')}</th>
<th scope="col">{t('mySubmissions.colStatus')}</th>
<th scope="col" className="col-actions">{t('mySubmissions.colActions')}</th>
</tr>
</thead>
<tbody>
@@ -83,7 +85,7 @@ export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProp
<span className="alias-pill">#{submission.proposedAlias}</span>
{submission.note ? <small className="row-description">{submission.note}</small> : null}
{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}
</td>
<td>
@@ -98,7 +100,7 @@ export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProp
<time dateTime={submission.createdAt}>{new Date(submission.createdAt).toLocaleString()}</time>
</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 className="col-actions">
{submission.status === 'needs_changes' ? (
@@ -108,7 +110,7 @@ export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProp
onClick={() => setResubmitting(submission)}
>
<PencilLineIcon aria-hidden="true" size={14} />
<span>Edit &amp; resubmit</span>
<span>{t('mySubmissions.editResubmit')}</span>
</button>
) : (
<span className="muted"></span>
@@ -129,4 +131,4 @@ export default function MySubmissionsList({ onSubmitted }: MySubmissionsListProp
) : null}
</div>
);
}
}
+23 -17
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import type { Link } from '../lib/api';
interface PromotionDialogProps {
@@ -15,6 +16,7 @@ function defaultAlias(link: Link): string {
}
export default function PromotionDialog({ links, onSubmit, onClose }: PromotionDialogProps) {
const { t } = useTranslation();
const [selectedLinkId, setSelectedLinkId] = useState<string>(() => links[0]?.id ?? '');
const [proposedAlias, setProposedAlias] = useState<string>(() => links[0]?.alias ?? '');
const [note, setNote] = useState<string>(EMPTY_NOTE);
@@ -56,12 +58,12 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
if (links.length === 0) {
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">
<h2>Promote to public</h2>
<p className="muted">Select at least one private link to promote.</p>
<h2>{t('promotionDialog.emptyTitle')}</h2>
<p className="muted">{t('promotionDialog.emptyDescription')}</p>
<div className="form-actions">
<button type="button" onClick={onClose}>Close</button>
<button type="button" onClick={onClose}>{t('promotionDialog.close')}</button>
</div>
</div>
</div>
@@ -69,16 +71,16 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
}
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}>
<div className="dialog-header">
<h2>Promote to public directory</h2>
<button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button>
<h2>{t('promotionDialog.title')}</h2>
<button type="button" className="dialog-close" aria-label={t('promotionDialog.closeLabel')} onClick={onClose} disabled={submitting}>×</button>
</div>
{!single ? (
<div className="field">
<label htmlFor="promotion-link">Private link</label>
<label htmlFor="promotion-link">{t('promotionDialog.fieldPrivateLink')}</label>
<select
id="promotion-link"
value={selectedLinkId}
@@ -89,17 +91,21 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
<option key={link.id} value={link.id}>#{link.alias}</option>
))}
</select>
<small className="hint">Choose which private link to submit for promotion.</small>
<small className="hint">{t('promotionDialog.linkHint')}</small>
</div>
) : (
<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}
</p>
)}
<div className="field">
<label htmlFor="promotion-alias">Proposed public alias</label>
<label htmlFor="promotion-alias">{t('promotionDialog.fieldAlias')}</label>
<div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">#</span>
<input
@@ -110,18 +116,18 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
required
maxLength={100}
autoComplete="off"
placeholder="public-alias"
placeholder={t('promotionDialog.aliasPlaceholder')}
disabled={submitting}
/>
</div>
<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}
</small>
</div>
<div className="field">
<label htmlFor="promotion-note">Note for reviewers (optional)</label>
<label htmlFor="promotion-note">{t('promotionDialog.fieldNote')}</label>
<textarea
id="promotion-note"
name="note"
@@ -129,7 +135,7 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={1000}
placeholder="Why should this be public?"
placeholder={t('promotionDialog.notePlaceholder')}
disabled={submitting}
/>
</div>
@@ -138,9 +144,9 @@ export default function PromotionDialog({ links, onSubmit, onClose }: PromotionD
<div className="form-actions">
<button type="submit" disabled={submitting || !proposedAlias.trim()}>
{submitting ? 'Submitting' : 'Submit for review'}
{submitting ? t('promotionDialog.submitting') : t('promotionDialog.submitForReview')}
</button>
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button>
<button type="button" onClick={onClose} disabled={submitting}>{t('promotionDialog.cancel')}</button>
</div>
</form>
</div>
+8 -6
View File
@@ -1,17 +1,18 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface ProviderInfo {
readonly id: string;
readonly label: string;
readonly loadingLabel: string;
readonly labelKey: string;
readonly loadingKey: string;
readonly href: string;
}
const PROVIDERS: readonly ProviderInfo[] = [
{
id: 'google',
label: 'Continue with Google',
loadingLabel: 'Opening Google...',
labelKey: 'login.continueWithGoogle',
loadingKey: 'login.openingGoogle',
href: '/api/auth/google',
},
];
@@ -21,6 +22,7 @@ interface ProviderButtonsProps {
}
export default function ProviderButtons({ disabled = false }: ProviderButtonsProps) {
const { t } = useTranslation();
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
const isBusy = disabled || loadingProvider !== null;
@@ -51,12 +53,12 @@ export default function ProviderButtons({ disabled = false }: ProviderButtonsPro
<span className="provider-button__icon">G</span>
) : null}
<span className="provider-button__label">
{loadingProvider === provider.id ? provider.loadingLabel : provider.label}
{loadingProvider === provider.id ? t(provider.loadingKey) : t(provider.labelKey)}
</span>
</a>
))}
<span className="sr-only" role="status" aria-live="polite">
{loadingProvider ? 'Opening Google sign in' : ''}
{loadingProvider ? t('login.openingGoogleSr') : ''}
</span>
</div>
);
+14 -12
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PromotionSubmission } from '../lib/api';
interface ResubmitDialogProps {
@@ -8,6 +9,7 @@ interface ResubmitDialogProps {
}
export default function ResubmitDialog({ submission, onSubmit, onClose }: ResubmitDialogProps) {
const { t } = useTranslation();
const [proposedAlias, setProposedAlias] = useState<string>(submission.proposedAlias);
const [note, setNote] = useState<string>(submission.note ?? '');
const [error, setError] = useState<string | null>(null);
@@ -34,11 +36,11 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
}
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}>
<div className="dialog-header">
<h2>Edit &amp; resubmit</h2>
<button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button>
<h2>{t('resubmitDialog.title')}</h2>
<button type="button" className="dialog-close" aria-label={t('resubmitDialog.closeLabel')} onClick={onClose} disabled={submitting}>×</button>
</div>
<p className="muted">
@@ -48,13 +50,13 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
{submission.rejectionReason ? (
<div className="resubmit-reason" role="note">
<strong>Requested changes:</strong>
<strong>{t('resubmitDialog.requestedChanges')}</strong>
<p>{submission.rejectionReason}</p>
</div>
) : null}
<div className="field">
<label htmlFor="resubmit-alias">Proposed public alias</label>
<label htmlFor="resubmit-alias">{t('resubmitDialog.fieldAlias')}</label>
<div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">#</span>
<input
@@ -65,18 +67,18 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
required
maxLength={100}
autoComplete="off"
placeholder="public-alias"
placeholder={t('resubmitDialog.aliasPlaceholder')}
disabled={submitting}
/>
</div>
<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}
</small>
</div>
<div className="field">
<label htmlFor="resubmit-note">Note for reviewers (optional)</label>
<label htmlFor="resubmit-note">{t('resubmitDialog.fieldNote')}</label>
<textarea
id="resubmit-note"
name="note"
@@ -84,7 +86,7 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={1000}
placeholder="What did you change?"
placeholder={t('resubmitDialog.notePlaceholder')}
disabled={submitting}
/>
</div>
@@ -93,11 +95,11 @@ export default function ResubmitDialog({ submission, onSubmit, onClose }: Resubm
<div className="form-actions">
<button type="submit" disabled={submitting || !proposedAlias.trim()}>
{submitting ? 'Submitting' : 'Resubmit for review'}
{submitting ? t('resubmitDialog.submitting') : t('resubmitDialog.resubmitForReview')}
</button>
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button>
<button type="button" onClick={onClose} disabled={submitting}>{t('resubmitDialog.cancel')}</button>
</div>
</form>
</div>
);
}
}
+14 -11
View File
@@ -1,4 +1,5 @@
import type { CurrentUser } from '../lib/auth';
import { useTranslation } from 'react-i18next';
interface UserMenuProps {
user: CurrentUser | null;
@@ -8,23 +9,25 @@ interface UserMenuProps {
}
export default function UserMenu({ user, loading, unreadCount, onLogout }: UserMenuProps) {
const { t } = useTranslation();
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) {
return (
<div className="user-menu">
<a className="signin-button" href="#/login">Sign In</a>
<a className="signin-button" href="#/login">{t('app.signIn')}</a>
</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 unreadLabel = hasUnread
? `${unreadCount} unread notification${unreadCount === 1 ? '' : 's'}`
: 'Notifications';
? t('app.unreadNotifications', { count: unreadCount })
: t('app.notifications');
return (
<div className="user-menu">
@@ -42,24 +45,24 @@ export default function UserMenu({ user, loading, unreadCount, onLogout }: UserM
) : null}
</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">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="user-dropdown" role="menu">
<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}
</a>
<a href="#/profile" className="dropdown-item" role="menuitem">Profile</a>
<a href="#/settings" className="dropdown-item" role="menuitem">Settings</a>
<a href="#/profile" className="dropdown-item" role="menuitem">{t('app.profile')}</a>
<a href="#/settings" className="dropdown-item" role="menuitem">{t('app.settings')}</a>
{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" />
<button type="button" className="dropdown-item dropdown-item--danger" onClick={onLogout}>
Logout
{t('app.logout')}
</button>
</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 App from './App';
import './styles.css';
import './i18n';
createRoot(document.getElementById('root')!).render(
<StrictMode>
+46 -36
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import LinkForm from '../components/LinkForm';
import LinkTable from '../components/LinkTable';
import {
@@ -22,11 +23,11 @@ type AdminTab = 'public-links' | 'submissions';
const STATUS_FILTERS: readonly AdminSubmissionFilter[] = ['pending', 'needs_changes', 'approved', 'rejected'];
const STATUS_LABELS: Record<SubmissionStatus, string> = {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
needs_changes: 'Needs changes',
const STATUS_I18N_KEYS: Record<SubmissionStatus, string> = {
pending: 'adminReview.statusPending',
approved: 'adminReview.statusApproved',
rejected: 'adminReview.statusRejected',
needs_changes: 'adminReview.statusNeedsChanges',
};
interface PendingAction {
@@ -35,14 +36,15 @@ interface PendingAction {
}
export default function AdminReviewPage() {
const { t } = useTranslation();
const [adminTab, setAdminTab] = useState<AdminTab>('public-links');
return (
<section className="panel">
<header className="panel-header">
<div>
<h1>Admin</h1>
<p className="muted">Manage public links and review promotion submissions.</p>
<h1>{t('adminReview.title')}</h1>
<p className="muted">{t('adminReview.subtitle')}</p>
</div>
<div className="panel-actions">
<button
@@ -50,14 +52,14 @@ export default function AdminReviewPage() {
className={`tab-button${adminTab === 'public-links' ? ' is-active' : ''}`}
onClick={() => setAdminTab('public-links')}
>
Public Links
{t('adminReview.publicLinks')}
</button>
<button
type="button"
className={`tab-button${adminTab === 'submissions' ? ' is-active' : ''}`}
onClick={() => setAdminTab('submissions')}
>
Submissions
{t('adminReview.submissions')}
</button>
</div>
</header>
@@ -68,6 +70,7 @@ export default function AdminReviewPage() {
}
function PublicLinksAdmin() {
const { t } = useTranslation();
const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -111,7 +114,7 @@ function PublicLinksAdmin() {
}
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);
try {
await deletePublicLink(link.id);
@@ -124,9 +127,9 @@ function PublicLinksAdmin() {
return (
<div>
<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); }}>
{showCreate ? 'Close form' : 'New public link'}
{showCreate ? t('adminReview.closeForm') : t('adminReview.newPublicLink')}
</button>
</div>
@@ -134,22 +137,28 @@ function PublicLinksAdmin() {
{showCreate ? (
<div className="form-card">
<h2>Create public link</h2>
<LinkForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} submitLabel="Create" />
<h2>{t('adminReview.createPublicLink')}</h2>
<LinkForm onSubmit={handleCreate} onCancel={() => setShowCreate(false)} submitLabel={t('adminReview.create')} />
</div>
) : null}
{editing ? (
<div className="form-card">
<h2>Edit <code>{editing.alias}</code></h2>
<LinkForm initial={editing} onSubmit={handleUpdate} onCancel={() => setEditing(null)} submitLabel="Save changes" />
<h2>
<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>
) : null}
<LinkTable
links={links}
loading={loading}
emptyMessage="No public links yet."
emptyMessage={t('adminReview.emptyPublicLinks')}
onEdit={(link) => { setShowCreate(false); setEditing(link); }}
onDelete={handleDelete}
/>
@@ -158,6 +167,7 @@ function PublicLinksAdmin() {
}
function SubmissionsAdmin() {
const { t } = useTranslation();
const [status, setStatus] = useState<AdminSubmissionFilter>('pending');
const [submissions, setSubmissions] = useState<PromotionSubmission[]>([]);
const [loading, setLoading] = useState(true);
@@ -220,27 +230,27 @@ function SubmissionsAdmin() {
className={`tab-button${status === filter ? ' is-active' : ''}`}
onClick={() => setStatus(filter)}
>
{STATUS_LABELS[filter]}
{t(STATUS_I18N_KEYS[filter])}
</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>
{error ? <p className="form-error" role="alert">{error}</p> : null}
{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 ? (
<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">
<thead>
<tr>
<th scope="col">Proposed alias</th>
<th scope="col">Private link</th>
<th scope="col">Submitted</th>
<th scope="col">Status</th>
<th scope="col" className="col-actions">Actions</th>
<th scope="col">{t('adminReview.colProposedAlias')}</th>
<th scope="col">{t('adminReview.colPrivateLink')}</th>
<th scope="col">{t('adminReview.colSubmitted')}</th>
<th scope="col">{t('adminReview.colStatus')}</th>
<th scope="col" className="col-actions">{t('adminReview.colActions')}</th>
</tr>
</thead>
<tbody>
@@ -250,7 +260,7 @@ function SubmissionsAdmin() {
<span className="alias-pill">#{submission.proposedAlias}</span>
{submission.note ? <small className="row-description">{submission.note}</small> : null}
{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}
</td>
<td>
@@ -265,34 +275,34 @@ function SubmissionsAdmin() {
<time dateTime={submission.createdAt}>{new Date(submission.createdAt).toLocaleString()}</time>
</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 className="col-actions">
{pending?.id === submission.id ? (
<div className="inline-review">
<input
type="text"
aria-label="Reason"
placeholder="Reason (optional)"
aria-label={t('adminReview.reasonLabel')}
placeholder={t('adminReview.reasonPlaceholder')}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
/>
<button type="button" className="link-action link-action--confirm" onClick={() => void confirmAction()} disabled={loading}>
Confirm
{t('adminReview.actionConfirm')}
</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>
) : submission.status === 'pending' || submission.status === 'needs_changes' ? (
<>
<button type="button" className="link-action link-action--approve" onClick={() => startAction(submission.id, 'approve')} disabled={loading}>
Approve
{t('adminReview.actionApprove')}
</button>
<button type="button" className="link-action link-action--danger" onClick={() => startAction(submission.id, 'reject')} disabled={loading}>
Reject
{t('adminReview.actionReject')}
</button>
<button type="button" className="link-action" onClick={() => startAction(submission.id, 'needs-changes')} disabled={loading}>
Needs changes
{t('adminReview.actionNeedsChanges')}
</button>
</>
) : (
@@ -306,4 +316,4 @@ function SubmissionsAdmin() {
)}
</div>
);
}
}
+31 -54
View File
@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
type LegalPageProps = {
kind: 'terms' | 'privacy';
};
@@ -5,13 +7,14 @@ type LegalPageProps = {
const updated = 'June 20, 2026';
export default function LegalPage({ kind }: LegalPageProps) {
const { t } = useTranslation();
const isTerms = kind === 'terms';
return (
<article className="panel legal-page">
<p className="legal-kicker">Heygo</p>
<h1>{isTerms ? 'Terms of Service' : 'Privacy Policy'}</h1>
<p className="muted">Last updated: {updated}</p>
<p className="legal-kicker">{t('legal.brand')}</p>
<h1>{isTerms ? t('legal.termsOfService') : t('legal.privacyPolicy')}</h1>
<p className="muted">{t('legal.lastUpdated', { date: updated })}</p>
{isTerms ? <TermsContent /> : <PrivacyContent />}
</article>
@@ -19,73 +22,47 @@ export default function LegalPage({ kind }: LegalPageProps) {
}
function TermsContent() {
const { t } = useTranslation();
return (
<>
<h2>1. Using Heygo</h2>
<p>
Heygo lets you create, manage, and share shortlinks. You are responsible for
the links, descriptions, and content you create or submit.
</p>
<h2>{t('legal.terms.section1.title')}</h2>
<p>{t('legal.terms.section1.body')}</p>
<h2>2. Accounts</h2>
<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>{t('legal.terms.section2.title')}</h2>
<p>{t('legal.terms.section2.body')}</p>
<h2>3. Acceptable use</h2>
<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>{t('legal.terms.section3.title')}</h2>
<p>{t('legal.terms.section3.body')}</p>
<h2>4. Service changes</h2>
<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>{t('legal.terms.section4.title')}</h2>
<p>{t('legal.terms.section4.body')}</p>
<h2>5. Contact</h2>
<p>
Questions about these terms can be sent to the service owner or administrator.
</p>
<h2>{t('legal.terms.section5.title')}</h2>
<p>{t('legal.terms.section5.body')}</p>
</>
);
}
function PrivacyContent() {
const { t } = useTranslation();
return (
<>
<h2>1. Information we collect</h2>
<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>{t('legal.privacy.section1.title')}</h2>
<p>{t('legal.privacy.section1.body')}</p>
<h2>2. How we use information</h2>
<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>{t('legal.privacy.section2.title')}</h2>
<p>{t('legal.privacy.section2.body')}</p>
<h2>3. Cookies and sessions</h2>
<p>
Heygo uses secure session cookies to keep you signed in and to authorize
private or admin actions.
</p>
<h2>{t('legal.privacy.section3.title')}</h2>
<p>{t('legal.privacy.section3.body')}</p>
<h2>4. Sharing</h2>
<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>{t('legal.privacy.section4.title')}</h2>
<p>{t('legal.privacy.section4.body')}</p>
<h2>5. Retention and deletion</h2>
<p>
We keep account, session, and link records as needed to provide the service.
Contact the service owner or administrator to request deletion.
</p>
<h2>{t('legal.privacy.section5.title')}</h2>
<p>{t('legal.privacy.section5.body')}</p>
</>
);
}
}
+87 -81
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Trans, useTranslation } from 'react-i18next';
import { Chart, registerables } from 'chart.js';
import { CheckIcon, CopyIcon, ExternalLinkIcon, GlobeIcon, LockIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from 'lucide-react';
import CopyPublicLinkDialog from '../components/CopyPublicLinkDialog';
@@ -46,20 +47,20 @@ type CopySuccessNotice = {
};
const CHANGE_TYPE_LABELS: Record<string, string> = {
created: 'Link created',
cloned_from_public: 'Cloned from public link',
url_changed: 'URL changed',
alias_changed: 'Alias changed',
description_changed: 'Description changed',
content_changed: 'Content changed',
type_changed: 'Type changed',
created: 'linkDetail.changeTypeCreated',
cloned_from_public: 'linkDetail.changeTypeCloned',
url_changed: 'linkDetail.changeTypeUrlChanged',
alias_changed: 'linkDetail.changeTypeAliasChanged',
description_changed: 'linkDetail.changeTypeDescChanged',
content_changed: 'linkDetail.changeTypeContentChanged',
type_changed: 'linkDetail.changeTypeTypeChanged',
};
const PERIODS: { id: StatsPeriod; label: string }[] = [
{ id: '3m', label: '3 Months' },
{ id: '6m', label: '6 Months' },
{ id: '1y', label: '1 Year' },
{ id: 'all', label: 'All Time' },
const PERIODS: { id: StatsPeriod; labelKey: string }[] = [
{ id: '3m', labelKey: 'linkDetail.period3m' },
{ id: '6m', labelKey: 'linkDetail.period6m' },
{ id: '1y', labelKey: 'linkDetail.period1y' },
{ id: 'all', labelKey: 'linkDetail.periodAll' },
];
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) {
const { t } = useTranslation();
const [link, setLink] = useState<Link | null>(null);
const [stats, setStats] = useState<DayCount[]>([]);
const [history, setHistory] = useState<ChangeLogEntry[]>([]);
@@ -125,7 +127,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
setLink(data.link);
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to load link details');
setError(err instanceof Error ? err.message : t('linkDetail.loadFailed'));
})
.finally(() => {
setLoadingLink(false);
@@ -275,7 +277,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
return date.toLocaleDateString('en-AU', { month: 'short', day: 'numeric' });
}),
datasets: [{
label: 'Clicks',
label: t('linkDetail.clicksChartLabel'),
data: stats.map((stat) => stat.count),
backgroundColor: 'rgba(99,102,241,0.08)',
borderColor: 'rgba(99,102,241,1)',
@@ -321,7 +323,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
chartInstance.current?.destroy();
chartInstance.current = null;
};
}, [stats]);
}, [stats, t]);
const templateParams = useMemo(
() => (link?.linkType === 'redirect' && link.targetUrl ? parseTemplateParameters(link.targetUrl) : []),
@@ -336,7 +338,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
if (loadingLink) {
return (
<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>
);
}
@@ -345,7 +347,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
return (
<section className="panel" style={{ maxWidth: 960 }}>
<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>
);
}
@@ -398,7 +400,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
if (err instanceof ApiClientError && err.status === 409) {
setShowCopyDialog(true);
} else {
setCopyError(err instanceof Error ? err.message : 'Failed to copy link');
setCopyError(err instanceof Error ? err.message : t('linkDetail.failedCopy'));
}
} finally {
setCopying(false);
@@ -412,20 +414,20 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
try {
await navigator.clipboard.writeText(buildShortLinkUrl(link, linkBaseUrls));
setShortLinkNotice('Short link copied');
} catch {
setShortLinkNotice('Could not copy link');
}
setShortLinkNotice(t('linkDetail.shortLinkCopied'));
} catch {
setShortLinkNotice(t('linkDetail.couldNotCopy'));
}
}
async function handlePropose(input: { privateLinkId: string; proposedAlias: string; note?: string }) {
try {
await promoteLink(input);
setShowPropose(false);
setProposeNotice('Submitted for review — admins will be notified.');
} catch (err) {
throw new Error(err instanceof ApiClientError ? err.message : 'Failed to submit promotion');
}
setProposeNotice(t('linkDetail.proposedSubmitted'));
} catch (err) {
throw new Error(err instanceof ApiClientError ? err.message : t('linkDetail.failedPromote'));
}
}
function startEdit() {
@@ -466,7 +468,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
setEditing(false);
setEditInput(null);
} catch (err) {
setEditError(err instanceof ApiClientError ? err.message : 'Failed to save link');
setEditError(err instanceof ApiClientError ? err.message : t('linkDetail.failedSave'));
} finally {
setSaving(false);
}
@@ -474,7 +476,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
async function handleDeleteLink() {
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);
try {
if (linkScope === 'private') {
@@ -484,7 +486,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
}
onBack();
} catch (err) {
setEditError(err instanceof ApiClientError ? err.message : 'Failed to delete link');
setEditError(err instanceof ApiClientError ? err.message : t('linkDetail.failedDelete'));
} finally {
setDeleting(false);
}
@@ -500,7 +502,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
) : null}
<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}
</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 className="detail-panel__title-group">
<h1 className="detail-panel__title">
Link Details
{t('linkDetail.linkDetails')}
</h1>
<DetailSectionStatusIcon scope={link.scope} />
</div>
@@ -521,15 +523,15 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
onClick={() => window.open(buildGoLinkUrl(link.id), '_blank', 'noopener,noreferrer')}
>
<ExternalLinkIcon aria-hidden="true" size={14} />
<span>Go</span>
<span>{t('linkDetail.go')}</span>
</button>
<button type="button" className="link-action detail-copy-button" onClick={() => void handleCopyShortLink()}>
<CopyIcon aria-hidden="true" size={14} />
<span>Copy link</span>
<span>{t('linkDetail.copyLink')}</span>
</button>
{canCopyToPrivate ? (
<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>
) : null}
{canPropose ? (
@@ -539,7 +541,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
onClick={() => setShowPropose(true)}
>
<MegaphoneIcon aria-hidden="true" size={14} />
<span>Propose</span>
<span>{t('linkDetail.propose')}</span>
</button>
) : null}
{canEdit ? (
@@ -550,7 +552,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
disabled={editing || saving}
>
<PencilIcon aria-hidden="true" size={14} />
<span>Edit</span>
<span>{t('linkDetail.edit')}</span>
</button>
) : null}
{canDelete ? (
@@ -561,7 +563,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
disabled={deleting || saving}
>
<Trash2Icon aria-hidden="true" size={14} />
<span>{deleting ? 'Deleting' : 'Delete'}</span>
<span>{deleting ? t('linkDetail.deleting') : t('linkDetail.delete')}</span>
</button>
) : null}
</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}
{editing && editInput ? (
<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">
<label>Alias</label>
<label>{t('linkDetail.fieldAlias')}</label>
<div className="alias-input">
<span className="alias-input__prefix" aria-hidden="true">/</span>
<input value={link.alias} disabled readOnly />
</div>
<small className="hint">Alias cant be changed after creation.</small>
<small className="hint">{t('linkDetail.aliasReadonly')}</small>
</div>
<div className="field">
<label>Type</label>
<input value={link.linkType === 'custom' ? 'Custom markdown page' : 'Redirect URL'} disabled readOnly />
<label>{t('linkDetail.fieldType')}</label>
<input value={link.linkType === 'custom' ? t('linkDetail.customMarkdownPage') : t('linkDetail.redirectUrl')} disabled readOnly />
</div>
{editInput.linkType === 'redirect' ? (
<div className="field">
<label htmlFor="edit-target">Target URL</label>
<label htmlFor="edit-target">{t('linkDetail.fieldTargetUrl')}</label>
<input
id="edit-target"
type="url"
value={editInput.targetUrl ?? ''}
onChange={(e) => updateEdit('targetUrl', e.target.value)}
required
placeholder="https://example.com/search?q={query}"
placeholder={t('linkDetail.editTargetPlaceholder')}
disabled={saving}
/>
<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>
</div>
) : (
<div className="field">
<label htmlFor="edit-content">Markdown content</label>
<label htmlFor="edit-content">{t('linkDetail.fieldContent')}</label>
<MarkdownEditor
value={editInput.contentMarkdown ?? ''}
onChange={(v) => updateEdit('contentMarkdown', v)}
placeholder="# Hello world"
placeholder={t('linkDetail.editContentPlaceholder')}
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 className="field">
<label htmlFor="edit-description">Description</label>
<label htmlFor="edit-description">{t('linkDetail.fieldDescription')}</label>
<input
id="edit-description"
value={editInput.description ?? ''}
onChange={(e) => updateEdit('description', e.target.value)}
maxLength={500}
placeholder="What this link is for"
placeholder={t('linkDetail.editDescriptionPlaceholder')}
disabled={saving}
/>
</div>
@@ -643,25 +649,25 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
<div className="form-actions">
<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 type="button" onClick={() => { setEditInHash(false); setEditing(false); setEditInput(null); setEditError(null); }} disabled={saving}>
Cancel
{t('linkDetail.cancel')}
</button>
</div>
</div>
) : (
<dl style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem 1.5rem' }}>
<MetaItem label="Alias"><span className="alias-pill">/{link.alias}</span></MetaItem>
<MetaItem label="Type">{link.linkType === 'custom' ? 'Custom page' : 'Redirect'}</MetaItem>
<MetaItem label="Total clicks">
<MetaItem label={t('linkDetail.fieldAlias')}><span className="alias-pill">/{link.alias}</span></MetaItem>
<MetaItem label={t('linkDetail.fieldType')}>{link.linkType === 'custom' ? t('linkDetail.customPage') : t('linkDetail.redirect')}</MetaItem>
<MetaItem label={t('linkDetail.totalClicks')}>
<strong style={{ color: 'var(--accent)', fontSize: '1.1rem' }}>{link.clickCount.toLocaleString()}</strong>
</MetaItem>
<MetaItem label="Created">{new Date(link.createdAt).toLocaleString()}</MetaItem>
<MetaItem label="Updated">{new Date(link.updatedAt).toLocaleString()}</MetaItem>
{link.description ? <MetaItem label="Description" wide>{link.description}</MetaItem> : null}
<MetaItem label={t('linkDetail.created')}>{new Date(link.createdAt).toLocaleString()}</MetaItem>
<MetaItem label={t('linkDetail.updated')}>{new Date(link.updatedAt).toLocaleString()}</MetaItem>
{link.description ? <MetaItem label={t('linkDetail.description')} wide>{link.description}</MetaItem> : null}
{link.linkType === 'redirect' && link.targetUrl ? (
<MetaItem label="Target URL" wide>
<MetaItem label={t('linkDetail.targetUrl')} wide>
{safeUrl ? (
<a
href={safeUrl}
@@ -685,7 +691,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
{!editing && link?.linkType === 'custom' && link.contentMarkdown ? (
<section className="panel" style={{ padding: '1.5rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem', letterSpacing: '-0.01em' }}>
Content
{t('linkDetail.content')}
</h2>
<MarkdownContent markdown={link.contentMarkdown} />
</section>
@@ -694,10 +700,10 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
{!editing && isTemplated && link?.targetUrl ? (
<section className="panel template-preview" style={{ padding: '1.5rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 0.5rem', letterSpacing: '-0.01em' }}>
Template parameters
{t('linkDetail.templateParams')}
</h2>
<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>
{templateParams.length > 0 ? (
<div className="template-params-grid">
@@ -715,7 +721,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
</div>
) : null}
<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>
<a
className="link-action detail-copy-button"
@@ -724,7 +730,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
rel="noreferrer noopener"
>
<ExternalLinkIcon aria-hidden="true" size={14} />
<span>Open</span>
<span>{t('linkDetail.open')}</span>
</a>
</div>
</section>
@@ -732,10 +738,10 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
<div className="detail-floating-actions">
<button type="button" className={`link-action${showStats ? ' link-action--confirm' : ''}`} onClick={() => setShowStats(v => !v)}>
📊 Stats
📊 {t('linkDetail.stats')}
</button>
<button type="button" className={`link-action${showHistory ? ' link-action--confirm' : ''}`} onClick={() => setShowHistory(v => !v)}>
📋 History
📋 {t('linkDetail.history')}
</button>
</div>
@@ -743,7 +749,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
<section className="panel" style={{ padding: '1.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' }}>
Click Statistics
{t('linkDetail.clickStatistics')}
</h2>
<div style={{ display: 'flex', gap: '0.35rem' }}>
{PERIODS.map((entry) => (
@@ -753,9 +759,9 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
className={`tab-button${period === entry.id ? ' is-active' : ''}`}
onClick={() => setPeriod(entry.id)}
disabled={loadingStats}
>
{entry.label}
</button>
>
{t(entry.labelKey)}
</button>
))}
</div>
</div>
@@ -765,16 +771,16 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
className="table-status"
aria-busy="true"
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', padding: 0 }}
>
Loading
</p>
) : stats.length === 0 ? (
<p
>
{t('linkDetail.loading')}
</p>
) : stats.length === 0 ? (
<p
className="table-empty"
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', padding: 0 }}
>
No click data for this period.
</p>
>
{t('linkDetail.noClickData')}
</p>
) : null}
<canvas ref={chartRef} style={{ display: stats.length === 0 || loadingStats ? 'none' : 'block' }} />
</div>
@@ -784,12 +790,12 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas
{showHistory ? (
<section className="panel" style={{ padding: '1.5rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 1rem', letterSpacing: '-0.01em' }}>
Change History
{t('linkDetail.changeHistory')}
</h2>
{loadingHistory ? (
<p className="table-status" aria-busy="true">Loading</p>
<p className="table-status" aria-busy="true">{t('linkDetail.loading')}</p>
) : 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' }}>
{history.map((entry) => (
+10 -4
View File
@@ -1,15 +1,21 @@
import { Trans, useTranslation } from 'react-i18next';
import ProviderButtons from '../components/ProviderButtons';
export default function LoginPage() {
const { t } = useTranslation();
return (
<section className="auth-page" aria-labelledby="login-title">
<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 />
<p className="auth-legal">
By logging in, you agree to our{' '}
<a href="#/terms">Terms of Service</a> and{' '}
<a href="#/privacy">Privacy Policy</a>.
<Trans
i18nKey="login.legal"
components={{
1: <a href="#/terms" />,
2: <a href="#/privacy" />,
}}
/>
</p>
</section>
);
+47 -31
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CheckIcon, Trash2Icon } from 'lucide-react';
import { useTranslation, Trans } from 'react-i18next';
import {
ApiClientError,
type Notification,
@@ -8,10 +9,10 @@ import {
listNotifications,
} from '../lib/api';
const KIND_LABELS: Record<string, string> = {
promotion_approved: 'Approved',
promotion_rejected: 'Rejected',
promotion_needs_changes: 'Needs changes',
const KIND_LABEL_KEYS: Record<string, string> = {
promotion_approved: 'notifications.kindApproved',
promotion_rejected: 'notifications.kindRejected',
promotion_needs_changes: 'notifications.kindNeedsChanges',
};
interface NotificationsPageProps {
@@ -19,6 +20,7 @@ interface NotificationsPageProps {
}
export default function NotificationsPage({ onMarkedViewed }: NotificationsPageProps) {
const { t } = useTranslation();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -35,11 +37,11 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
const result = await listNotifications();
setNotifications(result.notifications ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load notifications');
setError(err instanceof Error ? err.message : t('notifications.loadFailed'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
void refresh();
@@ -78,7 +80,7 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
}
async function handleDeleteOne(notification: Notification) {
if (!window.confirm(`Delete "${notification.title}"?`)) {
if (!window.confirm(t('notifications.deleteConfirm', { title: notification.title }))) {
return;
}
setError(null);
@@ -90,9 +92,9 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
next.delete(notification.id);
return next;
});
setNotice('Notification deleted');
setNotice(t('notifications.notificationDeleted'));
} 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) {
return;
}
if (!window.confirm(`Delete ${selected.size} notification${selected.size === 1 ? '' : 's'}?`)) {
if (!window.confirm(t('notifications.deleteSelectedConfirm', { count: selected.size }))) {
return;
}
setError(null);
@@ -110,9 +112,9 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
await deleteNotifications(ids);
setNotifications((prev) => prev.filter((n) => !selected.has(n.id)));
setSelected(new Set());
setNotice(`${ids.length} notification${ids.length === 1 ? '' : 's'} deleted`);
setNotice(t('notifications.notificationsDeleted', { count: ids.length }));
} catch (err) {
setError(err instanceof ApiClientError ? err.message : 'Failed to delete notifications');
setError(err instanceof ApiClientError ? err.message : t('notifications.deleteManyFailed'));
} finally {
setDeleting(false);
}
@@ -125,11 +127,11 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
<section className="panel">
<header className="panel-header">
<div>
<h1>Notifications</h1>
<p className="muted">Updates about your promotion proposals.</p>
<h1>{t('notifications.title')}</h1>
<p className="muted">{t('notifications.subtitle')}</p>
</div>
<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>
</header>
@@ -137,16 +139,16 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
{selected.size > 0 ? (
<div className="selection-bar" role="status">
<span>{selected.size} selected.</span>
<span>{t('notifications.selected', { count: selected.size })}</span>
<button
type="button"
className="link-action link-action--danger"
onClick={() => void handleDeleteSelected()}
disabled={deleting}
>
{deleting ? 'Deleting' : 'Delete selected'}
{deleting ? t('notifications.deleting') : t('notifications.deleteSelected')}
</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>
) : null}
@@ -158,20 +160,20 @@ export default function NotificationsPage({ onMarkedViewed }: NotificationsPageP
) : null}
{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 ? (
<p className="table-empty">No notifications yet.</p>
<p className="table-empty">{t('notifications.empty')}</p>
) : (
<div className="notifications-list">
<div className="notifications-list__select-all">
<label>
<input
type="checkbox"
aria-label={allSelected ? 'Deselect all notifications' : 'Select all notifications'}
aria-label={allSelected ? t('notifications.deselectAll') : t('notifications.selectAll')}
checked={allSelected}
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>
</div>
<ul>
@@ -202,7 +204,9 @@ function NotificationItem({
onToggleSelect: () => 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}`;
return (
@@ -210,14 +214,14 @@ function NotificationItem({
<div className="notification-item__select">
<input
type="checkbox"
aria-label={`Select ${notification.title}`}
aria-label={t('notifications.selectItem', { title: notification.title })}
checked={selected}
onChange={onToggleSelect}
/>
</div>
<div className="notification-item__body">
<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>
<h3 className="notification-item__title">{notification.title}</h3>
</div>
@@ -225,13 +229,25 @@ function NotificationItem({
<div className="notification-item__details">
<time dateTime={notification.createdAt}>{new Date(notification.createdAt).toLocaleString()}</time>
{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}
{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}
{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}
</div>
</div>
@@ -240,12 +256,12 @@ function NotificationItem({
type="button"
className="link-action link-action--danger"
onClick={onDelete}
aria-label={`Delete ${notification.title}`}
aria-label={t('notifications.deleteItem', { title: notification.title })}
>
<Trash2Icon aria-hidden="true" size={14} />
<span>Delete</span>
<span>{t('notifications.delete')}</span>
</button>
</div>
</li>
);
}
}
+26 -16
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { InboxIcon, PlusIcon } from 'lucide-react';
import { useTranslation, Trans } from 'react-i18next';
import LinkForm from '../components/LinkForm';
import LinkSearchBar from '../components/LinkSearchBar';
import LinkTable from '../components/LinkTable';
@@ -19,6 +20,7 @@ import {
type MyLinksView = 'links' | 'submissions';
export default function PrivateLinksPage() {
const { t } = useTranslation();
const [view, setView] = useState<MyLinksView>('links');
const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true);
@@ -93,7 +95,7 @@ export default function PrivateLinksPage() {
}
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;
}
try {
@@ -125,11 +127,11 @@ export default function PrivateLinksPage() {
<section className="panel">
<header className="panel-header">
<div>
<h1>{view === 'links' ? 'My Links' : 'My Submissions'}</h1>
<h1>{view === 'links' ? t('privateLinks.title') : t('privateLinks.submissionsTitle')}</h1>
<p className="muted">
{view === 'links'
? 'Your personal shortlinks. Only visible to you.'
: 'Links you have submitted for public promotion.'}
? t('privateLinks.subtitle')
: t('privateLinks.submissionsSubtitle')}
</p>
</div>
<div className="panel-actions">
@@ -141,7 +143,7 @@ export default function PrivateLinksPage() {
onClick={() => { setEditing(null); setShowCreate((v) => !v); }}
>
<PlusIcon aria-hidden="true" size={16} />
<span>{showCreate ? 'Close form' : 'New link'}</span>
<span>{showCreate ? t('privateLinks.closeForm') : t('privateLinks.newLink')}</span>
</button>
<button
type="button"
@@ -149,7 +151,7 @@ export default function PrivateLinksPage() {
onClick={() => setView('submissions')}
>
<InboxIcon aria-hidden="true" size={16} />
<span>My Submissions</span>
<span>{t('privateLinks.mySubmissions')}</span>
</button>
</>
) : (
@@ -158,7 +160,7 @@ export default function PrivateLinksPage() {
className="link-action"
onClick={() => setView('links')}
>
Back to My Links
{t('privateLinks.backToMyLinks')}
</button>
)}
</div>
@@ -168,15 +170,17 @@ export default function PrivateLinksPage() {
<>
{selected.size > 0 ? (
<div className="selection-bar" role="status">
<span>{selected.size} selected.</span>
<span>{t('privateLinks.selected', { count: selected.size })}</span>
<button
type="button"
className="link-action link-action--approve"
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 type="button" className="link-action" onClick={() => setSelected(new Set())}>Clear</button>
</div>
) : null}
@@ -192,34 +196,40 @@ export default function PrivateLinksPage() {
{showCreate ? (
<div className="form-card">
<h2>Create link</h2>
<h2>{t('privateLinks.createLink')}</h2>
<LinkForm
onSubmit={handleCreate}
onCancel={() => setShowCreate(false)}
submitLabel="Create"
submitLabel={t('privateLinks.create')}
/>
</div>
) : null}
{editing ? (
<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
initial={editing}
onSubmit={handleUpdate}
onCancel={() => setEditing(null)}
submitLabel="Save changes"
submitLabel={t('privateLinks.saveChanges')}
/>
</div>
) : null}
<LinkSearchBar scope="private" placeholder="Search my links by alias…" />
<LinkSearchBar scope="private" placeholder={t('privateLinks.searchPlaceholder')} />
<LinkTable
links={links}
loading={loading}
error={error}
emptyMessage="You have no private links yet. Create one to get started."
emptyMessage={t('privateLinks.empty')}
selectable
selectedIds={selected}
onToggleSelect={toggleSelect}
+6 -4
View File
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import LinkSearchBar from '../components/LinkSearchBar';
import LinkTable from '../components/LinkTable';
import { type Link, listPublicLinks } from '../lib/api';
export default function PublicLinksPage() {
const { t } = useTranslation();
const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -29,18 +31,18 @@ export default function PublicLinksPage() {
<section className="panel">
<header className="panel-header">
<div>
<h1>Public Links</h1>
<p className="muted">Short links that go!</p>
<h1>{t('publicLinks.title')}</h1>
<p className="muted">{t('publicLinks.subtitle')}</p>
</div>
</header>
<LinkSearchBar scope="public" placeholder="Search public links by alias…" />
<LinkSearchBar scope="public" placeholder={t('publicLinks.searchPlaceholder')} />
<LinkTable
links={links}
loading={loading}
error={error}
emptyMessage="No public links yet."
emptyMessage={t('publicLinks.empty')}
actions={false}
/>
</section>