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
2 changes: 2 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ body:
label: Installation Method
options:
- npm (global)
- Homebrew
- MacPorts
- GitHub Release binary
- Built from source
validations:
Expand Down
1 change: 1 addition & 0 deletions pages/src/assets/icons/macports.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
96 changes: 96 additions & 0 deletions pages/src/components/HeroSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
184 changes: 164 additions & 20 deletions pages/src/components/HeroSection.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
// 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';
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'));

Expand Down Expand Up @@ -122,19 +124,36 @@ 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();
const twoCol = isDesktop;
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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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={{
Expand All @@ -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>
Expand Down
3 changes: 3 additions & 0 deletions pages/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
3 changes: 3 additions & 0 deletions pages/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'ページの読み込みに失敗しました。',
Expand Down
3 changes: 3 additions & 0 deletions pages/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'Не удалось загрузить страницу.',
Expand Down
Loading