feat(app): i18n (#48)

This commit is contained in:
paisley
2026-02-11 15:34:53 +08:00
committed by GitHub
Unverified
parent 505a64438e
commit 6e09a69f4f
40 changed files with 3227 additions and 808 deletions

View File

@@ -40,6 +40,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import type { Skill, MarketplaceSkill } from '@/types/skill';
import { useTranslation } from 'react-i18next';
@@ -52,6 +53,7 @@ interface SkillDetailDialogProps {
}
function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps) {
const { t } = useTranslation('skills');
const { fetchSkills } = useSkillsStore();
const [activeTab, setActiveTab] = useState('info');
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>([]);
@@ -91,12 +93,12 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
try {
const result = await window.electron.ipcRenderer.invoke('clawhub:openSkillReadme', skill.slug) as { success: boolean; error?: string };
if (result.success) {
toast.success('Opened in editor');
toast.success(t('toast.openedEditor'));
} else {
toast.error(result.error || 'Failed to open editor');
toast.error(result.error || t('toast.failedEditor'));
}
} catch (err) {
toast.error('Failed to open editor: ' + String(err));
toast.error(t('toast.failedEditor') + ': ' + String(err));
}
}
};
@@ -148,9 +150,9 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
// Refresh skills from gateway to get updated config
await fetchSkills();
toast.success('Configuration saved');
toast.success(t('detail.configSaved'));
} catch (err) {
toast.error('Failed to save configuration: ' + String(err));
toast.error(t('toast.failedSave') + ': ' + String(err));
} finally {
setIsSaving(false);
}
@@ -176,7 +178,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={handleOpenEditor}>
<FileCode className="h-3 w-3" />
Open Manual
{t('detail.openManual')}
</Button>
</>
)}
@@ -191,8 +193,8 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
<div className="px-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="info">Information</TabsTrigger>
<TabsTrigger value="config" disabled={skill.isCore}>Configuration</TabsTrigger>
<TabsTrigger value="info">{t('detail.info')}</TabsTrigger>
<TabsTrigger value="config" disabled={skill.isCore}>{t('detail.config')}</TabsTrigger>
</TabsList>
</div>
@@ -201,27 +203,27 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
<TabsContent value="info" className="mt-0 space-y-4">
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t('detail.description')}</h3>
<p className="text-sm mt-1">{skill.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Version</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t('detail.version')}</h3>
<p className="font-mono text-sm">{skill.version}</p>
</div>
{skill.author && (
<div>
<h3 className="text-sm font-medium text-muted-foreground">Author</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t('detail.author')}</h3>
<p className="text-sm">{skill.author}</p>
</div>
)}
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Source</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t('detail.source')}</h3>
<Badge variant="secondary" className="mt-1 font-normal">
{skill.isCore ? 'Core System' : skill.isBundled ? 'Bundled' : 'User Installed'}
{skill.isCore ? t('detail.coreSystem') : skill.isBundled ? t('detail.bundled') : t('detail.userInstalled')}
</Badge>
</div>
</div>
@@ -236,14 +238,14 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
API Key
</h3>
<Input
placeholder="Enter API Key (optional)"
placeholder={t('detail.apiKeyPlaceholder')}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
type="password"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
The primary API key for this skill. Leave blank if not required or configured elsewhere.
{t('detail.apiKeyDesc')}
</p>
</div>
@@ -276,7 +278,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
}}
>
<Plus className="h-3 w-3" />
Add Variable
{t('detail.addVariable')}
</Button>
</div>
@@ -284,7 +286,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
<div className="pt-4 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
{envVars.length === 0 && (
<p className="text-xs text-muted-foreground italic h-8 flex items-center">
No environment variables configured.
{t('detail.noEnvVars')}
</p>
)}
@@ -294,14 +296,14 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
value={env.key}
onChange={(e) => handleUpdateEnv(index, 'key', e.target.value)}
className="flex-1 font-mono text-xs bg-muted/20"
placeholder="KEY (e.g. BASE_URL)"
placeholder={t('detail.keyPlaceholder')}
/>
<span className="text-muted-foreground ml-1 mr-1">=</span>
<Input
value={env.value}
onChange={(e) => handleUpdateEnv(index, 'value', e.target.value)}
className="flex-1 font-mono text-xs bg-muted/20"
placeholder="VALUE"
placeholder={t('detail.valuePlaceholder')}
/>
<Button
variant="ghost"
@@ -316,7 +318,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
{envVars.length > 0 && (
<p className="text-[10px] text-muted-foreground italic px-1 pt-1">
Note: Rows with empty keys will be automatically removed during save.
{t('detail.envNote')}
</p>
)}
</div>
@@ -327,7 +329,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
<div className="pt-4 flex justify-end">
<Button onClick={handleSaveConfig} className="gap-2" disabled={isSaving}>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Save Configuration'}
{isSaving ? t('detail.saving') : t('detail.saveConfig')}
</Button>
</div>
</TabsContent>
@@ -339,12 +341,12 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
{skill.enabled ? (
<>
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span className="text-green-600 dark:text-green-400">Enabled</span>
<span className="text-green-600 dark:text-green-400">{t('detail.enabled')}</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-muted-foreground" />
<span className="text-muted-foreground">Disabled</span>
<span className="text-muted-foreground">{t('detail.disabled')}</span>
</>
)}
</div>
@@ -532,6 +534,7 @@ export function Skills() {
searchError,
installing
} = useSkillsStore();
const { t } = useTranslation('skills');
const gatewayStatus = useGatewayStore((state) => state.status);
const [searchQuery, setSearchQuery] = useState('');
const [marketplaceQuery, setMarketplaceQuery] = useState('');
@@ -602,15 +605,15 @@ export function Skills() {
try {
if (enable) {
await enableSkill(skillId);
toast.success('Skill enabled');
toast.success(t('toast.enabled'));
} else {
await disableSkill(skillId);
toast.success('Skill disabled');
toast.success(t('toast.disabled'));
}
} catch (err) {
toast.error(String(err));
}
}, [enableSkill, disableSkill]);
}, [enableSkill, disableSkill, t]);
const handleOpenSkillsFolder = useCallback(async () => {
try {
@@ -623,9 +626,9 @@ export function Skills() {
throw new Error(result);
}
} catch (err) {
toast.error('Failed to open skills folder: ' + String(err));
toast.error(t('toast.failedOpenFolder') + ': ' + String(err));
}
}, []);
}, [t]);
// Handle marketplace search
const handleMarketplaceSearch = useCallback((e: React.FormEvent) => {
@@ -642,11 +645,11 @@ export function Skills() {
// Automatically enable after install
// We need to find the skill id which is usually the slug
await enableSkill(slug);
toast.success('Skill installed and enabled');
toast.success(t('toast.installed'));
} catch (err) {
toast.error(`Failed to install: ${String(err)}`);
toast.error(t('toast.failedInstall') + ': ' + String(err));
}
}, [installSkill, enableSkill]);
}, [installSkill, enableSkill, t]);
// Initial marketplace load (Discovery)
useEffect(() => {
@@ -670,11 +673,11 @@ export function Skills() {
const handleUninstall = useCallback(async (slug: string) => {
try {
await uninstallSkill(slug);
toast.success('Skill uninstalled successfully');
toast.success(t('toast.uninstalled'));
} catch (err) {
toast.error(`Failed to uninstall: ${String(err)}`);
toast.error(t('toast.failedUninstall') + ': ' + String(err));
}
}, [uninstallSkill]);
}, [uninstallSkill, t]);
if (loading) {
return (
@@ -689,19 +692,19 @@ export function Skills() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Skills</h1>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground">
Browse and manage AI capabilities
{t('subtitle')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchSkills} disabled={!isGatewayRunning}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
{t('refresh')}
</Button>
<Button variant="outline" onClick={handleOpenSkillsFolder}>
<FolderOpen className="h-4 w-4 mr-2" />
Open Skills Folder
{t('openFolder')}
</Button>
</div>
</div>
@@ -712,7 +715,7 @@ export function Skills() {
<CardContent className="py-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-600" />
<span className="text-yellow-700 dark:text-yellow-400">
Gateway is not running. Skills cannot be loaded without an active Gateway.
{t('gatewayWarning')}
</span>
</CardContent>
</Card>
@@ -723,11 +726,11 @@ export function Skills() {
<TabsList>
<TabsTrigger value="all" className="gap-2">
<Puzzle className="h-4 w-4" />
Installed
{t('tabs.installed')}
</TabsTrigger>
<TabsTrigger value="marketplace" className="gap-2">
<Globe className="h-4 w-4" />
Marketplace
{t('tabs.marketplace')}
</TabsTrigger>
{/* <TabsTrigger value="bundles" className="gap-2">
<Package className="h-4 w-4" />
@@ -741,7 +744,7 @@ export function Skills() {
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search skills..."
placeholder={t('search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -763,7 +766,7 @@ export function Skills() {
className="gap-2"
>
<Puzzle className="h-3 w-3" />
Built-in ({sourceStats.builtIn})
{t('filter.builtIn', { count: sourceStats.builtIn })}
</Button>
<Button
variant={selectedSource === 'marketplace' ? 'default' : 'outline'}
@@ -772,7 +775,7 @@ export function Skills() {
className="gap-2"
>
<Globe className="h-3 w-3" />
Marketplace ({sourceStats.marketplace})
{t('filter.marketplace', { count: sourceStats.marketplace })}
</Button>
</div>
</div>
@@ -792,9 +795,9 @@ export function Skills() {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Puzzle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No skills found</h3>
<h3 className="text-lg font-medium mb-2">{t('noSkills')}</h3>
<p className="text-muted-foreground">
{searchQuery ? 'Try a different search term' : 'No skills available'}
{searchQuery ? t('noSkillsSearch') : t('noSkillsAvailable')}
</p>
</CardContent>
</Card>
@@ -867,7 +870,7 @@ export function Skills() {
{skill.configurable && (
<Badge variant="secondary" className="text-xs">
<Settings className="h-3 w-3 mr-1" />
Configurable
{t('detail.configurable')}
</Badge>
)}
</div>
@@ -884,7 +887,7 @@ export function Skills() {
<CardContent className="py-4 flex items-start gap-3">
<ShieldCheck className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-muted-foreground">
Click skill card to view its documentation and security information on ClawHub before installation.
{t('marketplace.securityNote')}
</div>
</CardContent>
</Card>
@@ -893,7 +896,7 @@ export function Skills() {
<div className="relative flex-1">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search marketplace..."
placeholder={t('searchMarketplace')}
value={marketplaceQuery}
onChange={(e) => setMarketplaceQuery(e.target.value)}
className="pl-9"
@@ -933,7 +936,7 @@ export function Skills() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
Search
{t('searchButton')}
</motion.div>
)}
</AnimatePresence>
@@ -946,7 +949,7 @@ export function Skills() {
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="py-3 text-sm text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span>ClawHub search failed. Check your connection or installation.</span>
<span>{t('marketplace.searchError')}</span>
</CardContent>
</Card>
)}
@@ -971,13 +974,13 @@ export function Skills() {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Marketplace</h3>
<h3 className="text-lg font-medium mb-2">{t('marketplace.title')}</h3>
<p className="text-muted-foreground text-center max-w-sm">
{searching
? 'Searching ClawHub...'
? t('marketplace.searching')
: marketplaceQuery
? 'No skills found matching your search.'
: 'Search for new skills to expand your capabilities.'}
? t('marketplace.noResults')
: t('marketplace.emptyPrompt')}
</p>
</CardContent>
</Card>