Skip to content
Open
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
60 changes: 35 additions & 25 deletions app/(core)/components/ContributorsSection.jsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,37 @@
// app/components/ContributorsSection.jsx
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import useTranslation from "../hooks/useTranslation.ts";
import {
getGithubContributorsFallback,
loadGithubContributors,
} from "../lib/githubStats.js";

const avatarFallback = (login) =>
`https://github.com/identicons/${encodeURIComponent(login)}.png`;

export default function ContributorsSection() {
const [contributors, setContributors] = useState([]);
const [brokenAvatars, setBrokenAvatars] = useState({});
const { t, meta } = useTranslation();
const isCompleted = meta?.completed || false;

useEffect(() => {
async function getContributors(page = 1) {
const res = await fetch(
`https://api.github.com/repos/physicshub/physicshub.github.io/contributors?per_page=100&page=${page}`
);
return res.ok ? res.json() : [];
}

async function getAllContributors() {
let all = [];
let page = 1;
let batch = [];
let cancelled = false;

do {
batch = await getContributors(page);
all = all.concat(batch);
page++;
} while (batch.length > 0);

return all;
const fallback = getGithubContributorsFallback();
if (fallback.length > 0) {
setContributors(fallback);
}

getAllContributors().then(setContributors);
loadGithubContributors().then((nextContributors) => {
if (!cancelled && nextContributors.length > 0) {
setContributors(nextContributors);
}
});

return () => {
cancelled = true;
};
}, []);

return (
Expand All @@ -47,12 +47,22 @@ export default function ContributorsSection() {
{contributors.map((c) => (
<div key={c.id} className="contributor-card">
<a href={c.html_url} target="_blank" rel="noopener noreferrer">
<Image
src={c.avatar_url}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
brokenAvatars[c.id] ? avatarFallback(c.login) : c.avatar_url
}
alt={c.login}
className="contributor-avatar"
width={50}
height={50}
width={80}
height={80}
loading="lazy"
decoding="async"
onError={() =>
setBrokenAvatars((current) =>
current[c.id] ? current : { ...current, [c.id]: true }
)
}
/>
</a>
<div className="contributor-info">
Expand Down
78 changes: 39 additions & 39 deletions app/(core)/components/GitHubHeaderBadge.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,71 +3,70 @@
import { useEffect, useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { getGithubStatsFallback, loadGithubStats } from "../lib/githubStats.js";

const REPO_URL = "https://github.com/physicshub/physicshub.github.io";
const REPO_API = "https://api.github.com/repos/physicshub/physicshub.github.io";
const CONTRIBUTORS_API = `${REPO_API}/contributors?per_page=100`;

const PLACEHOLDER_MESSAGES = [
"Open source",
"Built in public",
"Star the repo",
];

export default function GitHubHeaderBadge({ mode }) {
const [mounted, setMounted] = useState(false);
const [stats, setStats] = useState({ stars: null, contributors: null });
const [messageIndex, setMessageIndex] = useState(0);

useEffect(() => {
let cancelled = false;

async function loadStats() {
try {
const [repoRes, contributorsRes] = await Promise.all([
fetch(REPO_API),
fetch(CONTRIBUTORS_API),
]);

const repoData = await repoRes.json();
const contributorsData = await contributorsRes.json();

if (cancelled) return;

setStats({
stars:
typeof repoData?.stargazers_count === "number"
? repoData.stargazers_count
: null,
contributors: Array.isArray(contributorsData)
? contributorsData.length
: null,
});
} catch {
if (!cancelled) {
setStats({ stars: null, contributors: null });
}
}
}

loadStats();
setMounted(true);

const fallback = getGithubStatsFallback();
setStats({
stars: fallback.stars,
contributors: fallback.contributors,
});

loadGithubStats().then((nextStats) => {
if (cancelled) return;
setStats({
stars: nextStats.stars,
contributors: nextStats.contributors,
});
});

return () => {
cancelled = true;
};
}, []);

const messages = useMemo(
() => [
const messages = useMemo(() => {
if (!mounted) return PLACEHOLDER_MESSAGES;

return [
stats.stars != null ? `${stats.stars} stars` : "Open source",
stats.contributors != null
? `${stats.contributors} contributors`
: "Built in public",
"Star the repo",
],
[stats]
);
];
}, [mounted, stats]);

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

const intervalId = window.setInterval(() => {
setMessageIndex((current) => (current + 1) % messages.length);
}, 2600);

return () => window.clearInterval(intervalId);
}, [messages.length]);
}, [mounted, messages.length]);

useEffect(() => {
setMessageIndex(0);
}, [messages]);

return (
<a
Expand All @@ -83,9 +82,10 @@ export default function GitHubHeaderBadge({ mode }) {
<span
className="github-header-badge__track"
style={{ transform: `translateY(-${messageIndex * 1.15}rem)` }}
suppressHydrationWarning
>
{messages.map((message) => (
<span className="github-header-badge__label" key={message}>
{messages.map((message, index) => (
<span className="github-header-badge__label" key={index}>
{message}
</span>
))}
Expand Down
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
132 changes: 132 additions & 0 deletions app/(core)/components/LanguageSwitcher.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"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}`;

if (languageCode === "en") {
document.cookie = `googtrans=; path=/${domainAttr}; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
return;
}

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());
}, []);

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>
);
}
6 changes: 5 additions & 1 deletion app/(core)/components/Tag.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"use client";

import { COLORS } from "../data/tags";
import useTranslation from "../hooks/useTranslation.ts";

function Tag({ tag, className = "" }) {
const { t } = useTranslation();
const colorData = COLORS[tag.color] || COLORS.grey;

const inlineStyle = {
Expand All @@ -10,7 +14,7 @@ function Tag({ tag, className = "" }) {

return (
<span className={`tag ${className}`} style={inlineStyle}>
{tag.name}
{t(tag.name)}
</span>
);
}
Expand Down
Loading
Loading