feat: switch language on the panel (#1882)

* feat: switch language on the panel

move nameMap to i18n.ts
add Switch Language i18n
add switch language in ConnectButton.tsx

close https://github.com/Crossbell-Box/xLog/issues/1881

* feat: Add auto placement option to Menu component to looks like  submenu
adjust dom in connectButton

* feat: Add support for nested menus in Menu component

---------

Co-authored-by: pengyunfei <pengyunfei@360.cn>
This commit is contained in:
cloudflypeng 2024-06-07 11:01:55 +08:00 committed by GitHub
parent 3a3f6c19cd
commit 2f76c6b420
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 254 additions and 191 deletions

View File

@ -1,6 +1,6 @@
"use client"
import { useTranslations } from "next-intl"
import { useLocale, useTranslations } from "next-intl"
import React, { useEffect, useState } from "react"
import {
@ -32,6 +32,7 @@ import { BellAlertIcon } from "@heroicons/react/24/solid"
import { Avatar } from "~/components/ui/Avatar"
import { Button, type Variant, type VariantColor } from "~/components/ui/Button"
import { Menu } from "~/components/ui/Menu"
import { nameMap } from "~/i18n"
import { SITE_URL } from "~/lib/env"
import { getSiteLink } from "~/lib/helpers"
import { cn } from "~/lib/utils"
@ -39,6 +40,8 @@ import { cn } from "~/lib/utils"
type HeaderLinkType = {
icon?: React.ReactNode
label: string | JSX.Element
isSubmenu?: boolean
subMenu?: HeaderLinkType[]
} & (
| {
href: string
@ -77,6 +80,7 @@ export const ConnectButton = ({
sizeDecrease = "xs"
}
const locale = useLocale()
const [ssrReady, account] = useAccountState(({ ssrReady, computed }) => [
ssrReady,
computed.account,
@ -95,23 +99,17 @@ export const ConnectButton = ({
const selectCharactersModal = useSelectCharactersModal()
const walletMintNewCharacterModal = useWalletMintNewCharacterModal()
const showNotificationModal = useShowNotificationModal()
const { isAllRead } = useNotifications()
const [InsufficientBalance, setInsufficientBalance] = useState<boolean>(false)
const t = useTranslations()
useEffect(() => {
if (balance) {
if (
BigInt(balance.value.toString()) >
BigInt("1" + "0".repeat(balance.decimals - 2))
) {
setInsufficientBalance(false)
} else {
setInsufficientBalance(true)
}
}
if (!balance) return
const balanceValue =
BigInt(balance.value.toString()) <=
BigInt("1" + "0".repeat(balance.decimals - 2))
setInsufficientBalance(balanceValue)
}, [balance])
const dropdownLinks: HeaderLinkType[] = [
@ -141,6 +139,21 @@ export const ConnectButton = ({
copyLabel()
},
},
{
icon: "i-mingcute-translate-2-line",
isSubmenu: true,
label: t("Switch Language") || "",
subMenu: Object.keys(nameMap).map((lo) => ({
label: nameMap[lo],
onClick: () => {
document.cookie = `NEXT_LOCALE=${lo};`
window.location.reload()
},
})),
onClick: (e) => {
e.preventDefault()
},
},
...(account?.type === "wallet"
? [
{
@ -217,182 +230,133 @@ export const ConnectButton = ({
href: `${SITE_URL}/dashboard/${account?.character?.handle}/editor?type=portfolio`,
},
]
// TODO: maybe test this style effect
if (!ssrReady) return <div aria-hidden></div>
if (!account)
return (
<div>
<Button
className="text-accent"
onClick={openConnectModal}
style={{ height: avatarSize + "px" }}
variant={variant || "primary"}
variantColor={variantColor}
>
{t("Connect")}
</Button>
</div>
)
return (
<div
{...(!ssrReady && {
"aria-hidden": true,
style: {
opacity: 0,
pointerEvents: "none",
userSelect: "none",
},
})}
>
{(() => {
if (!account) {
return (
<Button
className="text-accent"
onClick={openConnectModal}
style={{ height: avatarSize + "px" }}
variant={variant || "primary"}
variantColor={variantColor}
<div>
<div
className="relative flex items-center -mr-2"
style={{ height: avatarSize + "px" }}
>
{!hideNotification && <Notification />}
<Menu
placement="bottom"
target={
<PlusCircleIcon
className={`${
size === "base" ? "size-6 ml-2" : "size-5 ml-1"
} text-zinc-500 cursor-pointer hidden sm:block`}
/>
}
dropdown={
<div
className={`min-w-[140px] ${
size === "base" ? "text-base" : "text-sm"
}`}
>
{t("Connect")}
</Button>
)
}
return (
<div
className="relative flex items-center -mr-2"
style={{ height: avatarSize + "px" }}
>
{!hideNotification && (
<>
{isAllRead ? (
<BellIcon
className={`${
size === "base" ? "size-6" : "size-5"
} text-zinc-500 cursor-pointer sm:hover:animate-buzz-out`}
onClick={showNotificationModal}
/>
) : (
<BellAlertIcon
className={`${
size === "base" ? "size-6" : "size-5"
} text-accent cursor-pointer sm:hover:animate-buzz-out`}
onClick={showNotificationModal}
/>
)}
<Menu
placement="bottom"
target={
<PlusCircleIcon
className={`${
size === "base" ? "size-6 ml-2" : "size-5 ml-1"
} text-zinc-500 cursor-pointer hidden sm:block`}
/>
}
dropdown={
<div
className={`min-w-[140px] ${
size === "base" ? "text-base" : "text-sm"
}`}
{addDropdownLinks.map((link, i) => (
<ConnectMenuItem link={link} size={size} key={i} />
))}
</div>
}
></Menu>
<div className="h-full w-[2px] py-1 ml-2">
<div className="size-full bg-zinc-200 rounded-full"></div>
</div>
<Menu
placement="bottom-end"
target={
<button
className="flex items-center hover:bg-hover transition-colors py-1 px-2 rounded-lg ml-1 focus-visible:outline focus-visible:outline-accent focus-visible:outline-offset-1"
type="button"
aria-label="connector"
>
<Avatar
cid={account.character?.characterId}
className="align-middle"
images={account.character?.metadata?.content?.avatars || []}
name={account.character?.metadata?.content?.name}
size={avatarSize}
/>
{!hideName && (
<>
<div
className={`flex-1 flex-col min-w-0 ml-2 max-w-[100px] ${
mobileSimplification ? "hidden sm:flex" : "flex"
}`}
>
<span
className={`text-left leading-none font-medium truncate ${
InsufficientBalance ? "text-red-600" : "text-gray-600"
} ${size === "base" ? "text-base" : "text-sm"}`}
style={{ marginBottom: "0.15rem" }}
>
{addDropdownLinks.map((link, i) => (
<Menu.Item
key={i}
icon={<i className={cn(link.icon, "text-base")} />}
className={`${
size === "base" ? "pl-5 pr-6 h-11" : "pl-4 pr-5 h-9"
} whitespace-nowrap`}
{...("href" in link
? {
type: "link",
href: link.href,
}
: {
type: "button",
onClick: link.onClick,
})}
>
{link.label}
</Menu.Item>
))}
</div>
}
/>
<div className="h-full w-[2px] py-1 ml-2">
<div className="size-full bg-zinc-200 rounded-full"></div>
</div>
</>
)}
<Menu
placement="bottom-end"
target={
<button
className="flex items-center hover:bg-hover transition-colors py-1 px-2 rounded-lg ml-1 focus-visible:outline focus-visible:outline-accent focus-visible:outline-offset-1"
type="button"
aria-label="connector"
>
<Avatar
cid={account.character?.characterId}
className="align-middle"
images={account.character?.metadata?.content?.avatars || []}
name={account.character?.metadata?.content?.name}
size={avatarSize}
/>
{!hideName && (
<>
<div
className={`flex-1 flex-col min-w-0 ml-2 max-w-[100px] ${
mobileSimplification ? "hidden sm:flex" : "flex"
{account.character?.metadata?.content?.name ||
getAccountDisplayName(account)}
</span>
{account.character?.handle && (
<span
className={`text-left leading-none ${
sizeDecrease === "sm" ? "text-sm" : "text-xs"
} truncate ${
InsufficientBalance ? "text-red-400" : "text-gray-400"
}`}
>
<span
className={`text-left leading-none font-medium truncate ${
InsufficientBalance
? "text-red-600"
: "text-gray-600"
} ${size === "base" ? "text-base" : "text-sm"}`}
style={{ marginBottom: "0.15rem" }}
>
{account.character?.metadata?.content?.name ||
getAccountDisplayName(account)}
</span>
{account.character?.handle && (
<span
className={`text-left leading-none ${
sizeDecrease === "sm" ? "text-sm" : "text-xs"
} truncate ${
InsufficientBalance
? "text-red-400"
: "text-gray-400"
}`}
>
{"@" + account.character?.handle ||
getAccountDisplayName(account)}
</span>
)}
</div>
<i className="i-mingcute-down-line text-xl ml-[2px]" />
</>
)}
</button>
}
dropdown={
<div
className={`min-w-[140px] ${
size === "base" ? "text-base" : "text-sm"
}`}
>
{dropdownLinks.map((link, i) => (
<Menu.Item
{"@" + account.character?.handle ||
getAccountDisplayName(account)}
</span>
)}
</div>
<i className="i-mingcute-down-line text-xl ml-[2px]" />
</>
)}
</button>
}
dropdown={
<div
className={`min-w-[140px] ${
size === "base" ? "text-base" : "text-sm"
}`}
>
{dropdownLinks.map((link, i) => {
if (link.isSubmenu) {
return (
<Menu.SubMenu
key={i}
icon={<i className={cn(link.icon, "text-base")} />}
className={`${
size === "base" ? "pl-5 pr-6 h-11" : "pl-4 pr-5 h-9"
} whitespace-nowrap`}
{...("href" in link
? {
type: "link",
href: link.href,
}
: {
type: "button",
onClick: link.onClick,
})}
icon={
<i className={cn(link.icon, "text-base mr-2 size-4")} />
}
dropdown={link.subMenu?.map((subLink, j) => (
<ConnectMenuItem link={subLink} size={size} key={j} />
))}
>
{link.label}
</Menu.Item>
))}
</div>
}
/>
</div>
)
})()}
<span>{link.label}</span>
</Menu.SubMenu>
)
} else {
return <ConnectMenuItem link={link} size={size} key={i} />
}
})}
</div>
}
/>
</div>
</div>
)
}
@ -421,3 +385,52 @@ function getAccountDisplayName(account: GeneralAccount | null) {
return value.slice(0, 5) + "..." + value.slice(-4)
}
// maybe we could be create a file for this component
function Notification() {
const showNotificationModal = useShowNotificationModal()
const { isAllRead } = useNotifications()
if (isAllRead)
return (
<BellIcon
className="size-6 text-zinc-500 cursor-pointer sm:hover:animate-buzz-out"
onClick={showNotificationModal}
/>
)
return (
<BellAlertIcon
className="size-6 text-accent cursor-pointer sm:hover:animate-buzz-out"
onClick={showNotificationModal}
/>
)
}
// the all menuItem in this file
// it can just be used in this file, because it's props link is the headerLinkType
function ConnectMenuItem({
link,
size,
}: {
link: HeaderLinkType
size: "base" | "sm"
}) {
return (
<Menu.Item
icon={<i className={cn(link.icon, "text-base")} />}
className={`${
size === "base" ? "pl-5 pr-6 h-11" : "pl-4 pr-5 h-9"
} whitespace-nowrap`}
{...("href" in link
? {
type: "link",
href: link.href,
}
: {
type: "button",
onClick: link.onClick,
})}
>
{link.label}
</Menu.Item>
)
}

View File

@ -2,20 +2,13 @@
import { useLocale } from "next-intl"
import { locales } from "~/i18n"
import { locales, nameMap } from "~/i18n"
import { Menu } from "../ui/Menu"
export function LanguageSwitch() {
const locale = useLocale()
const nameMap: Record<string, string> = {
en: "English",
zh: "简体中文",
"zh-TW": "繁體中文",
ja: "日本語",
}
return (
<Menu
placement="top"

View File

@ -2,7 +2,13 @@ import { AnimatePresence, m } from "framer-motion"
import Link from "next/link"
import React, { Fragment } from "react"
import { autoUpdate, Placement, shift, useFloating } from "@floating-ui/react"
import {
autoPlacement,
autoUpdate,
Placement,
shift,
useFloating,
} from "@floating-ui/react"
import { Menu as HeadlessUiMenu } from "@headlessui/react"
import { cn } from "~/lib/utils"
@ -17,16 +23,21 @@ export function Menu({
target,
dropdown,
placement = "bottom-start",
enableAutoPlacement = false,
allowedPlacements = ["bottom-start"],
}: React.PropsWithChildren<{
target: JSX.Element
dropdown: React.ReactNode
placement?: Placement
enableAutoPlacement?: boolean
allowedPlacements?: Placement[]
}>) {
const { refs, floatingStyles } = useFloating({
placement,
middleware: [
// Prevent overflowing viewport
shift({ padding: 20 }),
enableAutoPlacement ? autoPlacement({ allowedPlacements }) : undefined,
],
whileElementsMounted: autoUpdate,
})
@ -145,3 +156,40 @@ Menu.Item = function MenuItem({
</HeadlessUiMenu.Item>
)
}
// TODO: Add support for nested menus
// this is a hack submenu use the div and <Menu /> component
// about why i'm not use the headlessui Menu.Item, because it's not support nested menu,
// and it will make the autoPlacement not work properly.
// problem that's will happen is the styles maybe not sync with the <Menu.Item /> component
Menu.SubMenu = function MenuSubMenu({
icon,
children,
dropdown,
}: React.PropsWithChildren<{
icon: React.ReactNode
dropdown: React.ReactNode
}>) {
return (
<Menu
placement="right-start"
enableAutoPlacement
allowedPlacements={["left-start", "right-start"]}
target={
<div
className="w-full px-3 flex items-center flex-nowrap
pl-5 pr-6 h-11 whitespace-nowrap
hover:bg-hover
cursor-pointer select-none
"
aria-hidden
>
{icon}
{children}
</div>
}
dropdown={dropdown}
/>
)
}

View File

@ -9,6 +9,12 @@ export const languageNames: Record<(typeof locales)[number], string> = {
zh: "Chinese (Simplified)",
"zh-TW": "Chinese (Traditional)",
}
export const nameMap: Record<string, string> = {
en: "English",
zh: "简体中文",
"zh-TW": "繁體中文",
ja: "日本語",
}
export const defaultLocale = "en"
export default getRequestConfig(async ({ locale }) => ({

View File

@ -47,6 +47,7 @@
"Dashboard": "ダッシュボード",
"Copied!": "コピーしました!",
"Operator Sign": "オペレーター署名",
"Switch Language": "言語を切り替える",
"Switch Characters": "キャラクターを切り替える",
"Upgrade to Wallet": "ウォレットにアップグレード",
"Disconnect": "中断する",

View File

@ -58,6 +58,7 @@
"Dashboard": "主控臺",
"Copied!": "拷貝完成",
"Operator Sign": "簽名授權",
"Switch Language": "切換語言",
"Switch Characters": "切換身份",
"Upgrade to Wallet": "升級為錢包認證",
"Disconnect": "中斷連線",

View File

@ -57,6 +57,7 @@
"Dashboard": "仪表盘",
"Copied!": "已复制!",
"Operator Sign": "签名授权",
"Switch Language": "切换语言",
"Switch Characters": "切换角色",
"Upgrade to Wallet": "升级为钱包",
"Disconnect": "断开连接",