diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee0b460..9b17d49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@
- Implemented token-based new-password submission (`?token=...&email=...`) wired to `POST /api/reset-password`.
- Updated reset flow UX to return to `/login` after successful reset-link request and after successful password update.
- Added English and German translations for password reset screens/messages.
+- Added new `/ping` endpoint returning connection status, build status, and notification state.
+- Added frontend ping polling with active/hidden intervals and console diagnostics.
+- Added update-available detection comparing loaded build vs ping build, with footer refresh CTA.
+- Added update info modal prompting users to refresh when a newer build is detected.
+- Tuned global dark mode palette to reduce overly bright text/surfaces in dark theme.
+- Refined accent button state styling (hover/active/focus) to avoid Bootstrap blue fallback and preserve contrast.
## 2026-02-18
- Added CLI default PHP version detection to system status (`php_default_version`) using the CLI `php` binary.
diff --git a/app/Http/Controllers/PingController.php b/app/Http/Controllers/PingController.php
new file mode 100644
index 0000000..993322e
--- /dev/null
+++ b/app/Http/Controllers/PingController.php
@@ -0,0 +1,23 @@
+where('key', 'build')->value('value');
+ $reportedBuild = $build !== null ? ((int) $build) + 1 : 1;
+
+ return response()->json([
+ 'connect' => 'ok',
+ 'version_status' => [
+ 'build' => $reportedBuild,
+ ],
+ 'notification_state' => false,
+ ]);
+ }
+}
diff --git a/resources/js/App.jsx b/resources/js/App.jsx
index 9fe833b..423ad0b 100644
--- a/resources/js/App.jsx
+++ b/resources/js/App.jsx
@@ -1,6 +1,6 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
-import { Container, NavDropdown } from 'react-bootstrap'
+import { Button, Container, Modal, NavDropdown } from 'react-bootstrap'
import { AuthProvider, useAuth } from './context/AuthContext'
import Home from './pages/Home'
import ForumView from './pages/ForumView'
@@ -13,7 +13,7 @@ import BoardIndex from './pages/BoardIndex'
import Ucp from './pages/Ucp'
import Profile from './pages/Profile'
import { useTranslation } from 'react-i18next'
-import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
+import { fetchPing, fetchSettings, fetchVersion, getForum, getThread } from './api/client'
function PortalHeader({
userMenu,
@@ -241,9 +241,16 @@ function PortalHeader({
}
function AppShell() {
+ const PING_INTERVAL_MS = 15000
+ const PING_INTERVAL_HIDDEN_MS = 60000
const { t } = useTranslation()
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
const [versionInfo, setVersionInfo] = useState(null)
+ const [availableBuild, setAvailableBuild] = useState(null)
+ const [pingBuild, setPingBuild] = useState(null)
+ const [showUpdateModal, setShowUpdateModal] = useState(false)
+ const currentBuildRef = useRef(null)
+ const promptedBuildRef = useRef(null)
const [theme, setTheme] = useState('auto')
const [resolvedTheme, setResolvedTheme] = useState('light')
const [accentOverride, setAccentOverride] = useState(
@@ -272,6 +279,73 @@ function AppShell() {
.catch(() => setVersionInfo(null))
}, [])
+ useEffect(() => {
+ currentBuildRef.current =
+ typeof versionInfo?.build === 'number' ? versionInfo.build : null
+ }, [versionInfo?.build])
+
+ useEffect(() => {
+ const currentBuild =
+ typeof versionInfo?.build === 'number' ? versionInfo.build : null
+ if (currentBuild !== null && pingBuild !== null && pingBuild > currentBuild) {
+ setAvailableBuild(pingBuild)
+ return
+ }
+ setAvailableBuild(null)
+ }, [versionInfo?.build, pingBuild])
+
+ useEffect(() => {
+ if (availableBuild === null) return
+ if (promptedBuildRef.current === availableBuild) return
+ promptedBuildRef.current = availableBuild
+ setShowUpdateModal(true)
+ }, [availableBuild])
+
+ useEffect(() => {
+ let active = true
+ let timeoutId = null
+
+ const scheduleNext = () => {
+ const delay = document.hidden ? PING_INTERVAL_HIDDEN_MS : PING_INTERVAL_MS
+ timeoutId = window.setTimeout(runPing, delay)
+ }
+
+ const runPing = async () => {
+ try {
+ const data = await fetchPing()
+ const currentBuild = currentBuildRef.current
+ const remoteBuild =
+ typeof data?.version_status?.build === 'number'
+ ? data.version_status.build
+ : null
+ console.log('speedBB ping', {
+ ...data,
+ current_version: currentBuild,
+ })
+ if (!active) return
+ if (remoteBuild !== null) {
+ setPingBuild(remoteBuild)
+ }
+ window.dispatchEvent(new CustomEvent('speedbb-ping', { detail: data }))
+ } catch {
+ // ignore transient ping failures
+ } finally {
+ if (active) {
+ scheduleNext()
+ }
+ }
+ }
+
+ runPing()
+
+ return () => {
+ active = false
+ if (timeoutId) {
+ window.clearTimeout(timeoutId)
+ }
+ }
+ }, [])
+
useEffect(() => {
let active = true
const loadSettings = async () => {
@@ -495,8 +569,35 @@ function AppShell() {
)
)}
+ {availableBuild !== null && (
+
+ )}
+ setShowUpdateModal(false)} centered>
+
+ {t('version.refresh_prompt_title')}
+
+
+ {t('version.refresh_prompt_body', { build: availableBuild ?? '-' })}
+
+
+
+
+
+
)
}
diff --git a/resources/js/api/client.js b/resources/js/api/client.js
index d3ace48..d3f89c1 100644
--- a/resources/js/api/client.js
+++ b/resources/js/api/client.js
@@ -129,6 +129,15 @@ export async function fetchVersion() {
return apiFetch('/version')
}
+export async function fetchPing() {
+ const response = await fetch('/ping', {
+ headers: {
+ Accept: 'application/json',
+ },
+ })
+ return parseResponse(response)
+}
+
export async function fetchVersionCheck() {
return apiFetch('/version/check')
}
diff --git a/resources/js/index.css b/resources/js/index.css
index 213a76a..cdcd0ae 100644
--- a/resources/js/index.css
+++ b/resources/js/index.css
@@ -862,10 +862,17 @@ a {
}
[data-bs-theme="dark"] {
- --bb-ink: #aaaeb4;
- --bb-ink-muted: #6b7483;
- --bb-border: #2a2f3a;
- --bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%);
+ --bb-ink: #a3acb9;
+ --bb-ink-muted: #626d7e;
+ --bb-border: #242a35;
+ --bb-page-bg: radial-gradient(circle at 10% 20%, #10151f 0%, #0b1018 45%, #080b11 100%);
+ --bs-body-bg: #080b11;
+ --bs-body-color: #b3bdca;
+ --bs-secondary-bg: #121822;
+ --bs-tertiary-bg: #0f141d;
+ --bs-border-color: #242a35;
+ --bs-modal-bg: #121822;
+ --bs-modal-color: #b3bdca;
}
[data-bs-theme="dark"] .bb-hero {
@@ -875,11 +882,30 @@ a {
[data-bs-theme="dark"] .bb-card,
[data-bs-theme="dark"] .bb-form {
- background: #171b22;
- border-color: #2a2f3a;
+ background: #121822;
+ border-color: #242a35;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
}
+[data-bs-theme="dark"] .modal-content {
+ background: #121822;
+ border-color: #242a35;
+ color: #b3bdca;
+}
+
+[data-bs-theme="dark"] .modal-content .modal-body,
+[data-bs-theme="dark"] .modal-content .modal-footer {
+ background: #121822;
+}
+
+[data-bs-theme="dark"] .modal-content .modal-header {
+ color: #b7c0cc;
+}
+
+[data-bs-theme="dark"] .modal-content .modal-title {
+ color: #b7c0cc;
+}
+
[data-bs-theme="light"] .bb-forum-row {
background: #fff;
}
@@ -2172,16 +2198,25 @@ a {
}
.bb-accent-button {
- background: var(--bb-accent, #f29b3f);
- border-color: var(--bb-accent, #f29b3f);
- color: #0e121b;
+ --bs-btn-bg: var(--bb-accent, #f29b3f);
+ --bs-btn-border-color: var(--bb-accent, #f29b3f);
+ --bs-btn-color: #0e121b;
+ --bs-btn-hover-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
+ --bs-btn-hover-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
+ --bs-btn-hover-color: #fff;
+ --bs-btn-active-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000);
+ --bs-btn-active-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000);
+ --bs-btn-active-color: #fff;
+ --bs-btn-disabled-bg: var(--bb-accent, #f29b3f);
+ --bs-btn-disabled-border-color: var(--bb-accent, #f29b3f);
+ --bs-btn-disabled-color: #0e121b;
+ background: var(--bs-btn-bg);
+ border-color: var(--bs-btn-border-color);
+ color: var(--bs-btn-color);
}
-.bb-accent-button:hover,
-.bb-accent-button:focus {
- background: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
- border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
- color: #0e121b;
+.bb-accent-button:focus-visible {
+ box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 35%, transparent);
}
.bb-accent-button:disabled,
diff --git a/resources/lang/de.json b/resources/lang/de.json
index 96fdf73..13687d3 100644
--- a/resources/lang/de.json
+++ b/resources/lang/de.json
@@ -186,6 +186,9 @@
"version.up_to_date": "Aktuell",
"version.update_available": "Update verfügbar (v{{version}})",
"version.update_available_short": "Update verfügbar",
+ "version.refresh_prompt_title": "Update verfuegbar",
+ "version.refresh_prompt_body": "Ein neuerer Build ({{build}}) ist verfuegbar. Jetzt neu laden, um die aktuelle Version zu verwenden?",
+ "version.remind_later": "Spaeter",
"version.unknown": "Version unbekannt",
"version.update_now": "Jetzt aktualisieren",
"version.update_title": "System aktualisieren",
diff --git a/resources/lang/en.json b/resources/lang/en.json
index df87488..2cf8e89 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -176,6 +176,9 @@
"version.up_to_date": "Up to date",
"version.update_available": "Update available (v{{version}})",
"version.update_available_short": "Update available",
+ "version.refresh_prompt_title": "Update available",
+ "version.refresh_prompt_body": "A newer build ({{build}}) is available. Refresh now to load the latest version?",
+ "version.remind_later": "Later",
"version.unknown": "Version unknown",
"version.update_now": "Update now",
"version.update_title": "Update system",
diff --git a/routes/web.php b/routes/web.php
index 0d9bc31..2f99a3a 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,6 +1,7 @@
name('password.reset');
+Route::get('/ping', PingController::class);
+
Route::get('/{any}', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');
diff --git a/tests/Feature/PingControllerTest.php b/tests/Feature/PingControllerTest.php
new file mode 100644
index 0000000..566b256
--- /dev/null
+++ b/tests/Feature/PingControllerTest.php
@@ -0,0 +1,18 @@
+ 'build'], ['value' => '1337']);
+
+ $response = $this->getJson('/ping');
+
+ $response->assertOk();
+ $response->assertJson([
+ 'connect' => 'ok',
+ 'version_status' => [
+ 'build' => 1337,
+ ],
+ 'notification_state' => false,
+ ]);
+});