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.
|
||||
- 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.
|
||||
|
||||
@@ -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.*"
|
||||
|
||||
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\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(
|
||||
|
||||
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"
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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