Skip to content

Commit

Permalink
feat: Multi language support (#128)
Browse files Browse the repository at this point in the history
* feat: add support for multiple languages

* feat: persist language preference

* feat: add browser language detection and set current page language

* remove localStorage.clear() used for testing
  • Loading branch information
HashCookie authored Jul 3, 2024
1 parent 96d16cf commit 076b9bb
Show file tree
Hide file tree
Showing 15 changed files with 581 additions and 17 deletions.
81 changes: 81 additions & 0 deletions app/components/LanguageMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';
import React, { useEffect, useState } from 'react';
import clsx from 'clsx';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useLang } from '@/app/context/LangContext';

const LanguageMenu = () => {
const { lang, setLang } = useLang();
const [mounted, setMounted] = useState(false);

const languageNames = {
en: 'English',
zh: '简体中文',
hant: '繁體中文',
ja: '日本語',
fr: 'Français',
de: 'Deutsch',
};

const handleLangChange = (newLang) => {
setLang(newLang);
};

const currentLanguage = languageNames[lang] || 'Unknown';

useEffect(() => {
setMounted(true);
}, []);

if (!mounted) {
return null;
}

return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<button
className={clsx(
'bg-transparent',
'cursor-pointer',
'flex items-center',
'transition-colors duration-500',
'rounded',
'px-2 py-1',
'border border-[#453d7d]',
'text-[#453d7a]',
'bg-[#efefef]',
'hover:bg-[#453d7d] hover:text-white',
)}
>
<img
src="LanguageSwitching.svg"
alt="Language"
className="w-6 h-6 mr-1"
style={{
transition: 'filter 0.5s',
}}
/>
{currentLanguage}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content sideOffset={5} className="bg-white rounded-md p-2 z-10">
{Object.keys(languageNames).map((code) => {
return (
<DropdownMenu.Item
key={code}
onSelect={() => {
handleLangChange(code);
}}
className="p-2 cursor-pointer"
>
{languageNames[code]}
</DropdownMenu.Item>
);
})}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

export default LanguageMenu;
4 changes: 3 additions & 1 deletion app/components/SidePanelChat.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { extractPageContent } from '../utils/contentExtractor';
import { useLang } from '@/app/context/LangContext';

const SidePanelChat = forwardRef((props, ref) => {
const [isOpen, setIsOpen] = useState(false);
const [message, setMessage] = useState('');
const [pageContent, setPageContent] = useState('');
const [boxType, setBoxType] = useState('');
const { t } = useLang();

const toggleDrawer = () => {
setIsOpen(!isOpen);
Expand Down Expand Up @@ -33,7 +35,7 @@ const SidePanelChat = forwardRef((props, ref) => {
return (
<>
<div className="text-red-600 flex items-center">
<span>Why this result?</span>
<span>{t('Why this result')}</span>
</div>
{isOpen && <div className="fixed inset-0 bg-black bg-opacity-50 z-40" onClick={toggleDrawer}></div>}
<div
Expand Down
34 changes: 20 additions & 14 deletions app/components/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import useIndex from '@/app/components/editor/hooks/useIndex';
import SidePanelChat from '@/app/components/SidePanelChat';
import { extractPageContent } from '../../utils/contentExtractor';
import { buttonPlugin } from './ButtonPlugin';
import { useLang } from '@/app/context/LangContext';
import LanguageMenu from '@/app/components/LanguageMenu';

export const EditorScreen = () => {
const {
Expand Down Expand Up @@ -48,6 +50,7 @@ export const EditorScreen = () => {
const { message } = extractPageContent(boxType);
return message;
};
const { t } = useLang();

useEffect(() => {
const fetchCasbinVersion = async () => {
Expand Down Expand Up @@ -112,7 +115,7 @@ export const EditorScreen = () => {
</svg>
</button>

<div className={'pt-6 h-12 flex items-center'}>{open && <div>Custom config</div>}</div>
<div className={'pt-6 h-12 flex items-center font-bold'}>{open && <div>{t('Custom config')}</div>}</div>
<div className="flex-grow overflow-auto h-full">
{open && (
<div className="flex flex-col h-full">
Expand All @@ -139,7 +142,7 @@ export const EditorScreen = () => {
<div className="flex flex-row gap-1 pt-4 flex-1 overflow-hidden">
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className={clsx('h-10 pl-2', 'flex items-center justify-start gap-2')}>
<div className={'font-bold'}>Model</div>
<div className={'font-bold'}>{t('Model')}</div>
<select
defaultValue={'basic'}
onChange={(e) => {
Expand All @@ -148,7 +151,7 @@ export const EditorScreen = () => {
className={'border-[#767676] border rounded'}
>
<option value="" disabled>
Select your model
{t('Select your model')}
</option>
{Object.keys(example).map((n) => {
return (
Expand All @@ -175,7 +178,7 @@ export const EditorScreen = () => {
}
}}
>
RESET
{t('RESET')}
</button>
</div>
<div className="flex-grow overflow-auto h-full">
Expand Down Expand Up @@ -205,7 +208,7 @@ export const EditorScreen = () => {
</div>
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="h-10 font-bold flex items-center justify-between">
<div>Policy</div>
<div>{t('Policy')}</div>
<div className="text-right font-bold mr-4 text-sm text-[#e13c3c]">
<a href={`https://github.com/casbin/node-casbin/releases/tag/v${casbinVersion}`} target="_blank" rel="noopener noreferrer">
Node-Casbin v{casbinVersion}
Expand Down Expand Up @@ -241,7 +244,7 @@ export const EditorScreen = () => {
<div className="flex flex-row gap-1 pt-2 flex-1 overflow-hidden">
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className={clsx('h-10 pl-2', 'flex items-center justify-start gap-3')}>
<div className={'font-bold'}>Request</div>
<div className={'font-bold'}>{t('Request')}</div>
<div className={'space-x-2'}>
<input
className={clsx('w-7 pl-1', 'border border-black rounded')}
Expand Down Expand Up @@ -306,7 +309,7 @@ export const EditorScreen = () => {
</div>
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className={clsx('h-10 font-bold', 'flex items-center justify-between')}>
<div>Enforcement Result</div>
<div>{t('Enforcement Result')}</div>
<div className="mr-4">
<SidePanelChat ref={sidePanelChatRef} />
</div>
Expand Down Expand Up @@ -340,7 +343,7 @@ export const EditorScreen = () => {
</div>
</div>
</div>
<div className={clsx('pt-2 px-1')}>
<div className={clsx('pt-2 px-1 flex items-center')}>
<button
className={clsx(
'rounded',
Expand All @@ -361,7 +364,7 @@ export const EditorScreen = () => {
}
}}
>
SYNTAX VALIDATE
{t('SYNTAX VALIDATE')}
</button>
<button
className={clsx(
Expand Down Expand Up @@ -399,7 +402,7 @@ export const EditorScreen = () => {
});
}}
>
RUN THE TEST
{t('RUN THE TEST')}
</button>
{!share ? (
<span>
Expand Down Expand Up @@ -427,7 +430,7 @@ export const EditorScreen = () => {
});
}}
>
SHARE
{t('SHARE')}
</button>
</span>
) : (
Expand All @@ -446,16 +449,19 @@ export const EditorScreen = () => {
return copy(
() => {
setShare('');
setEcho(<div>Copied.</div>);
setEcho(<div>{t('Copied')}</div>);
},
`${window.location.origin + window.location.pathname}#${share}`,
);
}}
>
COPY
{t('COPY')}
</button>
)}
<div style={{ display: 'inline-block' }}>{echo}</div>
<div style={{ display: 'inline-block', marginRight: 'auto' }}>{echo}</div>
<div className="mr-3">
<LanguageMenu />
</div>
</div>
</div>
</div>
Expand Down
72 changes: 72 additions & 0 deletions app/context/LangContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';

const translations = {
en: require('../../messages/en.json'),
zh: require('../../messages/zh.json'),
zhHant: require('../../messages/zh-Hant.json'),
ja: require('../../messages/ja.json'),
fr: require('../../messages/fr.json'),
de: require('../../messages/de.json'),
};

type LangContextType = {
lang: string;
setLang: (lang: string) => void;
t: (key: string) => string;
};

const LangContext = createContext<LangContextType | undefined>(undefined);

const langMapping = {
'zh-Hant': 'zhHant',
};

const getTranslationKey = (lang) => {
return langMapping[lang] || lang;
};

export const LangProvider = ({ children }: { children: ReactNode }) => {
const [lang, setLangState] = useState('en');
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const savedLang = localStorage.getItem('lang');

if (savedLang) {
setLangState(savedLang);
} else {
const browserLang = navigator.language.split('-')[0];
const supportedLangs = ['en', 'zh', 'zh-Hant', 'ja', 'fr', 'de'];
const defaultLang = supportedLangs.includes(browserLang) ? browserLang : 'en';
setLangState(defaultLang);
localStorage.setItem('lang', defaultLang);
}
setIsLoading(false);
}, []);

const setLang = (newLang: string) => {
setLangState(newLang);
localStorage.setItem('lang', newLang);
};

const t = (key: string) => {
const langKey = getTranslationKey(lang);
const value = translations[langKey][key];
return value || key;
};

if (isLoading) {
return null;
}

return <LangContext.Provider value={{ lang, setLang, t }}>{children}</LangContext.Provider>;
};

export const useLang = () => {
const context = useContext(LangContext);
if (!context) {
throw new Error('useLang must be used within a LangProvider');
}
return context;
};
4 changes: 4 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

button:hover img {
filter: brightness(0) invert(1);
}
5 changes: 4 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import type { Metadata } from 'next';
import './globals.css';
import { LangProvider } from './context/LangContext';

export const metadata: Metadata = {
title: 'casbin-editor',
Expand All @@ -27,7 +28,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body>{children}</body>
<body>
<LangProvider>{children}</LangProvider>
</body>
</html>
);
}
14 changes: 14 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"Custom config": "Benutzerdefinierte Konfiguration",
"Model": "Modell",
"Select your model": "Wählen Sie Ihr Modell",
"RESET": "ZURÜCKSETZEN",
"Policy": "Strategie",
"Request": "Anfrage",
"Enforcement Result": "Durchsetzungsergebnis",
"Why this result": "Warum dieses Ergebnis?",
"SYNTAX VALIDATE": "SYNTAX VALIDIEREN",
"RUN THE TEST": "DEN TEST AUSFÜHREN",
"SHARE": "TEILEN",
"COPY": "KOPIEREN"
}
14 changes: 14 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"Custom config": "Custom config",
"Model": "Model",
"Select your model": "Select your model",
"RESET": "RESET",
"Policy": "Policy",
"Request": "Request",
"Enforcement Result": "Enforcement Result",
"Why this result": "Why this result?",
"SYNTAX VALIDATE": "SYNTAX VALIDATE",
"RUN THE TEST": "RUN THE TEST",
"SHARE": "SHARE",
"COPY": "COPY"
}
14 changes: 14 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"Custom config": "Configuration personnalisée",
"Model": "Modèle",
"Select your model": "Sélectionnez votre modèle",
"RESET": "RÉINITIALISER",
"Policy": "Stratégie",
"Request": "Demande",
"Enforcement Result": "Résultat de l'exécution",
"Why this result": "Pourquoi ce résultat?",
"SYNTAX VALIDATE": "VALIDER LA SYNTAXE",
"RUN THE TEST": "EXÉCUTER LE TEST",
"SHARE": "PARTAGER",
"COPY": "COPIER"
}
Loading

0 comments on commit 076b9bb

Please sign in to comment.