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
62 changes: 32 additions & 30 deletions telcoinwiki-react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,40 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import styles from './App.module.css'
import type { SidebarHeading } from './config/types'
import { AppLayout } from './components/layout/AppLayout'
import { NAV_ITEMS } from './config/navigation'
import { PAGE_META } from './config/pageMeta'
import { SEARCH_CONFIG } from './config/search'

function App() {
const [count, setCount] = useState(0)
const demoHeadings: SidebarHeading[] = [
{ id: 'welcome', text: 'Welcome' },
{ id: 'next-steps', text: 'Next steps' },
]

function App() {
return (
<>
<div>
<a href="https://vite.dev" target="_blank" rel="noreferrer">
<img src={viteLogo} className={styles.logo} alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank" rel="noreferrer">
<img
src={reactLogo}
className={`${styles.logo} ${styles.react} ${styles.spin}`}
alt="React logo"
/>
</a>
</div>
<h1>Vite + React</h1>
<div className={styles.card}>
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<AppLayout
pageId="home"
navItems={NAV_ITEMS}
pageMeta={PAGE_META}
searchConfig={SEARCH_CONFIG}
headings={demoHeadings}
>
<section id="welcome" className="page-intro anchor-offset tc-card">
<p className="page-intro__eyebrow">Community Q&amp;A for Telcoin</p>
<h1 className="page-intro__title">React layout shell</h1>
<p className="page-intro__lede">
This demo renders the site header, sidebar, and breadcrumb trail from the shared
configuration so content authors only touch JSON-like data files.
</p>
</section>

<section id="next-steps" className="tc-card">
<h2>Next steps</h2>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
Replace the placeholder sections with real page content. Update navigation, breadcrumb, and
search behaviour exclusively through the configuration modules in <code>src/config</code>.
</p>
</div>
<p className={styles.readTheDocs}>
Click on the Vite and React logos to learn more
</p>
</>
</section>
</AppLayout>
)
}

Expand Down
57 changes: 57 additions & 0 deletions telcoinwiki-react/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useMemo } from 'react'
import type {
NavItem,
PageMetaMap,
SearchConfig,
SidebarHeading,
} from '../../config/types'
import type { ReactNode } from 'react'
import { Breadcrumbs } from './Breadcrumbs'
import { Header } from './Header'
import { Sidebar } from './Sidebar'

interface AppLayoutProps {
pageId: string
navItems: NavItem[]
pageMeta: PageMetaMap
searchConfig: SearchConfig
headings?: SidebarHeading[]
children: ReactNode
}

export function AppLayout({
pageId,
navItems,
pageMeta,
searchConfig,
headings = [],
children,
}: AppLayoutProps) {
const currentMeta = pageMeta[pageId] ?? pageMeta.home
const activeNavId = currentMeta?.navId ?? pageId

const sidebarItems = useMemo(
() =>
Object.entries(pageMeta)
.filter(([, meta]) => meta.sidebar)
.map(([id, meta]) => ({ id, label: meta.label, href: meta.url })),
[pageMeta],
)

return (
<>
<a className="skip-link" href="#main-content">
Skip to content
</a>
<Header navItems={navItems} activeNavId={activeNavId} searchConfig={searchConfig} />
<div className="sidebar-overlay" data-sidebar-overlay />
<div className="site-shell">
<Sidebar items={sidebarItems} activeId={currentMeta?.navId ?? pageId} headings={headings} />
<main id="main-content" className="site-main tc-card" tabIndex={-1}>
<Breadcrumbs pageId={pageId} pageMeta={pageMeta} />
{children}
</main>
</div>
</>
)
}
49 changes: 49 additions & 0 deletions telcoinwiki-react/src/components/layout/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { BreadcrumbNode, PageMeta, PageMetaMap } from '../../config/types'

interface BreadcrumbsProps {
pageId: string
pageMeta: PageMetaMap
}

function buildBreadcrumbsTrail(pageId: string, pageMeta: PageMetaMap): BreadcrumbNode[] {
const trail: BreadcrumbNode[] = []
let pointer: string | null = pageId

while (pointer) {
const node: PageMeta | undefined = pageMeta[pointer]
if (!node) break
trail.unshift({ id: pointer, label: node.label, url: node.url })
pointer = node.parent
}

if (!trail.length || trail[0].id !== 'home') {
const home = pageMeta.home
if (home) {
trail.unshift({ id: 'home', label: home.label, url: home.url })
}
}

return trail
}

export function Breadcrumbs({ pageId, pageMeta }: BreadcrumbsProps) {
const trail = buildBreadcrumbsTrail(pageId, pageMeta)

return (
<nav className="breadcrumbs" aria-label="Breadcrumb" data-breadcrumbs>
{trail.map((node, index) => {
const isLast = index === trail.length - 1
return (
<span key={node.id} className="breadcrumbs__segment">
{index > 0 ? <span className="breadcrumbs__separator">/</span> : null}
{isLast ? (
<span aria-current="page">{node.label}</span>
) : (
<a href={node.url}>{node.label}</a>
)}
</span>
)
})}
</nav>
)
}
179 changes: 179 additions & 0 deletions telcoinwiki-react/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { NavItem, SearchConfig } from '../../config/types'

interface HeaderProps {
navItems: NavItem[]
activeNavId?: string | null
searchConfig: SearchConfig
}

export function Header({ navItems, activeNavId, searchConfig }: HeaderProps) {
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const navListRef = useRef<HTMLUListElement>(null)
const { dataUrl, faqUrl, maxResultsPerGroup } = searchConfig

useEffect(() => {
function handleDocumentClick(event: MouseEvent) {
if (!openMenuId) return
if (!(event.target instanceof Node)) return
if (navListRef.current && !navListRef.current.contains(event.target)) {
setOpenMenuId(null)
}
}

document.addEventListener('click', handleDocumentClick)
return () => document.removeEventListener('click', handleDocumentClick)
}, [openMenuId])

useEffect(() => {
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpenMenuId(null)
}
}

if (openMenuId) {
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
}

return undefined
}, [openMenuId])

const mobileItems = useMemo(() => navItems, [navItems])

function toggleMenu(itemId: string) {
setOpenMenuId((current) => (current === itemId ? null : itemId))
}

function closeMenus() {
setOpenMenuId(null)
}

function toggleMobileNav() {
setMobileNavOpen((current) => !current)
}

function handleMobileLinkClick() {
setMobileNavOpen(false)
}

return (
<header className="site-header">
<div className="site-header__inner container">
<a className="site-brand" href="/">
<img
className="site-logo"
src="/logo.svg"
alt="Telcoin Wiki logo"
loading="eager"
decoding="async"
/>
</a>

<nav className="top-nav" aria-label="Primary">
<ul className="pill-nav top-nav__list" ref={navListRef}>
{navItems.map((item) => {
const isActive = item.id === activeNavId
const isOpen = openMenuId === item.id
if (!item.menu || item.menu.length === 0) {
return (
<li key={item.id} className="nav-item">
<a
className={`top-nav__link${isActive ? ' is-active' : ''}`}
href={item.href}
aria-current={isActive ? 'page' : undefined}
onClick={closeMenus}
>
{item.label}
</a>
</li>
)
}

return (
<li
key={item.id}
className="nav-item"
data-open={isOpen ? 'true' : undefined}
>
<button
type="button"
className={`nav-button${isActive ? ' is-active' : ''}`}
aria-haspopup="true"
aria-expanded={isOpen}
onClick={() => toggleMenu(item.id)}
>
{item.label} <span className="nav-caret">▾</span>
</button>
<div className="nav-menu" role="menu">
{item.menu.map((entry) => (
<a
key={entry.href}
href={entry.href}
role="menuitem"
onClick={closeMenus}
>
{entry.label}
</a>
))}
</div>
</li>
)
})}
</ul>
</nav>

<button
type="button"
className="menu-btn"
data-sidebar-toggle
aria-expanded={mobileNavOpen}
aria-controls="mobile-drawer"
onClick={toggleMobileNav}
>
Menu
</button>

<div
className="header-search"
role="search"
data-search-index-url={dataUrl}
data-search-faq-url={faqUrl}
data-search-max-results={String(maxResultsPerGroup)}
>
<label className="sr-only" htmlFor="site-search">
Search Telcoin Wiki
</label>
<input
id="site-search"
className="search-input"
type="search"
name="q"
placeholder="Search Telcoin Wiki"
autoComplete="off"
/>
</div>
</div>

<div
id="mobile-drawer"
className={`drawer container${mobileNavOpen ? ' is-open' : ''}`}
hidden={!mobileNavOpen}
>
<nav aria-label="Mobile">
<ul>
{mobileItems.map((item) => (
<li key={`mobile-${item.id}`} className="nav-item">
<a href={item.href} onClick={handleMobileLinkClick}>
{item.label}
</a>
</li>
))}
</ul>
</nav>
</div>
</header>
)
}
Loading