From 8b01c40da2dacbcab0fbee1cac52a07f0b22de4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hudaifa=20Abdullah=20=E3=83=84?= Date: Tue, 26 May 2026 01:11:13 +0300 Subject: [PATCH 1/4] feat(i18n): add Arabic language support with language picker popup - Add full Arabic (ar) locale covering all translation namespaces - Replace cycle button with animated popup language selector - Add `languageOptions` to i18n/index.ts as single source of truth - Register Arabic as RTL in `rtlLanguages` for automatic layout direction --- dashboard/src/components/Layout.css | 93 +++++ dashboard/src/components/Layout.tsx | 68 +++- dashboard/src/i18n/index.ts | 18 +- dashboard/src/i18n/locales/ar.json | 513 ++++++++++++++++++++++++++++ dashboard/src/i18n/locales/en.json | 1 + dashboard/src/i18n/locales/he.json | 1 + 6 files changed, 675 insertions(+), 19 deletions(-) create mode 100644 dashboard/src/i18n/locales/ar.json diff --git a/dashboard/src/components/Layout.css b/dashboard/src/components/Layout.css index 0db1288f..b3980950 100644 --- a/dashboard/src/components/Layout.css +++ b/dashboard/src/components/Layout.css @@ -337,6 +337,99 @@ } } +/* ==================== Language Picker ==================== */ +.lang-picker-wrap { + position: relative; +} + +.lang-trigger-btn { + width: 100%; +} + +.lang-popup { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + right: 0; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + overflow: hidden; + display: flex; + flex-direction: column; + transform-origin: bottom center; + transform: scaleY(0.85) translateY(6px); + opacity: 0; + pointer-events: none; + transition: + transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.15s ease; + z-index: 200; +} + +.lang-popup--open { + transform: scaleY(1) translateY(0); + opacity: 1; + pointer-events: auto; +} + +.lang-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.6rem 0.9rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + background: none; + border: none; + cursor: pointer; + text-align: start; + transition: background 0.15s, color 0.15s; +} + +.lang-option:focus, +.lang-option:focus-visible { + outline: none; +} + +.lang-option:hover { + background: var(--bg-light); + color: var(--text-primary); +} + +.lang-option--active { + color: var(--primary); + background: rgba(37, 211, 102, 0.08); +} + +.lang-option--active:hover { + background: rgba(37, 211, 102, 0.14); +} + +.lang-option-label { + flex: 1; +} + +.lang-check { + flex-shrink: 0; + color: var(--primary); +} + +.sidebar.collapsed .lang-popup { + left: 50%; + right: auto; + min-width: 140px; + transform-origin: bottom left; + transform: scaleY(0.85) translateX(-50%) translateY(6px); +} + +.sidebar.collapsed .lang-popup--open { + transform: scaleY(1) translateX(-50%) translateY(0); +} + /* ==================== RTL support ==================== */ [dir="rtl"] .sidebar { border-right: none; diff --git a/dashboard/src/components/Layout.tsx b/dashboard/src/components/Layout.tsx index f2815bb8..f3464333 100644 --- a/dashboard/src/components/Layout.tsx +++ b/dashboard/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -19,10 +19,11 @@ import { ChevronLeft, ChevronRight, Languages, + Check, } from 'lucide-react'; import { useTheme } from '../hooks/useTheme'; import { type UserRole } from '../hooks/useRole'; -import { supportedLanguages, type SupportedLanguage } from '../i18n'; +import { languageOptions, type SupportedLanguage } from '../i18n'; import './Layout.css'; interface LayoutProps { @@ -80,13 +81,28 @@ export function Layout({ onLogout, userRole }: LayoutProps) { const toggleMobile = () => setIsMobileOpen(!isMobileOpen); const currentLang = (i18n.resolvedLanguage || i18n.language || 'en').split('-')[0] as SupportedLanguage; - const cycleLanguage = () => { - const idx = supportedLanguages.indexOf(currentLang); - const next = supportedLanguages[(idx + 1) % supportedLanguages.length]; - void i18n.changeLanguage(next); + const isRtl = currentLang === 'he' || currentLang === 'ar'; + + const [isLangOpen, setIsLangOpen] = useState(false); + const langRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (langRef.current && !langRef.current.contains(e.target as Node)) { + setIsLangOpen(false); + } + }; + if (isLangOpen) document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isLangOpen]); + + const selectLanguage = (code: SupportedLanguage) => { + void i18n.changeLanguage(code); + setIsLangOpen(false); }; - const languageLabel = currentLang === 'he' ? 'עברית' : 'EN'; - const isRtl = currentLang === 'he'; + + const currentLangEntry = languageOptions.find(l => l.code === currentLang) ?? languageOptions[0]; + const languageLabel = isCollapsed ? currentLangEntry.short : currentLangEntry.label; return (
@@ -151,15 +167,33 @@ export function Layout({ onLogout, userRole }: LayoutProps) {
- +
+ + +
+ {languageOptions.map(lang => ( + + ))} +
+