Forum tree model, i18n, and frontend updates

This commit is contained in:
Micha
2025-12-24 13:15:02 +01:00
parent 98a2f1d536
commit 193273c843
29 changed files with 1115 additions and 218 deletions

8
CHANGELOG.md Normal file
View 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.

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
providers:

View File

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

View 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
{
}
}

View 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
View 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>

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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']]

View File

@@ -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
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
)
}

View 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>
)
}

View File

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

View File

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

View File

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

View File

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