feat(web): i18n (#12471)

This commit is contained in:
Adam
2026-02-06 08:54:51 -06:00
committed by GitHub
parent 0ec5f6608b
commit 812597bb8b
75 changed files with 9868 additions and 726 deletions

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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>
)