Compare commits

..
5 Commits
5 changed files with 421 additions and 106 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.22.1", "version": "2.24.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.22.1", "version": "2.24.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0", "@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.22.1", "version": "2.24.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+320 -102
View File
@@ -211,16 +211,120 @@
} }
.header-cta { .header-cta {
color: var(--brand-gold) !important; display: inline-flex;
border: 1px solid rgba(201, 168, 76, 0.5) !important; align-items: center;
gap: 0.5rem;
color: var(--brand-black) !important;
background: linear-gradient(180deg, var(--brand-gold-light), var(--brand-gold));
border: 0 !important;
border-radius: 999px; border-radius: 999px;
padding: 0.4rem 1.2rem !important; padding: 0.5rem 1.15rem !important;
transition: background 200ms, color 200ms !important; font-weight: 700 !important;
box-shadow: 0 0 0 1px rgba(201, 168, 76, 0.55), 0 4px 14px rgba(201, 168, 76, 0.16);
transition: transform 200ms, box-shadow 200ms, filter 200ms !important;
}
.header-cta svg {
flex-shrink: 0;
} }
.header-cta:hover { .header-cta:hover {
background: var(--brand-gold);
color: var(--brand-black) !important; color: var(--brand-black) !important;
filter: brightness(1.07);
transform: translateY(-1px);
box-shadow: 0 0 0 1px rgba(224, 192, 112, 0.8), 0 6px 18px rgba(201, 168, 76, 0.3);
}
.header-cta:focus-visible {
outline: 2px solid var(--brand-gold-light);
outline-offset: 3px;
}
/* ── 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 ── */
@@ -241,6 +345,13 @@
justify-content: center; justify-content: center;
} }
/* The glow behind the artwork is larger than its box; on narrow screens that made the page wider than the screen */
@media (max-width: 900px) {
.hero {
overflow-x: clip;
}
}
@keyframes glow-pulse { @keyframes glow-pulse {
0%, 100% { opacity: 0.96; transform: scale(1); } 0%, 100% { opacity: 0.96; transform: scale(1); }
50% { opacity: 0.72; transform: scale(1.2); } 50% { opacity: 0.72; transform: scale(1.2); }
@@ -7941,56 +8052,12 @@ a.study-track-card:hover {
padding: 0 1rem; padding: 0 1rem;
} }
.header-inner { .header-account-menu {
padding: 0.75rem 1rem; position: fixed;
position: relative; top: 4.2rem;
} right: 0.75rem;
left: 0.75rem;
.site-search-bar-inner { min-width: 0;
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;
} }
.admin-top-tabs .admin-tab { .admin-top-tabs .admin-tab {
@@ -10855,53 +10922,6 @@ a.study-track-card:hover {
} }
@media (max-width: 540px) { @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 { .finished-series-hero {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -13615,3 +13635,201 @@ a.study-track-card:hover {
.qna { padding: 1.5rem 0 3rem; } .qna { padding: 1.5rem 0 3rem; }
.qna-q-text { font-size: 1.15rem; } .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.45rem 0.9rem !important; gap: 0.4rem; }
}
/* ── 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;
padding: 0.8rem 1.2rem !important;
font-size: 1rem;
}
.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);
}
}
/* ── Phone type scale ─────────────────────────────────────────────────────
Public pages only (the admin lives in .admin-shell). Page titles, section
headings and small text were each sized differently page by page; on phones
they now share one scale. The vw terms keep it proportional on narrow phones. */
@media (max-width: 600px) {
#root > :not(.admin-shell) h1:not(.cert-title) {
font-size: clamp(1.75rem, 8.5vw, 2rem);
line-height: 1.15;
}
#root > :not(.admin-shell) h1.hero-title {
font-size: clamp(2.1rem, 10.7vw, 2.5rem);
}
#root > :not(.admin-shell) h2 {
font-size: clamp(1.4rem, 6.5vw, 1.6rem);
line-height: 1.2;
}
/* About copy was set larger than body text everywhere else */
.about-inner p {
font-size: 1.0625rem;
}
/* Nothing readable below 12px */
.study-module-lesson,
.study-section-reference,
.lesson-badge,
.latest-episode-player__spotify-link,
.latest-episode-player__times span,
.episode-audio-player__show-name,
.episode-audio-player__times span,
.scripture-source-kind,
.featured-badge {
font-size: 0.75rem;
}
.latest-episode-player__skip span {
font-size: 0.6875rem;
}
}
/* ── Desktop and tablet type scale ────────────────────────────────────────
Same idea as the phone scale: one size for page titles and one for section
headings, instead of each page choosing its own. The clamp() keeps tablets
in between. The home hero stays the one larger display headline. */
@media (min-width: 601px) {
#root > :not(.admin-shell) h1:not(.cert-title) {
font-size: clamp(2rem, 4.2vw, 3rem);
line-height: 1.1;
}
#root > :not(.admin-shell) h1.hero-title {
font-size: clamp(2.5rem, 6vw, 4rem);
}
/* Lesson and account titles are a notch smaller (long lesson names, user names) */
#root > :not(.admin-shell) .study-classroom-main h1,
#root > :not(.admin-shell) .account-header-body h1 {
font-size: clamp(1.9rem, 3.4vw, 2.5rem);
}
#root > :not(.admin-shell) h2 {
font-size: clamp(1.6rem, 3vw, 2rem);
line-height: 1.2;
}
/* Step titles and sub-headings inside an article sit below section headings */
#root > :not(.admin-shell) h2.start-step-title,
#root > :not(.admin-shell) h2.episode-detail-section-heading {
font-size: clamp(1.4rem, 2.4vw, 1.625rem);
}
.about-inner p {
font-size: 1.0625rem;
}
}
+3 -1
View File
@@ -13,6 +13,7 @@ import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch' import { GlobalSearch } from './components/GlobalSearch'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NuggetsHostPortal } from './components/NuggetsHostPortal' import { NuggetsHostPortal } from './components/NuggetsHostPortal'
import { HeaderAccount } from './components/HeaderAccount'
import { BIBLE_BOOKS } from '../shared/bible-books.js' import { BIBLE_BOOKS } from '../shared/bible-books.js'
import './App.css' import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics' import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics'
@@ -485,7 +486,6 @@ function SiteHeader({ content }: { content: SiteContent }) {
</div> </div>
</div> </div>
</div> </div>
<NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink>
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink> <NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink>
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink> <NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink> <NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
@@ -495,9 +495,11 @@ function SiteHeader({ content }: { content: SiteContent }) {
rel="noreferrer" rel="noreferrer"
className="header-cta" className="header-cta"
> >
<SpotifyIcon />
{content.headerFollowLabel || 'Follow on Spotify'} {content.headerFollowLabel || 'Follow on Spotify'}
</a> </a>
</nav> </nav>
<HeaderAccount />
</div> </div>
</header> </header>
) )
+95
View File
@@ -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 (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-3.6 3.6-6 8-6s8 2.4 8 6" />
</svg>
)
}
export function HeaderAccount() {
const [status, setStatus] = useState<AccountStatus | null>(cachedStatus)
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(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 (
<Link to="/study/account" className="header-account" aria-label="Sign in or create an account" title="Sign in">
<PersonIcon />
</Link>
)
}
const name = status.displayName?.trim() || status.username
const initial = (name || '?').charAt(0).toUpperCase()
return (
<div className="header-account-wrap" ref={wrapRef}>
<button
type="button"
className="header-account header-account--in"
aria-haspopup="menu"
aria-expanded={open}
aria-label={`Account menu for ${name}`}
onClick={() => setOpen(o => !o)}
>
{status.avatarUrl ? <img src={status.avatarUrl} alt="" className="header-account-avatar" /> : <span aria-hidden="true">{initial}</span>}
</button>
{open && (
<div className="header-account-menu" role="menu">
<p className="header-account-name">Signed in as <strong>{name}</strong></p>
<Link to="/study/account" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>My Account</Link>
<button type="button" role="menuitem" className="header-account-item" onClick={signOut}>Sign out</button>
</div>
)}
</div>
)
}