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

@@ -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"