[codex] Add AMR sign-in to landing header (#4934)

* feat(landing): add AMR sign-in header

* feat(landing): center sign-in popup, scrim the page, return home after login

Improve the AMR sign-in popup flow on the landing header:
- Center the vela login popup over the current window (multi-monitor aware)
  instead of letting the browser place it arbitrarily.
- Dim the page with a focus scrim while the popup is open so it reads as a
  modal step; remove it on success, cancel, or timeout.
- After a successful login, close the popup and return the main window to the
  localized Open Design homepage (in place when already there).
- Fall back to a same-tab navigation when the popup is blocked.

Applied to both the sub-page enhancer (header-enhancer.astro) and the
homepage's inlined copy (index.astro).

* fix(landing): normalize AMR home redirect

---------

Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
This commit is contained in:
Caprika
2026-07-01 06:35:07 +00:00
committed by GitHub
co-authored by NJUHua
parent 572b87cfc4
commit b5adcc2d76
5 changed files with 687 additions and 0 deletions
@@ -190,6 +190,191 @@
.catch(() => {});
}
// Open Design Cloud (AMR) header account. Detects an existing cloud
// session via the vela API, swaps the "Sign in" link for an avatar menu,
// drives popup login + polling, and handles sign-out. All endpoints/URLs
// arrive as `data-*` on `[data-amr-account]` (header.tsx) since this inline
// script is not processed by Vite. Every network path fails closed to the
// signed-out state: on a non-apex origin (preview/staging) or with
// third-party cookies blocked, the credentialed call is rejected and we
// simply keep showing "Sign in" rather than erroring.
const account = document.querySelector('[data-amr-account]');
if (account) {
const api = (account.getAttribute('data-amr-api') || '').replace(/\/$/, '');
const loginUrl = account.getAttribute('data-amr-login') || '';
// Localized Open Design homepage — where the user lands after a
// successful popup login ("回到官网首页"). Empty disables the redirect.
const homeUrl = account.getAttribute('data-amr-home') || '';
const signinEl = account.querySelector('[data-amr-signin]');
const menuEl = account.querySelector('[data-amr-menu]');
const avatarImg = account.querySelector('[data-amr-avatar]');
const avatarFallback = account.querySelector('[data-amr-avatar-fallback]');
const nameEl = account.querySelector('[data-amr-name]');
const emailEl = account.querySelector('[data-amr-email]');
const signoutBtn = account.querySelector('[data-amr-signout]');
const sessionUrl = api + '/api/auth/get-session';
const signoutUrl = api + '/api/auth/sign-out';
const fetchSession = () =>
fetch(sessionUrl, {
credentials: 'include',
headers: { Accept: 'application/json' },
})
.then((r) => (r.ok ? r.json() : null))
.then((data) => (data && data.user ? data.user : null))
.catch(() => null);
const initials = (user) => {
const base = (user.name || user.email || '').trim();
return base ? base[0].toUpperCase() : '·';
};
const showSignedIn = (user) => {
if (nameEl) nameEl.textContent = user.name || '';
if (emailEl) emailEl.textContent = user.email || '';
if (avatarImg && user.image) {
avatarImg.setAttribute('src', user.image);
avatarImg.removeAttribute('hidden');
if (avatarFallback) avatarFallback.setAttribute('hidden', '');
} else {
if (avatarImg) avatarImg.setAttribute('hidden', '');
if (avatarFallback) {
avatarFallback.textContent = initials(user);
avatarFallback.removeAttribute('hidden');
}
}
if (signinEl) signinEl.setAttribute('hidden', '');
if (menuEl) menuEl.removeAttribute('hidden');
};
const showSignedOut = () => {
if (menuEl) {
menuEl.setAttribute('hidden', '');
menuEl.removeAttribute('open');
}
if (signinEl) signinEl.removeAttribute('hidden');
};
// Focus scrim shown on the OD page while the login popup is open, so
// the popup reads as a modal step (the iframe-modal alternative is ruled
// out by vela's X-Frame-Options + cross-site cookie + OAuth-in-iframe
// limits). Mounted on demand and faded via `.is-active`.
let overlayEl = null;
const showOverlay = () => {
if (overlayEl) return;
overlayEl = document.createElement('div');
overlayEl.className = 'nav-login-overlay';
overlayEl.setAttribute('aria-hidden', 'true');
document.body.appendChild(overlayEl);
requestAnimationFrame(() => {
if (overlayEl) overlayEl.classList.add('is-active');
});
};
const hideOverlay = () => {
if (!overlayEl) return;
const el = overlayEl;
overlayEl = null;
el.classList.remove('is-active');
const drop = () => el.remove();
el.addEventListener('transitionend', drop, { once: true });
setTimeout(drop, 400); // fallback if transitionend never fires
};
// Send the main window back to the localized OD homepage after login.
// Skipped when already on it so a homepage sign-in just swaps in place;
// from a sub-page we navigate (the post-load probe re-renders the avatar).
const goHome = () => {
if (!homeUrl) return;
const targetUrl = new URL(homeUrl, window.location.href);
const here = window.location.pathname.replace(/\/+$/, '');
const target = targetUrl.pathname.replace(/\/+$/, '');
if (here !== target) window.location.assign(homeUrl);
};
// One terminal success path for every login completion route.
const completeSignIn = (user) => {
hideOverlay();
showSignedIn(user);
goHome();
};
// Initial silent probe for an existing session.
fetchSession().then((user) => {
if (user) showSignedIn(user);
});
// Popup login + poll. `window.open` MUST run synchronously in the click
// handler or the browser blocks the popup. The link keeps a real href so
// that with JS disabled the click still navigates to the login page.
if (signinEl && loginUrl) {
signinEl.addEventListener('click', (ev) => {
ev.preventDefault();
// Center the popup over the current browser window (multi-monitor
// aware: screenLeft/screenTop give the window's screen offset).
const W = 480;
const H = 720;
const dx = window.screenLeft ?? window.screenX ?? 0;
const dy = window.screenTop ?? window.screenY ?? 0;
const vw = window.innerWidth || document.documentElement.clientWidth || screen.width;
const vh = window.innerHeight || document.documentElement.clientHeight || screen.height;
const left = Math.round(dx + (vw - W) / 2);
const top = Math.round(dy + (vh - H) / 2);
const popup = window.open(
loginUrl,
'od-amr-login',
`width=${W},height=${H},left=${left},top=${top},menubar=no,toolbar=no,location=yes`,
);
// Popup blocked (or returned null): fall back to a same-tab navigation
// to the login page so the click never dead-ends.
if (!popup) {
window.location.assign(loginUrl);
return;
}
showOverlay();
const started = Date.now();
const POLL_MS = 2000;
const MAX_MS = 5 * 60 * 1000;
const timer = setInterval(() => {
if (Date.now() - started > MAX_MS) {
clearInterval(timer);
hideOverlay();
return;
}
if (popup.closed) {
clearInterval(timer);
// Re-check once: login may have completed just before close.
fetchSession().then((user) => {
if (user) completeSignIn(user);
else hideOverlay();
});
return;
}
fetchSession().then((user) => {
if (!user) return;
clearInterval(timer);
try {
if (!popup.closed) popup.close();
} catch {}
completeSignIn(user);
});
}, POLL_MS);
});
}
if (signoutBtn) {
signoutBtn.addEventListener('click', () => {
fetch(signoutUrl, {
method: 'POST',
credentials: 'include',
headers: { Accept: 'application/json' },
})
.catch(() => {})
.then(() => showSignedOut());
});
}
}
// Helper used by every clipboard-write button (link copy, share
// text copy). Tries the Clipboard API; falls back to a `prompt()`
// window the user can manually copy from on browsers that block
@@ -28,6 +28,18 @@ const X_PROFILE = 'https://x.com/OpenDesignHQ';
// dropdown entry, and the footer Partners column.
const AMR_URL = 'https://open-design.ai/amr/';
// Open Design Cloud (AMR / vela) endpoints for the header sign-in module.
// Production defaults; overridable at build time via PUBLIC_* env so a
// preview/staging build can point at a non-prod cloud. These are surfaced to
// the runtime via `data-*` on `.nav-account` because the auth logic lives in
// `header-enhancer.astro`'s `<script is:inline>` (NOT processed by Vite, so it
// cannot read `import.meta.env` itself).
const env = import.meta.env as Record<string, string | undefined>;
const AMR_API_BASE = env.PUBLIC_AMR_API_BASE ?? 'https://amr-api.open-design.ai';
const AMR_LOGIN_URL = env.PUBLIC_AMR_LOGIN_URL ?? 'https://open-design.ai/amr/login';
const AMR_CONSOLE_URL =
env.PUBLIC_AMR_CONSOLE_URL ?? 'https://open-design.ai/amr?source=open_design';
// Solution → Use cases / Roles. Hrefs mirror upstream main's header 1:1 and
// pair positionally with the localized `useCaseItems` / `roleItems` tuples.
const USE_CASE_HREFS = [
@@ -574,6 +586,65 @@ export function Header({
>
{headerCopy.download}
</a>
{/*
Open Design Cloud (AMR) account entry. Renders BOTH states up front
and lets `header-enhancer.astro` toggle them at runtime: the
signed-out "Sign in" link is visible by default (so no-JS / pre-hydration
shows a working login link), and the signed-in avatar menu stays
`hidden` until the enhancer confirms a live cloud session via
`GET {api}/api/auth/get-session`. Config flows through `data-*`
because the enhancer script cannot read `import.meta.env`.
*/}
<div
className='nav-account'
data-amr-account
data-amr-api={AMR_API_BASE}
data-amr-login={AMR_LOGIN_URL}
data-amr-console={AMR_CONSOLE_URL}
data-amr-home={href('/')}
>
<a className='nav-signin' href={AMR_LOGIN_URL} data-amr-signin>
{headerCopy.signIn}
</a>
<details className='nav-account-menu' data-amr-menu hidden>
<summary
className='nav-account-trigger'
aria-label={headerCopy.accountAria}
title={headerCopy.accountAria}
>
<img className='nav-avatar' alt='' data-amr-avatar />
<span
className='nav-avatar-fallback'
data-amr-avatar-fallback
aria-hidden='true'
/>
</summary>
<div className='nav-account-dropdown' role='menu'>
<div className='nav-account-id'>
<span className='nav-account-name' data-amr-name />
<span className='nav-account-email' data-amr-email />
</div>
<a
className='nav-account-item'
role='menuitem'
href={AMR_CONSOLE_URL}
target='_blank'
rel='noreferrer noopener'
data-amr-console-link
>
{headerCopy.menuConsole}
</a>
<button
type='button'
className='nav-account-item nav-account-signout'
role='menuitem'
data-amr-signout
>
{headerCopy.menuSignOut}
</button>
</div>
</details>
</div>
</div>
</div>
{/*
+176
View File
@@ -452,6 +452,182 @@ body::before {
.locale-menu { animation: none; }
.locale-trigger-caret { transition: none; }
}
/* ---------- Open Design Cloud (AMR) header account ---------- */
/*
* Two mutually-exclusive states share this slot: the signed-out "Sign in"
* link and the signed-in avatar menu. Both render in the markup; the avatar
* `<details>` carries `hidden` until `header-enhancer.astro` confirms a live
* cloud session, then toggles `hidden` on each. `[hidden]` on either element
* fully removes it from layout, so only one is ever visible.
*/
.nav-account {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
/* Sign in — an outlined pill so it sits beside the filled Download CTA
without competing with it as a second solid button. */
.nav-signin {
display: inline-flex;
align-items: center;
padding: 7px 15px;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink);
font-family: var(--sans);
font-size: 13px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: border-color 160ms ease, color 160ms ease;
}
.nav-signin:hover,
.nav-signin:focus-visible {
border-color: var(--ink);
color: var(--ink);
}
.nav-account [hidden] { display: none !important; }
/* Sign-in focus scrim — dims the page (but not the header chrome at z-index
50/60) while the vela login popup is open, so the popup reads as a modal
step. Mounted/removed by the account block in `header-enhancer.astro`;
`.is-active` drives the fade. */
.nav-login-overlay {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(8, 10, 14, 0.55);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
opacity: 0;
transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.nav-login-overlay.is-active { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.nav-login-overlay { transition: none; }
}
/* Avatar menu — a borderless <details> whose summary is the avatar disc. */
.nav-account-menu {
position: relative;
}
.nav-account-trigger {
display: inline-flex;
align-items: center;
cursor: pointer;
list-style: none;
border-radius: 999px;
}
.nav-account-trigger::-webkit-details-marker { display: none; }
.nav-account-trigger:focus-visible {
outline: 2px solid var(--coral);
outline-offset: 2px;
}
/* Circular avatar — a disc reads unmistakably as a user avatar. Dark tile +
single initial mirrors Open Design Cloud's identity styling. */
.nav-avatar,
.nav-avatar-fallback {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
}
.nav-avatar-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
background: #1f1f1f;
color: #fafafa;
font-family: var(--sans);
font-size: 14px;
font-weight: 500;
line-height: 1;
}
/* Dropdown panel — mirrors `.locale-menu` so both header menus read as one
panel style (same border, radius, shadow, and entrance). */
.nav-account-dropdown {
position: absolute;
top: calc(100% + var(--nav-flyout-gap, 12px));
right: 0;
z-index: 60;
min-width: 240px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px;
background: var(--paper);
border: 1px solid rgba(26, 26, 26, 0.12);
border-radius: 8px;
box-shadow: 0 12px 36px rgba(26, 26, 26, 0.08);
font-family: var(--sans);
letter-spacing: normal;
text-transform: none;
animation: locale-menu-in 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
/* Hover bridge across the gap, like `.locale-menu::before`. */
.nav-account-dropdown::before {
content: '';
position: absolute;
top: calc(-1 * var(--nav-flyout-gap, 12px));
left: 0;
right: 0;
height: var(--nav-flyout-gap, 12px);
}
.nav-account-id {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px 10px;
margin-bottom: 4px;
border-bottom: 1px solid var(--line-soft);
}
.nav-account-name {
color: var(--ink);
font-size: 13px;
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-account-name:empty { display: none; }
.nav-account-email {
color: var(--ink-faint);
font-size: 11.5px;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-account-email:empty { display: none; }
.nav-account-item {
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--ink);
font-family: var(--sans);
font-size: 12.5px;
line-height: 1.2;
text-align: left;
text-decoration: none;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.nav-account-item:hover,
.nav-account-item:focus-visible {
background: var(--coral-tint);
color: var(--ink);
}
.nav-account-signout {
color: var(--ink-mute);
}
@media (prefers-reduced-motion: reduce) {
.nav-account-dropdown { animation: none; }
}
.topbar .pulse {
width: 6px; height: 6px;
border-radius: 50%;
+80
View File
@@ -226,6 +226,14 @@ export interface HeaderCopy {
starAria: string;
starTitle: string;
starPrefix: string;
/** Open Design Cloud (AMR) account entry — see header-enhancer.astro. */
signIn: string;
/** aria-label for the signed-in avatar / account menu trigger. */
accountAria: string;
/** Avatar dropdown: open the Cloud console (AMR app). */
menuConsole: string;
/** Avatar dropdown: sign out of the Cloud session. */
menuSignOut: string;
}
export interface HeaderProductMenuCopy {
@@ -1635,6 +1643,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Star Open Design on GitHub',
starTitle: 'Click to star us on GitHub',
starPrefix: 'Star',
signIn: 'Sign in',
accountAria: 'Account menu',
menuConsole: 'Console',
menuSignOut: 'Sign out',
},
},
zh: {
@@ -1677,6 +1689,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: '在 GitHub 为 Open Design 点 Star',
starTitle: '去 GitHub 点 Star',
starPrefix: 'Star',
signIn: '登录',
accountAria: '账户菜单',
menuConsole: '控制台',
menuSignOut: '退出登录',
},
},
'zh-tw': {
@@ -1719,6 +1735,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: '在 GitHub 為 Open Design 按 Star',
starTitle: '去 GitHub 按 Star',
starPrefix: '點星',
signIn: '登入',
accountAria: '帳戶選單',
menuConsole: '控制台',
menuSignOut: '登出',
},
},
ja: {
@@ -1761,6 +1781,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'GitHub で Open Design にスター',
starTitle: 'GitHub でスターする',
starPrefix: 'スター',
signIn: 'ログイン',
accountAria: 'アカウントメニュー',
menuConsole: 'コンソール',
menuSignOut: 'ログアウト',
},
},
ko: {
@@ -1803,6 +1827,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'GitHub에서 Open Design에 스타 주기',
starTitle: 'GitHub에서 스타 주기',
starPrefix: '스타',
signIn: '로그인',
accountAria: '계정 메뉴',
menuConsole: '콘솔',
menuSignOut: '로그아웃',
},
},
de: {
@@ -1845,6 +1873,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Open Design auf GitHub mit Stern markieren',
starTitle: 'Auf GitHub sternen',
starPrefix: 'Stern',
signIn: 'Anmelden',
accountAria: 'Kontomenü',
menuConsole: 'Konsole',
menuSignOut: 'Abmelden',
},
},
fr: {
@@ -1887,6 +1919,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Ajouter une étoile à Open Design sur GitHub',
starTitle: 'Mettre une étoile sur GitHub',
starPrefix: 'Étoile',
signIn: 'Se connecter',
accountAria: 'Menu du compte',
menuConsole: 'Console',
menuSignOut: 'Se déconnecter',
},
},
ru: {
@@ -1929,6 +1965,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Поставить звезду Open Design на GitHub',
starTitle: 'Поставить звезду на GitHub',
starPrefix: 'Звезда',
signIn: 'Войти',
accountAria: 'Меню аккаунта',
menuConsole: 'Консоль',
menuSignOut: 'Выйти',
},
},
es: {
@@ -1971,6 +2011,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Dar Star a Open Design en GitHub',
starTitle: 'Dar Star en GitHub',
starPrefix: 'Estrella',
signIn: 'Iniciar sesión',
accountAria: 'Menú de cuenta',
menuConsole: 'Consola',
menuSignOut: 'Cerrar sesión',
},
},
'pt-br': {
@@ -2013,6 +2057,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Dar Star no Open Design no GitHub',
starTitle: 'Dar Star no GitHub',
starPrefix: 'Estrela',
signIn: 'Entrar',
accountAria: 'Menu da conta',
menuConsole: 'Console',
menuSignOut: 'Sair',
},
},
it: {
@@ -2055,6 +2103,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Metti una Star a Open Design su GitHub',
starTitle: 'Metti una Star su GitHub',
starPrefix: 'Stella',
signIn: 'Accedi',
accountAria: 'Menu account',
menuConsole: 'Console',
menuSignOut: 'Esci',
},
},
vi: {
@@ -2097,6 +2149,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Star Open Design trên GitHub',
starTitle: 'Star trên GitHub',
starPrefix: 'Sao',
signIn: 'Đăng nhập',
accountAria: 'Menu tài khoản',
menuConsole: 'Bảng điều khiển',
menuSignOut: 'Đăng xuất',
},
},
pl: {
@@ -2139,6 +2195,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Daj gwiazdkę Open Design na GitHubie',
starTitle: 'Daj gwiazdkę na GitHubie',
starPrefix: 'Gwiazdka',
signIn: 'Zaloguj się',
accountAria: 'Menu konta',
menuConsole: 'Konsola',
menuSignOut: 'Wyloguj się',
},
},
id: {
@@ -2181,6 +2241,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Beri Star Open Design di GitHub',
starTitle: 'Beri Star di GitHub',
starPrefix: 'Bintang',
signIn: 'Masuk',
accountAria: 'Menu akun',
menuConsole: 'Konsol',
menuSignOut: 'Keluar',
},
},
nl: {
@@ -2223,6 +2287,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Geef Open Design een Star op GitHub',
starTitle: 'Star op GitHub',
starPrefix: 'Ster',
signIn: 'Inloggen',
accountAria: 'Accountmenu',
menuConsole: 'Console',
menuSignOut: 'Uitloggen',
},
},
ar: {
@@ -2265,6 +2333,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'ضع نجمة لـ Open Design على GitHub',
starTitle: 'ضع نجمة على GitHub',
starPrefix: 'نجمة',
signIn: 'تسجيل الدخول',
accountAria: 'قائمة الحساب',
menuConsole: 'لوحة التحكم',
menuSignOut: 'تسجيل الخروج',
},
},
tr: {
@@ -2307,6 +2379,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: "GitHub'da Open Design'a Star ver",
starTitle: "GitHub'da Star ver",
starPrefix: 'Yıldız',
signIn: 'Giriş yap',
accountAria: 'Hesap menüsü',
menuConsole: 'Konsol',
menuSignOut: 'Çıkış yap',
},
},
uk: {
@@ -2349,6 +2425,10 @@ const COMMON_COPY: Record<LandingLocaleCode, CommonCopy> = {
starAria: 'Поставити зірку Open Design на GitHub',
starTitle: 'Поставити зірку на GitHub',
starPrefix: 'Зірка',
signIn: 'Увійти',
accountAria: 'Меню облікового запису',
menuConsole: 'Консоль',
menuSignOut: 'Вийти',
},
},
};
+175
View File
@@ -1428,6 +1428,181 @@ const matterRuntime = readFileSync(
if (ev.key === 'Escape') setNavOpen(false);
});
})();
// Open Design Cloud (AMR) header account. Same as `enhanceMobileNav`
// above, this mirrors `header-enhancer.astro` because the homepage does
// not include that enhancer. Detect a live cloud session, swap the
// "Sign in" link for the avatar menu, drive popup login + polling, and
// sign out. Everything fails closed to signed-out (non-apex origin or
// blocked third-party cookies just keep "Sign in" showing).
(function enhanceAmrAccount() {
const account = document.querySelector('[data-amr-account]');
if (!account) return;
const api = (account.getAttribute('data-amr-api') || '').replace(/\/$/, '');
const loginUrl = account.getAttribute('data-amr-login') || '';
// Localized OD homepage — post-login destination ("回到官网首页").
const homeUrl = account.getAttribute('data-amr-home') || '';
const signinEl = account.querySelector('[data-amr-signin]');
const menuEl = account.querySelector('[data-amr-menu]');
const avatarImg = account.querySelector('[data-amr-avatar]');
const avatarFallback = account.querySelector('[data-amr-avatar-fallback]');
const nameEl = account.querySelector('[data-amr-name]');
const emailEl = account.querySelector('[data-amr-email]');
const signoutBtn = account.querySelector('[data-amr-signout]');
const sessionUrl = api + '/api/auth/get-session';
const signoutUrl = api + '/api/auth/sign-out';
const fetchSession = () =>
fetch(sessionUrl, {
credentials: 'include',
headers: { Accept: 'application/json' },
})
.then((r) => (r.ok ? r.json() : null))
.then((data) => (data && data.user ? data.user : null))
.catch(() => null);
const initials = (user) => {
const base = (user.name || user.email || '').trim();
return base ? base[0].toUpperCase() : '·';
};
const showSignedIn = (user) => {
if (nameEl) nameEl.textContent = user.name || '';
if (emailEl) emailEl.textContent = user.email || '';
if (avatarImg && user.image) {
avatarImg.setAttribute('src', user.image);
avatarImg.removeAttribute('hidden');
if (avatarFallback) avatarFallback.setAttribute('hidden', '');
} else {
if (avatarImg) avatarImg.setAttribute('hidden', '');
if (avatarFallback) {
avatarFallback.textContent = initials(user);
avatarFallback.removeAttribute('hidden');
}
}
if (signinEl) signinEl.setAttribute('hidden', '');
if (menuEl) menuEl.removeAttribute('hidden');
};
const showSignedOut = () => {
if (menuEl) {
menuEl.setAttribute('hidden', '');
menuEl.removeAttribute('open');
}
if (signinEl) signinEl.removeAttribute('hidden');
};
// Focus scrim shown while the vela login popup is open (popup is the
// chosen modal mechanism; iframe is ruled out by X-Frame-Options +
// cross-site cookies + OAuth-in-iframe). Faded via `.is-active`.
let overlayEl = null;
const showOverlay = () => {
if (overlayEl) return;
overlayEl = document.createElement('div');
overlayEl.className = 'nav-login-overlay';
overlayEl.setAttribute('aria-hidden', 'true');
document.body.appendChild(overlayEl);
requestAnimationFrame(() => {
if (overlayEl) overlayEl.classList.add('is-active');
});
};
const hideOverlay = () => {
if (!overlayEl) return;
const el = overlayEl;
overlayEl = null;
el.classList.remove('is-active');
const drop = () => el.remove();
el.addEventListener('transitionend', drop, { once: true });
setTimeout(drop, 400);
};
// Return the main window to the localized OD homepage after login;
// a homepage sign-in just swaps in place (already on it).
const goHome = () => {
if (!homeUrl) return;
const targetUrl = new URL(homeUrl, window.location.href);
const here = window.location.pathname.replace(/\/+$/, '');
const target = targetUrl.pathname.replace(/\/+$/, '');
if (here !== target) window.location.assign(homeUrl);
};
// Single terminal success path for every login completion route.
const completeSignIn = (user) => {
hideOverlay();
showSignedIn(user);
goHome();
};
// Initial silent probe for an existing session.
fetchSession().then((user) => {
if (user) showSignedIn(user);
});
if (signinEl && loginUrl) {
signinEl.addEventListener('click', (ev) => {
ev.preventDefault();
// Center the popup over the current browser window (multi-monitor
// aware via screenLeft/screenTop).
const W = 480;
const H = 720;
const dx = window.screenLeft ?? window.screenX ?? 0;
const dy = window.screenTop ?? window.screenY ?? 0;
const vw = window.innerWidth || document.documentElement.clientWidth || screen.width;
const vh = window.innerHeight || document.documentElement.clientHeight || screen.height;
const left = Math.round(dx + (vw - W) / 2);
const top = Math.round(dy + (vh - H) / 2);
const popup = window.open(
loginUrl,
'od-amr-login',
`width=${W},height=${H},left=${left},top=${top},menubar=no,toolbar=no,location=yes`,
);
// Popup blocked: fall back to a same-tab navigation.
if (!popup) {
window.location.assign(loginUrl);
return;
}
showOverlay();
const started = Date.now();
const POLL_MS = 2000;
const MAX_MS = 5 * 60 * 1000;
const timer = setInterval(() => {
if (Date.now() - started > MAX_MS) {
clearInterval(timer);
hideOverlay();
return;
}
if (popup.closed) {
clearInterval(timer);
fetchSession().then((user) => {
if (user) completeSignIn(user);
else hideOverlay();
});
return;
}
fetchSession().then((user) => {
if (!user) return;
clearInterval(timer);
try {
if (!popup.closed) popup.close();
} catch {}
completeSignIn(user);
});
}, POLL_MS);
});
}
if (signoutBtn) {
signoutBtn.addEventListener('click', () => {
fetch(signoutUrl, {
method: 'POST',
credentials: 'include',
headers: { Accept: 'application/json' },
})
.catch(() => {})
.then(() => showSignedOut());
});
}
})();
// Product and Resources are category-label <button class="nav-trigger">
// triggers (not links, so they never navigate) whose dropdown is
// revealed by the same pure-CSS :hover / :focus-within rule as the hub