diff --git a/package-lock.json b/package-lock.json index 792b524..d3c5575 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.22.1", + "version": "2.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.22.1", + "version": "2.23.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 5e1c8f2..84cb8e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.22.1", + "version": "2.23.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index 6f715db..052fc1c 100644 --- a/src/App.css +++ b/src/App.css @@ -223,6 +223,93 @@ color: var(--brand-black) !important; } +/* ── Account icon (top right) ── */ +.header-account-wrap { + position: relative; + flex-shrink: 0; +} + +.header-account { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; + padding: 0; + border-radius: 50%; + border: 1px solid rgba(201, 168, 76, 0.5); + background: transparent; + color: var(--brand-gold); + font: inherit; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + overflow: hidden; + text-decoration: none; + transition: background 200ms, color 200ms; +} + +.header-account:hover, +.header-account[aria-expanded='true'] { + background: var(--brand-gold); + color: var(--brand-black); +} + +.header-account-avatar { + width: 100%; + height: 100%; + object-fit: cover; +} + +.header-account-menu { + position: absolute; + top: calc(100% + 10px); + right: 0; + z-index: 130; + min-width: 220px; + padding: 0.4rem; + border-radius: 12px; + border: 1px solid rgba(201, 168, 76, 0.3); + background: rgba(15, 15, 12, 0.98); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5); +} + +.header-account-name { + padding: 0.55rem 0.75rem 0.65rem; + margin-bottom: 0.25rem; + font-size: 0.82rem; + color: var(--brand-muted); + border-bottom: 1px solid rgba(201, 168, 76, 0.15); + overflow-wrap: anywhere; +} + +.header-account-name strong { + color: var(--brand-warm-white); + font-weight: 600; +} + +.header-account-item { + display: block; + width: 100%; + padding: 0.65rem 0.75rem; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--brand-warm-white); + font: inherit; + font-size: 0.95rem; + text-align: left; + text-decoration: none; + cursor: pointer; +} + +.header-account-item:hover, +.header-account-item:focus-visible { + background: rgba(201, 168, 76, 0.14); + color: var(--brand-gold-light); +} + /* ── Hero ── */ .hero { display: grid; @@ -7941,56 +8028,12 @@ a.study-track-card:hover { padding: 0 1rem; } - .header-inner { - padding: 0.75rem 1rem; - position: relative; - } - - .site-search-bar-inner { - padding: 0.4rem 1rem; - } - - .header-menu-btn { - display: inline-flex; - margin-left: auto; - } - - .header-nav { - max-height: 0; - opacity: 0; - pointer-events: none; - overflow: hidden; - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 120; - flex-direction: column; - align-items: stretch; - gap: 0; - padding: 0 1rem; - background: rgba(10, 10, 8, 0.98); - border-bottom: 1px solid rgba(201, 168, 76, 0.2); - transition: max-height 0.28s ease, opacity 0.22s ease, padding 0.28s ease; - } - - .header-nav--open { - max-height: 500px; - opacity: 1; - pointer-events: auto; - padding: 0.5rem 1rem 1rem; - } - - .header-nav .header-nav-link { - display: block; - padding: 0.8rem 0.25rem; - border-bottom: 1px solid rgba(201, 168, 76, 0.12); - } - - .header-nav .header-cta { - margin-top: 0.75rem; - justify-content: center; - text-align: center; + .header-account-menu { + position: fixed; + top: 4.2rem; + right: 0.75rem; + left: 0.75rem; + min-width: 0; } .admin-top-tabs .admin-tab { @@ -10855,53 +10898,6 @@ a.study-track-card:hover { } @media (max-width: 540px) { - .header-nav--open { - max-height: 660px; - } - - .nav-dropdown-trigger { - width: 100%; - display: flex; - justify-content: space-between; - padding: 0.8rem 0.25rem; - border-bottom: 1px solid rgba(201, 168, 76, 0.12); - } - - .nav-dropdown-label { - flex: 1; - padding: 0; - } - - .nav-dropdown-menu { - display: none; - position: static; - transform: none; - padding-top: 0; - min-width: 0; - } - - .nav-dropdown-menu-inner { - background: transparent; - border: none; - border-radius: 0; - padding: 0; - box-shadow: none; - } - - .nav-dropdown:hover .nav-dropdown-menu { - display: none; - } - - .nav-dropdown--open .nav-dropdown-menu { - display: block; - } - - .nav-dropdown-item { - padding: 0.65rem 0.25rem 0.65rem 1.5rem; - font-size: 0.88rem; - border-bottom: 1px solid rgba(201, 168, 76, 0.08); - } - .finished-series-hero { flex-direction: column; gap: 1rem; @@ -13615,3 +13611,121 @@ a.study-track-card:hover { .qna { padding: 1.5rem 0 3rem; } .qna-q-text { font-size: 1.15rem; } } + +/* Keep the full header on one line between 901px and 1100px */ +.header-cta { white-space: nowrap; } + +@media (min-width: 901px) and (max-width: 1100px) { + .header-inner { padding-left: 1rem; padding-right: 1rem; } + .header-nav { gap: 1rem; } + .header-nav a, + .header-nav button { font-size: 0.88rem; letter-spacing: 0.04em; } + .header-cta { padding: 0.4rem 0.9rem !important; } +} + +/* ── Collapsed header (menu button + account icon) below 900px ── */ +@media (max-width: 900px) { + .header-inner { + padding: 0.75rem 1rem; + position: relative; + } + + .site-search-bar-inner { + padding: 0.4rem 1rem; + } + + .header-menu-btn { + display: inline-flex; + margin-left: auto; + } + + .header-account { + width: 44px; + height: 44px; + } + + .header-nav { + max-height: 0; + opacity: 0; + pointer-events: none; + overflow: hidden; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 120; + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 0 1rem; + background: rgba(10, 10, 8, 0.98); + border-bottom: 1px solid rgba(201, 168, 76, 0.2); + transition: max-height 0.28s ease, opacity 0.22s ease, padding 0.28s ease; + } + + .header-nav--open { + max-height: 500px; + opacity: 1; + pointer-events: auto; + padding: 0.5rem 1rem 1rem; + } + + .header-nav .header-nav-link { + display: block; + padding: 0.8rem 0.25rem; + border-bottom: 1px solid rgba(201, 168, 76, 0.12); + } + + .header-nav .header-cta { + margin-top: 0.75rem; + justify-content: center; + text-align: center; + } + + .header-nav--open { + max-height: 660px; + } + + .nav-dropdown-trigger { + width: 100%; + display: flex; + justify-content: space-between; + padding: 0.8rem 0.25rem; + border-bottom: 1px solid rgba(201, 168, 76, 0.12); + } + + .nav-dropdown-label { + flex: 1; + padding: 0; + } + + .nav-dropdown-menu { + display: none; + position: static; + transform: none; + padding-top: 0; + min-width: 0; + } + + .nav-dropdown-menu-inner { + background: transparent; + border: none; + border-radius: 0; + padding: 0; + box-shadow: none; + } + + .nav-dropdown:hover .nav-dropdown-menu { + display: none; + } + + .nav-dropdown--open .nav-dropdown-menu { + display: block; + } + + .nav-dropdown-item { + padding: 0.65rem 0.25rem 0.65rem 1.5rem; + font-size: 0.88rem; + border-bottom: 1px solid rgba(201, 168, 76, 0.08); + } +} diff --git a/src/App.tsx b/src/App.tsx index defb311..947351b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { useGlobalSearch } from './hooks/useGlobalSearch' import { GlobalSearch } from './components/GlobalSearch' import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' import { NuggetsHostPortal } from './components/NuggetsHostPortal' +import { HeaderAccount } from './components/HeaderAccount' import { BIBLE_BOOKS } from '../shared/bible-books.js' import './App.css' import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics' @@ -485,7 +486,6 @@ function SiteHeader({ content }: { content: SiteContent }) { - `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact @@ -498,6 +498,7 @@ function SiteHeader({ content }: { content: SiteContent }) { {content.headerFollowLabel || 'Follow on Spotify'} + ) diff --git a/src/components/HeaderAccount.tsx b/src/components/HeaderAccount.tsx new file mode 100644 index 0000000..41b1f0d --- /dev/null +++ b/src/components/HeaderAccount.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' + +interface AccountStatus { + authenticated: boolean + username: string + displayName?: string + avatarUrl?: string +} + +// Shared across header instances so moving between pages doesn't flash the signed-out icon +let cachedStatus: AccountStatus | null = null + +function PersonIcon() { + return ( + + + + + ) +} + +export function HeaderAccount() { + const [status, setStatus] = useState(cachedStatus) + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + + useEffect(() => { + let cancelled = false + fetch('/api/study-auth/status') + .then(r => (r.ok ? r.json() : Promise.reject(new Error('status failed')))) + .then((data: AccountStatus) => { + cachedStatus = data + if (!cancelled) setStatus(data) + }) + .catch(() => {}) + return () => { cancelled = true } + }, []) + + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent | TouchEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', onDown) + document.addEventListener('touchstart', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('touchstart', onDown) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + async function signOut() { + try { await fetch('/api/study-auth/logout', { method: 'POST' }) } catch { /* reload below shows the real state */ } + cachedStatus = null + window.location.assign('/') + } + + // Signed out (or still checking): one tap goes to the sign-in page + if (!status?.authenticated) { + return ( + + + + ) + } + + const name = status.displayName?.trim() || status.username + const initial = (name || '?').charAt(0).toUpperCase() + + return ( + + setOpen(o => !o)} + > + {status.avatarUrl ? : {initial}} + + {open && ( + + Signed in as {name} + setOpen(false)}>My Account + Sign out + + )} + + ) +}
Signed in as {name}