diff --git a/CHANGELOG.md b/CHANGELOG.md index 9425419..42c6fe3 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/api/composer.json b/api/composer.json index 6393633..2a437fd 100644 --- a/api/composer.json +++ b/api/composer.json @@ -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.*" diff --git a/api/migrations/Version20251224184500.php b/api/migrations/Version20251224184500.php new file mode 100644 index 0000000..6fb5398 --- /dev/null +++ b/api/migrations/Version20251224184500.php @@ -0,0 +1,27 @@ +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'); + } +} diff --git a/api/migrations/Version20251224191500.php b/api/migrations/Version20251224191500.php new file mode 100644 index 0000000..9ba8d80 --- /dev/null +++ b/api/migrations/Version20251224191500.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/api/migrations/Version20251224193000.php b/api/migrations/Version20251224193000.php new file mode 100644 index 0000000..9ce8fd6 --- /dev/null +++ b/api/migrations/Version20251224193000.php @@ -0,0 +1,28 @@ +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'); + } +} diff --git a/api/migrations/Version20251224194000.php b/api/migrations/Version20251224194000.php new file mode 100644 index 0000000..393b083 --- /dev/null +++ b/api/migrations/Version20251224194000.php @@ -0,0 +1,26 @@ +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'"); + } +} diff --git a/api/src/Controller/VersionController.php b/api/src/Controller/VersionController.php new file mode 100644 index 0000000..cc27851 --- /dev/null +++ b/api/src/Controller/VersionController.php @@ -0,0 +1,24 @@ +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, + ]); + } +} diff --git a/api/src/Entity/Forum.php b/api/src/Entity/Forum.php index 2643d2e..8e7c8f7 100644 --- a/api/src/Entity/Forum.php +++ b/api/src/Entity/Forum.php @@ -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( diff --git a/api/src/Entity/Settings.php b/api/src/Entity/Settings.php new file mode 100644 index 0000000..e137b34 --- /dev/null +++ b/api/src/Entity/Settings.php @@ -0,0 +1,69 @@ + '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; + } +} diff --git a/api/translations/messages.de.po b/api/translations/messages.de.po index 14a0e6d..f00d483 100644 --- a/api/translations/messages.de.po +++ b/api/translations/messages.de.po @@ -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" diff --git a/api/translations/messages.en.po b/api/translations/messages.en.po index 1ea98b8..91848d7 100644 --- a/api/translations/messages.en.po +++ b/api/translations/messages.en.po @@ -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" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ce23135..e1755cd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( {t('app.brand')} + {isAdmin && ( + + )} @@ -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 (
- + } /> } /> @@ -95,7 +156,21 @@ function AppShell() {
{t('footer.copy')} - {loadMs !== null && Loaded in {loadMs} ms} + {versionInfo?.version && ( + + Version:{' '} + {versionInfo.version}{' '} + (build:{' '} + {versionInfo.build} + ) + + )} + {loadMs !== null && ( + + Page load time{' '} + {loadMs}ms + + )}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index a0b58dd..725b0f9 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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}`) } diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 82d7db5..022a054 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -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( () => ({ diff --git a/frontend/src/index.css b/frontend/src/index.css index c97800c..33c57aa 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/pages/Acp.jsx b/frontend/src/pages/Acp.jsx index 62cb897..f3d2b5c 100644 --- a/frontend/src/pages/Acp.jsx +++ b/frontend/src/pages/Acp.jsx @@ -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))} >
@@ -326,9 +382,6 @@ export default function Acp({ isAdmin }) { handleDragStart(event, node.id)} - onDragEnd={handleDragEnd} title={t('acp.drag_handle')} >