Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/(core)/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useSticky } from "../hooks/useSticky";
import { useTheme } from "../hooks/useTheme";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHamburger } from "@fortawesome/free-solid-svg-icons";
import GoogleTranslator from "./GoogleTranslator.jsx";
import LanguageSwitcher from "./LanguageSwitcher.jsx";
import { usePathname } from "next/navigation.js";

export default function Header() {
Expand Down Expand Up @@ -129,8 +129,8 @@ export default function Header() {
<NavMenu onNavigate={handleMenuClose} />

<div className="controls">
<GoogleTranslator />
<GitHubHeaderBadge mode={mode} />
<LanguageSwitcher />
<Theme mode={mode} onToggle={toggleMode} />
</div>
</div>
Expand Down
131 changes: 131 additions & 0 deletions app/(core)/components/LanguageSwitcher.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"use client";

import React, { useCallback, useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLanguage, faChevronDown } from "@fortawesome/free-solid-svg-icons";
import useTranslation from "../hooks/useTranslation.ts";
import "../styles/translator.css";

const LANGUAGES = {
en: "English",
ru: "Русский",
};

const getStoredLang = () => {
if (typeof document === "undefined") return "en";
const value = `; ${document.cookie}`;
const parts = value.split("; googtrans=");
if (parts.length === 2) {
const cookie = parts.pop()?.split(";").shift();
if (cookie) {
const segments = cookie.split("/");
if (segments.length >= 3 && LANGUAGES[segments[2]]) {
return segments[2];
}
}
}
return "en";
};

const setLangCookie = (languageCode) => {
const hostname = window.location.hostname;
const isLocal = hostname === "localhost" || hostname === "127.0.0.1";
const domainAttr = isLocal ? "" : `; domain=.${hostname}`;
document.cookie = `googtrans=/en/${languageCode}; path=/${domainAttr}; SameSite=Lax`;
};

export default function LanguageSwitcher() {
const { t, meta } = useTranslation();
const isCompleted = meta?.completed || false;
const [currentLanguage, setCurrentLanguage] = useState("en");
const [open, setOpen] = useState(false);
const containerRef = useRef(null);

useEffect(() => {
setCurrentLanguage(getStoredLang());
}, []);

useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = currentLanguage === "ru" ? "ru" : "en";
}, [currentLanguage]);

const changeLanguage = useCallback((languageCode) => {
setCurrentLanguage(languageCode);
setLangCookie(languageCode);
setOpen(false);

window.dispatchEvent(
new CustomEvent("gtrans:languagechange", {
detail: { lang: languageCode },
})
);
}, []);

useEffect(() => {
if (!open) return;

const handleClickOutside = (event) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target)
) {
setOpen(false);
}
};

const handleEscape = (event) => {
if (event.key === "Escape") setOpen(false);
};

document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open]);

return (
<div
ref={containerRef}
className={`lang-switcher ${isCompleted ? "notranslate" : ""}`.trim()}
>
<button
type="button"
className="lang-switcher__trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={t("language")}
onClick={() => setOpen((value) => !value)}
>
<FontAwesomeIcon icon={faLanguage} className="lang-switcher__icon" />
<span className="lang-switcher__code notranslate">
{currentLanguage.toUpperCase()}
</span>
<FontAwesomeIcon
icon={faChevronDown}
className={`lang-switcher__chevron ${open ? "is-open" : ""}`}
/>
</button>

{open ? (
<ul className="lang-switcher__menu" role="listbox">
{Object.entries(LANGUAGES).map(([code, name]) => (
<li key={code} role="none">
<button
type="button"
role="option"
aria-selected={currentLanguage === code}
className={`lang-switcher__option ${currentLanguage === code ? "is-active" : ""}`}
onClick={() => changeLanguage(code)}
>
<span className="notranslate">{name}</span>
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
7 changes: 7 additions & 0 deletions app/(core)/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,13 @@ nav a:hover::after {
}

.controls {
display: flex;
gap: 0.45rem;
position: relative;
z-index: 2200;
}

.github-header-badge {
display: none;
}

Expand Down
111 changes: 111 additions & 0 deletions app/(core)/styles/translator.css
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,114 @@ body {
transform: scale(1);
}
}

/* Compact language switcher button + dropdown */
.lang-switcher {
position: relative;
flex-shrink: 0;
z-index: 2200;
}

.lang-switcher__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
height: 2.75rem;
min-width: 2.75rem;
padding: 0 0.65rem;
border: 1px solid var(--border-color);
border-radius: 0.75rem;
background: var(--bg-color);
color: var(--ph-fg-muted, var(--text-color));
cursor: pointer;
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.12);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
}

.lang-switcher__trigger:hover,
.lang-switcher__trigger:focus-visible {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 230, 230, 0.25);
outline: none;
}

.lang-switcher__icon {
font-size: 1rem;
color: var(--accent-color);
pointer-events: none;
}

.lang-switcher__code {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1;
pointer-events: none;
}

.lang-switcher__chevron {
font-size: 0.55rem;
opacity: 0.7;
transition: transform 0.2s ease;
pointer-events: none;
}

.lang-switcher__chevron.is-open {
transform: rotate(180deg);
}

.lang-switcher__menu {
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
z-index: 2300;
min-width: 8.5rem;
margin: 0;
padding: 0.35rem;
list-style: none;
border: 1px solid var(--border-color);
border-radius: 0.75rem;
background: var(--bg-color);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.25);
}

.lang-switcher__option {
display: block;
width: 100%;
padding: 0.55rem 0.75rem;
border: none;
border-radius: 0.5rem;
background: transparent;
color: var(--ph-fg-muted, var(--text-color));
font-size: 0.9rem;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}

.lang-switcher__option:hover,
.lang-switcher__option:focus-visible {
background: rgba(0, 230, 230, 0.1);
outline: none;
}

.lang-switcher__option.is-active {
color: var(--accent-color);
font-weight: 600;
background: rgba(0, 230, 230, 0.08);
}

@media (max-width: 840px) {
.lang-switcher__code {
display: none;
}

.lang-switcher__trigger {
padding: 0 0.55rem;
min-width: 2.75rem;
}
}
Loading