mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
feat: add internationalization (i18n) to web dashboard — English + Chinese (#9453)
Add a lightweight i18n system to the web dashboard with English (default) and Chinese language support. A language switcher with flag icons is placed in the header bar, allowing users to toggle between languages. The choice persists to localStorage. Implementation: - src/i18n/ — types, translation files (en.ts, zh.ts), React context + hook - LanguageSwitcher component shows the *other* language's flag as the toggle - I18nProvider wraps the app in main.tsx - All 8 pages + OAuth components updated to use t() translation calls - Zero new dependencies — pure React context + localStorage
This commit is contained in:
parent
19199cd38d
commit
a2ea237db2
19 changed files with 1715 additions and 977 deletions
|
|
@ -9,7 +9,8 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectOption } from "@/components/ui/select";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
|
|
@ -29,6 +30,7 @@ export default function CronPage() {
|
|||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
// New job form state
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
|
@ -41,7 +43,7 @@ export default function CronPage() {
|
|||
api
|
||||
.getCronJobs()
|
||||
.then(setJobs)
|
||||
.catch(() => showToast("Failed to load cron jobs", "error"))
|
||||
.catch(() => showToast(t.common.loading, "error"))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
|
|
@ -51,7 +53,7 @@ export default function CronPage() {
|
|||
|
||||
const handleCreate = async () => {
|
||||
if (!prompt.trim() || !schedule.trim()) {
|
||||
showToast("Prompt and schedule are required", "error");
|
||||
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
|
|
@ -62,14 +64,14 @@ export default function CronPage() {
|
|||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
});
|
||||
showToast("Cron job created", "success");
|
||||
showToast(t.common.create + " ✓", "success");
|
||||
setPrompt("");
|
||||
setSchedule("");
|
||||
setName("");
|
||||
setDeliver("local");
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Failed to create job: ${e}`, "error");
|
||||
showToast(`${t.config.failedToSave}: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
|
|
@ -80,34 +82,34 @@ export default function CronPage() {
|
|||
const isPaused = job.state === "paused";
|
||||
if (isPaused) {
|
||||
await api.resumeCronJob(job.id);
|
||||
showToast(`Resumed "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(`${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
} else {
|
||||
await api.pauseCronJob(job.id);
|
||||
showToast(`Paused "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
}
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Action failed: ${e}`, "error");
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrigger = async (job: CronJob) => {
|
||||
try {
|
||||
await api.triggerCronJob(job.id);
|
||||
showToast(`Triggered "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Trigger failed: ${e}`, "error");
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (job: CronJob) => {
|
||||
try {
|
||||
await api.deleteCronJob(job.id);
|
||||
showToast(`Deleted "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Delete failed: ${e}`, "error");
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -128,27 +130,27 @@ export default function CronPage() {
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Cron Job
|
||||
{t.cron.newJob}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-name">Name (optional)</Label>
|
||||
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
id="cron-name"
|
||||
placeholder="e.g. Daily summary"
|
||||
placeholder={t.cron.namePlaceholder}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-prompt">Prompt</Label>
|
||||
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
|
||||
<textarea
|
||||
id="cron-prompt"
|
||||
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="What should the agent do on each run?"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder={t.cron.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
|
|
@ -156,34 +158,34 @@ export default function CronPage() {
|
|||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-schedule">Schedule (cron expression)</Label>
|
||||
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
|
||||
<Input
|
||||
id="cron-schedule"
|
||||
placeholder="0 9 * * *"
|
||||
placeholder={t.cron.schedulePlaceholder}
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-deliver">Deliver to</Label>
|
||||
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="cron-deliver"
|
||||
value={deliver}
|
||||
onValueChange={setDeliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="local">Local</SelectOption>
|
||||
<SelectOption value="telegram">Telegram</SelectOption>
|
||||
<SelectOption value="discord">Discord</SelectOption>
|
||||
<SelectOption value="slack">Slack</SelectOption>
|
||||
<SelectOption value="email">Email</SelectOption>
|
||||
<option value="local">{t.cron.delivery.local}</option>
|
||||
<option value="telegram">{t.cron.delivery.telegram}</option>
|
||||
<option value="discord">{t.cron.delivery.discord}</option>
|
||||
<option value="slack">{t.cron.delivery.slack}</option>
|
||||
<option value="email">{t.cron.delivery.email}</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button onClick={handleCreate} disabled={creating} className="w-full">
|
||||
<Plus className="h-3 w-3" />
|
||||
{creating ? "Creating..." : "Create"}
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -195,13 +197,13 @@ export default function CronPage() {
|
|||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Scheduled Jobs ({jobs.length})
|
||||
{t.cron.scheduledJobs} ({jobs.length})
|
||||
</h2>
|
||||
|
||||
{jobs.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No cron jobs configured. Create one above.
|
||||
{t.cron.noJobs}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
@ -229,8 +231,8 @@ export default function CronPage() {
|
|||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{job.schedule_display}</span>
|
||||
<span>Last: {formatTime(job.last_run_at)}</span>
|
||||
<span>Next: {formatTime(job.next_run_at)}</span>
|
||||
<span>{t.cron.last}: {formatTime(job.last_run_at)}</span>
|
||||
<span>{t.cron.next}: {formatTime(job.next_run_at)}</span>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<p className="text-xs text-destructive mt-1">{job.last_error}</p>
|
||||
|
|
@ -242,8 +244,8 @@ export default function CronPage() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={job.state === "paused" ? "Resume" : "Pause"}
|
||||
aria-label={job.state === "paused" ? "Resume job" : "Pause job"}
|
||||
title={job.state === "paused" ? t.cron.resume : t.cron.pause}
|
||||
aria-label={job.state === "paused" ? t.cron.resume : t.cron.pause}
|
||||
onClick={() => handlePauseResume(job)}
|
||||
>
|
||||
{job.state === "paused" ? (
|
||||
|
|
@ -256,8 +258,8 @@ export default function CronPage() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Trigger now"
|
||||
aria-label="Trigger job now"
|
||||
title={t.cron.triggerNow}
|
||||
aria-label={t.cron.triggerNow}
|
||||
onClick={() => handleTrigger(job)}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
|
|
@ -266,8 +268,8 @@ export default function CronPage() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete"
|
||||
aria-label="Delete job"
|
||||
title={t.common.delete}
|
||||
aria-label={t.common.delete}
|
||||
onClick={() => handleDelete(job)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue