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 (
{logoUrl && (

)}
{(showHeaderName || !logoUrl) && (
{forumName || '24unix.net'}
)}
{t('portal.quick_links')}
{t('portal.link_faq')}
{t('portal.link_acp')}
{t('portal.link_mcp')}
{isAuthenticated ? (
<>
{t('portal.notifications')}
{t('portal.messages')}
{userMenu}
>
) : (
<>
{t('nav.register')}
{t('nav.login')}
>
)}
{crumbs.map((crumb, index) => (
{index > 0 && ›}
{crumb.current ? (
{index === 0 && }
{index === 1 && }
{crumb.label}
) : (
{index === 0 && }
{index === 1 && }
{crumb.label}
)}
))}
)
}
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 (
{email}
}
align="end"
className="bb-user-menu__dropdown"
>
{t('portal.user_control_panel')}
{t('portal.user_profile')}
{t('portal.user_logout')}
) : null
}
/>
} />
} />
} />
} />
} />
} />
} />
}
/>
)
}
export default function App() {
return (
)
}