Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
import { useI18n } from '../i18n'
import { getSiteName } from '../lib/site'

export function Footer() {
const { t } = useI18n()
const siteName = getSiteName()
return (
<footer className="site-footer">
<div className="site-footer-inner">
<div className="site-footer-divider" aria-hidden="true" />
<div className="site-footer-row">
<div className="site-footer-copy">
{siteName} · An{' '}
{siteName} · {t('footer.an')}{t('footer.an') ? ' ' : ''}
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

t() called twice; empty-string contract is implicit and fragile

t('footer.an') is evaluated twice in this expression: once to render the word, and again to conditionally add a trailing space. This works because footer.an is intentionally set to "" (empty string) in zh-CN, but that contract is not enforced anywhere:

  • t() only falls back to English when the resolved value is undefined — an empty string "" is returned as-is (see translate.ts).
  • The locale-parity test only validates that en values are non-empty; it does not enforce that zh-CN values are non-empty, so an accidental "" in a key that should have a translation would silently render nothing.

Consider using a single variable or restructuring the key to avoid the double call and make the conditional explicit:

Suggested change
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
{siteName} · {t('footer.an') ? `${t('footer.an')} ` : ''}

Or better, collapse this into a single key footer.anSpace with value "An " (en) / "" (zh-CN), removing the two-step pattern entirely.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/Footer.tsx
Line: 14

Comment:
**`t()` called twice; empty-string contract is implicit and fragile**

`t('footer.an')` is evaluated twice in this expression: once to render the word, and again to conditionally add a trailing space. This works because `footer.an` is intentionally set to `""` (empty string) in `zh-CN`, but that contract is not enforced anywhere:

- `t()` only falls back to English when the resolved value is `undefined` — an empty string `""` is returned as-is (see `translate.ts`).
- The locale-parity test only validates that **en** values are non-empty; it does **not** enforce that `zh-CN` values are non-empty, so an accidental `""` in a key that *should* have a translation would silently render nothing.

Consider using a single variable or restructuring the key to avoid the double call and make the conditional explicit:

```suggestion
            {siteName} · {t('footer.an') ? `${t('footer.an')} ` : ''}
```

Or better, collapse this into a single key `footer.anSpace` with value `"An "` (en) / `""` (zh-CN), removing the two-step pattern entirely.

How can I resolve this? If you propose a fix, please make it concise.

OpenClaw
{t('footer.openClaw')}
</a>{' '}
project · Deployed on{' '}
{t('footer.project')} · {t('footer.deployedOn')}{' '}
<a href="https://vercel.com" target="_blank" rel="noreferrer">
Vercel
</a>{' '}
· Powered by{' '}
· {t('footer.poweredBy')}{' '}
<a href="https://www.convex.dev" target="_blank" rel="noreferrer">
Convex
</a>{' '}
·{' '}
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
Open source (MIT)
{t('footer.openSourceMIT')}
</a>{' '}
·{' '}
<a href="https://steipete.me" target="_blank" rel="noreferrer">
Expand Down
95 changes: 64 additions & 31 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { Link } from '@tanstack/react-router'
import { Menu, Monitor, Moon, Sun } from 'lucide-react'
import { Globe, Menu, Monitor, Moon, Sun } from 'lucide-react'
import { useMemo, useRef } from 'react'
import { useI18n } from '../i18n'
import type { Locale } from '../i18n'
import { gravatarUrl } from '../lib/gravatar'
import { isModerator } from '../lib/roles'
import { getClawHubSiteUrl, getSiteMode, getSiteName } from '../lib/site'
Expand All @@ -17,10 +19,16 @@ import {
} from './ui/dropdown-menu'
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group'

const localeLabels: Record<Locale, string> = {
en: 'EN',
'zh-CN': '中文',
}

export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus()
const { signIn, signOut } = useAuthActions()
const { mode, setMode } = useThemeMode()
const { t, locale, setLocale } = useI18n()
const toggleRef = useRef<HTMLDivElement | null>(null)
const siteMode = getSiteMode()
const siteName = useMemo(() => getSiteName(siteMode), [siteMode])
Expand Down Expand Up @@ -72,7 +80,7 @@ export default function Header() {
focus: undefined,
}}
>
Souls
{t('header.souls')}
</Link>
) : (
<Link
Expand All @@ -87,13 +95,13 @@ export default function Header() {
focus: undefined,
}}
>
Skills
{t('header.skills')}
</Link>
)}
<Link to="/upload" search={{ updateSlug: undefined }}>
Upload
{t('header.upload')}
</Link>
{isSoulMode ? null : <Link to="/import">Import</Link>}
{isSoulMode ? null : <Link to="/import">{t('header.import')}</Link>}
<Link
to={isSoulMode ? '/souls' : '/skills'}
search={
Expand All @@ -116,20 +124,20 @@ export default function Header() {
}
}
>
Search
{t('header.search')}
</Link>
{me ? <Link to="/stars">Stars</Link> : null}
{me ? <Link to="/stars">{t('header.stars')}</Link> : null}
{isStaff ? (
<Link to="/management" search={{ skill: undefined }}>
Management
{t('header.management')}
</Link>
) : null}
</nav>
<div className="nav-actions">
<div className="nav-mobile">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="nav-mobile-trigger" type="button" aria-label="Open menu">
<button className="nav-mobile-trigger" type="button" aria-label={t('header.openMenu')}>
<Menu className="h-4 w-4" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
Expand All @@ -151,7 +159,7 @@ export default function Header() {
focus: undefined,
}}
>
Souls
{t('header.souls')}
</Link>
) : (
<Link
Expand All @@ -166,18 +174,18 @@ export default function Header() {
focus: undefined,
}}
>
Skills
{t('header.skills')}
</Link>
)}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/upload" search={{ updateSlug: undefined }}>
Upload
{t('header.upload')}
</Link>
</DropdownMenuItem>
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link to="/import">Import</Link>
<Link to="/import">{t('header.import')}</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
Expand All @@ -203,33 +211,42 @@ export default function Header() {
}
}
>
Search
{t('header.search')}
</Link>
</DropdownMenuItem>
{me ? (
<DropdownMenuItem asChild>
<Link to="/stars">Stars</Link>
<Link to="/stars">{t('header.stars')}</Link>
</DropdownMenuItem>
) : null}
{isStaff ? (
<DropdownMenuItem asChild>
<Link to="/management" search={{ skill: undefined }}>
Management
{t('header.management')}
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setTheme('system')}>
<Monitor className="h-4 w-4" aria-hidden="true" />
System
{t('header.system')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="h-4 w-4" aria-hidden="true" />
Light
{t('header.light')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="h-4 w-4" aria-hidden="true" />
Dark
{t('header.dark')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLocale('en')}>
<Globe className="h-4 w-4" aria-hidden="true" />
EN
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLocale('zh-CN')}>
<Globe className="h-4 w-4" aria-hidden="true" />
中文
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Expand All @@ -244,26 +261,42 @@ export default function Header() {
}}
aria-label="Theme mode"
>
<ToggleGroupItem value="system" aria-label="System theme">
<ToggleGroupItem value="system" aria-label={t('header.systemTheme')}>
<Monitor className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">System</span>
<span className="sr-only">{t('header.system')}</span>
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<ToggleGroupItem value="light" aria-label={t('header.lightTheme')}>
<Sun className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Light</span>
<span className="sr-only">{t('header.light')}</span>
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<ToggleGroupItem value="dark" aria-label={t('header.darkTheme')}>
<Moon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Dark</span>
<span className="sr-only">{t('header.dark')}</span>
</ToggleGroupItem>
</ToggleGroup>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="btn btn-ghost btn-sm" type="button" aria-label={t('header.language')}>
<Globe className="h-4 w-4" />
<span>{localeLabels[locale]}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setLocale('en')}>
EN
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLocale('zh-CN')}>
中文
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isAuthenticated && me ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="user-trigger" type="button">
{avatar ? (
<img src={avatar} alt={me.displayName ?? me.name ?? 'User avatar'} />
<img src={avatar} alt={me.displayName ?? me.name ?? t('header.userAvatar')} />
) : (
<span className="user-menu-fallback">{initial}</span>
)}
Expand All @@ -273,13 +306,13 @@ export default function Header() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
<Link to="/dashboard">{t('header.dashboard')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings">Settings</Link>
<Link to="/settings">{t('header.settings')}</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
<DropdownMenuItem onClick={() => void signOut()}>{t('header.signOut')}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
Expand All @@ -294,8 +327,8 @@ export default function Header() {
)
}
>
<span className="sign-in-label">Sign in</span>
<span className="sign-in-provider">with GitHub</span>
<span className="sign-in-label">{t('header.signIn')}</span>
<span className="sign-in-provider">{t('header.withGitHub')}</span>
</button>
)}
</div>
Expand Down
6 changes: 4 additions & 2 deletions src/components/InstallSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
import { useI18n } from '../i18n/useI18n'

type PackageManager = 'npm' | 'pnpm' | 'bun'

Expand All @@ -13,6 +14,7 @@ const PACKAGE_MANAGERS: Array<{ id: PackageManager; label: string }> = [
]

export function InstallSwitcher({ exampleSlug = 'sonoscli' }: InstallSwitcherProps) {
const { t } = useI18n()
const [pm, setPm] = useState<PackageManager>('npm')

const command = useMemo(() => {
Expand All @@ -29,8 +31,8 @@ export function InstallSwitcher({ exampleSlug = 'sonoscli' }: InstallSwitcherPro
return (
<div className="install-switcher">
<div className="install-switcher-row">
<div className="stat">Install any skill folder in one shot:</div>
<div className="install-switcher-toggle" role="tablist" aria-label="Install command">
<div className="stat">{t('installSwitcher.instruction')}</div>
<div className="install-switcher-toggle" role="tablist" aria-label={t('installSwitcher.ariaLabel')}>
{PACKAGE_MANAGERS.map((entry) => (
<button
key={entry.id}
Expand Down
Loading