mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 14:37:44 +02:00
feat(i18n): translate layout namespace (sidebar nav, help, loader)
Translates the cross-cutting layout chrome: - 9 sidebar nav labels (inbox / my issues / issues / projects / autopilots / agents / runtimes / skills / settings) — driven by labelKey instead of inline strings, resolved via useT at render. - HelpLauncher dropdown (trigger aria + 3 items: Docs / Change log / Feedback) - WorkspaceLoader (named + unnamed loading states) - SortablePinItem unpin tooltip Pattern shift in app-sidebar.tsx: nav arrays carry `labelKey: NavLabelKey` (typed against the layout JSON) instead of `label: string`. The string comparison checks (`item.label === "Inbox"`) became cleaner ID-based checks (`item.key === "inbox"`). Deferred: deeper sidebar surfaces — workspace switcher dropdown, "New Issue" CTA, "Pinned" / "Workspace" / "Configure" group labels — remain English. The 9 nav labels are the ones that read in every session. Verified: pnpm --filter @multica/views typecheck (clean) + test (238/238). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,9 @@ const TRANSLATED_FILES = [
|
||||
"runtimes/components/runtime-detail-page.tsx",
|
||||
"runtimes/components/runtime-detail.tsx",
|
||||
"runtimes/components/shared.tsx",
|
||||
"layout/app-sidebar.tsx",
|
||||
"layout/help-launcher.tsx",
|
||||
"layout/workspace-loader.tsx",
|
||||
];
|
||||
|
||||
export default [
|
||||
|
||||
@@ -19,6 +19,7 @@ import type skills from "../locales/en/skills.json";
|
||||
import type chat from "../locales/en/chat.json";
|
||||
import type modals from "../locales/en/modals.json";
|
||||
import type runtimes from "../locales/en/runtimes.json";
|
||||
import type layout from "../locales/en/layout.json";
|
||||
|
||||
// Module augmentation enables i18next v26 selector API across the monorepo:
|
||||
// `t($ => $.signin.title)` resolves to the value in en/auth.json.
|
||||
@@ -52,6 +53,7 @@ declare module "i18next" {
|
||||
chat: typeof chat;
|
||||
modals: typeof modals;
|
||||
runtimes: typeof runtimes;
|
||||
layout: typeof layout;
|
||||
};
|
||||
enableSelector: true;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ import { projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import type { PinnedItem } from "@multica/core/types";
|
||||
import { useLogout } from "../auth";
|
||||
import { ProjectIcon } from "../projects/components/project-icon";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
// Top-level nav items stay active when the user is on a child route
|
||||
// (e.g. "Projects" stays lit on /:slug/projects/:id). Pinned items keep
|
||||
@@ -109,22 +110,34 @@ type NavKey =
|
||||
| "skills"
|
||||
| "settings";
|
||||
|
||||
const personalNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
|
||||
{ key: "inbox", label: "Inbox", icon: Inbox },
|
||||
{ key: "myIssues", label: "My Issues", icon: CircleUser },
|
||||
// Static schema (key + icon) — labels resolved at render via useT("layout").
|
||||
type NavLabelKey =
|
||||
| "inbox"
|
||||
| "my_issues"
|
||||
| "issues"
|
||||
| "projects"
|
||||
| "autopilots"
|
||||
| "agents"
|
||||
| "runtimes"
|
||||
| "skills"
|
||||
| "settings";
|
||||
|
||||
const personalNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
|
||||
{ key: "inbox", labelKey: "inbox", icon: Inbox },
|
||||
{ key: "myIssues", labelKey: "my_issues", icon: CircleUser },
|
||||
];
|
||||
|
||||
const workspaceNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
|
||||
{ key: "issues", label: "Issues", icon: ListTodo },
|
||||
{ key: "projects", label: "Projects", icon: FolderKanban },
|
||||
{ key: "autopilots", label: "Autopilot", icon: Zap },
|
||||
{ key: "agents", label: "Agents", icon: Bot },
|
||||
const workspaceNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
|
||||
{ key: "issues", labelKey: "issues", icon: ListTodo },
|
||||
{ key: "projects", labelKey: "projects", icon: FolderKanban },
|
||||
{ key: "autopilots", labelKey: "autopilots", icon: Zap },
|
||||
{ key: "agents", labelKey: "agents", icon: Bot },
|
||||
];
|
||||
|
||||
const configureNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
|
||||
{ key: "runtimes", label: "Runtimes", icon: Monitor },
|
||||
{ key: "skills", label: "Skills", icon: BookOpenText },
|
||||
{ key: "settings", label: "Settings", icon: Settings },
|
||||
const configureNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
|
||||
{ key: "runtimes", labelKey: "runtimes", icon: Monitor },
|
||||
{ key: "skills", labelKey: "skills", icon: BookOpenText },
|
||||
{ key: "settings", labelKey: "settings", icon: Settings },
|
||||
];
|
||||
|
||||
function DraftDot() {
|
||||
@@ -154,6 +167,7 @@ function SortablePinItem({
|
||||
label: string;
|
||||
iconNode: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useT("layout");
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
|
||||
const wasDragged = useRef(false);
|
||||
|
||||
@@ -208,7 +222,7 @@ function SortablePinItem({
|
||||
>
|
||||
<X className="size-1" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>Unpin</TooltipContent>
|
||||
<TooltipContent side="top" sideOffset={4}>{t(($) => $.sidebar.unpin_tooltip)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -308,6 +322,7 @@ interface AppSidebarProps {
|
||||
}
|
||||
|
||||
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
|
||||
const { t } = useT("layout");
|
||||
const { pathname, push } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
@@ -589,8 +604,8 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
{item.label === "Inbox" && unreadCount > 0 && (
|
||||
<span>{t(($) => $.nav[item.labelKey])}</span>
|
||||
{item.key === "inbox" && unreadCount > 0 && (
|
||||
<span className="ml-auto text-xs">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
@@ -653,7 +668,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
<span>{t(($) => $.nav[item.labelKey])}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
@@ -677,8 +692,8 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
{item.label === "Runtimes" && hasRuntimeUpdates && (
|
||||
<span>{t(($) => $.nav[item.labelKey])}</span>
|
||||
{item.key === "runtimes" && hasRuntimeUpdates && (
|
||||
<span className="ml-auto size-1.5 rounded-full bg-destructive" />
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -8,16 +8,18 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
const DOCS_URL = "https://multica.ai/docs";
|
||||
const CHANGELOG_URL = "https://multica.ai/changelog";
|
||||
|
||||
export function HelpLauncher() {
|
||||
const { t } = useT("layout");
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Help"
|
||||
title="Help"
|
||||
aria-label={t(($) => $.help.trigger)}
|
||||
title={t(($) => $.help.trigger)}
|
||||
className="inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors cursor-pointer hover:bg-accent hover:text-foreground data-popup-open:bg-accent data-popup-open:text-foreground"
|
||||
>
|
||||
<CircleHelp className="size-4" />
|
||||
@@ -34,7 +36,7 @@ export function HelpLauncher() {
|
||||
}
|
||||
>
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
Docs
|
||||
{t(($) => $.help.docs)}
|
||||
<ArrowUpRight className="size-3 translate-y-px text-muted-foreground/50" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -47,14 +49,14 @@ export function HelpLauncher() {
|
||||
}
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
Change log
|
||||
{t(($) => $.help.changelog)}
|
||||
<ArrowUpRight className="size-3 translate-y-px text-muted-foreground/50" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => useModalStore.getState().open("feedback")}
|
||||
>
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
Feedback
|
||||
{t(($) => $.help.feedback)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
/**
|
||||
* Full-screen workspace loader. Renders IN PLACE OF the dashboard during:
|
||||
@@ -13,6 +14,7 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
* workspace have been freshly fetched.
|
||||
*/
|
||||
export function WorkspaceLoader({ name }: { name?: string | null }) {
|
||||
const { t } = useT("layout");
|
||||
return (
|
||||
<div
|
||||
className="flex h-svh w-full items-center justify-center bg-background"
|
||||
@@ -23,10 +25,11 @@ export function WorkspaceLoader({ name }: { name?: string | null }) {
|
||||
<MulticaIcon className="size-8 animate-pulse" />
|
||||
{name ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading <span className="font-medium text-foreground">{name}</span>…
|
||||
{t(($) => $.workspace_loader.loading_named_prefix)}{" "}
|
||||
<span className="font-medium text-foreground">{name}</span>…
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Loading workspace…</p>
|
||||
<p className="text-sm text-muted-foreground">{t(($) => $.workspace_loader.loading_workspace)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
26
packages/views/locales/en/layout.json
Normal file
26
packages/views/locales/en/layout.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"nav": {
|
||||
"inbox": "Inbox",
|
||||
"my_issues": "My Issues",
|
||||
"issues": "Issues",
|
||||
"projects": "Projects",
|
||||
"autopilots": "Autopilot",
|
||||
"agents": "Agents",
|
||||
"runtimes": "Runtimes",
|
||||
"skills": "Skills",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"help": {
|
||||
"trigger": "Help",
|
||||
"docs": "Docs",
|
||||
"changelog": "Change log",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"workspace_loader": {
|
||||
"loading_workspace": "Loading workspace…",
|
||||
"loading_named_prefix": "Loading"
|
||||
},
|
||||
"sidebar": {
|
||||
"unpin_tooltip": "Unpin"
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import enSkills from "./en/skills.json";
|
||||
import enChat from "./en/chat.json";
|
||||
import enModals from "./en/modals.json";
|
||||
import enRuntimes from "./en/runtimes.json";
|
||||
import enLayout from "./en/layout.json";
|
||||
import zhHansCommon from "./zh-Hans/common.json";
|
||||
import zhHansAuth from "./zh-Hans/auth.json";
|
||||
import zhHansSettings from "./zh-Hans/settings.json";
|
||||
@@ -33,6 +34,7 @@ import zhHansSkills from "./zh-Hans/skills.json";
|
||||
import zhHansChat from "./zh-Hans/chat.json";
|
||||
import zhHansModals from "./zh-Hans/modals.json";
|
||||
import zhHansRuntimes from "./zh-Hans/runtimes.json";
|
||||
import zhHansLayout from "./zh-Hans/layout.json";
|
||||
|
||||
// Single source of truth for the resource bundle. Both apps (web layout +
|
||||
// desktop App.tsx) import from here so adding a locale or namespace happens
|
||||
@@ -56,6 +58,7 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
|
||||
chat: enChat,
|
||||
modals: enModals,
|
||||
runtimes: enRuntimes,
|
||||
layout: enLayout,
|
||||
},
|
||||
"zh-Hans": {
|
||||
common: zhHansCommon,
|
||||
@@ -75,5 +78,6 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
|
||||
chat: zhHansChat,
|
||||
modals: zhHansModals,
|
||||
runtimes: zhHansRuntimes,
|
||||
layout: zhHansLayout,
|
||||
},
|
||||
};
|
||||
|
||||
26
packages/views/locales/zh-Hans/layout.json
Normal file
26
packages/views/locales/zh-Hans/layout.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"nav": {
|
||||
"inbox": "收件箱",
|
||||
"my_issues": "我的 issue",
|
||||
"issues": "issue",
|
||||
"projects": "project",
|
||||
"autopilots": "autopilot",
|
||||
"agents": "智能体",
|
||||
"runtimes": "运行时",
|
||||
"skills": "skill",
|
||||
"settings": "设置"
|
||||
},
|
||||
"help": {
|
||||
"trigger": "帮助",
|
||||
"docs": "文档",
|
||||
"changelog": "更新日志",
|
||||
"feedback": "反馈"
|
||||
},
|
||||
"workspace_loader": {
|
||||
"loading_workspace": "正在加载工作区…",
|
||||
"loading_named_prefix": "正在加载"
|
||||
},
|
||||
"sidebar": {
|
||||
"unpin_tooltip": "取消固定"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user