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/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*", "symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*", "symfony/serializer": "8.0.*",
"symfony/translation": "8.0.*",
"symfony/twig-bundle": "8.0.*", "symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*", "symfony/validator": "8.0.*",
"symfony/yaml": "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7c78a734d525250702b4a298784f81dd", "content-hash": "678b66a22585eb57ff21a981f084502f",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -6233,6 +6233,99 @@
], ],
"time": "2025-12-01T09:13:36+00:00" "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", "name": "symfony/translation-contracts",
"version": "v3.6.1", "version": "v3.6.1",

View File

@@ -1,6 +1,9 @@
api_platform: api_platform:
title: speedBB API title: speedBB API
version: 1.0.0 version: 1.0.0
formats:
json: ['application/json']
jsonld: ['application/ld+json']
defaults: defaults:
stateless: true stateless: true
cache_headers: 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 * translator?: bool|array{ // Translator configuration
* enabled?: bool, // Default: false * enabled?: bool, // Default: true
* fallbacks?: list<scalar|null>, * fallbacks?: list<scalar|null>,
* logging?: bool, // Default: false * logging?: bool, // Default: false
* formatter?: scalar|null, // Default: "translator.formatter.default" * 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; 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\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
@@ -16,48 +19,67 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity] #[ORM\Entity]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['parent' => 'exact', 'type' => 'exact'])]
#[ApiFilter(ExistsFilter::class, properties: ['parent'])]
#[ApiResource( #[ApiResource(
operations : [ normalizationContext: ['groups' => ['forum:read']],
denormalizationContext: ['groups' => ['forum:write']],
operations: [
new Get(), new Get(),
new GetCollection(), new GetCollection(),
new Post(security: "is_granted('ROLE_ADMIN')"), new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(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\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['category:read', 'thread:read'])] #[Groups(['forum:read', 'thread:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 100)] #[ORM\Column(length: 100)]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Groups(['category:read', 'category:write', 'thread:read'])] #[Groups(['forum:read', 'forum:write', 'thread:read'])]
private ?string $name = null; private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
#[Groups(['category:read', 'category:write'])] #[Groups(['forum:read', 'forum:write'])]
private ?string $description = null; 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] #[ORM\Column]
#[Groups(['category:read'])] #[Groups(['forum:read'])]
private ?\DateTimeImmutable $createdAt = null; private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column] #[ORM\Column]
#[Groups(['category:read'])] #[Groups(['forum:read'])]
private ?\DateTimeImmutable $updatedAt = null; private ?\DateTimeImmutable $updatedAt = null;
#[ORM\OneToMany(mappedBy: 'category', targetEntity: Thread::class)]
#[Groups(['category:read'])]
private Collection $threads;
public function __construct() public function __construct()
{ {
$this->children = new ArrayCollection();
$this->threads = new ArrayCollection(); $this->threads = new ArrayCollection();
} }
@@ -104,14 +126,36 @@ class Category
return $this; 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; 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\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post as PostOperation;
use App\State\ThreadOwnerProcessor; use App\State\ThreadOwnerProcessor;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -19,14 +19,14 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity] #[ORM\Entity]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['category' => 'exact'])] #[ApiFilter(SearchFilter::class, properties: ['forum' => 'exact'])]
#[ApiResource( #[ApiResource(
normalizationContext: ['groups' => ['thread:read']], normalizationContext: ['groups' => ['thread:read']],
denormalizationContext: ['groups' => ['thread:write']], denormalizationContext: ['groups' => ['thread:write']],
operations: [ operations: [
new Get(), new Get(),
new GetCollection(), new GetCollection(),
new Post( new PostOperation(
security: "is_granted('ROLE_USER')", security: "is_granted('ROLE_USER')",
processor: ThreadOwnerProcessor::class processor: ThreadOwnerProcessor::class
), ),
@@ -39,12 +39,12 @@ class Thread
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['thread:read', 'category:read', 'post:read'])] #[Groups(['thread:read', 'forum:read', 'post:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 200)] #[ORM\Column(length: 200)]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Groups(['thread:read', 'thread:write', 'category:read', 'post:read'])] #[Groups(['thread:read', 'thread:write', 'forum:read', 'post:read'])]
private ?string $title = null; private ?string $title = null;
#[ORM\Column(type: 'text')] #[ORM\Column(type: 'text')]
@@ -52,11 +52,12 @@ class Thread
#[Groups(['thread:read', 'thread:write'])] #[Groups(['thread:read', 'thread:write'])]
private ?string $body = null; private ?string $body = null;
#[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'threads')] #[ORM\ManyToOne(targetEntity: Forum::class, inversedBy: 'threads')]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
#[Assert\NotNull] #[Assert\NotNull]
#[Assert\Expression("this.getForum() and this.getForum().isForum()", message: "Thread must belong to a forum.")]
#[Groups(['thread:read', 'thread:write'])] #[Groups(['thread:read', 'thread:write'])]
private ?Category $category = null; private ?Forum $forum = null;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'threads')] #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'threads')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
@@ -123,14 +124,14 @@ class Thread
return $this; 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; return $this;
} }

View File

@@ -7,7 +7,7 @@ use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post as PostOperation;
use App\State\UserPasswordHasherProcessor; use App\State\UserPasswordHasherProcessor;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -29,7 +29,7 @@ use Symfony\Component\Validator\Constraints as Assert;
operations: [ operations: [
new Get(security: "is_granted('ROLE_ADMIN')"), new Get(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(security: "is_granted('ROLE_ADMIN')"), new GetCollection(security: "is_granted('ROLE_ADMIN')"),
new Post( new PostOperation(
security: "is_granted('PUBLIC_ACCESS')", security: "is_granted('PUBLIC_ACCESS')",
processor: UserPasswordHasherProcessor::class, processor: UserPasswordHasherProcessor::class,
validationContext: ['groups' => ['Default', 'user:create']] validationContext: ['groups' => ['Default', 'user:create']]

View File

@@ -144,6 +144,19 @@
"config/routes/security.yaml" "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": { "symfony/twig-bundle": {
"version": "8.0", "version": "8.0",
"recipe": { "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", "version": "0.0.0",
"dependencies": { "dependencies": {
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"i18next": "^25.7.3",
"i18next-http-backend": "^3.0.2",
"react": "^19.2.0", "react": "^19.2.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-i18next": "^16.5.0",
"react-router-dom": "^7.11.0" "react-router-dom": "^7.11.0"
}, },
"devDependencies": { "devDependencies": {
@@ -1659,6 +1662,14 @@
"url": "https://opencollective.com/express" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2113,6 +2124,52 @@
"hermes-estree": "0.25.1" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2346,6 +2403,25 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "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": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -2572,6 +2648,32 @@
"react": "^19.2.3" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -2781,6 +2883,11 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2851,6 +2958,14 @@
"punycode": "^2.1.0" "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": { "node_modules/vite": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "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": { "node_modules/warning": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
@@ -2933,6 +3056,20 @@
"loose-envify": "^1.0.0" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -12,9 +12,12 @@
}, },
"dependencies": { "dependencies": {
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"i18next": "^25.7.3",
"i18next-http-backend": "^3.0.2",
"react": "^19.2.0", "react": "^19.2.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-i18next": "^16.5.0",
"react-router-dom": "^7.11.0" "react-router-dom": "^7.11.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,43 +1,58 @@
import { BrowserRouter, Link, Route, Routes } from 'react-router-dom' 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 { AuthProvider, useAuth } from './context/AuthContext'
import Home from './pages/Home' import Home from './pages/Home'
import CategoryView from './pages/CategoryView' import ForumView from './pages/ForumView'
import ThreadView from './pages/ThreadView' import ThreadView from './pages/ThreadView'
import Login from './pages/Login' import Login from './pages/Login'
import Register from './pages/Register' import Register from './pages/Register'
import { useTranslation } from 'react-i18next'
function Navigation() { function Navigation() {
const { token, email, logout } = useAuth() const { token, email, logout } = useAuth()
const { t, i18n } = useTranslation()
const handleLanguageChange = (locale) => {
i18n.changeLanguage(locale)
localStorage.setItem('speedbb_lang', locale)
}
return ( return (
<Navbar expand="lg" className="bb-nav"> <Navbar expand="lg" className="bb-nav">
<Container> <Container>
<Navbar.Brand as={Link} to="/" className="fw-semibold"> <Navbar.Brand as={Link} to="/" className="fw-semibold">
speedBB {t('app.brand')}
</Navbar.Brand> </Navbar.Brand>
<Navbar.Toggle aria-controls="bb-nav" /> <Navbar.Toggle aria-controls="bb-nav" />
<Navbar.Collapse id="bb-nav"> <Navbar.Collapse id="bb-nav">
<Nav className="ms-auto align-items-lg-center gap-2"> <Nav className="ms-auto align-items-lg-center gap-2">
<Nav.Link as={Link} to="/"> <Nav.Link as={Link} to="/">
Categories {t('nav.forums')}
</Nav.Link> </Nav.Link>
{!token && ( {!token && (
<> <>
<Nav.Link as={Link} to="/login"> <Nav.Link as={Link} to="/login">
Login {t('nav.login')}
</Nav.Link> </Nav.Link>
<Nav.Link as={Link} to="/register"> <Nav.Link as={Link} to="/register">
Register {t('nav.register')}
</Nav.Link> </Nav.Link>
</> </>
)} )}
{token && ( {token && (
<> <>
<span className="bb-chip">{email}</span> <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> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
</Container> </Container>
@@ -46,19 +61,21 @@ function Navigation() {
} }
function AppShell() { function AppShell() {
const { t } = useTranslation()
return ( return (
<div className="bb-shell"> <div className="bb-shell">
<Navigation /> <Navigation />
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/category/:id" element={<CategoryView />} /> <Route path="/forum/:id" element={<ForumView />} />
<Route path="/thread/:id" element={<ThreadView />} /> <Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
</Routes> </Routes>
<footer className="bb-footer"> <footer className="bb-footer">
<Container> <Container>
speedBB forum. Powered by API Platform and React-Bootstrap. {t('footer.copy')}
</Container> </Container>
</footer> </footer>
</div> </div>

View File

@@ -50,16 +50,20 @@ export async function registerUser({ email, username, plainPassword }) {
}) })
} }
export async function listCategories() { export async function listRootForums() {
return getCollection('/categories') return getCollection('/forums?parent[exists]=false')
} }
export async function getCategory(id) { export async function listForumsByParent(parentId) {
return apiFetch(`/categories/${id}`) return getCollection(`/forums?parent=/api/forums/${parentId}`)
} }
export async function listThreadsByCategory(categoryId) { export async function getForum(id) {
return getCollection(`/threads?category=/api/categories/${categoryId}`) return apiFetch(`/forums/${id}`)
}
export async function listThreadsByForum(forumId) {
return getCollection(`/threads?forum=/api/forums/${forumId}`)
} }
export async function getThread(id) { export async function getThread(id) {
@@ -70,13 +74,13 @@ export async function listPostsByThread(threadId) {
return getCollection(`/posts?thread=/api/threads/${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', { return apiFetch('/threads', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
title, title,
body, 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 { createRoot } from 'react-dom/client'
import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap/dist/css/bootstrap.min.css'
import './index.css' import './index.css'
import './i18n'
import App from './App.jsx' import App from './App.jsx'
createRoot(document.getElementById('root')).render( 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 { useEffect, useState } from 'react'
import { Card, Col, Container, Row } from 'react-bootstrap' import { Card, Col, Container, Row } from 'react-bootstrap'
import { Link } from 'react-router-dom' 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() { export default function Home() {
const [categories, setCategories] = useState([]) const [forums, setForums] = useState([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { t } = useTranslation()
useEffect(() => { useEffect(() => {
listCategories() listRootForums()
.then(setCategories) .then(setForums)
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
@@ -18,31 +20,30 @@ export default function Home() {
return ( return (
<Container className="py-5"> <Container className="py-5">
<div className="bb-hero mb-4"> <div className="bb-hero mb-4">
<p className="bb-chip">speedBB</p> <p className="bb-chip">{t('app.brand')}</p>
<h1 className="mt-3">Forum categories</h1> <h1 className="mt-3">{t('home.hero_title')}</h1>
<p className="bb-muted mb-0"> <p className="bb-muted mb-0">
Explore conversations, ask questions, and share ideas. Start in a category {t('home.hero_body')}
that matches your topic.
</p> </p>
</div> </div>
<h3 className="bb-section-title mb-3">Browse categories</h3> <h3 className="bb-section-title mb-3">{t('home.browse')}</h3>
{loading && <p className="bb-muted">Loading categories...</p>} {loading && <p className="bb-muted">{t('home.loading')}</p>}
{error && <p className="text-danger">{error}</p>} {error && <p className="text-danger">{error}</p>}
{!loading && categories.length === 0 && ( {!loading && forums.length === 0 && (
<p className="bb-muted">No categories yet. Create the first one in the API.</p> <p className="bb-muted">{t('home.empty')}</p>
)} )}
<Row xs={1} md={2} lg={3} className="g-4"> <Row xs={1} md={2} lg={3} className="g-4">
{categories.map((category) => ( {forums.map((forum) => (
<Col key={category.id}> <Col key={forum.id}>
<Card className="bb-card h-100"> <Card className="bb-card h-100">
<Card.Body> <Card.Body>
<Card.Title>{category.name}</Card.Title> <Card.Title>{forum.name}</Card.Title>
<Card.Text className="bb-muted"> <Card.Text className="bb-muted">
{category.description || 'No description yet.'} {forum.description || t('forum.no_description')}
</Card.Text> </Card.Text>
<Link to={`/category/${category.id}`} className="stretched-link"> <Link to={`/forum/${forum.id}`} className="stretched-link">
Open category {t('forum.open')}
</Link> </Link>
</Card.Body> </Card.Body>
</Card> </Card>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Button, Card, Container, Form } from 'react-bootstrap' import { Button, Card, Container, Form } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function Login() { export default function Login() {
const { login } = useAuth() const { login } = useAuth()
@@ -10,6 +11,7 @@ export default function Login() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { t } = useTranslation()
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault() event.preventDefault()
@@ -29,14 +31,12 @@ export default function Login() {
<Container className="py-5"> <Container className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}> <Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
<Card.Body> <Card.Body>
<Card.Title className="mb-3">Log in</Card.Title> <Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
<Card.Text className="bb-muted"> <Card.Text className="bb-muted">{t('auth.login_hint')}</Card.Text>
Access your account to start new threads and reply.
</Card.Text>
{error && <p className="text-danger">{error}</p>} {error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Email</Form.Label> <Form.Label>{t('form.email')}</Form.Label>
<Form.Control <Form.Control
type="email" type="email"
value={email} value={email}
@@ -45,7 +45,7 @@ export default function Login() {
/> />
</Form.Group> </Form.Group>
<Form.Group className="mb-4"> <Form.Group className="mb-4">
<Form.Label>Password</Form.Label> <Form.Label>{t('form.password')}</Form.Label>
<Form.Control <Form.Control
type="password" type="password"
value={password} value={password}
@@ -54,7 +54,7 @@ export default function Login() {
/> />
</Form.Group> </Form.Group>
<Button type="submit" variant="dark" disabled={loading}> <Button type="submit" variant="dark" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'} {loading ? t('form.signing_in') : t('form.sign_in')}
</Button> </Button>
</Form> </Form>
</Card.Body> </Card.Body>

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Button, Card, Container, Form } from 'react-bootstrap' import { Button, Card, Container, Form } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { registerUser } from '../api/client' import { registerUser } from '../api/client'
import { useTranslation } from 'react-i18next'
export default function Register() { export default function Register() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -10,6 +11,7 @@ export default function Register() {
const [plainPassword, setPlainPassword] = useState('') const [plainPassword, setPlainPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { t } = useTranslation()
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault() event.preventDefault()
@@ -29,14 +31,12 @@ export default function Register() {
<Container className="py-5"> <Container className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}> <Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
<Card.Body> <Card.Body>
<Card.Title className="mb-3">Create account</Card.Title> <Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>
<Card.Text className="bb-muted"> <Card.Text className="bb-muted">{t('auth.register_hint')}</Card.Text>
Register with an email and a unique username.
</Card.Text>
{error && <p className="text-danger">{error}</p>} {error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Email</Form.Label> <Form.Label>{t('form.email')}</Form.Label>
<Form.Control <Form.Control
type="email" type="email"
value={email} value={email}
@@ -45,7 +45,7 @@ export default function Register() {
/> />
</Form.Group> </Form.Group>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Username</Form.Label> <Form.Label>{t('form.username')}</Form.Label>
<Form.Control <Form.Control
type="text" type="text"
value={username} value={username}
@@ -54,7 +54,7 @@ export default function Register() {
/> />
</Form.Group> </Form.Group>
<Form.Group className="mb-4"> <Form.Group className="mb-4">
<Form.Label>Password</Form.Label> <Form.Label>{t('form.password')}</Form.Label>
<Form.Control <Form.Control
type="password" type="password"
value={plainPassword} value={plainPassword}
@@ -64,7 +64,7 @@ export default function Register() {
/> />
</Form.Group> </Form.Group>
<Button type="submit" variant="dark" disabled={loading}> <Button type="submit" variant="dark" disabled={loading}>
{loading ? 'Registering...' : 'Create account'} {loading ? t('form.registering') : t('form.create_account')}
</Button> </Button>
</Form> </Form>
</Card.Body> </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 { Link, useParams } from 'react-router-dom'
import { createPost, getThread, listPostsByThread } from '../api/client' import { createPost, getThread, listPostsByThread } from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function ThreadView() { export default function ThreadView() {
const { id } = useParams() const { id } = useParams()
@@ -13,6 +14,7 @@ export default function ThreadView() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [body, setBody] = useState('') const [body, setBody] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const { t } = useTranslation()
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
@@ -43,19 +45,19 @@ export default function ThreadView() {
return ( return (
<Container className="py-5"> <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>} {error && <p className="text-danger">{error}</p>}
{thread && ( {thread && (
<> <>
<div className="bb-hero mb-4"> <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> <h2 className="mt-3">{thread.title}</h2>
<p className="bb-muted mb-2">{thread.body}</p> <p className="bb-muted mb-2">{thread.body}</p>
{thread.category && ( {thread.forum && (
<p className="bb-muted mb-0"> <p className="bb-muted mb-0">
Category:{' '} {t('thread.category')}{' '}
<Link to={`/category/${thread.category.id || thread.category.split('/').pop()}`}> <Link to={`/forum/${thread.forum.id || thread.forum.split('/').pop()}`}>
{thread.category.name || 'Back to category'} {thread.forum.name || t('thread.back_to_category')}
</Link> </Link>
</p> </p>
)} )}
@@ -63,32 +65,34 @@ export default function ThreadView() {
<Row className="g-4"> <Row className="g-4">
<Col lg={7}> <Col lg={7}>
<h4 className="bb-section-title mb-3">Replies</h4> <h4 className="bb-section-title mb-3">{t('thread.replies')}</h4>
{posts.length === 0 && <p className="bb-muted">Be the first to reply.</p>} {posts.length === 0 && (
<p className="bb-muted">{t('thread.empty')}</p>
)}
{posts.map((post) => ( {posts.map((post) => (
<Card className="bb-card mb-3" key={post.id}> <Card className="bb-card mb-3" key={post.id}>
<Card.Body> <Card.Body>
<Card.Text>{post.body}</Card.Text> <Card.Text>{post.body}</Card.Text>
<small className="bb-muted"> <small className="bb-muted">
{post.author?.username || 'Anonymous'} {post.author?.username || t('thread.anonymous')}
</small> </small>
</Card.Body> </Card.Body>
</Card> </Card>
))} ))}
</Col> </Col>
<Col lg={5}> <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"> <div className="bb-form">
{!token && ( {!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 onSubmit={handleSubmit}>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Message</Form.Label> <Form.Label>{t('form.message')}</Form.Label>
<Form.Control <Form.Control
as="textarea" as="textarea"
rows={5} rows={5}
placeholder="Share your reply." placeholder={t('form.reply_placeholder')}
value={body} value={body}
onChange={(event) => setBody(event.target.value)} onChange={(event) => setBody(event.target.value)}
disabled={!token || saving} disabled={!token || saving}
@@ -96,7 +100,7 @@ export default function ThreadView() {
/> />
</Form.Group> </Form.Group>
<Button type="submit" variant="dark" disabled={!token || saving}> <Button type="submit" variant="dark" disabled={!token || saving}>
{saving ? 'Posting...' : 'Post reply'} {saving ? t('form.posting') : t('form.post_reply')}
</Button> </Button>
</Form> </Form>
</div> </div>