feat(pages): add macports to pages homepage (#873)

* docs: translations update keys

* tsx: new macports hero and import mp svg icon

* new macports svg

* add options

* keys translations

* tsx updates

* fix typo

* fixes

* tests: new
This commit is contained in:
Tao Xin
2026-08-14 15:30:32 +08:00
committed by GitHub
parent 92fd0d088e
commit d59eddaa5a
8 changed files with 275 additions and 20 deletions
+2
View File
@@ -39,6 +39,8 @@ body:
label: Installation Method
options:
- npm (global)
- Homebrew
- MacPorts
- GitHub Release binary
- Built from source
validations:
+1
View File
@@ -0,0 +1 @@
<svg fill="#1E79E9" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MacPorts</title><path d="M14.055 4.154c-1.323 0-2.395 1.16-2.395 2.593v10.506c0 1.432 1.072 2.593 2.395 2.593h7.55c1.323 0 2.395-1.161 2.395-2.593V6.747c0-1.432-1.072-2.593-2.395-2.593zm3.604 3.004c1.34 0 2.296.256 2.938.77.628.485 1.027 1.283 1.027 2.225 0 .941-.3 1.754-.884 2.282-.742.685-1.84 1.013-3.124 1.013-.285 0-.542-.015-.741-.057v3.451H14.72V7.357c.67-.114 1.612-.2 2.938-.2zM.614 7.193 0 16.807h2.011l.186-3.652c.057-1.198.114-2.681.156-3.979h.029c.243 1.255.557 2.624.87 3.765L4.28 16.65h1.697l1.17-3.751A60.6 60.6 0 0 0 8.23 9.176h.029c0 1.398.042 2.796.085 3.95l.157 3.68h2.11L10.1 7.194H7.246l-1.013 3.195a53.196 53.196 0 0 0-.913 3.452h-.057a41.43 41.43 0 0 0-.813-3.438L3.51 7.193zm17.145 1.62c-.428 0-.727.028-.884.07v2.839c.185.043.399.057.727.057 1.155 0 1.868-.585 1.868-1.555 0-.884-.613-1.412-1.711-1.412z"/></svg>

After

Width:  |  Height:  |  Size: 932 B

+96
View File
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { LanguageProvider } from '../i18n';
import HeroSection from './HeroSection';
function renderHero() {
render(
<MemoryRouter>
<LanguageProvider>
<HeroSection />
</LanguageProvider>
</MemoryRouter>,
);
}
// The panel is found through the id that `aria-controls` already points at, so
// the test leans on the accessibility wiring instead of a test-only hook.
const panel = () => document.getElementById('install-more-panel');
const trigger = () => screen.getByRole('button', { name: /More|MacPorts/i });
describe('HeroSection install channels', () => {
it('starts on the first channel with the panel closed', () => {
renderHero();
expect(screen.getByText('npm i -g @alibaba-group/open-code-review')).toBeTruthy();
expect(panel()).toBeNull();
});
it('picking an overflow channel swaps the command and closes the panel', async () => {
const user = userEvent.setup();
renderHero();
await user.click(trigger());
expect(panel()).not.toBeNull();
await user.click(screen.getByRole('button', { name: /MacPorts/i }));
expect(screen.getByText('sudo port install open-code-review')).toBeTruthy();
expect(panel()).toBeNull();
});
it('closes when a primary tab is clicked', async () => {
const user = userEvent.setup();
renderHero();
await user.click(trigger());
await user.click(screen.getByRole('button', { name: /Homebrew/i }));
expect(screen.getByText('brew install open-code-review')).toBeTruthy();
expect(panel()).toBeNull();
});
// Keyboard activation dispatches `click` with no preceding `mousedown`, so
// this does not go through the same path as the test above.
it('closes when a primary tab is activated by keyboard', async () => {
const user = userEvent.setup();
renderHero();
await user.click(trigger());
screen.getByRole('button', { name: /Homebrew/i }).focus();
await user.keyboard('{Enter}');
expect(screen.getByText('brew install open-code-review')).toBeTruthy();
expect(panel()).toBeNull();
});
it('closes on Escape and on an outside click', async () => {
const user = userEvent.setup();
renderHero();
await user.click(trigger());
await user.keyboard('{Escape}');
expect(panel()).toBeNull();
await user.click(trigger());
await user.click(document.body);
expect(panel()).toBeNull();
});
it('reflects the selected overflow channel on the trigger', async () => {
const user = userEvent.setup();
renderHero();
expect(screen.getByRole('button', { name: /^More$/i })).toBeTruthy();
await user.click(trigger());
await user.click(screen.getByRole('button', { name: /MacPorts/i }));
const collapsed = screen.getByRole('button', { name: /MacPorts/i });
expect(collapsed.getAttribute('aria-expanded')).toBe('false');
expect(screen.queryByRole('button', { name: /^More$/i })).toBeNull();
});
});
+164 -20
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
import React, { Suspense, useCallback, useState, useEffect } from 'react';
import React, { Suspense, useCallback, useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { Link } from 'react-router-dom';
import { useTranslation } from '../i18n';
@@ -9,7 +9,9 @@ import { useResponsive } from '../hooks/useResponsive';
import ErrorBoundary from './ErrorBoundary';
import npmIcon from '../assets/icons/npm.svg';
import brewIcon from '../assets/icons/brew.svg';
import macportsIcon from '../assets/icons/macports.svg';
import copyIcon from '../assets/icons/icon-copy.svg';
import chevronDownIcon from '../assets/icons/icon-chevron-down.svg';
const ColorBends = React.lazy(() => import(/* webpackChunkName: "color-bends" */ './ColorBends'));
@@ -122,11 +124,23 @@ const terminalLines = [
{ num: 12, content: <span className="terminal-cursor" style={{ color: TC.text }}>|</span> }, // allow-non-english: fullwidth bar renders the terminal cursor
];
const INSTALL_CHANNELS = [
{ key: 'npm', labelKey: 'hero.installNpm', cmd: 'npm i -g @alibaba-group/open-code-review', icons: [npmIcon] },
{ key: 'brew', labelKey: 'hero.installBrew', cmd: 'brew install open-code-review', icons: [brewIcon] },
interface InstallChannel {
key: string;
labelKey: string;
cmd: string;
icons: string[];
primary: boolean;
}
const INSTALL_CHANNELS: InstallChannel[] = [
{ key: 'npm', labelKey: 'hero.installNpm', cmd: 'npm i -g @alibaba-group/open-code-review', icons: [npmIcon], primary: true },
{ key: 'brew', labelKey: 'hero.installBrew', cmd: 'brew install open-code-review', icons: [brewIcon], primary: true },
{ key: 'macports', labelKey: 'hero.installMacPorts', cmd: 'sudo port install open-code-review', icons: [macportsIcon], primary: false },
];
const PRIMARY_CHANNELS = INSTALL_CHANNELS.filter((ch) => ch.primary);
const SECONDARY_CHANNELS = INSTALL_CHANNELS.filter((ch) => !ch.primary);
const HeroSection: React.FC = () => {
const { t } = useTranslation();
const { isMobile, isTablet, isDesktop } = useResponsive();
@@ -134,7 +148,12 @@ const HeroSection: React.FC = () => {
const [toastVisible, setToastVisible] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [showShaderBackground, setShowShaderBackground] = useState(false);
const [activeChannel, setActiveChannel] = useState(0);
const [activeChannelKey, setActiveChannelKey] = useState(INSTALL_CHANNELS[0].key);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const activeChannel = INSTALL_CHANNELS.find((ch) => ch.key === activeChannelKey) ?? INSTALL_CHANNELS[0];
const activeIsSecondary = !activeChannel.primary;
const showToast = (message: string) => {
setToastMessage(message);
@@ -176,6 +195,24 @@ const HeroSection: React.FC = () => {
return () => clearTimeout(timer);
}, [toastVisible]);
useEffect(() => {
if (!menuOpen) return;
const handlePointerDown = (e: MouseEvent | TouchEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenuOpen(false);
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('touchstart', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('touchstart', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [menuOpen]);
useEffect(() => {
// Wait until after the first paint before loading the heavy shader chunk.
let secondFrame: number | undefined;
@@ -325,33 +362,140 @@ const HeroSection: React.FC = () => {
</div>
{/* Install channels — tab switcher */}
<div style={{ width: '100%', maxWidth: 460 }}>
<div style={{ display: 'flex', gap: 0, marginBottom: 8 }}>
{INSTALL_CHANNELS.map((ch, idx) => (
<div style={{ width: '100%', maxWidth: 520 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 8 }}>
{PRIMARY_CHANNELS.map((ch) => {
const isActive = ch.key === activeChannelKey;
return (
<button
key={ch.key}
type="button"
onClick={() => { setActiveChannelKey(ch.key); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 9px',
background: isActive ? 'rgba(255,255,255,0.1)' : 'transparent',
border: 'none',
borderBottom: isActive ? '2px solid rgba(255,255,255,0.8)' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
{ch.icons.map((icon, i) => (
<img key={i} src={icon} alt="" style={{ width: 14, height: 14, flexShrink: 0, opacity: isActive ? 1 : 0.5 }} />
))}
<span style={{ fontSize: 13, fontWeight: isActive ? 600 : 500, color: isActive ? '#fff' : 'rgba(255,255,255,0.45)' }}>
{t(ch.labelKey)}
</span>
</button>
);
})}
{/* Overflow channels live behind a "More" trigger so the row stops
growing each time a new channel is added. */}
<div ref={menuRef} style={{ position: 'relative' }}>
<button
key={ch.key}
type="button"
onClick={() => setActiveChannel(idx)}
aria-expanded={menuOpen}
aria-controls="install-more-panel"
onClick={() => setMenuOpen((open) => !open)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 9px',
background: activeChannel === idx ? 'rgba(255,255,255,0.1)' : 'transparent',
background: activeIsSecondary ? 'rgba(255,255,255,0.1)' : 'transparent',
border: 'none',
borderBottom: activeChannel === idx ? '2px solid rgba(255,255,255,0.8)' : '2px solid transparent',
borderBottom: activeIsSecondary ? '2px solid rgba(255,255,255,0.8)' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
{ch.icons.map((icon, i) => (
<img key={i} src={icon} alt="" style={{ width: 14, height: 14, flexShrink: 0, opacity: activeChannel === idx ? 1 : 0.5 }} />
))}
<span style={{ fontSize: 13, fontWeight: activeChannel === idx ? 600 : 500, color: activeChannel === idx ? '#fff' : 'rgba(255,255,255,0.45)' }}>
{t(ch.labelKey)}
{activeIsSecondary && (
<img src={activeChannel.icons[0]} alt="" style={{ width: 14, height: 14, flexShrink: 0, opacity: 1 }} />
)}
<span style={{ fontSize: 13, fontWeight: activeIsSecondary ? 600 : 500, color: activeIsSecondary ? '#fff' : 'rgba(255,255,255,0.45)' }}>
{activeIsSecondary ? t(activeChannel.labelKey) : t('hero.installMore')}
</span>
<img
src={chevronDownIcon}
alt=""
style={{ width: 12, height: 12, flexShrink: 0, opacity: activeIsSecondary ? 0.8 : 0.5, transform: menuOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}
/>
</button>
))}
{menuOpen && (
<div
id="install-more-panel"
style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: 8,
background: 'rgba(26,26,26,0.92)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: 8,
padding: 4,
zIndex: 200,
minWidth: 200,
}}
>
{SECONDARY_CHANNELS.map((ch) => {
const isActive = ch.key === activeChannelKey;
return (
<button
key={ch.key}
type="button"
onClick={() => { setActiveChannelKey(ch.key); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '8px 12px',
background: isActive ? 'rgba(255,255,255,0.08)' : 'transparent',
border: 'none',
borderRadius: 6,
color: isActive ? '#fff' : 'rgba(255,255,255,0.6)',
fontSize: 13,
textAlign: 'left',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
{ch.icons.map((icon, i) => (
<img key={i} src={icon} alt="" style={{ width: 14, height: 14, flexShrink: 0, opacity: isActive ? 1 : 0.5 }} />
))}
<span style={{ fontWeight: isActive ? 600 : 500 }}>{t(ch.labelKey)}</span>
</button>
);
})}
<div style={{ height: 1, background: 'rgba(255,255,255,0.12)', margin: '4px 8px' }} />
<Link
to="/docs/installation"
onClick={() => setMenuOpen(false)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
padding: '8px 12px',
borderRadius: 6,
color: 'rgba(255,255,255,0.6)',
fontSize: 13,
textDecoration: 'none',
whiteSpace: 'nowrap',
}}
>
<span>{t('hero.allInstallOptions')}</span>
<span aria-hidden="true">→</span>
</Link>
</div>
)}
</div>
</div>
<div
style={{
@@ -377,13 +521,13 @@ const HeroSection: React.FC = () => {
textOverflow: 'ellipsis',
}}
>
{INSTALL_CHANNELS[activeChannel].cmd}
{activeChannel.cmd}
</span>
<img
src={copyIcon}
alt="Copy"
style={{ width: 16, height: 16, cursor: 'pointer', flexShrink: 0, opacity: 0.7 }}
onClick={() => handleCopy(INSTALL_CHANNELS[activeChannel].cmd)}
onClick={() => handleCopy(activeChannel.cmd)}
/>
</div>
</div>
+3
View File
@@ -20,6 +20,9 @@ export const en = {
'hero.copyFailed': 'Copy failed',
'hero.installNpm': 'npm',
'hero.installBrew': 'Homebrew',
'hero.installMacPorts': 'MacPorts',
'hero.installMore': 'More',
'hero.allInstallOptions': 'All install options',
// Error boundary
'error.pageLoadFailed': 'Failed to load this page.',
+3
View File
@@ -22,6 +22,9 @@ export const ja: TranslationKeys = {
'hero.copyFailed': 'コピー失敗',
'hero.installNpm': 'npm',
'hero.installBrew': 'Homebrew',
'hero.installMacPorts': 'MacPorts',
'hero.installMore': 'その他',
'hero.allInstallOptions': 'すべてのインストール方法',
// Error boundary
'error.pageLoadFailed': 'ページの読み込みに失敗しました。',
+3
View File
@@ -22,6 +22,9 @@ export const ru: TranslationKeys = {
'hero.copyFailed': 'Не удалось скопировать',
'hero.installNpm': 'npm',
'hero.installBrew': 'Homebrew',
'hero.installMacPorts': 'MacPorts',
'hero.installMore': 'Ещё',
'hero.allInstallOptions': 'Все способы установки',
// Error boundary
'error.pageLoadFailed': 'Не удалось загрузить страницу.',
+3
View File
@@ -22,6 +22,9 @@ export const zh: TranslationKeys = {
'hero.copyFailed': '复制失败',
'hero.installNpm': 'npm',
'hero.installBrew': 'Homebrew',
'hero.installMacPorts': 'MacPorts',
'hero.installMore': '更多',
'hero.allInstallOptions': '全部安装方式',
// Error boundary
'error.pageLoadFailed': '页面加载失败。',