Add settings-driven theme and version metadata
This commit is contained in:
@@ -9,8 +9,9 @@ import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
import Acp from './pages/Acp'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fetchSetting, fetchVersion } from './api/client'
|
||||
|
||||
function Navigation() {
|
||||
function Navigation({ theme, onThemeChange }) {
|
||||
const { token, email, logout, isAdmin } = useAuth()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
@@ -19,23 +20,27 @@ function Navigation() {
|
||||
localStorage.setItem('speedbb_lang', locale)
|
||||
}
|
||||
|
||||
const handleThemeChange = (value) => {
|
||||
onThemeChange(value)
|
||||
localStorage.setItem('speedbb_theme', value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Navbar expand="lg" className="bb-nav">
|
||||
<Container>
|
||||
<Navbar.Brand as={Link} to="/" className="fw-semibold">
|
||||
{t('app.brand')}
|
||||
</Navbar.Brand>
|
||||
{isAdmin && (
|
||||
<Nav className="me-auto">
|
||||
<Nav.Link as={Link} to="/acp">
|
||||
{t('nav.acp')}
|
||||
</Nav.Link>
|
||||
</Nav>
|
||||
)}
|
||||
<Navbar.Toggle aria-controls="bb-nav" />
|
||||
<Navbar.Collapse id="bb-nav">
|
||||
<Nav className="ms-auto align-items-lg-center gap-2">
|
||||
<Nav.Link as={Link} to="/">
|
||||
{t('nav.forums')}
|
||||
</Nav.Link>
|
||||
{isAdmin && (
|
||||
<Nav.Link as={Link} to="/acp">
|
||||
{t('nav.acp')}
|
||||
</Nav.Link>
|
||||
)}
|
||||
{!token && (
|
||||
<>
|
||||
<Nav.Link as={Link} to="/login">
|
||||
@@ -60,6 +65,17 @@ function Navigation() {
|
||||
Deutsch
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
<NavDropdown title={t('nav.theme')} align="end">
|
||||
<NavDropdown.Item onClick={() => handleThemeChange('auto')} active={theme === 'auto'}>
|
||||
{t('nav.theme_auto')}
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => handleThemeChange('light')} active={theme === 'light'}>
|
||||
{t('nav.theme_light')}
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => handleThemeChange('dark')} active={theme === 'dark'}>
|
||||
{t('nav.theme_dark')}
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
@@ -71,6 +87,8 @@ function AppShell() {
|
||||
const { t } = useTranslation()
|
||||
const { isAdmin } = useAuth()
|
||||
const [loadMs, setLoadMs] = useState(null)
|
||||
const [versionInfo, setVersionInfo] = useState(null)
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('speedbb_theme') || 'auto')
|
||||
|
||||
useEffect(() => {
|
||||
const [entry] = performance.getEntriesByType('navigation')
|
||||
@@ -81,9 +99,52 @@ function AppShell() {
|
||||
setLoadMs(Math.round(performance.now()))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersion()
|
||||
.then((data) => setVersionInfo(data))
|
||||
.catch(() => setVersionInfo(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSetting('accent_color')
|
||||
.then((setting) => {
|
||||
if (setting?.value) {
|
||||
document.documentElement.style.setProperty('--bb-accent', setting.value)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
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">
|
||||
<Navigation />
|
||||
<Navigation theme={theme} onThemeChange={setTheme} />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/forum/:id" element={<ForumView />} />
|
||||
@@ -95,7 +156,21 @@ function AppShell() {
|
||||
<footer className="bb-footer">
|
||||
<div className="ms-3 d-flex align-items-center gap-3">
|
||||
<span>{t('footer.copy')}</span>
|
||||
{loadMs !== null && <span className="bb-muted">Loaded in {loadMs} ms</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>
|
||||
)}
|
||||
{loadMs !== null && (
|
||||
<span className="bb-load-time">
|
||||
<span className="bb-load-label">Page load time</span>{' '}
|
||||
<span className="bb-load-value">{loadMs}ms</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,15 @@ export async function listAllForums() {
|
||||
return getCollection('/forums?pagination=false')
|
||||
}
|
||||
|
||||
export async function fetchVersion() {
|
||||
return apiFetch('/version')
|
||||
}
|
||||
|
||||
export async function fetchSetting(key) {
|
||||
const data = await getCollection(`/settings?key=${encodeURIComponent(key)}&pagination=false`)
|
||||
return data[0] || null
|
||||
}
|
||||
|
||||
export async function listForumsByParent(parentId) {
|
||||
return getCollection(`/forums?parent=/api/forums/${parentId}`)
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ export function AuthProvider({ children }) {
|
||||
return Array.isArray(payload?.roles) ? payload.roles : []
|
||||
})
|
||||
|
||||
const effectiveRoles = token ? roles : ['ROLE_ADMIN']
|
||||
const effectiveUserId = token ? userId : '1'
|
||||
const effectiveRoles = token ? roles : []
|
||||
const effectiveUserId = token ? userId : null
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=Source+Sans+3:wght@400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bb-ink: #0e121b;
|
||||
--bb-ink-muted: #5b6678;
|
||||
@@ -17,14 +15,14 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Source Sans 3", system-ui, -apple-system, sans-serif;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--bb-ink);
|
||||
background: radial-gradient(circle at 10% 20%, #fff6e9 0%, #f4e7d5 40%, #e8d9c5 100%);
|
||||
background: var(--bb-page-bg, radial-gradient(circle at 10% 20%, #fff6e9 0%, #f4e7d5 40%, #e8d9c5 100%));
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-family: "Space Grotesk", system-ui, -apple-system, sans-serif;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@@ -103,6 +101,97 @@ a {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--bb-ink: #e6e8eb;
|
||||
--bb-ink-muted: #9aa4b2;
|
||||
--bb-border: #2a2f3a;
|
||||
--bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-nav {
|
||||
background: rgba(15, 18, 26, 0.9);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-hero {
|
||||
background: linear-gradient(135deg, rgba(21, 122, 110, 0.12), rgba(228, 166, 52, 0.08));
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-card,
|
||||
[data-bs-theme="dark"] .bb-form {
|
||||
background: #171b22;
|
||||
border-color: #2a2f3a;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-chip {
|
||||
background: #20252f;
|
||||
color: #c7cdd7;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-icon {
|
||||
background: rgba(21, 122, 110, 0.24);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-icon--forum {
|
||||
background: rgba(228, 166, 52, 0.25);
|
||||
color: #e0b26b;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bb-collapse-toggle {
|
||||
background: #0f1218;
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.bb-collapse-toggle {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-outline-dark {
|
||||
color: #e6e8eb;
|
||||
border-color: #3a4150;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-outline-dark:hover,
|
||||
[data-bs-theme="dark"] .btn-outline-dark:focus {
|
||||
color: #0f1218;
|
||||
background-color: #e6e8eb;
|
||||
border-color: #e6e8eb;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bb-version {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-version-label {
|
||||
color: var(--bb-accent, #f29b3f);
|
||||
}
|
||||
|
||||
.bb-version-value {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bb-load-time {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bb-load-label {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bb-load-value {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bb-acp {
|
||||
max-width: 1880px;
|
||||
}
|
||||
@@ -152,8 +241,29 @@ a {
|
||||
}
|
||||
|
||||
.bb-drop-target {
|
||||
border-color: #157a6e;
|
||||
box-shadow: 0 0 0 2px rgba(21, 122, 110, 0.2);
|
||||
border: 2px dashed rgba(21, 122, 110, 0.75);
|
||||
background-color: rgba(21, 122, 110, 0.08);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgba(21, 122, 110, 0.25) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(21, 122, 110, 0.25) 50%,
|
||||
rgba(21, 122, 110, 0.25) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 18px 18px;
|
||||
animation: bb-marching-ants 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bb-marching-ants {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 18px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.bb-collapse-toggle {
|
||||
|
||||
@@ -217,8 +217,58 @@ export default function Acp({ isAdmin }) {
|
||||
setOverId(null)
|
||||
}
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
const applyLocalOrder = (parentId, orderedIds) => {
|
||||
setForums((prev) =>
|
||||
prev.map((forum) => {
|
||||
const pid = getParentId(forum)
|
||||
if (String(pid ?? '') !== String(parentId ?? '')) {
|
||||
return forum
|
||||
}
|
||||
const newIndex = orderedIds.indexOf(String(forum.id))
|
||||
return newIndex === -1 ? forum : { ...forum, position: newIndex + 1 }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const handleDragOver = (event, targetId, parentId) => {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
if (!draggingId || String(draggingId) === String(targetId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const draggedForum = forums.find((forum) => String(forum.id) === String(draggingId))
|
||||
if (!draggedForum) {
|
||||
return
|
||||
}
|
||||
|
||||
const draggedParentId = getParentId(draggedForum)
|
||||
if (String(draggedParentId ?? '') !== String(parentId ?? '')) {
|
||||
return
|
||||
}
|
||||
|
||||
const siblings = forums.filter((forum) => {
|
||||
const pid = getParentId(forum)
|
||||
return String(pid ?? '') === String(parentId ?? '')
|
||||
})
|
||||
|
||||
const ordered = siblings
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
.map((forum) => String(forum.id))
|
||||
|
||||
const fromIndex = ordered.indexOf(String(draggingId))
|
||||
const toIndex = ordered.indexOf(String(targetId))
|
||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
|
||||
setOverId(String(targetId))
|
||||
applyLocalOrder(parentId, ordered)
|
||||
}
|
||||
|
||||
const handleDragEnter = (forumId) => {
|
||||
@@ -227,7 +277,10 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (forumId) => {
|
||||
const handleDragLeave = (event, forumId) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget)) {
|
||||
return
|
||||
}
|
||||
if (overId === String(forumId)) {
|
||||
setOverId(null)
|
||||
}
|
||||
@@ -290,9 +343,12 @@ export default function Acp({ isAdmin }) {
|
||||
overId === String(node.id) ? 'bb-drop-target' : ''
|
||||
} ${draggingId === String(node.id) ? 'bb-dragging' : ''}`}
|
||||
style={{ marginLeft: depth * 16 }}
|
||||
onDragOver={handleDragOver}
|
||||
draggable
|
||||
onDragStart={(event) => handleDragStart(event, node.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(event) => handleDragOver(event, node.id, getParentId(node))}
|
||||
onDragEnter={() => handleDragEnter(node.id)}
|
||||
onDragLeave={() => handleDragLeave(node.id)}
|
||||
onDragLeave={(event) => handleDragLeave(event, node.id)}
|
||||
onDrop={(event) => handleDrop(event, node.id, getParentId(node))}
|
||||
>
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
@@ -326,9 +382,6 @@ export default function Acp({ isAdmin }) {
|
||||
<span
|
||||
className="bb-drag-handle text-muted"
|
||||
style={{ cursor: 'grab', display: 'inline-flex' }}
|
||||
draggable
|
||||
onDragStart={(event) => handleDragStart(event, node.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
title={t('acp.drag_handle')}
|
||||
>
|
||||
<i className="bi bi-arrow-down-up" aria-hidden="true" />
|
||||
|
||||
Reference in New Issue
Block a user