summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/component
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 08:54:51 -0600
committerGitHub <[email protected]>2026-02-06 08:54:51 -0600
commit812597bb8b101896a8988493d37261ff851ae502 (patch)
tree3b713c13f49c65665e6a7296ed483a3f6f8befbf /packages/console/app/src/component
parent0ec5f6608bdfea5be62dbbdc4c04a61de6d3e67c (diff)
downloadopencode-812597bb8b101896a8988493d37261ff851ae502.tar.gz
opencode-812597bb8b101896a8988493d37261ff851ae502.zip
feat(web): i18n (#12471)
Diffstat (limited to 'packages/console/app/src/component')
-rw-r--r--packages/console/app/src/component/email-signup.tsx14
-rw-r--r--packages/console/app/src/component/footer.tsx16
-rw-r--r--packages/console/app/src/component/header.tsx66
-rw-r--r--packages/console/app/src/component/language-picker.css135
-rw-r--r--packages/console/app/src/component/language-picker.tsx34
-rw-r--r--packages/console/app/src/component/legal.tsx12
6 files changed, 233 insertions, 44 deletions
diff --git a/packages/console/app/src/component/email-signup.tsx b/packages/console/app/src/component/email-signup.tsx
index 65f81b5fc..bd33e9200 100644
--- a/packages/console/app/src/component/email-signup.tsx
+++ b/packages/console/app/src/component/email-signup.tsx
@@ -2,6 +2,7 @@ import { action, useSubmission } from "@solidjs/router"
import dock from "../asset/lander/dock.png"
import { Resource } from "@opencode-ai/console-resource"
import { Show } from "solid-js"
+import { useI18n } from "~/context/i18n"
const emailSignup = action(async (formData: FormData) => {
"use server"
@@ -23,22 +24,21 @@ const emailSignup = action(async (formData: FormData) => {
export function EmailSignup() {
const submission = useSubmission(emailSignup)
+ const i18n = useI18n()
return (
<section data-component="email">
<div data-slot="section-title">
- <h3>Be the first to know when we release new products</h3>
- <p>Join the waitlist for early access.</p>
+ <h3>{i18n.t("email.title")}</h3>
+ <p>{i18n.t("email.subtitle")}</p>
</div>
<form data-slot="form" action={emailSignup} method="post">
- <input type="email" name="email" placeholder="Email address" required />
+ <input type="email" name="email" placeholder={i18n.t("email.placeholder")} required />
<button type="submit" disabled={submission.pending}>
- Subscribe
+ {i18n.t("email.subscribe")}
</button>
</form>
<Show when={submission.result}>
- <div style="color: #03B000; margin-top: 24px;">
- Almost done, check your inbox and confirm your email address
- </div>
+ <div style="color: #03B000; margin-top: 24px;">{i18n.t("email.success")}</div>
</Show>
<Show when={submission.error}>
<div style="color: #FF408F; margin-top: 24px;">{submission.error}</div>
diff --git a/packages/console/app/src/component/footer.tsx b/packages/console/app/src/component/footer.tsx
index 27f8ddd65..45dae87ec 100644
--- a/packages/console/app/src/component/footer.tsx
+++ b/packages/console/app/src/component/footer.tsx
@@ -2,12 +2,16 @@ import { createAsync } from "@solidjs/router"
import { createMemo } from "solid-js"
import { github } from "~/lib/github"
import { config } from "~/config"
+import { useLanguage } from "~/context/language"
+import { useI18n } from "~/context/i18n"
export function Footer() {
+ const language = useLanguage()
+ const i18n = useI18n()
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
- ? new Intl.NumberFormat("en-US", {
+ ? new Intl.NumberFormat(language.tag(language.locale()), {
notation: "compact",
compactDisplay: "short",
}).format(githubData()!.stars!)
@@ -18,20 +22,20 @@ export function Footer() {
<footer data-component="footer">
<div data-slot="cell">
<a href={config.github.repoUrl} target="_blank">
- GitHub <span>[{starCount()}]</span>
+ {i18n.t("footer.github")} <span>[{starCount()}]</span>
</a>
</div>
<div data-slot="cell">
- <a href="/docs">Docs</a>
+ <a href="/docs">{i18n.t("footer.docs")}</a>
</div>
<div data-slot="cell">
- <a href="/changelog">Changelog</a>
+ <a href="/changelog">{i18n.t("footer.changelog")}</a>
</div>
<div data-slot="cell">
- <a href="/discord">Discord</a>
+ <a href="/discord">{i18n.t("footer.discord")}</a>
</div>
<div data-slot="cell">
- <a href={config.social.twitter}>X</a>
+ <a href={config.social.twitter}>{i18n.t("footer.x")}</a>
</div>
</footer>
)
diff --git a/packages/console/app/src/component/header.tsx b/packages/console/app/src/component/header.tsx
index 72e9d0418..3eca8b88c 100644
--- a/packages/console/app/src/component/header.tsx
+++ b/packages/console/app/src/component/header.tsx
@@ -19,6 +19,7 @@ import { createStore } from "solid-js/store"
import { github } from "~/lib/github"
import { createEffect, onCleanup } from "solid-js"
import { config } from "~/config"
+import { useI18n } from "~/context/i18n"
import "./header-context-menu.css"
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
@@ -36,12 +37,14 @@ const fetchSvgContent = async (svgPath: string): Promise<string> => {
export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
const navigate = useNavigate()
+ const i18n = useI18n()
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
? new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
+ maximumFractionDigits: 0,
}).format(githubData()?.stars!)
: config.github.starsFormatted.compact,
)
@@ -119,8 +122,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
<section data-component="top">
<div onContextMenu={handleLogoContextMenu}>
<A href="/">
- <img data-slot="logo light" src={logoLight} alt="opencode logo light" width="189" height="34" />
- <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" width="189" height="34" />
+ <img data-slot="logo light" src={logoLight} alt="OpenCode" width="189" height="34" />
+ <img data-slot="logo dark" src={logoDark} alt="OpenCode" width="189" height="34" />
</A>
</div>
@@ -130,49 +133,56 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
style={`left: ${store.contextMenuPosition.x}px; top: ${store.contextMenuPosition.y}px;`}
>
<button class="context-menu-item" onClick={copyLogoToClipboard}>
- <img data-slot="copy light" src={copyLogoLight} alt="Logo" />
- <img data-slot="copy dark" src={copyLogoDark} alt="Logo" />
- Copy logo as SVG
+ <img data-slot="copy light" src={copyLogoLight} alt="" />
+ <img data-slot="copy dark" src={copyLogoDark} alt="" />
+ {i18n.t("nav.context.copyLogo")}
</button>
<button class="context-menu-item" onClick={copyWordmarkToClipboard}>
- <img data-slot="copy light" src={copyWordmarkLight} alt="Wordmark" />
- <img data-slot="copy dark" src={copyWordmarkDark} alt="Wordmark" />
- Copy wordmark as SVG
+ <img data-slot="copy light" src={copyWordmarkLight} alt="" />
+ <img data-slot="copy dark" src={copyWordmarkDark} alt="" />
+ {i18n.t("nav.context.copyWordmark")}
</button>
<button class="context-menu-item" onClick={() => navigate("/brand")}>
- <img data-slot="copy light" src={copyBrandAssetsLight} alt="Brand Assets" />
- <img data-slot="copy dark" src={copyBrandAssetsDark} alt="Brand Assets" />
- Brand assets
+ <img data-slot="copy light" src={copyBrandAssetsLight} alt="" />
+ <img data-slot="copy dark" src={copyBrandAssetsDark} alt="" />
+ {i18n.t("nav.context.brandAssets")}
</button>
</div>
</Show>
<nav data-component="nav-desktop">
<ul>
<li>
- <a href={config.github.repoUrl} target="_blank">
- GitHub <span>[{starCount()}]</span>
+ <a href={config.github.repoUrl} target="_blank" style="white-space: nowrap;">
+ {i18n.t("nav.github")} <span>[{starCount()}]</span>
</a>
</li>
<li>
- <a href="/docs">Docs</a>
+ <a href="/docs">{i18n.t("nav.docs")}</a>
</li>
<li>
- <A href="/enterprise">Enterprise</A>
+ <A href="/enterprise">{i18n.t("nav.enterprise")}</A>
</li>
<li>
<Switch>
<Match when={props.zen}>
- <a href="/auth">Login</a>
+ <a href="/auth">{i18n.t("nav.login")}</a>
</Match>
<Match when={!props.zen}>
- <A href="/zen">Zen</A>
+ <A href="/zen">{i18n.t("nav.zen")}</A>
</Match>
</Switch>
</li>
<Show when={!props.hideGetStarted}>
<li>
<A href="/download" data-slot="cta-button">
- <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <svg
+ width="18"
+ height="18"
+ viewBox="0 0 18 18"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style="flex-shrink: 0;"
+ >
<path
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
stroke="currentColor"
@@ -180,7 +190,7 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
stroke-linecap="square"
/>
</svg>
- Free
+ {i18n.t("nav.free")}
</A>
</li>
</Show>
@@ -195,7 +205,7 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
class="nav-toggle"
onClick={() => setStore("mobileMenuOpen", !store.mobileMenuOpen)}
>
- <span class="sr-only">Open menu</span>
+ <span class="sr-only">{i18n.t("nav.openMenu")}</span>
<Switch>
<Match when={store.mobileMenuOpen}>
<svg
@@ -235,33 +245,33 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
<nav data-component="nav-mobile-menu-list">
<ul>
<li>
- <A href="/">Home</A>
+ <A href="/">{i18n.t("nav.home")}</A>
</li>
<li>
- <a href={config.github.repoUrl} target="_blank">
- GitHub <span>[{starCount()}]</span>
+ <a href={config.github.repoUrl} target="_blank" style="white-space: nowrap;">
+ {i18n.t("nav.github")} <span>[{starCount()}]</span>
</a>
</li>
<li>
- <a href="/docs">Docs</a>
+ <a href="/docs">{i18n.t("nav.docs")}</a>
</li>
<li>
- <A href="/enterprise">Enterprise</A>
+ <A href="/enterprise">{i18n.t("nav.enterprise")}</A>
</li>
<li>
<Switch>
<Match when={props.zen}>
- <a href="/auth">Login</a>
+ <a href="/auth">{i18n.t("nav.login")}</a>
</Match>
<Match when={!props.zen}>
- <A href="/zen">Zen</A>
+ <A href="/zen">{i18n.t("nav.zen")}</A>
</Match>
</Switch>
</li>
<Show when={!props.hideGetStarted}>
<li>
<A href="/download" data-slot="cta-button">
- Get started for free
+ {i18n.t("nav.getStartedFree")}
</A>
</li>
</Show>
diff --git a/packages/console/app/src/component/language-picker.css b/packages/console/app/src/component/language-picker.css
new file mode 100644
index 000000000..8ef2306d7
--- /dev/null
+++ b/packages/console/app/src/component/language-picker.css
@@ -0,0 +1,135 @@
+[data-component="language-picker"] {
+ width: auto;
+}
+
+[data-component="footer"] [data-component="language-picker"] {
+ width: 100%;
+}
+
+[data-component="footer"] [data-component="language-picker"] [data-component="dropdown"] {
+ width: 100%;
+}
+
+/* Standard site footer (grid of cells) */
+[data-component="footer"] [data-slot="cell"] [data-component="language-picker"] {
+ height: 100%;
+}
+
+[data-component="footer"] [data-slot="cell"] [data-component="language-picker"] [data-slot="trigger"] {
+ width: 100%;
+ padding: 2rem 0;
+ border-radius: 0;
+ justify-content: center;
+ gap: var(--space-2);
+ color: inherit;
+ font: inherit;
+}
+
+[data-component="footer"] [data-slot="cell"] [data-component="language-picker"] [data-slot="trigger"] span {
+ flex: 0 0 auto;
+ text-align: center;
+ font-weight: inherit;
+}
+
+[data-component="footer"] [data-slot="cell"] [data-component="language-picker"] [data-slot="trigger"]:hover {
+ background: var(--color-background-weak);
+ text-decoration: underline;
+ text-underline-offset: var(--space-1);
+ text-decoration-thickness: 1px;
+}
+
+/* Footer dropdown should open upward */
+[data-component="footer"] [data-component="language-picker"] [data-slot="dropdown"] {
+ top: auto;
+ bottom: 100%;
+ margin-top: 0;
+ margin-bottom: var(--space-2);
+ max-height: min(60vh, 420px);
+ overflow: auto;
+}
+
+[data-component="legal"] {
+ flex-wrap: wrap;
+ row-gap: var(--space-2);
+}
+
+[data-component="legal"] [data-component="language-picker"] {
+ width: auto;
+}
+
+[data-component="legal"] [data-component="language-picker"] [data-slot="trigger"] {
+ padding: 0;
+ border-radius: 0;
+ background: transparent;
+ font: inherit;
+ color: var(--color-text-weak);
+ white-space: nowrap;
+}
+
+[data-component="legal"] [data-component="language-picker"] [data-slot="trigger"] span {
+ font-weight: inherit;
+}
+
+[data-component="legal"] [data-component="language-picker"] [data-slot="trigger"]:hover {
+ background: transparent;
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-1);
+ text-decoration-thickness: 1px;
+}
+
+[data-component="legal"] [data-component="language-picker"] [data-slot="dropdown"] {
+ top: auto;
+ bottom: 100%;
+ margin-top: 0;
+ margin-bottom: var(--space-2);
+ max-height: min(60vh, 420px);
+ overflow: auto;
+}
+
+/* Black pages footer */
+[data-page="black"] [data-component="language-picker"] {
+ width: auto;
+}
+
+[data-page="black"] [data-component="language-picker"] [data-component="dropdown"] {
+ width: auto;
+}
+
+[data-page="black"] [data-component="language-picker"] [data-slot="trigger"] {
+ padding: 0;
+ border-radius: 0;
+ background: transparent;
+ font: inherit;
+ color: rgba(255, 255, 255, 0.39);
+}
+
+[data-page="black"] [data-component="language-picker"] [data-slot="trigger"]:hover {
+ background: transparent;
+ text-decoration: underline;
+ text-underline-offset: 4px;
+}
+
+[data-page="black"] [data-component="language-picker"] [data-slot="dropdown"] {
+ top: auto;
+ bottom: 100%;
+ margin-top: 0;
+ margin-bottom: 12px;
+ background-color: #0e0e10;
+ border-color: rgba(255, 255, 255, 0.14);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
+ max-height: min(60vh, 420px);
+ overflow: auto;
+}
+
+[data-page="black"] [data-component="language-picker"] [data-slot="item"] {
+ color: rgba(255, 255, 255, 0.86);
+}
+
+[data-page="black"] [data-component="language-picker"] [data-slot="item"]:hover {
+ background-color: rgba(255, 255, 255, 0.06);
+}
+
+[data-page="black"] [data-component="language-picker"] [data-slot="item"][data-selected="true"] {
+ background-color: rgba(255, 255, 255, 0.1);
+}
diff --git a/packages/console/app/src/component/language-picker.tsx b/packages/console/app/src/component/language-picker.tsx
new file mode 100644
index 000000000..b30471d01
--- /dev/null
+++ b/packages/console/app/src/component/language-picker.tsx
@@ -0,0 +1,34 @@
+import { For, createSignal } from "solid-js"
+import { Dropdown, DropdownItem } from "~/component/dropdown"
+import { useLanguage } from "~/context/language"
+import "./language-picker.css"
+
+export function LanguagePicker(props: { align?: "left" | "right" } = {}) {
+ const language = useLanguage()
+ const [open, setOpen] = createSignal(false)
+
+ return (
+ <div data-component="language-picker">
+ <Dropdown
+ trigger={language.label(language.locale())}
+ align={props.align ?? "left"}
+ open={open()}
+ onOpenChange={setOpen}
+ >
+ <For each={language.locales}>
+ {(locale) => (
+ <DropdownItem
+ selected={locale === language.locale()}
+ onClick={() => {
+ language.setLocale(locale)
+ setOpen(false)
+ }}
+ >
+ {language.label(locale)}
+ </DropdownItem>
+ )}
+ </For>
+ </Dropdown>
+ </div>
+ )
+}
diff --git a/packages/console/app/src/component/legal.tsx b/packages/console/app/src/component/legal.tsx
index e971a31e1..3cc3627a7 100644
--- a/packages/console/app/src/component/legal.tsx
+++ b/packages/console/app/src/component/legal.tsx
@@ -1,19 +1,25 @@
import { A } from "@solidjs/router"
+import { LanguagePicker } from "~/component/language-picker"
+import { useI18n } from "~/context/i18n"
export function Legal() {
+ const i18n = useI18n()
return (
<div data-component="legal">
<span>
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
</span>
<span>
- <A href="/brand">Brand</A>
+ <A href="/brand">{i18n.t("legal.brand")}</A>
</span>
<span>
- <A href="/legal/privacy-policy">Privacy</A>
+ <A href="/legal/privacy-policy">{i18n.t("legal.privacy")}</A>
</span>
<span>
- <A href="/legal/terms-of-service">Terms</A>
+ <A href="/legal/terms-of-service">{i18n.t("legal.terms")}</A>
+ </span>
+ <span>
+ <LanguagePicker align="right" />
</span>
</div>
)