Files
speedBB/resources/js/App.jsx

332 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
import { Container, NavDropdown } from 'react-bootstrap'
import { AuthProvider, useAuth } from './context/AuthContext'
import Home from './pages/Home'
import ForumView from './pages/ForumView'
import ThreadView from './pages/ThreadView'
import Login from './pages/Login'
import Register from './pages/Register'
import Acp from './pages/Acp'
import BoardIndex from './pages/BoardIndex'
import Ucp from './pages/Ucp'
import { useTranslation } from 'react-i18next'
import { fetchSetting, fetchVersion, getForum, getThread } from './api/client'
function PortalHeader({ userMenu, isAuthenticated }) {
const { t } = useTranslation()
const location = useLocation()
const [crumbs, setCrumbs] = useState([])
useEffect(() => {
let active = true
const parseForumId = (parent) => {
if (!parent) return null
if (typeof parent === 'string') {
const parts = parent.split('/')
return parts[parts.length - 1] || null
}
if (typeof parent === 'object' && parent.id) {
return parent.id
}
return null
}
const buildForumChain = async (forum) => {
const chain = []
let cursor = forum
while (cursor) {
chain.unshift({ label: cursor.name, to: `/forum/${cursor.id}` })
const parentId = parseForumId(cursor.parent)
if (!parentId) break
cursor = await getForum(parentId)
}
return chain
}
const buildCrumbs = async () => {
const base = [
{ label: t('portal.portal'), to: '/' },
{ label: t('portal.board_index'), to: '/forums' },
]
if (location.pathname === '/') {
setCrumbs([{ ...base[0], current: true }, { ...base[1] }])
return
}
if (location.pathname === '/forums') {
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
return
}
if (location.pathname.startsWith('/forum/')) {
const forumId = location.pathname.split('/')[2]
if (forumId) {
const forum = await getForum(forumId)
const chain = await buildForumChain(forum)
if (!active) return
setCrumbs([...base, ...chain.map((crumb, idx) => ({
...crumb,
current: idx === chain.length - 1,
}))])
return
}
}
if (location.pathname.startsWith('/thread/')) {
const threadId = location.pathname.split('/')[2]
if (threadId) {
const thread = await getThread(threadId)
const forumId = thread?.forum?.split('/').pop()
if (forumId) {
const forum = await getForum(forumId)
const chain = await buildForumChain(forum)
if (!active) return
const chainWithCurrent = chain.map((crumb, index) => ({
...crumb,
current: index === chain.length - 1,
}))
setCrumbs([...base, ...chainWithCurrent])
return
}
}
}
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
}
buildCrumbs()
return () => {
active = false
}
}, [location.pathname, t])
return (
<Container className="pt-2 pb-2 bb-portal-shell">
<div className="bb-portal-banner">
<div className="bb-portal-brand">
<div className="bb-portal-logo">24unix.net</div>
<div className="bb-portal-tagline">{t('portal.tagline')}</div>
</div>
<div className="bb-portal-search">
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
<span className="bb-portal-search-icon">
<i className="bi bi-search" aria-hidden="true" />
</span>
</div>
</div>
<div className="bb-portal-bars">
<div className="bb-portal-bar bb-portal-bar--top">
<div className="bb-portal-bar-left">
<span className="bb-portal-bar-title">
<i className="bi bi-list" aria-hidden="true" /> {t('portal.quick_links')}
</span>
<div className="bb-portal-bar-links">
<span>
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
</span>
<Link to="/acp" className="bb-portal-link">
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
</Link>
<span>
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
</span>
</div>
</div>
<div
className={`bb-portal-user-links${isAuthenticated ? '' : ' bb-portal-user-links--guest'}`}
>
{isAuthenticated ? (
<>
<span>
<i className="bi bi-bell-fill" aria-hidden="true" /> {t('portal.notifications')}
</span>
<span>
<i className="bi bi-envelope-fill" aria-hidden="true" /> {t('portal.messages')}
</span>
{userMenu}
</>
) : (
<>
<Link to="/register" className="bb-portal-user-link">
<i className="bi bi-pencil-square" aria-hidden="true" /> {t('nav.register')}
</Link>
<Link to="/login" className="bb-portal-user-link">
<i className="bi bi-power" aria-hidden="true" /> {t('nav.login')}
</Link>
</>
)}
</div>
</div>
<div className="bb-portal-bar bb-portal-bar--bottom">
<div className="bb-portal-breadcrumb">
{crumbs.map((crumb, index) => (
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
{index > 0 && <span className="bb-portal-sep"></span>}
{crumb.current ? (
<span className="bb-portal-current">
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
{crumb.label}
</span>
) : (
<Link to={crumb.to} className="bb-portal-link">
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
{crumb.label}
</Link>
)}
</span>
))}
</div>
</div>
</div>
</Container>
)
}
function AppShell() {
const { t } = useTranslation()
const { token, email, logout, isAdmin } = useAuth()
const [versionInfo, setVersionInfo] = useState(null)
const [theme, setTheme] = useState(() => localStorage.getItem('speedbb_theme') || 'auto')
const [accentOverride, setAccentOverride] = useState(
() => localStorage.getItem('speedbb_accent') || ''
)
useEffect(() => {
fetchVersion()
.then((data) => setVersionInfo(data))
.catch(() => setVersionInfo(null))
}, [])
useEffect(() => {
fetchSetting('accent_color')
.then((setting) => {
if (setting?.value && !accentOverride) {
document.documentElement.style.setProperty('--bb-accent', setting.value)
}
})
.catch(() => {})
}, [accentOverride])
useEffect(() => {
if (accentOverride) {
document.documentElement.style.setProperty('--bb-accent', accentOverride)
localStorage.setItem('speedbb_accent', accentOverride)
} else {
localStorage.removeItem('speedbb_accent')
}
}, [accentOverride])
useEffect(() => {
const root = document.documentElement
const media = window.matchMedia('(prefers-color-scheme: dark)')
const applyTheme = (mode) => {
if (mode === 'auto') {
root.setAttribute('data-bs-theme', media.matches ? 'dark' : 'light')
} else {
root.setAttribute('data-bs-theme', mode)
}
}
applyTheme(theme)
const handleChange = () => {
if (theme === 'auto') {
applyTheme('auto')
}
}
media.addEventListener('change', handleChange)
return () => {
media.removeEventListener('change', handleChange)
}
}, [theme])
return (
<div className="bb-shell">
<PortalHeader
isAuthenticated={!!token}
userMenu={
token ? (
<NavDropdown
title={
<span className="bb-user-menu">
<span className="bb-user-menu__name">{email}</span>
<i className="bi bi-caret-down-fill" aria-hidden="true" />
</span>
}
align="end"
className="bb-user-menu__dropdown"
>
<NavDropdown.Item as={Link} to="/ucp">
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
</NavDropdown.Item>
<NavDropdown.Item as={Link} to="/ucp">
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={logout}>
<i className="bi bi-power" aria-hidden="true" /> {t('portal.user_logout')}
</NavDropdown.Item>
</NavDropdown>
) : null
}
/>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/forums" element={<BoardIndex />} />
<Route path="/forum/:id" element={<ForumView />} />
<Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
<Route
path="/ucp"
element={
<Ucp
theme={theme}
setTheme={setTheme}
accentOverride={accentOverride}
setAccentOverride={setAccentOverride}
/>
}
/>
</Routes>
<footer className="bb-footer">
<div className="ms-3 d-flex align-items-center gap-3">
<span>{t('footer.copy')}</span>
{versionInfo?.version && (
<span className="bb-version">
<span className="bb-version-label">Version:</span>{' '}
<span className="bb-version-value">{versionInfo.version}</span>{' '}
<span className="bb-version-label">(build:</span>{' '}
<span className="bb-version-value">{versionInfo.build}</span>
<span className="bb-version-label">)</span>
</span>
)}
</div>
</footer>
</div>
)
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<AppShell />
</BrowserRouter>
</AuthProvider>
)
}