feat: add installer, ranks/groups enhancements, and founder protections
This commit is contained in:
@@ -15,496 +15,496 @@ import { useTranslation } from 'react-i18next'
|
||||
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
|
||||
|
||||
function PortalHeader({
|
||||
userMenu,
|
||||
isAuthenticated,
|
||||
forumName,
|
||||
logoUrl,
|
||||
showHeaderName,
|
||||
canAccessAcp,
|
||||
canAccessMcp,
|
||||
userMenu,
|
||||
isAuthenticated,
|
||||
forumName,
|
||||
logoUrl,
|
||||
showHeaderName,
|
||||
canAccessAcp,
|
||||
canAccessMcp,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const [crumbs, setCrumbs] = useState([])
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const [crumbs, setCrumbs] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
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 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
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
const buildCrumbs = async () => {
|
||||
const base = [
|
||||
{ label: t('portal.portal'), to: '/' },
|
||||
{ label: t('portal.board_index'), to: '/forums' },
|
||||
]
|
||||
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 === '/') {
|
||||
setCrumbs([{ ...base[0], current: true }, { ...base[1] }])
|
||||
return
|
||||
}
|
||||
|
||||
if (location.pathname === '/forums') {
|
||||
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
|
||||
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('/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
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/acp')) {
|
||||
setCrumbs([
|
||||
{ ...base[0] },
|
||||
{ ...base[1] },
|
||||
{ label: t('portal.link_acp'), to: '/acp', current: true },
|
||||
])
|
||||
return
|
||||
}
|
||||
if (location.pathname.startsWith('/acp')) {
|
||||
setCrumbs([
|
||||
{ ...base[0] },
|
||||
{ ...base[1] },
|
||||
{ label: t('portal.link_acp'), to: '/acp', current: true },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/ucp')) {
|
||||
setCrumbs([
|
||||
{ ...base[0] },
|
||||
{ ...base[1] },
|
||||
{ label: t('portal.user_control_panel'), to: '/ucp', current: true },
|
||||
])
|
||||
return
|
||||
}
|
||||
if (location.pathname.startsWith('/ucp')) {
|
||||
setCrumbs([
|
||||
{ ...base[0] },
|
||||
{ ...base[1] },
|
||||
{ label: t('portal.user_control_panel'), to: '/ucp', current: true },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/profile/')) {
|
||||
setCrumbs([
|
||||
{ ...base[0] },
|
||||
{ ...base[1] },
|
||||
{ label: t('portal.user_profile'), to: location.pathname, current: true },
|
||||
])
|
||||
return
|
||||
}
|
||||
if (location.pathname.startsWith('/profile/')) {
|
||||
setCrumbs([
|
||||
{ ...base[0] },
|
||||
{ ...base[1] },
|
||||
{ label: t('portal.user_profile'), to: location.pathname, current: true },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
|
||||
}
|
||||
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
|
||||
}
|
||||
|
||||
buildCrumbs()
|
||||
buildCrumbs()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [location.pathname, t])
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [location.pathname, t])
|
||||
|
||||
return (
|
||||
<Container fluid className="pt-2 pb-2 bb-portal-shell">
|
||||
<div className="bb-portal-banner">
|
||||
<div className="bb-portal-brand">
|
||||
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
|
||||
{logoUrl && (
|
||||
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
||||
)}
|
||||
{(showHeaderName || !logoUrl) && (
|
||||
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
||||
)}
|
||||
</Link>
|
||||
</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>
|
||||
return (
|
||||
<Container fluid className="pt-2 pb-2 bb-portal-shell">
|
||||
<div className="bb-portal-banner">
|
||||
<div className="bb-portal-brand">
|
||||
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
|
||||
{logoUrl && (
|
||||
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
||||
)}
|
||||
{(showHeaderName || !logoUrl) && (
|
||||
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
||||
)}
|
||||
</Link>
|
||||
</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>
|
||||
{isAuthenticated && canAccessAcp && (
|
||||
<>
|
||||
<Link to="/acp" className="bb-portal-link">
|
||||
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated && canAccessMcp && (
|
||||
<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>}
|
||||
<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>
|
||||
{isAuthenticated && canAccessAcp && (
|
||||
<>
|
||||
<Link to="/acp" className="bb-portal-link">
|
||||
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{isAuthenticated && canAccessMcp && (
|
||||
<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">
|
||||
<Link to={crumb.to} className="bb-portal-current 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}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation()
|
||||
const { token, email, userId, logout, isAdmin, isModerator } = 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: '',
|
||||
})
|
||||
const { t } = useTranslation()
|
||||
const { token, email, userId, logout, isAdmin, isModerator } = 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(() => {
|
||||
fetchVersion()
|
||||
.then((data) => setVersionInfo(data))
|
||||
.catch(() => setVersionInfo(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const allSettings = await fetchSettings()
|
||||
const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value]))
|
||||
if (!active) return
|
||||
const next = {
|
||||
forumName: settingsMap.get('forum_name') || '',
|
||||
defaultTheme: settingsMap.get('default_theme') || 'auto',
|
||||
accentDark: settingsMap.get('accent_color_dark') || '',
|
||||
accentLight: settingsMap.get('accent_color_light') || '',
|
||||
logoDark: settingsMap.get('logo_dark') || '',
|
||||
logoLight: settingsMap.get('logo_light') || '',
|
||||
showHeaderName: settingsMap.get('show_header_name') !== 'false',
|
||||
faviconIco: settingsMap.get('favicon_ico') || '',
|
||||
favicon16: settingsMap.get('favicon_16') || '',
|
||||
favicon32: settingsMap.get('favicon_32') || '',
|
||||
favicon48: settingsMap.get('favicon_48') || '',
|
||||
favicon64: settingsMap.get('favicon_64') || '',
|
||||
favicon128: settingsMap.get('favicon_128') || '',
|
||||
favicon256: settingsMap.get('favicon_256') || '',
|
||||
}
|
||||
setSettings(next)
|
||||
} catch {
|
||||
// keep defaults
|
||||
}
|
||||
}
|
||||
loadSettings()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const allSettings = await fetchSettings()
|
||||
const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value]))
|
||||
if (!active) return
|
||||
const next = {
|
||||
forumName: settingsMap.get('forum_name') || '',
|
||||
defaultTheme: settingsMap.get('default_theme') || 'auto',
|
||||
accentDark: settingsMap.get('accent_color_dark') || '',
|
||||
accentLight: settingsMap.get('accent_color_light') || '',
|
||||
logoDark: settingsMap.get('logo_dark') || '',
|
||||
logoLight: settingsMap.get('logo_light') || '',
|
||||
showHeaderName: settingsMap.get('show_header_name') !== 'false',
|
||||
faviconIco: settingsMap.get('favicon_ico') || '',
|
||||
favicon16: settingsMap.get('favicon_16') || '',
|
||||
favicon32: settingsMap.get('favicon_32') || '',
|
||||
favicon48: settingsMap.get('favicon_48') || '',
|
||||
favicon64: settingsMap.get('favicon_64') || '',
|
||||
favicon128: settingsMap.get('favicon_128') || '',
|
||||
favicon256: settingsMap.get('favicon_256') || '',
|
||||
}
|
||||
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 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 }))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
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(() => {
|
||||
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)')
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
applyTheme(theme)
|
||||
|
||||
const handleChange = () => {
|
||||
if (theme === 'auto') {
|
||||
applyTheme('auto')
|
||||
}
|
||||
}
|
||||
const handleChange = () => {
|
||||
if (theme === 'auto') {
|
||||
applyTheme('auto')
|
||||
}
|
||||
}
|
||||
|
||||
media.addEventListener('change', handleChange)
|
||||
media.addEventListener('change', handleChange)
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('change', handleChange)
|
||||
}
|
||||
}, [theme])
|
||||
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(() => {
|
||||
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(() => {
|
||||
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')
|
||||
}
|
||||
}
|
||||
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,
|
||||
])
|
||||
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={`/profile/${userId ?? ''}`}>
|
||||
<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
|
||||
}
|
||||
canAccessAcp={isAdmin}
|
||||
canAccessMcp={isModerator}
|
||||
/>
|
||||
<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="/profile/:id" element={<Profile />} />
|
||||
<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>
|
||||
)
|
||||
return (
|
||||
<div className="bb-shell" id="top">
|
||||
<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={`/profile/${userId ?? ''}`}>
|
||||
<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
|
||||
}
|
||||
canAccessAcp={isAdmin}
|
||||
canAccessMcp={isModerator}
|
||||
/>
|
||||
<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="/profile/:id" element={<Profile />} />
|
||||
<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>
|
||||
)
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user