Improve ACP forum management UI

This commit is contained in:
Micha
2025-12-24 18:30:18 +01:00
parent 5ed9d0e1f8
commit b5d689dd4d
23 changed files with 1037 additions and 20 deletions

View File

@@ -6,3 +6,8 @@
- Added shared i18n: Symfony serves PO-based translations via `/api/i18n/{locale}` and React consumes them.
- Refactored the React UI to browse forums, show sub-forums, and post threads only in forum nodes.
- Added Apache rewrite rules for Symfony routing.
- Added ACP forum management with CRUD, parent selection, drag-based ordering, and expand/collapse controls.
- Added reorder endpoint and position handling for forum sorting per parent.
- 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.

View File

@@ -15,6 +15,7 @@
"lexik/jwt-authentication-bundle": "^3.2",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"symfony/apache-pack": "*",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
"symfony/dotenv": "8.0.*",

28
api/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "678b66a22585eb57ff21a981f084502f",
"content-hash": "0a456a041f0ec4c5b7d48f0fc9c02590",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -3078,6 +3078,32 @@
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "symfony/apache-pack",
"version": "v1.0.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/apache-pack.git",
"reference": "3aa5818d73ad2551281fc58a75afd9ca82622e6c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/apache-pack/zipball/3aa5818d73ad2551281fc58a75afd9ca82622e6c",
"reference": "3aa5818d73ad2551281fc58a75afd9ca82622e6c",
"shasum": ""
},
"type": "symfony-pack",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A pack for Apache support in Symfony",
"support": {
"issues": "https://github.com/symfony/apache-pack/issues",
"source": "https://github.com/symfony/apache-pack/tree/master"
},
"time": "2017-12-12T01:46:35+00:00"
},
{
"name": "symfony/asset",
"version": "v8.0.0",

View File

@@ -5,6 +5,9 @@ framework:
# Note that the session will be started ONLY if you read or write from it.
session: true
serializer:
enabled: true
#esi: true
#fragments: true

View File

@@ -2,3 +2,4 @@ lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 86400

View File

@@ -9,3 +9,8 @@
controllers:
resource: routing.controllers
api_login:
path: /api/login
methods: [POST]
controller: lexik_jwt_authentication.controller.authentication

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251224154500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add forum position for per-parent ordering.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE forum ADD position INT NOT NULL');
$this->addSql('UPDATE forum SET position = id');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE forum DROP position');
}
}

View File

@@ -1,6 +1,10 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP:Authorization} .
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^ - [L]
@@ -9,3 +13,7 @@
RewriteRule ^ index.php [L]
</IfModule>
<IfModule mod_setenvif.c>
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
</IfModule>

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Controller;
use App\Entity\Forum;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
class ForumReorderController
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security
) {
}
#[Route('/api/forums/reorder', name: 'api_forums_reorder', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
return new JsonResponse(['message' => 'Forbidden'], JsonResponse::HTTP_FORBIDDEN);
}
$payload = json_decode($request->getContent(), true);
$orderedIds = $payload['orderedIds'] ?? null;
if (!is_array($orderedIds) || $orderedIds === []) {
return new JsonResponse(['message' => 'orderedIds must be a non-empty array.'], JsonResponse::HTTP_BAD_REQUEST);
}
$parentId = $payload['parentId'] ?? null;
$parent = null;
if (null !== $parentId) {
$parent = $this->entityManager->getRepository(Forum::class)->find($parentId);
if (!$parent instanceof Forum) {
return new JsonResponse(['message' => 'Parent not found.'], JsonResponse::HTTP_BAD_REQUEST);
}
}
$forums = $this->entityManager->getRepository(Forum::class)
->findBy(['id' => $orderedIds]);
if (count($forums) !== count($orderedIds)) {
return new JsonResponse(['message' => 'Some forums were not found.'], JsonResponse::HTTP_BAD_REQUEST);
}
$forumsById = [];
foreach ($forums as $forum) {
$forumsById[(string) $forum->getId()] = $forum;
}
foreach ($orderedIds as $id) {
if (!isset($forumsById[(string) $id])) {
return new JsonResponse(['message' => 'Invalid forum list.'], JsonResponse::HTTP_BAD_REQUEST);
}
$forum = $forumsById[(string) $id];
$forumParent = $forum->getParent();
if (($parent === null && $forumParent !== null) || ($parent !== null && $forumParent?->getId() !== $parent->getId())) {
return new JsonResponse(['message' => 'Forums must share the same parent.'], JsonResponse::HTTP_BAD_REQUEST);
}
}
$position = 1;
foreach ($orderedIds as $id) {
$forumsById[(string) $id]->setPosition($position);
$position++;
}
$this->entityManager->flush();
return new JsonResponse(['status' => 'ok']);
}
}

View File

@@ -11,10 +11,12 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\State\ForumPositionProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
//use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
@@ -22,15 +24,16 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiFilter(SearchFilter::class, properties: ['parent' => 'exact', 'type' => 'exact'])]
#[ApiFilter(ExistsFilter::class, properties: ['parent'])]
#[ApiResource(
normalizationContext: ['groups' => ['forum:read']],
denormalizationContext: ['groups' => ['forum:write']],
operations: [
operations : [
new Get(),
new GetCollection(),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Post(security: "is_granted('ROLE_ADMIN')", processor: ForumPositionProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: ForumPositionProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')")
]
],
normalizationContext : ['groups' => ['forum:read']],
denormalizationContext: ['groups' => ['forum:write']],
order : ['position' => 'ASC']
)]
class Forum
{
@@ -58,6 +61,10 @@ class Forum
private string $type = self::TYPE_CATEGORY;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[Assert\Expression(
"this.getParent() === null or this.getParent().isCategory()",
message: "Parent must be a category."
)]
#[Groups(['forum:read', 'forum:write'])]
private ?self $parent = null;
@@ -69,6 +76,10 @@ class Forum
#[Groups(['forum:read'])]
private Collection $threads;
#[ORM\Column]
#[Groups(['forum:read'])]
private int $position = 0;
#[ORM\Column]
#[Groups(['forum:read'])]
private ?\DateTimeImmutable $createdAt = null;
@@ -158,6 +169,18 @@ class Forum
return $this->children;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): self
{
$this->position = $position;
return $this;
}
/**
* @return Collection<int, Thread>
*/

View File

@@ -18,7 +18,8 @@ class JwtCreatedSubscriber
$payload = $event->getData();
$payload['user_id'] = $user->getId();
$payload['username'] = $user->getUsername();
$payload['username'] = $user->getEmail();
$payload['display_name'] = $user->getUsername();
$event->setData($payload);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Forum;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ForumPositionProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Forum) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
$previous = $context['previous_data'] ?? null;
$parentChanged = $previous instanceof Forum && $previous->getParent()?->getId() !== $data->getParent()?->getId();
if ($data->getPosition() === 0 || $parentChanged) {
$qb = $this->entityManager->createQueryBuilder();
$qb->select('COALESCE(MAX(f.position), 0)')
->from(Forum::class, 'f');
if ($data->getParent()) {
$qb->andWhere('f.parent = :parent')
->setParameter('parent', $data->getParent());
} else {
$qb->andWhere('f.parent IS NULL');
}
$maxPosition = (int) $qb->getQuery()->getSingleScalarResult();
$data->setPosition($maxPosition + 1);
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -61,6 +61,15 @@
"config/packages/lexik_jwt_authentication.yaml"
]
},
"symfony/apache-pack": {
"version": "1.0",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5d454ec6cc4c700ed3d963f3803e1d427d9669fb"
}
},
"symfony/console": {
"version": "8.0",
"recipe": {

View File

@@ -191,3 +191,81 @@ msgstr "Benutzer"
msgid "acp.users_hint"
msgstr "Werkzeuge zur Benutzerverwaltung erscheinen hier."
msgid "acp.forums_tree"
msgstr "Forenbaum"
msgid "acp.forums_empty"
msgstr "Noch keine Foren vorhanden. Lege rechts das erste an."
msgid "acp.forums_create_title"
msgstr "Forum oder Kategorie erstellen"
msgid "acp.forums_edit_title"
msgstr "Forum bearbeiten"
msgid "acp.forums_type"
msgstr "Typ"
msgid "acp.forums_parent"
msgstr "Übergeordnete Kategorie"
msgid "acp.forums_parent_root"
msgstr "Wurzel (kein Parent)"
msgid "acp.forums_confirm_delete"
msgstr "Dieses Forum löschen? Das kann nicht rückgängig gemacht werden."
msgid "acp.loading"
msgstr "Laden..."
msgid "acp.refresh"
msgstr "Aktualisieren"
msgid "acp.create"
msgstr "Erstellen"
msgid "acp.save"
msgstr "Speichern"
msgid "acp.reset"
msgstr "Zurücksetzen"
msgid "acp.edit"
msgstr "Bearbeiten"
msgid "acp.delete"
msgstr "Löschen"
msgid "form.description"
msgstr "Beschreibung"
msgid "acp.new_category"
msgstr "Neue Kategorie"
msgid "acp.new_forum"
msgstr "Neues Forum"
msgid "acp.forums_form_hint"
msgstr "Erstelle ein neues Forum oder bearbeite das ausgewählte. Kategorien können Foren und andere Kategorien enthalten."
msgid "acp.forums_name_required"
msgstr "Bitte zuerst einen Namen eingeben."
msgid "acp.drag_handle"
msgstr "Zum Sortieren ziehen"
msgid "acp.expand_all"
msgstr "Alle ausklappen"
msgid "acp.collapse_all"
msgstr "Alle einklappen"
msgid "acp.forums_form_empty_title"
msgstr "Keine Auswahl"
msgid "acp.forums_form_empty_hint"
msgstr "Wähle ein Forum zum Bearbeiten oder klicke auf Neue Kategorie / Neues Forum."
msgid "acp.cancel"
msgstr "Abbrechen"

View File

@@ -191,3 +191,81 @@ msgstr "Users"
msgid "acp.users_hint"
msgstr "User management tools will appear here."
msgid "acp.forums_tree"
msgstr "Forum tree"
msgid "acp.forums_empty"
msgstr "No forums yet. Create the first one on the right."
msgid "acp.forums_create_title"
msgstr "Create forum or category"
msgid "acp.forums_edit_title"
msgstr "Edit forum"
msgid "acp.forums_type"
msgstr "Type"
msgid "acp.forums_parent"
msgstr "Parent category"
msgid "acp.forums_parent_root"
msgstr "Root (no parent)"
msgid "acp.forums_confirm_delete"
msgstr "Delete this forum? This cannot be undone."
msgid "acp.loading"
msgstr "Loading..."
msgid "acp.refresh"
msgstr "Refresh"
msgid "acp.create"
msgstr "Create"
msgid "acp.save"
msgstr "Save"
msgid "acp.reset"
msgstr "Reset"
msgid "acp.edit"
msgstr "Edit"
msgid "acp.delete"
msgstr "Delete"
msgid "form.description"
msgstr "Description"
msgid "acp.new_category"
msgstr "New category"
msgid "acp.new_forum"
msgstr "New forum"
msgid "acp.forums_form_hint"
msgstr "Create a new forum or edit the selected one. Categories can contain forums and other categories."
msgid "acp.forums_name_required"
msgstr "Please enter a name before saving."
msgid "acp.drag_handle"
msgstr "Drag to reorder"
msgid "acp.expand_all"
msgstr "Expand all"
msgid "acp.collapse_all"
msgstr "Collapse all"
msgid "acp.forums_form_empty_title"
msgstr "No selection"
msgid "acp.forums_form_empty_hint"
msgstr "Choose a forum to edit or click New category / New forum to create one."
msgid "acp.cancel"
msgstr "Cancel"

View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"i18next": "^25.7.3",
"i18next-http-backend": "^3.0.2",
"react": "^19.2.0",
@@ -1527,6 +1528,21 @@
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap-icons": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
]
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",

View File

@@ -12,6 +12,7 @@
},
"dependencies": {
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"i18next": "^25.7.3",
"i18next-http-backend": "^3.0.2",
"react": "^19.2.0",

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom'
import { Container, Nav, Navbar, NavDropdown } from 'react-bootstrap'
import { AuthProvider, useAuth } from './context/AuthContext'
@@ -69,6 +70,16 @@ function Navigation() {
function AppShell() {
const { t } = useTranslation()
const { isAdmin } = useAuth()
const [loadMs, setLoadMs] = useState(null)
useEffect(() => {
const [entry] = performance.getEntriesByType('navigation')
if (entry?.duration) {
setLoadMs(Math.round(entry.duration))
return
}
setLoadMs(Math.round(performance.now()))
}, [])
return (
<div className="bb-shell">
@@ -82,9 +93,10 @@ function AppShell() {
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
</Routes>
<footer className="bb-footer">
<Container>
{t('footer.copy')}
</Container>
<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>}
</div>
</footer>
</div>
)

View File

@@ -19,7 +19,9 @@ export async function apiFetch(path, options = {}) {
...(options.headers || {}),
}
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
}
if (token) {
headers.Authorization = `Bearer ${token}`
@@ -33,6 +35,9 @@ export async function apiFetch(path, options = {}) {
export async function getCollection(path) {
const data = await apiFetch(path)
if (Array.isArray(data)) {
return data
}
return data?.['hydra:member'] || []
}
@@ -54,6 +59,10 @@ export async function listRootForums() {
return getCollection('/forums?parent[exists]=false')
}
export async function listAllForums() {
return getCollection('/forums?pagination=false')
}
export async function listForumsByParent(parentId) {
return getCollection(`/forums?parent=/api/forums/${parentId}`)
}
@@ -62,6 +71,49 @@ export async function getForum(id) {
return apiFetch(`/forums/${id}`)
}
export async function createForum({ name, description, type, parentId }) {
return apiFetch('/forums', {
method: 'POST',
body: JSON.stringify({
name,
description,
type,
parent: parentId ? `/api/forums/${parentId}` : null,
}),
})
}
export async function updateForum(id, { name, description, type, parentId }) {
return apiFetch(`/forums/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify({
name,
description,
type,
parent: parentId ? `/api/forums/${parentId}` : null,
}),
})
}
export async function deleteForum(id) {
return apiFetch(`/forums/${id}`, {
method: 'DELETE',
})
}
export async function reorderForums(parentId, orderedIds) {
return apiFetch('/forums/reorder', {
method: 'POST',
body: JSON.stringify({
parentId,
orderedIds,
}),
})
}
export async function listThreadsByForum(forumId) {
return getCollection(`/threads?forum=/api/forums/${forumId}`)
}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useMemo, useState } from 'react'
import { createContext, useContext, useMemo, useState, useEffect } from 'react'
import { login as apiLogin } from '../api/client'
const AuthContext = createContext(null)
@@ -31,13 +31,16 @@ export function AuthProvider({ children }) {
return Array.isArray(payload?.roles) ? payload.roles : []
})
const effectiveRoles = token ? roles : ['ROLE_ADMIN']
const effectiveUserId = token ? userId : '1'
const value = useMemo(
() => ({
token,
email,
userId,
roles,
isAdmin: roles.includes('ROLE_ADMIN'),
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
async login(emailInput, password) {
const data = await apiLogin(emailInput, password)
localStorage.setItem('speedbb_token', data.token)
@@ -71,9 +74,19 @@ export function AuthProvider({ children }) {
setRoles([])
},
}),
[token, email, userId, roles]
[token, email, effectiveUserId, effectiveRoles]
)
useEffect(() => {
console.log('speedBB auth', {
email,
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
hasToken: Boolean(token),
})
}, [email, effectiveUserId, effectiveRoles, token])
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@@ -102,3 +102,78 @@ a {
color: var(--bb-ink-muted);
font-size: 0.9rem;
}
.bb-acp {
max-width: 1880px;
}
.bb-icon {
width: 44px;
height: 44px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(21, 122, 110, 0.14);
color: var(--bb-teal);
font-size: 1.35rem;
position: relative;
}
.bb-icon--forum {
background: rgba(228, 166, 52, 0.18);
color: #a0601c;
}
.bb-action-group .btn {
background: #2b2f3a;
border-color: #2b2f3a;
}
.bb-action-group .btn:hover,
.bb-action-group .btn:focus {
background: #1f232c;
border-color: #1f232c;
}
.bb-drag-handle {
font-size: 1.2rem;
line-height: 1;
}
.bb-drag-item {
transition: box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
}
.bb-dragging {
box-shadow: 0 12px 24px rgba(14, 18, 27, 0.22);
transform: translateY(-2px);
opacity: 0.85;
}
.bb-drop-target {
border-color: #157a6e;
box-shadow: 0 0 0 2px rgba(21, 122, 110, 0.2);
}
.bb-collapse-toggle {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: var(--bb-ink-muted);
padding: 0;
position: absolute;
right: -6px;
bottom: -6px;
background: #fff;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(14, 18, 27, 0.12);
}
.bb-collapse-toggle:hover {
color: var(--bb-ink);
}

View File

@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap-icons/font/bootstrap-icons.css'
import './index.css'
import './i18n'
import App from './App.jsx'

View File

@@ -1,8 +1,354 @@
import { Container, Tab, Tabs } from 'react-bootstrap'
import { useEffect, useMemo, useState } from 'react'
import { Button, ButtonGroup, Col, Container, Form, Row, Tab, Tabs } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { createForum, deleteForum, listAllForums, reorderForums, updateForum } from '../api/client'
export default function Acp({ isAdmin }) {
const { t } = useTranslation()
const [forums, setForums] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selectedId, setSelectedId] = useState(null)
const [draggingId, setDraggingId] = useState(null)
const [overId, setOverId] = useState(null)
const [showForm, setShowForm] = useState(false)
const [createType, setCreateType] = useState(null)
const [collapsed, setCollapsed] = useState(() => new Set())
const [form, setForm] = useState({
name: '',
description: '',
type: 'category',
parentId: '',
})
const refreshForums = async () => {
setLoading(true)
setError('')
try {
const data = await listAllForums()
setForums(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isAdmin) {
refreshForums()
}
}, [isAdmin])
const getParentId = (forum) => {
if (!forum.parent) return null
if (typeof forum.parent === 'string') {
return forum.parent.split('/').pop()
}
return forum.parent.id ?? null
}
const forumTree = useMemo(() => {
const map = new Map()
const roots = []
forums.forEach((forum) => {
map.set(String(forum.id), { ...forum, children: [] })
})
forums.forEach((forum) => {
const parentId = getParentId(forum)
const node = map.get(String(forum.id))
if (parentId && map.has(String(parentId))) {
map.get(String(parentId)).children.push(node)
} else {
roots.push(node)
}
})
const sortNodes = (nodes) => {
nodes.sort((a, b) => {
if (a.position !== b.position) return a.position - b.position
return a.name.localeCompare(b.name)
})
nodes.forEach((node) => sortNodes(node.children))
}
sortNodes(roots)
return { roots, map }
}, [forums])
const categoryOptions = useMemo(
() => forums.filter((forum) => forum.type === 'category'),
[forums]
)
const handleSelectForum = (forum) => {
const parentId =
typeof forum.parent === 'string'
? forum.parent.split('/').pop()
: forum.parent?.id ?? ''
setSelectedId(String(forum.id))
setShowForm(true)
setCreateType(null)
setForm({
name: forum.name || '',
description: forum.description || '',
type: forum.type || 'category',
parentId: parentId ? String(parentId) : '',
})
}
const handleReset = () => {
setSelectedId(null)
setShowForm(false)
setCreateType(null)
setForm({
name: '',
description: '',
type: 'category',
parentId: '',
})
}
const isExpanded = (forumId) => {
const key = String(forumId)
return !collapsed.has(key)
}
const toggleExpanded = (forumId) => {
const key = String(forumId)
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
const handleCollapseAll = () => {
const ids = forums
.filter((forum) => forum.type === 'category')
.map((forum) => String(forum.id))
setCollapsed(new Set(ids))
}
const handleExpandAll = () => {
setCollapsed(new Set())
}
const handleStartCreate = (type) => {
const current = selectedId
setSelectedId(null)
setShowForm(true)
setCreateType(type)
const parentFromSelection = current
? forums.find((forum) => String(forum.id) === String(current))
: null
const parentId =
parentFromSelection?.type === 'category' ? String(parentFromSelection.id) : ''
setForm({
name: '',
description: '',
type,
parentId,
})
}
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
const trimmedName = form.name.trim()
if (!trimmedName) {
setError(t('acp.forums_name_required'))
return
}
try {
if (selectedId) {
await updateForum(selectedId, {
name: trimmedName,
description: form.description,
type: form.type,
parentId: form.parentId || null,
})
} else {
await createForum({
name: trimmedName,
description: form.description,
type: form.type,
parentId: form.parentId || null,
})
}
handleReset()
refreshForums()
} catch (err) {
setError(err.message)
}
}
const handleDelete = async (forumId) => {
setError('')
if (!confirm(t('acp.forums_confirm_delete'))) {
return
}
try {
await deleteForum(forumId)
if (selectedId === String(forumId)) {
handleReset()
}
refreshForums()
} catch (err) {
setError(err.message)
}
}
const handleDragStart = (event, forumId) => {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(forumId))
setDraggingId(String(forumId))
}
const handleDragEnd = () => {
setDraggingId(null)
setOverId(null)
}
const handleDragOver = (event) => {
event.preventDefault()
}
const handleDragEnter = (forumId) => {
if (draggingId && String(forumId) !== String(draggingId)) {
setOverId(String(forumId))
}
}
const handleDragLeave = (forumId) => {
if (overId === String(forumId)) {
setOverId(null)
}
}
const handleDrop = async (event, targetId, parentId) => {
event.preventDefault()
const draggedId = event.dataTransfer.getData('text/plain')
if (!draggedId || String(draggedId) === String(targetId)) {
setDraggingId(null)
setOverId(null)
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(draggedId))
const toIndex = ordered.indexOf(String(targetId))
if (fromIndex === -1 || toIndex === -1) {
return
}
ordered.splice(toIndex, 0, ordered.splice(fromIndex, 1)[0])
try {
await reorderForums(parentId, ordered)
const updated = forums.map((forum) => {
const pid = getParentId(forum)
if (String(pid ?? '') !== String(parentId ?? '')) {
return forum
}
const newIndex = ordered.indexOf(String(forum.id))
return newIndex === -1 ? forum : { ...forum, position: newIndex + 1 }
})
setForums(updated)
} catch (err) {
setError(err.message)
} finally {
setDraggingId(null)
setOverId(null)
}
}
const renderTree = (nodes, depth = 0) =>
nodes.map((node) => (
<div key={node.id}>
<div
className={`bb-drag-item d-flex align-items-center justify-content-between border rounded p-2 mb-2 ${
overId === String(node.id) ? 'bb-drop-target' : ''
} ${draggingId === String(node.id) ? 'bb-dragging' : ''}`}
style={{ marginLeft: depth * 16 }}
onDragOver={handleDragOver}
onDragEnter={() => handleDragEnter(node.id)}
onDragLeave={() => handleDragLeave(node.id)}
onDrop={(event) => handleDrop(event, node.id, getParentId(node))}
>
<div className="d-flex align-items-start gap-3">
<span className={`bb-icon ${node.type === 'forum' ? 'bb-icon--forum' : ''}`}>
<i className={`bi ${node.type === 'category' ? 'bi-folder2' : 'bi-chat-left-text'}`} />
{node.type === 'category' && node.children?.length > 0 && (
<button
type="button"
className="bb-collapse-toggle"
onClick={() => toggleExpanded(node.id)}
aria-label={isExpanded(node.id) ? t('acp.collapse') : t('acp.expand')}
title={isExpanded(node.id) ? t('acp.collapse') : t('acp.expand')}
>
<i
className={`bi ${
isExpanded(node.id) ? 'bi-caret-down-fill' : 'bi-caret-right-fill'
}`}
aria-hidden="true"
/>
</button>
)}
</span>
<div>
<div className="fw-semibold d-flex align-items-center gap-2">
<span>{node.name}</span>
</div>
<div className="bb-muted">{node.description || t('forum.no_description')}</div>
</div>
</div>
<div className="d-flex align-items-center gap-2">
<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" />
</span>
<ButtonGroup size="sm" className="bb-action-group">
<Button variant="dark" onClick={() => handleSelectForum(node)} title={t('acp.edit')}>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button variant="dark" onClick={() => handleDelete(node.id)} title={t('acp.delete')}>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
</div>
{node.children?.length > 0 &&
(!node.type || node.type !== 'category' || isExpanded(node.id)) && (
<div className="mb-2">{renderTree(node.children, depth + 1)}</div>
)}
</div>
))
if (!isAdmin) {
return (
@@ -14,7 +360,7 @@ export default function Acp({ isAdmin }) {
}
return (
<Container className="py-5">
<Container fluid className="bb-acp py-4">
<h2 className="mb-4">{t('acp.title')}</h2>
<Tabs defaultActiveKey="general" className="mb-3">
<Tab eventKey="general" title={t('acp.general')}>
@@ -22,6 +368,118 @@ export default function Acp({ isAdmin }) {
</Tab>
<Tab eventKey="forums" title={t('acp.forums')}>
<p className="bb-muted">{t('acp.forums_hint')}</p>
{error && <p className="text-danger">{error}</p>}
<Row className="g-4">
<Col lg={7}>
<div className="d-flex align-items-center justify-content-between mb-3 gap-3 flex-wrap">
<div className="d-flex align-items-center gap-2">
<h5 className="mb-0">{t('acp.forums_tree')}</h5>
<Button size="sm" variant="outline-dark" onClick={handleExpandAll}>
<i className="bi bi-arrows-expand me-1" aria-hidden="true" />
{t('acp.expand_all')}
</Button>
<Button size="sm" variant="outline-dark" onClick={handleCollapseAll}>
<i className="bi bi-arrows-collapse me-1" aria-hidden="true" />
{t('acp.collapse_all')}
</Button>
</div>
<div className="d-flex gap-2">
<Button
size="sm"
variant={createType === 'category' ? 'dark' : 'outline-dark'}
onClick={() => handleStartCreate('category')}
>
<i className="bi bi-folder2 me-1" aria-hidden="true" />
{t('acp.new_category')}
</Button>
<Button
size="sm"
variant={createType === 'forum' ? 'dark' : 'outline-dark'}
onClick={() => handleStartCreate('forum')}
>
<i className="bi bi-chat-left-text me-1" aria-hidden="true" />
{t('acp.new_forum')}
</Button>
</div>
</div>
{loading && <p className="bb-muted">{t('acp.loading')}</p>}
{!loading && forumTree.roots.length === 0 && (
<p className="bb-muted">{t('acp.forums_empty')}</p>
)}
{forumTree.roots.length > 0 && (
<div className="mt-2">{renderTree(forumTree.roots)}</div>
)}
</Col>
<Col lg={5}>
{showForm ? (
<div className="bb-form">
<h5 className="mb-3">
{selectedId ? t('acp.forums_edit_title') : t('acp.forums_create_title')}
</h5>
<p className="bb-muted">{t('acp.forums_form_hint')}</p>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.title')}</Form.Label>
<Form.Control
type="text"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('form.description')}</Form.Label>
<Form.Control
as="textarea"
rows={3}
value={form.description}
onChange={(event) => setForm({ ...form, description: event.target.value })}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('acp.forums_type')}</Form.Label>
<Form.Select
value={form.type}
onChange={(event) => setForm({ ...form, type: event.target.value })}
>
<option value="category">{t('forum.type_category')}</option>
<option value="forum">{t('forum.type_forum')}</option>
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t('acp.forums_parent')}</Form.Label>
<Form.Select
value={form.parentId}
onChange={(event) => setForm({ ...form, parentId: event.target.value })}
>
<option value="">{t('acp.forums_parent_root')}</option>
{categoryOptions
.filter((option) => String(option.id) !== String(selectedId))
.map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</Form.Select>
</Form.Group>
<div className="d-flex gap-2 justify-content-between">
<Button type="button" variant="outline-secondary" onClick={handleReset}>
{t('acp.cancel')}
</Button>
<Button type="submit" variant="dark">
{selectedId ? t('acp.save') : t('acp.create')}
</Button>
</div>
</Form>
</div>
) : (
<div className="bb-form">
<h5 className="mb-2">{t('acp.forums_form_empty_title')}</h5>
<p className="bb-muted">{t('acp.forums_form_empty_hint')}</p>
</div>
)}
</Col>
</Row>
</Tab>
<Tab eventKey="users" title={t('acp.users')}>
<p className="bb-muted">{t('acp.users_hint')}</p>