Add ping endpoint, update-refresh prompt, and dark-mode polish
This commit is contained in:
@@ -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.
|
||||
|
||||
23
app/Http/Controllers/PingController.php
Normal file
23
app/Http/Controllers/PingController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PingController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$build = Setting::query()->where('key', 'build')->value('value');
|
||||
$reportedBuild = $build !== null ? ((int) $build) + 1 : 1;
|
||||
|
||||
return response()->json([
|
||||
'connect' => 'ok',
|
||||
'version_status' => [
|
||||
'build' => $reportedBuild,
|
||||
],
|
||||
'notification_state' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
<span className="bb-version-label">)</span>
|
||||
</span>
|
||||
)}
|
||||
{availableBuild !== null && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bb-accent-button"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t('version.update_available_short')} (build {availableBuild}) ·{' '}
|
||||
{t('version.update_now')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
<Modal show={showUpdateModal} onHide={() => setShowUpdateModal(false)} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('version.refresh_prompt_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{t('version.refresh_prompt_body', { build: availableBuild ?? '-' })}
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="justify-content-between">
|
||||
<Button variant="outline-secondary" onClick={() => setShowUpdateModal(false)}>
|
||||
{t('version.remind_later')}
|
||||
</Button>
|
||||
<Button className="bb-accent-button" onClick={() => window.location.reload()}>
|
||||
{t('version.update_now')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstallerController;
|
||||
use App\Http\Controllers\PingController;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -29,6 +30,8 @@ Route::get('/reset-password', function () {
|
||||
return view('app');
|
||||
})->name('password.reset');
|
||||
|
||||
Route::get('/ping', PingController::class);
|
||||
|
||||
Route::get('/{any}', function () {
|
||||
if (!file_exists(base_path('.env'))) {
|
||||
return redirect('/install');
|
||||
|
||||
18
tests/Feature/PingControllerTest.php
Normal file
18
tests/Feature/PingControllerTest.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
|
||||
it('returns ping status with build and notification state', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'build'], ['value' => '1337']);
|
||||
|
||||
$response = $this->getJson('/ping');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'connect' => 'ok',
|
||||
'version_status' => [
|
||||
'build' => 1337,
|
||||
],
|
||||
'notification_state' => false,
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user