Add settings-driven theme and version metadata
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -11,3 +11,13 @@
|
|||||||
- Improved ACP UX: iconized action buttons, category/forum badges, and responsive layout tweaks.
|
- 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 JWT username payload fix and increased token TTL to 24h.
|
||||||
- Added frontend load-time indicator in the footer.
|
- 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.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"type": "project",
|
"type": "project",
|
||||||
|
"version": "25.00.1",
|
||||||
"license": "proprietary",
|
"license": "proprietary",
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
@@ -79,6 +80,9 @@
|
|||||||
"symfony/symfony": "*"
|
"symfony/symfony": "*"
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
|
"speedbb": {
|
||||||
|
"build": 3
|
||||||
|
},
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "8.0.*"
|
"require": "8.0.*"
|
||||||
|
|||||||
27
api/migrations/Version20251224184500.php
Normal file
27
api/migrations/Version20251224184500.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
api/migrations/Version20251224191500.php
Normal file
33
api/migrations/Version20251224191500.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
28
api/migrations/Version20251224193000.php
Normal file
28
api/migrations/Version20251224193000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
api/migrations/Version20251224194000.php
Normal file
26
api/migrations/Version20251224194000.php
Normal 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'");
|
||||||
|
}
|
||||||
|
}
|
||||||
24
api/src/Controller/VersionController.php
Normal file
24
api/src/Controller/VersionController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
|
|
||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[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(SearchFilter::class, properties: ['parent' => 'exact', 'type' => 'exact'])]
|
||||||
#[ApiFilter(ExistsFilter::class, properties: ['parent'])]
|
#[ApiFilter(ExistsFilter::class, properties: ['parent'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
|
|||||||
69
api/src/Entity/Settings.php
Normal file
69
api/src/Entity/Settings.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,18 @@ msgstr "Abmelden"
|
|||||||
msgid "nav.language"
|
msgid "nav.language"
|
||||||
msgstr "Sprache"
|
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"
|
msgid "home.hero_title"
|
||||||
msgstr "Foren"
|
msgstr "Foren"
|
||||||
|
|
||||||
@@ -115,7 +127,7 @@ msgid "auth.register_hint"
|
|||||||
msgstr "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen."
|
msgstr "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen."
|
||||||
|
|
||||||
msgid "footer.copy"
|
msgid "footer.copy"
|
||||||
msgstr "speedBB Forum. Powered by API Platform und React-Bootstrap."
|
msgstr "speedBB"
|
||||||
|
|
||||||
msgid "form.title"
|
msgid "form.title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ msgstr "Logout"
|
|||||||
msgid "nav.language"
|
msgid "nav.language"
|
||||||
msgstr "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"
|
msgid "home.hero_title"
|
||||||
msgstr "Forums"
|
msgstr "Forums"
|
||||||
|
|
||||||
@@ -115,7 +127,7 @@ msgid "auth.register_hint"
|
|||||||
msgstr "Register with an email and a unique username."
|
msgstr "Register with an email and a unique username."
|
||||||
|
|
||||||
msgid "footer.copy"
|
msgid "footer.copy"
|
||||||
msgstr "speedBB forum. Powered by API Platform and React-Bootstrap."
|
msgstr "speedBB"
|
||||||
|
|
||||||
msgid "form.title"
|
msgid "form.title"
|
||||||
msgstr "Title"
|
msgstr "Title"
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import Login from './pages/Login'
|
|||||||
import Register from './pages/Register'
|
import Register from './pages/Register'
|
||||||
import Acp from './pages/Acp'
|
import Acp from './pages/Acp'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { fetchSetting, fetchVersion } from './api/client'
|
||||||
|
|
||||||
function Navigation() {
|
function Navigation({ theme, onThemeChange }) {
|
||||||
const { token, email, logout, isAdmin } = useAuth()
|
const { token, email, logout, isAdmin } = useAuth()
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
|
||||||
@@ -19,23 +20,27 @@ function Navigation() {
|
|||||||
localStorage.setItem('speedbb_lang', locale)
|
localStorage.setItem('speedbb_lang', locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleThemeChange = (value) => {
|
||||||
|
onThemeChange(value)
|
||||||
|
localStorage.setItem('speedbb_theme', value)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar expand="lg" className="bb-nav">
|
<Navbar expand="lg" className="bb-nav">
|
||||||
<Container>
|
<Container>
|
||||||
<Navbar.Brand as={Link} to="/" className="fw-semibold">
|
<Navbar.Brand as={Link} to="/" className="fw-semibold">
|
||||||
{t('app.brand')}
|
{t('app.brand')}
|
||||||
</Navbar.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.Toggle aria-controls="bb-nav" />
|
||||||
<Navbar.Collapse id="bb-nav">
|
<Navbar.Collapse id="bb-nav">
|
||||||
<Nav className="ms-auto align-items-lg-center gap-2">
|
<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 && (
|
{!token && (
|
||||||
<>
|
<>
|
||||||
<Nav.Link as={Link} to="/login">
|
<Nav.Link as={Link} to="/login">
|
||||||
@@ -60,6 +65,17 @@ function Navigation() {
|
|||||||
Deutsch
|
Deutsch
|
||||||
</NavDropdown.Item>
|
</NavDropdown.Item>
|
||||||
</NavDropdown>
|
</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>
|
</Nav>
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -71,6 +87,8 @@ function AppShell() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isAdmin } = useAuth()
|
const { isAdmin } = useAuth()
|
||||||
const [loadMs, setLoadMs] = useState(null)
|
const [loadMs, setLoadMs] = useState(null)
|
||||||
|
const [versionInfo, setVersionInfo] = useState(null)
|
||||||
|
const [theme, setTheme] = useState(() => localStorage.getItem('speedbb_theme') || 'auto')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const [entry] = performance.getEntriesByType('navigation')
|
const [entry] = performance.getEntriesByType('navigation')
|
||||||
@@ -81,9 +99,52 @@ function AppShell() {
|
|||||||
setLoadMs(Math.round(performance.now()))
|
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 (
|
return (
|
||||||
<div className="bb-shell">
|
<div className="bb-shell">
|
||||||
<Navigation />
|
<Navigation theme={theme} onThemeChange={setTheme} />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/forum/:id" element={<ForumView />} />
|
<Route path="/forum/:id" element={<ForumView />} />
|
||||||
@@ -95,7 +156,21 @@ function AppShell() {
|
|||||||
<footer className="bb-footer">
|
<footer className="bb-footer">
|
||||||
<div className="ms-3 d-flex align-items-center gap-3">
|
<div className="ms-3 d-flex align-items-center gap-3">
|
||||||
<span>{t('footer.copy')}</span>
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ export async function listAllForums() {
|
|||||||
return getCollection('/forums?pagination=false')
|
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) {
|
export async function listForumsByParent(parentId) {
|
||||||
return getCollection(`/forums?parent=/api/forums/${parentId}`)
|
return getCollection(`/forums?parent=/api/forums/${parentId}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export function AuthProvider({ children }) {
|
|||||||
return Array.isArray(payload?.roles) ? payload.roles : []
|
return Array.isArray(payload?.roles) ? payload.roles : []
|
||||||
})
|
})
|
||||||
|
|
||||||
const effectiveRoles = token ? roles : ['ROLE_ADMIN']
|
const effectiveRoles = token ? roles : []
|
||||||
const effectiveUserId = token ? userId : '1'
|
const effectiveUserId = token ? userId : null
|
||||||
|
|
||||||
const value = useMemo(
|
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 {
|
:root {
|
||||||
--bb-ink: #0e121b;
|
--bb-ink: #0e121b;
|
||||||
--bb-ink-muted: #5b6678;
|
--bb-ink-muted: #5b6678;
|
||||||
@@ -17,14 +15,14 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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);
|
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;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5 {
|
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;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +101,97 @@ a {
|
|||||||
font-size: 0.9rem;
|
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 {
|
.bb-acp {
|
||||||
max-width: 1880px;
|
max-width: 1880px;
|
||||||
}
|
}
|
||||||
@@ -152,8 +241,29 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bb-drop-target {
|
.bb-drop-target {
|
||||||
border-color: #157a6e;
|
border: 2px dashed rgba(21, 122, 110, 0.75);
|
||||||
box-shadow: 0 0 0 2px rgba(21, 122, 110, 0.2);
|
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 {
|
.bb-collapse-toggle {
|
||||||
|
|||||||
@@ -217,8 +217,58 @@ export default function Acp({ isAdmin }) {
|
|||||||
setOverId(null)
|
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.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) => {
|
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)) {
|
if (overId === String(forumId)) {
|
||||||
setOverId(null)
|
setOverId(null)
|
||||||
}
|
}
|
||||||
@@ -290,9 +343,12 @@ export default function Acp({ isAdmin }) {
|
|||||||
overId === String(node.id) ? 'bb-drop-target' : ''
|
overId === String(node.id) ? 'bb-drop-target' : ''
|
||||||
} ${draggingId === String(node.id) ? 'bb-dragging' : ''}`}
|
} ${draggingId === String(node.id) ? 'bb-dragging' : ''}`}
|
||||||
style={{ marginLeft: depth * 16 }}
|
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)}
|
onDragEnter={() => handleDragEnter(node.id)}
|
||||||
onDragLeave={() => handleDragLeave(node.id)}
|
onDragLeave={(event) => handleDragLeave(event, node.id)}
|
||||||
onDrop={(event) => handleDrop(event, node.id, getParentId(node))}
|
onDrop={(event) => handleDrop(event, node.id, getParentId(node))}
|
||||||
>
|
>
|
||||||
<div className="d-flex align-items-start gap-3">
|
<div className="d-flex align-items-start gap-3">
|
||||||
@@ -326,9 +382,6 @@ export default function Acp({ isAdmin }) {
|
|||||||
<span
|
<span
|
||||||
className="bb-drag-handle text-muted"
|
className="bb-drag-handle text-muted"
|
||||||
style={{ cursor: 'grab', display: 'inline-flex' }}
|
style={{ cursor: 'grab', display: 'inline-flex' }}
|
||||||
draggable
|
|
||||||
onDragStart={(event) => handleDragStart(event, node.id)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
title={t('acp.drag_handle')}
|
title={t('acp.drag_handle')}
|
||||||
>
|
>
|
||||||
<i className="bi bi-arrow-down-up" aria-hidden="true" />
|
<i className="bi bi-arrow-down-up" aria-hidden="true" />
|
||||||
|
|||||||
Reference in New Issue
Block a user