Add settings-driven theme and version metadata

This commit is contained in:
Micha
2025-12-25 14:35:01 +01:00
parent b5d689dd4d
commit 882ef26982
16 changed files with 525 additions and 29 deletions

View File

@@ -11,3 +11,13 @@
- Improved ACP UX: iconized action buttons, category/forum badges, and responsive layout tweaks.
- Added JWT username payload fix and increased token TTL to 24h.
- Added frontend load-time indicator in the footer.
- Added settings entity + migration for version/build metadata and synced version/build to composer.json.
- Added `/api/version` endpoint returning version and build.
- Refactored settings storage to key/value rows for version/build metadata.
- Added indexes to speed up forum tree queries (parent ordering and type filters).
- Added settings-driven accent color and API filter for settings key lookup.
- Added settings-backed theme dropdown (auto/light/dark) and dark-mode styling tweaks.
- Added settings key/value storage and `/api/version` for version/build in the UI footer.
- Added system font stack to remove external font requests.
- Improved ACP drag-and-drop hover reordering and visual drop target feedback.
- Hardened ACP access so admin tools require authentication.

View File

@@ -1,5 +1,6 @@
{
"type": "project",
"version": "25.00.1",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
@@ -79,6 +80,9 @@
"symfony/symfony": "*"
},
"extra": {
"speedbb": {
"build": 3
},
"symfony": {
"allow-contrib": false,
"require": "8.0.*"

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251224184500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add settings table with version and build metadata.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE settings (id INT AUTO_INCREMENT NOT NULL, version VARCHAR(20) NOT NULL, build INT NOT NULL, PRIMARY KEY(id))');
$this->addSql("INSERT INTO settings (id, version, build) VALUES (1, '25.00.1', 3)");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE settings');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251224191500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Convert settings table to key/value rows for version/build.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE settings_new (id INT AUTO_INCREMENT NOT NULL, `key` VARCHAR(100) NOT NULL, value LONGTEXT NOT NULL, UNIQUE INDEX UNIQ_SETTINGS_KEY (`key`), PRIMARY KEY(id))');
$this->addSql("INSERT INTO settings_new (`key`, value) SELECT 'version', version FROM settings LIMIT 1");
$this->addSql("INSERT INTO settings_new (`key`, value) SELECT 'build', CAST(build AS CHAR) FROM settings LIMIT 1");
$this->addSql('DROP TABLE settings');
$this->addSql('RENAME TABLE settings_new TO settings');
}
public function down(Schema $schema): void
{
$this->addSql('CREATE TABLE settings_old (id INT AUTO_INCREMENT NOT NULL, version VARCHAR(20) NOT NULL, build INT NOT NULL, PRIMARY KEY(id))');
$this->addSql("INSERT INTO settings_old (id, version, build) VALUES (1, (SELECT value FROM settings WHERE `key` = 'version' LIMIT 1), CAST((SELECT value FROM settings WHERE `key` = 'build' LIMIT 1) AS UNSIGNED))");
$this->addSql('DROP TABLE settings');
$this->addSql('RENAME TABLE settings_old TO settings');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251224193000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add indexes for forum parent ordering and type filters.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE INDEX idx_forum_parent_position ON forum (parent_id, position)');
$this->addSql('CREATE INDEX idx_forum_type ON forum (type)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_forum_parent_position ON forum');
$this->addSql('DROP INDEX idx_forum_type ON forum');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251224194000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add accent color setting.';
}
public function up(Schema $schema): void
{
$this->addSql("INSERT IGNORE INTO settings (`key`, value) VALUES ('accent_color', '#f29b3f')");
}
public function down(Schema $schema): void
{
$this->addSql("DELETE FROM settings WHERE `key` = 'accent_color'");
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Controller;
use App\Entity\Settings;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class VersionController
{
#[Route('/api/version', name: 'api_version', methods: ['GET'])]
public function __invoke(EntityManagerInterface $entityManager): JsonResponse
{
$repository = $entityManager->getRepository(Settings::class);
$version = $repository->findOneBy(['key' => 'version']);
$build = $repository->findOneBy(['key' => 'build']);
return new JsonResponse([
'version' => $version?->getValue(),
'build' => $build ? (int) $build->getValue() : null,
]);
}
}

View File

@@ -21,6 +21,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(indexes: [
new ORM\Index(name: 'idx_forum_parent_position', columns: ['parent_id', 'position']),
new ORM\Index(name: 'idx_forum_type', columns: ['type']),
])]
#[ApiFilter(SearchFilter::class, properties: ['parent' => 'exact', 'type' => 'exact'])]
#[ApiFilter(ExistsFilter::class, properties: ['parent'])]
#[ApiResource(

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ApiFilter(SearchFilter::class, properties: ['key' => 'exact'])]
#[ApiResource(
operations : [
new Get(),
new GetCollection(),
new Patch(security: "is_granted('ROLE_ADMIN')")
],
normalizationContext : ['groups' => ['settings:read']],
denormalizationContext: ['groups' => ['settings:write']]
)]
class Settings
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['settings:read'])]
private ?int $id = null;
#[ORM\Column(length: 100, unique: true)]
#[Groups(['settings:read', 'settings:write'])]
private string $key = '';
#[ORM\Column(type: 'text')]
#[Groups(['settings:read', 'settings:write'])]
private string $value = '';
public function getId(): ?int
{
return $this->id;
}
public function getKey(): string
{
return $this->key;
}
public function setKey(string $key): self
{
$this->key = $key;
return $this;
}
public function getValue(): string
{
return $this->value;
}
public function setValue(string $value): self
{
$this->value = $value;
return $this;
}
}

View File

@@ -21,6 +21,18 @@ msgstr "Abmelden"
msgid "nav.language"
msgstr "Sprache"
msgid "nav.theme"
msgstr "Design"
msgid "nav.theme_auto"
msgstr "Auto"
msgid "nav.theme_light"
msgstr "Hell"
msgid "nav.theme_dark"
msgstr "Dunkel"
msgid "home.hero_title"
msgstr "Foren"
@@ -115,7 +127,7 @@ msgid "auth.register_hint"
msgstr "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen."
msgid "footer.copy"
msgstr "speedBB Forum. Powered by API Platform und React-Bootstrap."
msgstr "speedBB"
msgid "form.title"
msgstr "Titel"

View File

@@ -21,6 +21,18 @@ msgstr "Logout"
msgid "nav.language"
msgstr "Language"
msgid "nav.theme"
msgstr "Theme"
msgid "nav.theme_auto"
msgstr "Auto"
msgid "nav.theme_light"
msgstr "Light"
msgid "nav.theme_dark"
msgstr "Dark"
msgid "home.hero_title"
msgstr "Forums"
@@ -115,7 +127,7 @@ msgid "auth.register_hint"
msgstr "Register with an email and a unique username."
msgid "footer.copy"
msgstr "speedBB forum. Powered by API Platform and React-Bootstrap."
msgstr "speedBB"
msgid "form.title"
msgstr "Title"

View File

@@ -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>

View File

@@ -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}`)
}

View File

@@ -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(
() => ({

View File

@@ -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 {

View File

@@ -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" />