From b5d689dd4d26b7a8ef46ea45f63da74fd5a7ae97 Mon Sep 17 00:00:00 2001 From: Micha Date: Wed, 24 Dec 2025 18:30:18 +0100 Subject: [PATCH] Improve ACP forum management UI --- CHANGELOG.md | 5 + api/composer.json | 1 + api/composer.lock | 28 +- api/config/packages/framework.yaml | 3 + .../packages/lexik_jwt_authentication.yaml | 1 + api/config/routes.yaml | 5 + api/migrations/Version20251224154500.php | 27 + api/public/.htaccess | 8 + api/src/Controller/ForumReorderController.php | 77 +++ api/src/Entity/Forum.php | 37 +- .../EventSubscriber/JwtCreatedSubscriber.php | 3 +- api/src/State/ForumPositionProcessor.php | 47 ++ api/symfony.lock | 9 + api/translations/messages.de.po | 78 +++ api/translations/messages.en.po | 78 +++ frontend/package-lock.json | 16 + frontend/package.json | 1 + frontend/src/App.jsx | 18 +- frontend/src/api/client.js | 54 +- frontend/src/context/AuthContext.jsx | 23 +- frontend/src/index.css | 75 +++ frontend/src/main.jsx | 1 + frontend/src/pages/Acp.jsx | 462 +++++++++++++++++- 23 files changed, 1037 insertions(+), 20 deletions(-) create mode 100644 api/migrations/Version20251224154500.php create mode 100644 api/src/Controller/ForumReorderController.php create mode 100644 api/src/State/ForumPositionProcessor.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aca207..9425419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/api/composer.json b/api/composer.json index 0c70db8..6393633 100644 --- a/api/composer.json +++ b/api/composer.json @@ -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.*", diff --git a/api/composer.lock b/api/composer.lock index 953588c..32d8895 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -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", diff --git a/api/config/packages/framework.yaml b/api/config/packages/framework.yaml index 7e1ee1f..64821ac 100644 --- a/api/config/packages/framework.yaml +++ b/api/config/packages/framework.yaml @@ -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 diff --git a/api/config/packages/lexik_jwt_authentication.yaml b/api/config/packages/lexik_jwt_authentication.yaml index edfb69d..c5bbc28 100644 --- a/api/config/packages/lexik_jwt_authentication.yaml +++ b/api/config/packages/lexik_jwt_authentication.yaml @@ -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 diff --git a/api/config/routes.yaml b/api/config/routes.yaml index cef258c..6b459fd 100644 --- a/api/config/routes.yaml +++ b/api/config/routes.yaml @@ -9,3 +9,8 @@ controllers: resource: routing.controllers + +api_login: + path: /api/login + methods: [POST] + controller: lexik_jwt_authentication.controller.authentication diff --git a/api/migrations/Version20251224154500.php b/api/migrations/Version20251224154500.php new file mode 100644 index 0000000..124baf2 --- /dev/null +++ b/api/migrations/Version20251224154500.php @@ -0,0 +1,27 @@ +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'); + } +} diff --git a/api/public/.htaccess b/api/public/.htaccess index 82fe815..b348b76 100644 --- a/api/public/.htaccess +++ b/api/public/.htaccess @@ -1,6 +1,10 @@ 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] + + + SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 + diff --git a/api/src/Controller/ForumReorderController.php b/api/src/Controller/ForumReorderController.php new file mode 100644 index 0000000..b390e59 --- /dev/null +++ b/api/src/Controller/ForumReorderController.php @@ -0,0 +1,77 @@ +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']); + } +} diff --git a/api/src/Entity/Forum.php b/api/src/Entity/Forum.php index 2ed047c..2643d2e 100644 --- a/api/src/Entity/Forum.php +++ b/api/src/Entity/Forum.php @@ -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 */ diff --git a/api/src/EventSubscriber/JwtCreatedSubscriber.php b/api/src/EventSubscriber/JwtCreatedSubscriber.php index e3f4994..16aaa42 100644 --- a/api/src/EventSubscriber/JwtCreatedSubscriber.php +++ b/api/src/EventSubscriber/JwtCreatedSubscriber.php @@ -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); } } diff --git a/api/src/State/ForumPositionProcessor.php b/api/src/State/ForumPositionProcessor.php new file mode 100644 index 0000000..2231021 --- /dev/null +++ b/api/src/State/ForumPositionProcessor.php @@ -0,0 +1,47 @@ +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); + } +} diff --git a/api/symfony.lock b/api/symfony.lock index 5f6bc8d..5695bbc 100644 --- a/api/symfony.lock +++ b/api/symfony.lock @@ -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": { diff --git a/api/translations/messages.de.po b/api/translations/messages.de.po index e13a186..14a0e6d 100644 --- a/api/translations/messages.de.po +++ b/api/translations/messages.de.po @@ -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" diff --git a/api/translations/messages.en.po b/api/translations/messages.en.po index 6f10fdf..1ea98b8 100644 --- a/api/translations/messages.en.po +++ b/api/translations/messages.en.po @@ -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" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f27f018..b2204c2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 4d6e6ab..7848dac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3544ea0..ce23135 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
@@ -82,9 +93,10 @@ function AppShell() { } />
- - {t('footer.copy')} - +
+ {t('footer.copy')} + {loadMs !== null && Loaded in {loadMs} ms} +
) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 6173c28..a0b58dd 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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}`) } diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 057eb8b..82d7db5 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -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 {children} } diff --git a/frontend/src/index.css b/frontend/src/index.css index 7ddc2e3..c97800c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 2ef6b29..a9c90f4 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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' diff --git a/frontend/src/pages/Acp.jsx b/frontend/src/pages/Acp.jsx index 789147a..62cb897 100644 --- a/frontend/src/pages/Acp.jsx +++ b/frontend/src/pages/Acp.jsx @@ -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) => ( +
+
handleDragEnter(node.id)} + onDragLeave={() => handleDragLeave(node.id)} + onDrop={(event) => handleDrop(event, node.id, getParentId(node))} + > +
+ + + {node.type === 'category' && node.children?.length > 0 && ( +