Files
speedBB/resources/js/App.jsx

493 lines
16 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, forumName, logoUrl, showHeaderName }) {
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">
{logoUrl && (
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
)}
{(showHeaderName || !logoUrl) && (
<div className="bb-portal-logo">{forumName || '24unix.net'}</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('auto')
const [resolvedTheme, setResolvedTheme] = useState('light')
const [accentOverride, setAccentOverride] = useState(
() => localStorage.getItem('speedbb_accent') || ''
)
const [settings, setSettings] = useState({
forumName: '',
defaultTheme: 'auto',
accentDark: '',
accentLight: '',
logoDark: '',
logoLight: '',
showHeaderName: true,
faviconIco: '',
favicon16: '',
favicon32: '',
favicon48: '',
favicon64: '',
favicon128: '',
favicon256: '',
})
useEffect(() => {
fetchVersion()
.then((data) => setVersionInfo(data))
.catch(() => setVersionInfo(null))
}, [])
useEffect(() => {
let active = true
const loadSettings = async () => {
try {
const [
forumNameSetting,
defaultThemeSetting,
accentDarkSetting,
accentLightSetting,
logoDarkSetting,
logoLightSetting,
showHeaderNameSetting,
faviconIcoSetting,
favicon16Setting,
favicon32Setting,
favicon48Setting,
favicon64Setting,
favicon128Setting,
favicon256Setting,
] = await Promise.all([
fetchSetting('forum_name'),
fetchSetting('default_theme'),
fetchSetting('accent_color_dark'),
fetchSetting('accent_color_light'),
fetchSetting('logo_dark'),
fetchSetting('logo_light'),
fetchSetting('show_header_name'),
fetchSetting('favicon_ico'),
fetchSetting('favicon_16'),
fetchSetting('favicon_32'),
fetchSetting('favicon_48'),
fetchSetting('favicon_64'),
fetchSetting('favicon_128'),
fetchSetting('favicon_256'),
])
if (!active) return
const next = {
forumName: forumNameSetting?.value || '',
defaultTheme: defaultThemeSetting?.value || 'auto',
accentDark: accentDarkSetting?.value || '',
accentLight: accentLightSetting?.value || '',
logoDark: logoDarkSetting?.value || '',
logoLight: logoLightSetting?.value || '',
showHeaderName: showHeaderNameSetting?.value !== 'false',
faviconIco: faviconIcoSetting?.value || '',
favicon16: favicon16Setting?.value || '',
favicon32: favicon32Setting?.value || '',
favicon48: favicon48Setting?.value || '',
favicon64: favicon64Setting?.value || '',
favicon128: favicon128Setting?.value || '',
favicon256: favicon256Setting?.value || '',
}
setSettings(next)
} catch {
// keep defaults
}
}
loadSettings()
return () => {
active = false
}
}, [])
useEffect(() => {
const stored = token ? localStorage.getItem('speedbb_theme') : null
const nextTheme = stored || settings.defaultTheme || 'auto'
setTheme(nextTheme)
}, [token, settings.defaultTheme])
useEffect(() => {
const handleSettingsUpdate = (event) => {
const next = event.detail
if (!next) return
setSettings((prev) => ({ ...prev, ...next }))
}
window.addEventListener('speedbb-settings-updated', handleSettingsUpdate)
return () => {
window.removeEventListener('speedbb-settings-updated', handleSettingsUpdate)
}
}, [])
useEffect(() => {
if (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') {
const next = media.matches ? 'dark' : 'light'
root.setAttribute('data-bs-theme', next)
setResolvedTheme(next)
} else {
root.setAttribute('data-bs-theme', mode)
setResolvedTheme(mode)
}
}
applyTheme(theme)
const handleChange = () => {
if (theme === 'auto') {
applyTheme('auto')
}
}
media.addEventListener('change', handleChange)
return () => {
media.removeEventListener('change', handleChange)
}
}, [theme])
useEffect(() => {
const accent =
accentOverride ||
(resolvedTheme === 'dark' ? settings.accentDark : settings.accentLight) ||
settings.accentDark ||
settings.accentLight
if (accent) {
document.documentElement.style.setProperty('--bb-accent', accent)
}
}, [accentOverride, resolvedTheme, settings])
useEffect(() => {
if (settings.forumName) {
document.title = settings.forumName
}
}, [settings.forumName])
useEffect(() => {
const upsertIcon = (id, rel, href, sizes, type) => {
if (!href) {
const existing = document.getElementById(id)
if (existing) {
existing.remove()
}
return
}
let link = document.getElementById(id)
if (!link) {
link = document.createElement('link')
link.id = id
document.head.appendChild(link)
}
link.setAttribute('rel', rel)
link.setAttribute('href', href)
if (sizes) {
link.setAttribute('sizes', sizes)
} else {
link.removeAttribute('sizes')
}
if (type) {
link.setAttribute('type', type)
} else {
link.removeAttribute('type')
}
}
upsertIcon('favicon-ico', 'icon', settings.faviconIco, null, 'image/x-icon')
upsertIcon('favicon-16', 'icon', settings.favicon16, '16x16', 'image/png')
upsertIcon('favicon-32', 'icon', settings.favicon32, '32x32', 'image/png')
upsertIcon('favicon-48', 'icon', settings.favicon48, '48x48', 'image/png')
upsertIcon('favicon-64', 'icon', settings.favicon64, '64x64', 'image/png')
upsertIcon('favicon-128', 'icon', settings.favicon128, '128x128', 'image/png')
upsertIcon('favicon-256', 'icon', settings.favicon256, '256x256', 'image/png')
}, [
settings.faviconIco,
settings.favicon16,
settings.favicon32,
settings.favicon48,
settings.favicon64,
settings.favicon128,
settings.favicon256,
])
return (
<div className="bb-shell">
<PortalHeader
isAuthenticated={!!token}
forumName={settings.forumName}
logoUrl={resolvedTheme === 'dark' ? settings.logoDark : settings.logoLight}
showHeaderName={settings.showHeaderName}
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>
)
}