Skip to content

Commit 2ea3450

Browse files
committed
Add language selector
1 parent 36e1af3 commit 2ea3450

File tree

2 files changed

+162
-0
lines changed

2 files changed

+162
-0
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useRef, useState } from "react";
4+
import type { SVGProps } from "react";
5+
6+
import { cn } from "@/lib/utils/cn";
7+
8+
export function LucideLanguages(props: SVGProps<SVGSVGElement>) {
9+
return (
10+
<svg
11+
xmlns="http://www.w3.org/2000/svg"
12+
width="1em"
13+
height="1em"
14+
viewBox="0 0 24 24"
15+
{...props}
16+
>
17+
{/* Icon from Lucide by Lucide Contributors - https://github.com/lucide-icons/lucide/blob/main/LICENSE */}
18+
<path
19+
fill="none"
20+
stroke="currentColor"
21+
strokeLinecap="round"
22+
strokeLinejoin="round"
23+
strokeWidth="2"
24+
d="m5 8l6 6m-7 0l6-6l2-3M2 5h12M7 2h1m14 20l-5-10l-5 10m2-4h6"
25+
/>
26+
</svg>
27+
);
28+
}
29+
30+
const LANGUAGES = [
31+
{ id: "en", label: "English", domain: "https://loro.dev" },
32+
{ id: "zh", label: "中文", domain: "https://cn.loro.dev" },
33+
] as const;
34+
35+
type LanguageId = (typeof LANGUAGES)[number]["id"];
36+
37+
function getInitialLanguage(): LanguageId {
38+
if (typeof window === "undefined") {
39+
return "en";
40+
}
41+
42+
const host = window.location.hostname.toLowerCase();
43+
if (host === "cn.loro.dev" || host.endsWith(".cn.loro.dev")) {
44+
return "zh";
45+
}
46+
47+
return "en";
48+
}
49+
50+
function buildTargetUrl(domain: string) {
51+
if (typeof window === "undefined") {
52+
return domain;
53+
}
54+
55+
const { pathname, search, hash } = window.location;
56+
return `${domain}${pathname}${search}${hash}`;
57+
}
58+
59+
export default function LanguageDropdown() {
60+
const [isOpen, setIsOpen] = useState(false);
61+
const [activeLanguage, setActiveLanguage] = useState<LanguageId>("en");
62+
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
63+
64+
useEffect(() => {
65+
setActiveLanguage(getInitialLanguage());
66+
}, []);
67+
68+
const clearCloseTimer = useCallback(() => {
69+
if (closeTimerRef.current) {
70+
clearTimeout(closeTimerRef.current);
71+
closeTimerRef.current = null;
72+
}
73+
}, []);
74+
75+
const scheduleClose = useCallback(() => {
76+
clearCloseTimer();
77+
closeTimerRef.current = setTimeout(() => {
78+
setIsOpen(false);
79+
}, 300);
80+
}, [clearCloseTimer]);
81+
82+
const handleMouseEnter = useCallback(() => {
83+
clearCloseTimer();
84+
setIsOpen(true);
85+
}, [clearCloseTimer]);
86+
87+
const handleMouseLeave = useCallback(() => {
88+
scheduleClose();
89+
}, [scheduleClose]);
90+
91+
const handleSelect = useCallback((language: LanguageId) => {
92+
const target = LANGUAGES.find((item) => item.id === language);
93+
if (!target) {
94+
return;
95+
}
96+
97+
setActiveLanguage(language);
98+
setIsOpen(false);
99+
100+
if (typeof window !== "undefined") {
101+
window.location.href = buildTargetUrl(target.domain);
102+
}
103+
}, []);
104+
105+
return (
106+
<div
107+
className="relative"
108+
onMouseEnter={handleMouseEnter}
109+
onMouseLeave={handleMouseLeave}
110+
>
111+
<button
112+
type="button"
113+
onClick={() => setIsOpen((prev) => !prev)}
114+
className="flex h-9 w-9 items-center justify-center rounded-md border border-transparent text-sm font-medium text-gray-500 transition-colors hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-gray-400 dark:hover:text-gray-100"
115+
aria-haspopup="menu"
116+
aria-expanded={isOpen}
117+
>
118+
<LucideLanguages className="h-5 w-5" />
119+
</button>
120+
<div
121+
onMouseEnter={handleMouseEnter}
122+
onMouseLeave={handleMouseLeave}
123+
className={cn(
124+
"absolute right-0 mt-2 w-32 overflow-hidden rounded-md border border-gray-200 bg-white py-1 shadow-lg transition-opacity duration-150 dark:border-neutral-700 dark:bg-neutral-900",
125+
isOpen
126+
? "pointer-events-auto opacity-100"
127+
: "pointer-events-none opacity-0",
128+
)}
129+
>
130+
{LANGUAGES.map(({ id, label }) => (
131+
<button
132+
key={id}
133+
type="button"
134+
className={cn(
135+
"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors",
136+
id === activeLanguage
137+
? "bg-gray-100 font-medium text-gray-900 dark:bg-neutral-800 dark:text-gray-100"
138+
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-neutral-800 dark:hover:text-gray-100",
139+
)}
140+
onClick={() => handleSelect(id)}
141+
>
142+
{label}
143+
</button>
144+
))}
145+
</div>
146+
</div>
147+
);
148+
}

theme.config.jsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useRouter } from "next/router";
33
import { useConfig } from "nextra-theme-docs";
44
import Image from "next/image";
55
import Footer from "./components/landing/Footer";
6+
import LanguageDropdown from "./components/LanguageDropdown";
67

78
export default {
89
logo: (
@@ -32,13 +33,25 @@ export default {
3233
chat: {
3334
link: "https://discord.gg/tUsBSVfqzf",
3435
},
36+
navbar: {
37+
extraContent: <LanguageDropdown />,
38+
},
3539
docsRepositoryBase: "https://github.com/loro-dev/loro-docs/tree/main",
3640
footer: {
3741
text: "Loro 2024 ©",
3842
component: Footer,
3943
},
4044
head: () => {
4145
const config = useConfig();
46+
const { asPath } = useRouter();
47+
const rawPath =
48+
!asPath || asPath === "/"
49+
? "/"
50+
: asPath.startsWith("/")
51+
? asPath
52+
: `/${asPath}`;
53+
const normalizedPath = rawPath.split("#")[0] || "/";
54+
const canonicalUrl = `https://loro.dev${normalizedPath}`;
4255
// Nextra v3 moves reserved fields like `title`, `description`, `image`
4356
// out of `frontMatter` into top-level config. Fallback to frontMatter for
4457
// older content that still sets them there.
@@ -71,6 +84,7 @@ export default {
7184
<meta property="og:title" content={pageTitle} />
7285
<meta property="og:image" content={metaImage || DEFAULT_IMAGE} />
7386
<meta name="apple-mobile-web-app-title" content="Loro" />
87+
<link rel="alternate" hrefLang="en" href={canonicalUrl} />
7488
</>
7589
);
7690
},

0 commit comments

Comments
 (0)