Forum tree model, i18n, and frontend updates
This commit is contained in:
8
CHANGELOG.md
Normal file
8
CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-24
|
||||
- Reworked the domain model into a single forum tree (category/forum types) with parent/child hierarchy and threads restricted to forum nodes.
|
||||
- Updated API Platform resources, filters, migrations, and JSON format support.
|
||||
- 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.
|
||||
@@ -26,6 +26,7 @@
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
"symfony/translation": "8.0.*",
|
||||
"symfony/twig-bundle": "8.0.*",
|
||||
"symfony/validator": "8.0.*",
|
||||
"symfony/yaml": "8.0.*"
|
||||
|
||||
95
api/composer.lock
generated
95
api/composer.lock
generated
@@ -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": "7c78a734d525250702b4a298784f81dd",
|
||||
"content-hash": "678b66a22585eb57ff21a981f084502f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -6233,6 +6233,99 @@
|
||||
],
|
||||
"time": "2025-12-01T09:13:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation",
|
||||
"version": "v8.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/translation.git",
|
||||
"reference": "770e3b8b0ba8360958abedcabacd4203467333ca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/translation/zipball/770e3b8b0ba8360958abedcabacd4203467333ca",
|
||||
"reference": "770e3b8b0ba8360958abedcabacd4203467333ca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/polyfill-mbstring": "^1.0",
|
||||
"symfony/translation-contracts": "^3.6.1"
|
||||
},
|
||||
"conflict": {
|
||||
"nikic/php-parser": "<5.0",
|
||||
"symfony/http-client-contracts": "<2.5",
|
||||
"symfony/service-contracts": "<2.5"
|
||||
},
|
||||
"provide": {
|
||||
"symfony/translation-implementation": "2.3|3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"nikic/php-parser": "^5.0",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/config": "^7.4|^8.0",
|
||||
"symfony/console": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/finder": "^7.4|^8.0",
|
||||
"symfony/http-client-contracts": "^2.5|^3.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/intl": "^7.4|^8.0",
|
||||
"symfony/polyfill-intl-icu": "^1.21",
|
||||
"symfony/routing": "^7.4|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/yaml": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"Resources/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Translation\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides tools to internationalize your application",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/translation/tree/v8.0.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-01T09:13:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation-contracts",
|
||||
"version": "v3.6.1",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
api_platform:
|
||||
title: speedBB API
|
||||
version: 1.0.0
|
||||
formats:
|
||||
json: ['application/json']
|
||||
jsonld: ['application/ld+json']
|
||||
defaults:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
|
||||
5
api/config/packages/translation.yaml
Normal file
5
api/config/packages/translation.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
framework:
|
||||
default_locale: en
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
providers:
|
||||
@@ -297,7 +297,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
* },
|
||||
* },
|
||||
* translator?: bool|array{ // Translator configuration
|
||||
* enabled?: bool, // Default: false
|
||||
* enabled?: bool, // Default: true
|
||||
* fallbacks?: list<scalar|null>,
|
||||
* logging?: bool, // Default: false
|
||||
* formatter?: scalar|null, // Default: "translator.formatter.default"
|
||||
|
||||
24
api/migrations/Version20251224115331.php
Normal file
24
api/migrations/Version20251224115331.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20251224115331 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Initial schema (executed previously).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
}
|
||||
}
|
||||
46
api/migrations/Version20251224120510.php
Normal file
46
api/migrations/Version20251224120510.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251224120510 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE forum (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL, description LONGTEXT DEFAULT NULL, type VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, parent_id INT DEFAULT NULL, INDEX IDX_852BBECD727ACA70 (parent_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE forum ADD CONSTRAINT FK_852BBECD727ACA70 FOREIGN KEY (parent_id) REFERENCES forum (id)');
|
||||
$this->addSql('ALTER TABLE thread DROP FOREIGN KEY `FK_31204C8312469DE2`');
|
||||
$this->addSql('DROP INDEX IDX_31204C8312469DE2 ON thread');
|
||||
$this->addSql('INSERT INTO forum (id, name, description, type, created_at, updated_at) SELECT id, name, description, \'forum\', created_at, updated_at FROM category');
|
||||
$this->addSql('ALTER TABLE thread CHANGE category_id forum_id INT NOT NULL');
|
||||
$this->addSql('ALTER TABLE thread ADD CONSTRAINT FK_31204C8329CCBAD0 FOREIGN KEY (forum_id) REFERENCES forum (id)');
|
||||
$this->addSql('CREATE INDEX IDX_31204C8329CCBAD0 ON thread (forum_id)');
|
||||
$this->addSql('DROP TABLE category');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE category (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_general_ci`, description LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
$this->addSql('ALTER TABLE forum DROP FOREIGN KEY FK_852BBECD727ACA70');
|
||||
$this->addSql('DROP TABLE forum');
|
||||
$this->addSql('ALTER TABLE thread DROP FOREIGN KEY FK_31204C8329CCBAD0');
|
||||
$this->addSql('DROP INDEX IDX_31204C8329CCBAD0 ON thread');
|
||||
$this->addSql('ALTER TABLE thread CHANGE forum_id category_id INT NOT NULL');
|
||||
$this->addSql('ALTER TABLE thread ADD CONSTRAINT `FK_31204C8312469DE2` FOREIGN KEY (category_id) REFERENCES category (id)');
|
||||
$this->addSql('CREATE INDEX IDX_31204C8312469DE2 ON thread (category_id)');
|
||||
}
|
||||
}
|
||||
11
api/public/.htaccess
Normal file
11
api/public/.htaccess
Normal file
@@ -0,0 +1,11 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} -f
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} -d
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
55
api/src/Controller/I18nController.php
Normal file
55
api/src/Controller/I18nController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class I18nController
|
||||
{
|
||||
public function __construct(
|
||||
private TranslatorInterface $translator,
|
||||
#[Autowire('%kernel.default_locale%')]
|
||||
private string $defaultLocale
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(
|
||||
'/api/i18n/{locale}',
|
||||
name: 'api_i18n',
|
||||
methods: ['GET'],
|
||||
requirements: ['locale' => '[A-Za-z0-9_-]+']
|
||||
)]
|
||||
public function __invoke(string $locale): JsonResponse
|
||||
{
|
||||
$messages = $this->getMessagesForLocale($locale);
|
||||
|
||||
if (!$messages && $locale !== $this->defaultLocale) {
|
||||
$messages = $this->getMessagesForLocale($this->defaultLocale);
|
||||
}
|
||||
|
||||
return new JsonResponse($messages);
|
||||
}
|
||||
|
||||
private function getMessagesForLocale(string $locale): array
|
||||
{
|
||||
$catalogue = $this->translator->getCatalogue($locale);
|
||||
$messages = $catalogue->all('messages');
|
||||
|
||||
$fallback = $catalogue->getFallbackCatalogue();
|
||||
while ($fallback) {
|
||||
foreach ($fallback->all('messages') as $key => $value) {
|
||||
if (!array_key_exists($key, $messages)) {
|
||||
$messages[$key] = $value;
|
||||
}
|
||||
}
|
||||
$fallback = $fallback->getFallbackCatalogue();
|
||||
}
|
||||
|
||||
ksort($messages);
|
||||
|
||||
return $messages;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -16,48 +19,67 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['parent' => 'exact', 'type' => 'exact'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['parent'])]
|
||||
#[ApiResource(
|
||||
operations : [
|
||||
normalizationContext: ['groups' => ['forum:read']],
|
||||
denormalizationContext: ['groups' => ['forum:write']],
|
||||
operations: [
|
||||
new Get(),
|
||||
new GetCollection(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')")
|
||||
],
|
||||
normalizationContext : ['groups' => ['category:read']],
|
||||
denormalizationContext: ['groups' => ['category:write']]
|
||||
]
|
||||
)]
|
||||
class Category
|
||||
class Forum
|
||||
{
|
||||
public const TYPE_CATEGORY = 'category';
|
||||
public const TYPE_FORUM = 'forum';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['category:read', 'thread:read'])]
|
||||
#[Groups(['forum:read', 'thread:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['category:read', 'category:write', 'thread:read'])]
|
||||
#[Groups(['forum:read', 'forum:write', 'thread:read'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
#[Groups(['category:read', 'category:write'])]
|
||||
#[Groups(['forum:read', 'forum:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\Choice(choices: [self::TYPE_CATEGORY, self::TYPE_FORUM])]
|
||||
#[Groups(['forum:read', 'forum:write'])]
|
||||
private string $type = self::TYPE_CATEGORY;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
|
||||
#[Groups(['forum:read', 'forum:write'])]
|
||||
private ?self $parent = null;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
|
||||
#[Groups(['forum:read'])]
|
||||
private Collection $children;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'forum', targetEntity: Thread::class)]
|
||||
#[Groups(['forum:read'])]
|
||||
private Collection $threads;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['category:read'])]
|
||||
#[Groups(['forum:read'])]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['category:read'])]
|
||||
#[Groups(['forum:read'])]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'category', targetEntity: Thread::class)]
|
||||
#[Groups(['category:read'])]
|
||||
private Collection $threads;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->children = new ArrayCollection();
|
||||
$this->threads = new ArrayCollection();
|
||||
}
|
||||
|
||||
@@ -104,14 +126,36 @@ class Category
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->createdAt;
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
public function setType(string $type): self
|
||||
{
|
||||
return $this->updatedAt;
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParent(): ?self
|
||||
{
|
||||
return $this->parent;
|
||||
}
|
||||
|
||||
public function setParent(?self $parent): self
|
||||
{
|
||||
$this->parent = $parent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Forum>
|
||||
*/
|
||||
public function getChildren(): Collection
|
||||
{
|
||||
return $this->children;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,4 +165,14 @@ class Category
|
||||
{
|
||||
return $this->threads;
|
||||
}
|
||||
|
||||
public function isCategory(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_CATEGORY;
|
||||
}
|
||||
|
||||
public function isForum(): bool
|
||||
{
|
||||
return $this->type === self::TYPE_FORUM;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Post as PostOperation;
|
||||
use App\State\ThreadOwnerProcessor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -19,14 +19,14 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['category' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['forum' => 'exact'])]
|
||||
#[ApiResource(
|
||||
normalizationContext: ['groups' => ['thread:read']],
|
||||
denormalizationContext: ['groups' => ['thread:write']],
|
||||
operations: [
|
||||
new Get(),
|
||||
new GetCollection(),
|
||||
new Post(
|
||||
new PostOperation(
|
||||
security: "is_granted('ROLE_USER')",
|
||||
processor: ThreadOwnerProcessor::class
|
||||
),
|
||||
@@ -39,12 +39,12 @@ class Thread
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['thread:read', 'category:read', 'post:read'])]
|
||||
#[Groups(['thread:read', 'forum:read', 'post:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 200)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['thread:read', 'thread:write', 'category:read', 'post:read'])]
|
||||
#[Groups(['thread:read', 'thread:write', 'forum:read', 'post:read'])]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
@@ -52,11 +52,12 @@ class Thread
|
||||
#[Groups(['thread:read', 'thread:write'])]
|
||||
private ?string $body = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'threads')]
|
||||
#[ORM\ManyToOne(targetEntity: Forum::class, inversedBy: 'threads')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Expression("this.getForum() and this.getForum().isForum()", message: "Thread must belong to a forum.")]
|
||||
#[Groups(['thread:read', 'thread:write'])]
|
||||
private ?Category $category = null;
|
||||
private ?Forum $forum = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'threads')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
@@ -123,14 +124,14 @@ class Thread
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCategory(): ?Category
|
||||
public function getForum(): ?Forum
|
||||
{
|
||||
return $this->category;
|
||||
return $this->forum;
|
||||
}
|
||||
|
||||
public function setCategory(?Category $category): self
|
||||
public function setForum(?Forum $forum): self
|
||||
{
|
||||
$this->category = $category;
|
||||
$this->forum = $forum;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Post as PostOperation;
|
||||
use App\State\UserPasswordHasherProcessor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -29,7 +29,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_ADMIN')"),
|
||||
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Post(
|
||||
new PostOperation(
|
||||
security: "is_granted('PUBLIC_ACCESS')",
|
||||
processor: UserPasswordHasherProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'user:create']]
|
||||
|
||||
@@ -144,6 +144,19 @@
|
||||
"config/routes/security.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/translation": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.3",
|
||||
"ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/translation.yaml",
|
||||
"translations/.gitignore"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
|
||||
0
api/translations/.gitignore
vendored
Normal file
0
api/translations/.gitignore
vendored
Normal file
166
api/translations/messages.de.po
Normal file
166
api/translations/messages.de.po
Normal file
@@ -0,0 +1,166 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: de\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
|
||||
msgid "app.brand"
|
||||
msgstr "speedBB"
|
||||
|
||||
msgid "nav.forums"
|
||||
msgstr "Foren"
|
||||
|
||||
msgid "nav.login"
|
||||
msgstr "Anmelden"
|
||||
|
||||
msgid "nav.register"
|
||||
msgstr "Registrieren"
|
||||
|
||||
msgid "nav.logout"
|
||||
msgstr "Abmelden"
|
||||
|
||||
msgid "nav.language"
|
||||
msgstr "Sprache"
|
||||
|
||||
msgid "home.hero_title"
|
||||
msgstr "Foren"
|
||||
|
||||
msgid "home.hero_body"
|
||||
msgstr "Entdecke Diskussionen, stelle Fragen und teile Ideen in Kategorien und Foren."
|
||||
|
||||
msgid "home.browse"
|
||||
msgstr "Foren durchsuchen"
|
||||
|
||||
msgid "home.loading"
|
||||
msgstr "Foren werden geladen..."
|
||||
|
||||
msgid "home.empty"
|
||||
msgstr "Noch keine Foren vorhanden. Lege das erste Forum in der API an."
|
||||
|
||||
msgid "forum.threads"
|
||||
msgstr "Threads"
|
||||
|
||||
msgid "forum.start_thread"
|
||||
msgstr "Thread starten"
|
||||
|
||||
msgid "forum.loading"
|
||||
msgstr "Forum wird geladen..."
|
||||
|
||||
msgid "forum.type_category"
|
||||
msgstr "Kategorie"
|
||||
|
||||
msgid "forum.type_forum"
|
||||
msgstr "Forum"
|
||||
|
||||
msgid "forum.no_description"
|
||||
msgstr "Noch keine Beschreibung vorhanden."
|
||||
|
||||
msgid "forum.empty_threads"
|
||||
msgstr "Noch keine Threads vorhanden. Starte unten einen."
|
||||
|
||||
msgid "forum.login_hint"
|
||||
msgstr "Melde dich an, um einen neuen Thread zu erstellen."
|
||||
|
||||
msgid "forum.open"
|
||||
msgstr "Forum öffnen"
|
||||
|
||||
msgid "forum.children"
|
||||
msgstr "Unterforen"
|
||||
|
||||
msgid "forum.empty_children"
|
||||
msgstr "Noch keine Unterforen vorhanden."
|
||||
|
||||
msgid "forum.only_forums"
|
||||
msgstr "Threads können nur in Foren erstellt werden."
|
||||
|
||||
msgid "thread.replies"
|
||||
msgstr "Antworten"
|
||||
|
||||
msgid "thread.reply"
|
||||
msgstr "Antworten"
|
||||
|
||||
msgid "thread.loading"
|
||||
msgstr "Thread wird geladen..."
|
||||
|
||||
msgid "thread.label"
|
||||
msgstr "Thread"
|
||||
|
||||
msgid "thread.category"
|
||||
msgstr "Forum:"
|
||||
|
||||
msgid "thread.back_to_category"
|
||||
msgstr "Zurück zum Forum"
|
||||
|
||||
msgid "thread.empty"
|
||||
msgstr "Sei die erste Person, die antwortet."
|
||||
|
||||
msgid "thread.anonymous"
|
||||
msgstr "Anonym"
|
||||
|
||||
msgid "thread.login_hint"
|
||||
msgstr "Melde dich an, um auf diesen Thread zu antworten."
|
||||
|
||||
msgid "thread.view"
|
||||
msgstr "Thread ansehen"
|
||||
|
||||
msgid "auth.login_title"
|
||||
msgstr "Anmelden"
|
||||
|
||||
msgid "auth.login_hint"
|
||||
msgstr "Melde dich an, um neue Threads zu starten und zu antworten."
|
||||
|
||||
msgid "auth.register_title"
|
||||
msgstr "Konto erstellen"
|
||||
|
||||
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."
|
||||
|
||||
msgid "form.title"
|
||||
msgstr "Titel"
|
||||
|
||||
msgid "form.body"
|
||||
msgstr "Inhalt"
|
||||
|
||||
msgid "form.message"
|
||||
msgstr "Nachricht"
|
||||
|
||||
msgid "form.email"
|
||||
msgstr "E-Mail"
|
||||
|
||||
msgid "form.username"
|
||||
msgstr "Benutzername"
|
||||
|
||||
msgid "form.password"
|
||||
msgstr "Passwort"
|
||||
|
||||
msgid "form.thread_title_placeholder"
|
||||
msgstr "Thema"
|
||||
|
||||
msgid "form.thread_body_placeholder"
|
||||
msgstr "Teile den Kontext und deine Frage."
|
||||
|
||||
msgid "form.reply_placeholder"
|
||||
msgstr "Schreibe deine Antwort."
|
||||
|
||||
msgid "form.posting"
|
||||
msgstr "Wird gesendet..."
|
||||
|
||||
msgid "form.create_thread"
|
||||
msgstr "Thread erstellen"
|
||||
|
||||
msgid "form.post_reply"
|
||||
msgstr "Antwort posten"
|
||||
|
||||
msgid "form.signing_in"
|
||||
msgstr "Anmeldung läuft..."
|
||||
|
||||
msgid "form.sign_in"
|
||||
msgstr "Anmelden"
|
||||
|
||||
msgid "form.registering"
|
||||
msgstr "Registrierung läuft..."
|
||||
|
||||
msgid "form.create_account"
|
||||
msgstr "Konto erstellen"
|
||||
166
api/translations/messages.en.po
Normal file
166
api/translations/messages.en.po
Normal file
@@ -0,0 +1,166 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: en\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
|
||||
msgid "app.brand"
|
||||
msgstr "speedBB"
|
||||
|
||||
msgid "nav.forums"
|
||||
msgstr "Forums"
|
||||
|
||||
msgid "nav.login"
|
||||
msgstr "Login"
|
||||
|
||||
msgid "nav.register"
|
||||
msgstr "Register"
|
||||
|
||||
msgid "nav.logout"
|
||||
msgstr "Logout"
|
||||
|
||||
msgid "nav.language"
|
||||
msgstr "Language"
|
||||
|
||||
msgid "home.hero_title"
|
||||
msgstr "Forums"
|
||||
|
||||
msgid "home.hero_body"
|
||||
msgstr "Explore conversations, ask questions, and share ideas across categories and forums."
|
||||
|
||||
msgid "home.browse"
|
||||
msgstr "Browse forums"
|
||||
|
||||
msgid "home.loading"
|
||||
msgstr "Loading forums..."
|
||||
|
||||
msgid "home.empty"
|
||||
msgstr "No forums yet. Create the first one in the API."
|
||||
|
||||
msgid "forum.threads"
|
||||
msgstr "Threads"
|
||||
|
||||
msgid "forum.start_thread"
|
||||
msgstr "Start a thread"
|
||||
|
||||
msgid "forum.loading"
|
||||
msgstr "Loading forum..."
|
||||
|
||||
msgid "forum.type_category"
|
||||
msgstr "Category"
|
||||
|
||||
msgid "forum.type_forum"
|
||||
msgstr "Forum"
|
||||
|
||||
msgid "forum.no_description"
|
||||
msgstr "No description added yet."
|
||||
|
||||
msgid "forum.empty_threads"
|
||||
msgstr "No threads here yet. Start one below."
|
||||
|
||||
msgid "forum.login_hint"
|
||||
msgstr "Log in to create a new thread."
|
||||
|
||||
msgid "forum.open"
|
||||
msgstr "Open forum"
|
||||
|
||||
msgid "forum.children"
|
||||
msgstr "Sub-forums"
|
||||
|
||||
msgid "forum.empty_children"
|
||||
msgstr "No sub-forums yet."
|
||||
|
||||
msgid "forum.only_forums"
|
||||
msgstr "Threads can only be created in forums."
|
||||
|
||||
msgid "thread.replies"
|
||||
msgstr "Replies"
|
||||
|
||||
msgid "thread.reply"
|
||||
msgstr "Reply"
|
||||
|
||||
msgid "thread.loading"
|
||||
msgstr "Loading thread..."
|
||||
|
||||
msgid "thread.label"
|
||||
msgstr "Thread"
|
||||
|
||||
msgid "thread.category"
|
||||
msgstr "Forum:"
|
||||
|
||||
msgid "thread.back_to_category"
|
||||
msgstr "Back to forum"
|
||||
|
||||
msgid "thread.empty"
|
||||
msgstr "Be the first to reply."
|
||||
|
||||
msgid "thread.anonymous"
|
||||
msgstr "Anonymous"
|
||||
|
||||
msgid "thread.login_hint"
|
||||
msgstr "Log in to reply to this thread."
|
||||
|
||||
msgid "thread.view"
|
||||
msgstr "View thread"
|
||||
|
||||
msgid "auth.login_title"
|
||||
msgstr "Log in"
|
||||
|
||||
msgid "auth.login_hint"
|
||||
msgstr "Access your account to start new threads and reply."
|
||||
|
||||
msgid "auth.register_title"
|
||||
msgstr "Create account"
|
||||
|
||||
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."
|
||||
|
||||
msgid "form.title"
|
||||
msgstr "Title"
|
||||
|
||||
msgid "form.body"
|
||||
msgstr "Body"
|
||||
|
||||
msgid "form.message"
|
||||
msgstr "Message"
|
||||
|
||||
msgid "form.email"
|
||||
msgstr "Email"
|
||||
|
||||
msgid "form.username"
|
||||
msgstr "Username"
|
||||
|
||||
msgid "form.password"
|
||||
msgstr "Password"
|
||||
|
||||
msgid "form.thread_title_placeholder"
|
||||
msgstr "Topic headline"
|
||||
|
||||
msgid "form.thread_body_placeholder"
|
||||
msgstr "Share the context and your question."
|
||||
|
||||
msgid "form.reply_placeholder"
|
||||
msgstr "Share your reply."
|
||||
|
||||
msgid "form.posting"
|
||||
msgstr "Posting..."
|
||||
|
||||
msgid "form.create_thread"
|
||||
msgstr "Create thread"
|
||||
|
||||
msgid "form.post_reply"
|
||||
msgstr "Post reply"
|
||||
|
||||
msgid "form.signing_in"
|
||||
msgstr "Signing in..."
|
||||
|
||||
msgid "form.sign_in"
|
||||
msgstr "Sign in"
|
||||
|
||||
msgid "form.registering"
|
||||
msgstr "Registering..."
|
||||
|
||||
msgid "form.create_account"
|
||||
msgstr "Create account"
|
||||
137
frontend/package-lock.json
generated
137
frontend/package-lock.json
generated
@@ -9,9 +9,12 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.8",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.2.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1659,6 +1662,14 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2113,6 +2124,52 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.7.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
|
||||
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
|
||||
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2346,6 +2403,25 @@
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -2572,6 +2648,32 @@
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.0.tgz",
|
||||
"integrity": "sha512-IMpPTyCTKxEj8klCrLKUTIUa8uYTd851+jcu2fJuUB9Agkk9Qq8asw4omyeHVnOXHrLgQJGTm5zTvn8HpaPiqw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.6.2",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -2781,6 +2883,11 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -2851,6 +2958,14 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
@@ -2925,6 +3040,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
@@ -2933,6 +3056,20 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.8",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.2.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-router-dom": "^7.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,43 +1,58 @@
|
||||
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom'
|
||||
import { Container, Nav, Navbar } from 'react-bootstrap'
|
||||
import { Container, Nav, Navbar, NavDropdown } from 'react-bootstrap'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import Home from './pages/Home'
|
||||
import CategoryView from './pages/CategoryView'
|
||||
import ForumView from './pages/ForumView'
|
||||
import ThreadView from './pages/ThreadView'
|
||||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function Navigation() {
|
||||
const { token, email, logout } = useAuth()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const handleLanguageChange = (locale) => {
|
||||
i18n.changeLanguage(locale)
|
||||
localStorage.setItem('speedbb_lang', locale)
|
||||
}
|
||||
|
||||
return (
|
||||
<Navbar expand="lg" className="bb-nav">
|
||||
<Container>
|
||||
<Navbar.Brand as={Link} to="/" className="fw-semibold">
|
||||
speedBB
|
||||
{t('app.brand')}
|
||||
</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="bb-nav" />
|
||||
<Navbar.Collapse id="bb-nav">
|
||||
<Nav className="ms-auto align-items-lg-center gap-2">
|
||||
<Nav.Link as={Link} to="/">
|
||||
Categories
|
||||
{t('nav.forums')}
|
||||
</Nav.Link>
|
||||
{!token && (
|
||||
<>
|
||||
<Nav.Link as={Link} to="/login">
|
||||
Login
|
||||
{t('nav.login')}
|
||||
</Nav.Link>
|
||||
<Nav.Link as={Link} to="/register">
|
||||
Register
|
||||
{t('nav.register')}
|
||||
</Nav.Link>
|
||||
</>
|
||||
)}
|
||||
{token && (
|
||||
<>
|
||||
<span className="bb-chip">{email}</span>
|
||||
<Nav.Link onClick={logout}>Logout</Nav.Link>
|
||||
<Nav.Link onClick={logout}>{t('nav.logout')}</Nav.Link>
|
||||
</>
|
||||
)}
|
||||
<NavDropdown title={t('nav.language')} align="end">
|
||||
<NavDropdown.Item onClick={() => handleLanguageChange('en')}>
|
||||
English
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => handleLanguageChange('de')}>
|
||||
Deutsch
|
||||
</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
@@ -46,19 +61,21 @@ function Navigation() {
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="bb-shell">
|
||||
<Navigation />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/category/:id" element={<CategoryView />} />
|
||||
<Route path="/forum/:id" element={<ForumView />} />
|
||||
<Route path="/thread/:id" element={<ThreadView />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
</Routes>
|
||||
<footer className="bb-footer">
|
||||
<Container>
|
||||
speedBB forum. Powered by API Platform and React-Bootstrap.
|
||||
{t('footer.copy')}
|
||||
</Container>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -50,16 +50,20 @@ export async function registerUser({ email, username, plainPassword }) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function listCategories() {
|
||||
return getCollection('/categories')
|
||||
export async function listRootForums() {
|
||||
return getCollection('/forums?parent[exists]=false')
|
||||
}
|
||||
|
||||
export async function getCategory(id) {
|
||||
return apiFetch(`/categories/${id}`)
|
||||
export async function listForumsByParent(parentId) {
|
||||
return getCollection(`/forums?parent=/api/forums/${parentId}`)
|
||||
}
|
||||
|
||||
export async function listThreadsByCategory(categoryId) {
|
||||
return getCollection(`/threads?category=/api/categories/${categoryId}`)
|
||||
export async function getForum(id) {
|
||||
return apiFetch(`/forums/${id}`)
|
||||
}
|
||||
|
||||
export async function listThreadsByForum(forumId) {
|
||||
return getCollection(`/threads?forum=/api/forums/${forumId}`)
|
||||
}
|
||||
|
||||
export async function getThread(id) {
|
||||
@@ -70,13 +74,13 @@ export async function listPostsByThread(threadId) {
|
||||
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
||||
}
|
||||
|
||||
export async function createThread({ title, body, categoryId }) {
|
||||
export async function createThread({ title, body, forumId }) {
|
||||
return apiFetch('/threads', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
category: `/api/categories/${categoryId}`,
|
||||
forum: `/api/forums/${forumId}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
25
frontend/src/i18n.js
Normal file
25
frontend/src/i18n.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import i18n from 'i18next'
|
||||
import HttpBackend from 'i18next-http-backend'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
const storedLanguage = localStorage.getItem('speedbb_lang') || 'en'
|
||||
|
||||
i18n
|
||||
.use(HttpBackend)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: storedLanguage,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'de'],
|
||||
backend: {
|
||||
loadPath: '/api/i18n/{{lng}}',
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createThread, getCategory, listThreadsByCategory } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function CategoryView() {
|
||||
const { id } = useParams()
|
||||
const { token } = useAuth()
|
||||
const [category, setCategory] = useState(null)
|
||||
const [threads, setThreads] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([getCategory(id), listThreadsByCategory(id)])
|
||||
.then(([categoryData, threadData]) => {
|
||||
setCategory(categoryData)
|
||||
setThreads(threadData)
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await createThread({ title, body, categoryId: id })
|
||||
setTitle('')
|
||||
setBody('')
|
||||
const updated = await listThreadsByCategory(id)
|
||||
setThreads(updated)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
{loading && <p className="bb-muted">Loading category...</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{category && (
|
||||
<>
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">Category</p>
|
||||
<h2 className="mt-3">{category.name}</h2>
|
||||
<p className="bb-muted mb-0">
|
||||
{category.description || 'No description added yet.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Row className="g-4">
|
||||
<Col lg={7}>
|
||||
<h4 className="bb-section-title mb-3">Threads</h4>
|
||||
{threads.length === 0 && (
|
||||
<p className="bb-muted">No threads here yet. Start one below.</p>
|
||||
)}
|
||||
{threads.map((thread) => (
|
||||
<Card className="bb-card mb-3" key={thread.id}>
|
||||
<Card.Body>
|
||||
<Card.Title>{thread.title}</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
{thread.body.length > 160 ? `${thread.body.slice(0, 160)}...` : thread.body}
|
||||
</Card.Text>
|
||||
<Link to={`/thread/${thread.id}`} className="stretched-link">
|
||||
View thread
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</Col>
|
||||
<Col lg={5}>
|
||||
<h4 className="bb-section-title mb-3">Start a thread</h4>
|
||||
<div className="bb-form">
|
||||
{!token && (
|
||||
<p className="bb-muted mb-3">Log in to create a new thread.</p>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Topic headline"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Body</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder="Share the context and your question."
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={!token || saving}>
|
||||
{saving ? 'Posting...' : 'Create thread'}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
180
frontend/src/pages/ForumView.jsx
Normal file
180
frontend/src/pages/ForumView.jsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ForumView() {
|
||||
const { id } = useParams()
|
||||
const { token } = useAuth()
|
||||
const [forum, setForum] = useState(null)
|
||||
const [children, setChildren] = useState([])
|
||||
const [threads, setThreads] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const forumData = await getForum(id)
|
||||
if (!active) return
|
||||
setForum(forumData)
|
||||
const childData = await listForumsByParent(id)
|
||||
if (!active) return
|
||||
setChildren(childData)
|
||||
if (forumData.type === 'forum') {
|
||||
const threadData = await listThreadsByForum(id)
|
||||
if (!active) return
|
||||
setThreads(threadData)
|
||||
} else {
|
||||
setThreads([])
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) setError(err.message)
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await createThread({ title, body, forumId: id })
|
||||
setTitle('')
|
||||
setBody('')
|
||||
const updated = await listThreadsByForum(id)
|
||||
setThreads(updated)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{forum && (
|
||||
<>
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">
|
||||
{forum.type === 'forum' ? t('forum.type_forum') : t('forum.type_category')}
|
||||
</p>
|
||||
<h2 className="mt-3">{forum.name}</h2>
|
||||
<p className="bb-muted mb-0">
|
||||
{forum.description || t('forum.no_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Row className="g-4">
|
||||
<Col lg={7}>
|
||||
<h4 className="bb-section-title mb-3">{t('forum.children')}</h4>
|
||||
{children.length === 0 && (
|
||||
<p className="bb-muted">{t('forum.empty_children')}</p>
|
||||
)}
|
||||
{children.map((child) => (
|
||||
<Card className="bb-card mb-3" key={child.id}>
|
||||
<Card.Body>
|
||||
<Card.Title>{child.name}</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
{child.description || t('forum.no_description')}
|
||||
</Card.Text>
|
||||
<Link to={`/forum/${child.id}`} className="stretched-link">
|
||||
{t('forum.open')}
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{forum.type === 'forum' && (
|
||||
<>
|
||||
<h4 className="bb-section-title mb-3 mt-4">{t('forum.threads')}</h4>
|
||||
{threads.length === 0 && (
|
||||
<p className="bb-muted">{t('forum.empty_threads')}</p>
|
||||
)}
|
||||
{threads.map((thread) => (
|
||||
<Card className="bb-card mb-3" key={thread.id}>
|
||||
<Card.Body>
|
||||
<Card.Title>{thread.title}</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
{thread.body.length > 160
|
||||
? `${thread.body.slice(0, 160)}...`
|
||||
: thread.body}
|
||||
</Card.Text>
|
||||
<Link to={`/thread/${thread.id}`} className="stretched-link">
|
||||
{t('thread.view')}
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
<Col lg={5}>
|
||||
<h4 className="bb-section-title mb-3">{t('forum.start_thread')}</h4>
|
||||
<div className="bb-form">
|
||||
{forum.type !== 'forum' && (
|
||||
<p className="bb-muted mb-3">{t('forum.only_forums')}</p>
|
||||
)}
|
||||
{forum.type === 'forum' && !token && (
|
||||
<p className="bb-muted mb-3">{t('forum.login_hint')}</p>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.title')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder={t('form.thread_title_placeholder')}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
disabled={!token || saving || forum.type !== 'forum'}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('form.body')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder={t('form.thread_body_placeholder')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving || forum.type !== 'forum'}
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
disabled={!token || saving || forum.type !== 'forum'}
|
||||
>
|
||||
{saving ? t('form.posting') : t('form.create_thread')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Col, Container, Row } from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listCategories } from '../api/client'
|
||||
import { listRootForums } from '../api/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Home() {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [forums, setForums] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
listCategories()
|
||||
.then(setCategories)
|
||||
listRootForums()
|
||||
.then(setForums)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
@@ -18,31 +20,30 @@ export default function Home() {
|
||||
return (
|
||||
<Container className="py-5">
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">speedBB</p>
|
||||
<h1 className="mt-3">Forum categories</h1>
|
||||
<p className="bb-chip">{t('app.brand')}</p>
|
||||
<h1 className="mt-3">{t('home.hero_title')}</h1>
|
||||
<p className="bb-muted mb-0">
|
||||
Explore conversations, ask questions, and share ideas. Start in a category
|
||||
that matches your topic.
|
||||
{t('home.hero_body')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 className="bb-section-title mb-3">Browse categories</h3>
|
||||
{loading && <p className="bb-muted">Loading categories...</p>}
|
||||
<h3 className="bb-section-title mb-3">{t('home.browse')}</h3>
|
||||
{loading && <p className="bb-muted">{t('home.loading')}</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{!loading && categories.length === 0 && (
|
||||
<p className="bb-muted">No categories yet. Create the first one in the API.</p>
|
||||
{!loading && forums.length === 0 && (
|
||||
<p className="bb-muted">{t('home.empty')}</p>
|
||||
)}
|
||||
<Row xs={1} md={2} lg={3} className="g-4">
|
||||
{categories.map((category) => (
|
||||
<Col key={category.id}>
|
||||
{forums.map((forum) => (
|
||||
<Col key={forum.id}>
|
||||
<Card className="bb-card h-100">
|
||||
<Card.Body>
|
||||
<Card.Title>{category.name}</Card.Title>
|
||||
<Card.Title>{forum.name}</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
{category.description || 'No description yet.'}
|
||||
{forum.description || t('forum.no_description')}
|
||||
</Card.Text>
|
||||
<Link to={`/category/${category.id}`} className="stretched-link">
|
||||
Open category
|
||||
<Link to={`/forum/${forum.id}`} className="stretched-link">
|
||||
{t('forum.open')}
|
||||
</Link>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Button, Card, Container, Form } from 'react-bootstrap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
@@ -10,6 +11,7 @@ export default function Login() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
@@ -29,14 +31,12 @@ export default function Login() {
|
||||
<Container className="py-5">
|
||||
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
|
||||
<Card.Body>
|
||||
<Card.Title className="mb-3">Log in</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
Access your account to start new threads and reply.
|
||||
</Card.Text>
|
||||
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
|
||||
<Card.Text className="bb-muted">{t('auth.login_hint')}</Card.Text>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Email</Form.Label>
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -45,7 +45,7 @@ export default function Login() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-4">
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Form.Label>{t('form.password')}</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -54,7 +54,7 @@ export default function Login() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
{loading ? t('form.signing_in') : t('form.sign_in')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Button, Card, Container, Form } from 'react-bootstrap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { registerUser } from '../api/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate()
|
||||
@@ -10,6 +11,7 @@ export default function Register() {
|
||||
const [plainPassword, setPlainPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
@@ -29,14 +31,12 @@ export default function Register() {
|
||||
<Container className="py-5">
|
||||
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
|
||||
<Card.Body>
|
||||
<Card.Title className="mb-3">Create account</Card.Title>
|
||||
<Card.Text className="bb-muted">
|
||||
Register with an email and a unique username.
|
||||
</Card.Text>
|
||||
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>
|
||||
<Card.Text className="bb-muted">{t('auth.register_hint')}</Card.Text>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Email</Form.Label>
|
||||
<Form.Label>{t('form.email')}</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
@@ -45,7 +45,7 @@ export default function Register() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Username</Form.Label>
|
||||
<Form.Label>{t('form.username')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={username}
|
||||
@@ -54,7 +54,7 @@ export default function Register() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-4">
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Form.Label>{t('form.password')}</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={plainPassword}
|
||||
@@ -64,7 +64,7 @@ export default function Register() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={loading}>
|
||||
{loading ? 'Registering...' : 'Create account'}
|
||||
{loading ? t('form.registering') : t('form.create_account')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Card, Col, Container, Form, Row } from 'react-bootstrap'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { createPost, getThread, listPostsByThread } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ThreadView() {
|
||||
const { id } = useParams()
|
||||
@@ -13,6 +14,7 @@ export default function ThreadView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -43,19 +45,19 @@ export default function ThreadView() {
|
||||
|
||||
return (
|
||||
<Container className="py-5">
|
||||
{loading && <p className="bb-muted">Loading thread...</p>}
|
||||
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{thread && (
|
||||
<>
|
||||
<div className="bb-hero mb-4">
|
||||
<p className="bb-chip">Thread</p>
|
||||
<p className="bb-chip">{t('thread.label')}</p>
|
||||
<h2 className="mt-3">{thread.title}</h2>
|
||||
<p className="bb-muted mb-2">{thread.body}</p>
|
||||
{thread.category && (
|
||||
{thread.forum && (
|
||||
<p className="bb-muted mb-0">
|
||||
Category:{' '}
|
||||
<Link to={`/category/${thread.category.id || thread.category.split('/').pop()}`}>
|
||||
{thread.category.name || 'Back to category'}
|
||||
{t('thread.category')}{' '}
|
||||
<Link to={`/forum/${thread.forum.id || thread.forum.split('/').pop()}`}>
|
||||
{thread.forum.name || t('thread.back_to_category')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
@@ -63,32 +65,34 @@ export default function ThreadView() {
|
||||
|
||||
<Row className="g-4">
|
||||
<Col lg={7}>
|
||||
<h4 className="bb-section-title mb-3">Replies</h4>
|
||||
{posts.length === 0 && <p className="bb-muted">Be the first to reply.</p>}
|
||||
<h4 className="bb-section-title mb-3">{t('thread.replies')}</h4>
|
||||
{posts.length === 0 && (
|
||||
<p className="bb-muted">{t('thread.empty')}</p>
|
||||
)}
|
||||
{posts.map((post) => (
|
||||
<Card className="bb-card mb-3" key={post.id}>
|
||||
<Card.Body>
|
||||
<Card.Text>{post.body}</Card.Text>
|
||||
<small className="bb-muted">
|
||||
{post.author?.username || 'Anonymous'}
|
||||
{post.author?.username || t('thread.anonymous')}
|
||||
</small>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</Col>
|
||||
<Col lg={5}>
|
||||
<h4 className="bb-section-title mb-3">Reply</h4>
|
||||
<h4 className="bb-section-title mb-3">{t('thread.reply')}</h4>
|
||||
<div className="bb-form">
|
||||
{!token && (
|
||||
<p className="bb-muted mb-3">Log in to reply to this thread.</p>
|
||||
<p className="bb-muted mb-3">{t('thread.login_hint')}</p>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Message</Form.Label>
|
||||
<Form.Label>{t('form.message')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder="Share your reply."
|
||||
placeholder={t('form.reply_placeholder')}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
disabled={!token || saving}
|
||||
@@ -96,7 +100,7 @@ export default function ThreadView() {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={!token || saving}>
|
||||
{saving ? 'Posting...' : 'Post reply'}
|
||||
{saving ? t('form.posting') : t('form.post_reply')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user