diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0aca207 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/api/composer.json b/api/composer.json index 1eedd1e..0c70db8 100644 --- a/api/composer.json +++ b/api/composer.json @@ -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.*" diff --git a/api/composer.lock b/api/composer.lock index 351d2e9..953588c 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "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", diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml index 27dfe7e..c787de6 100644 --- a/api/config/packages/api_platform.yaml +++ b/api/config/packages/api_platform.yaml @@ -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: diff --git a/api/config/packages/translation.yaml b/api/config/packages/translation.yaml new file mode 100644 index 0000000..490bfc2 --- /dev/null +++ b/api/config/packages/translation.yaml @@ -0,0 +1,5 @@ +framework: + default_locale: en + translator: + default_path: '%kernel.project_dir%/translations' + providers: diff --git a/api/config/reference.php b/api/config/reference.php index 5fe47b7..39c9654 100644 --- a/api/config/reference.php +++ b/api/config/reference.php @@ -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, * logging?: bool, // Default: false * formatter?: scalar|null, // Default: "translator.formatter.default" diff --git a/api/migrations/Version20251224115331.php b/api/migrations/Version20251224115331.php new file mode 100644 index 0000000..7ebe0e9 --- /dev/null +++ b/api/migrations/Version20251224115331.php @@ -0,0 +1,24 @@ +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)'); + } +} diff --git a/api/public/.htaccess b/api/public/.htaccess new file mode 100644 index 0000000..82fe815 --- /dev/null +++ b/api/public/.htaccess @@ -0,0 +1,11 @@ + + RewriteEngine On + + RewriteCond %{REQUEST_FILENAME} -f + RewriteRule ^ - [L] + + RewriteCond %{REQUEST_FILENAME} -d + RewriteRule ^ - [L] + + RewriteRule ^ index.php [L] + diff --git a/api/src/Controller/I18nController.php b/api/src/Controller/I18nController.php new file mode 100644 index 0000000..7b0758a --- /dev/null +++ b/api/src/Controller/I18nController.php @@ -0,0 +1,55 @@ + '[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; + } +} diff --git a/api/src/Entity/Category.php b/api/src/Entity/Forum.php similarity index 52% rename from api/src/Entity/Category.php rename to api/src/Entity/Forum.php index 3b54e69..2ed047c 100644 --- a/api/src/Entity/Category.php +++ b/api/src/Entity/Forum.php @@ -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 + */ + 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; + } } diff --git a/api/src/Entity/Thread.php b/api/src/Entity/Thread.php index a5d0902..cd8cc4d 100644 --- a/api/src/Entity/Thread.php +++ b/api/src/Entity/Thread.php @@ -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; } diff --git a/api/src/Entity/User.php b/api/src/Entity/User.php index 59e5bc1..071d570 100644 --- a/api/src/Entity/User.php +++ b/api/src/Entity/User.php @@ -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']] diff --git a/api/symfony.lock b/api/symfony.lock index 90da4b4..5f6bc8d 100644 --- a/api/symfony.lock +++ b/api/symfony.lock @@ -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": { diff --git a/api/translations/.gitignore b/api/translations/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/api/translations/messages.de.po b/api/translations/messages.de.po new file mode 100644 index 0000000..d0fc0b3 --- /dev/null +++ b/api/translations/messages.de.po @@ -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" diff --git a/api/translations/messages.en.po b/api/translations/messages.en.po new file mode 100644 index 0000000..1f29c0a --- /dev/null +++ b/api/translations/messages.en.po @@ -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" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 936c05a..f27f018 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index fec0f22..4d6e6ab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a87190c..d283cdd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( - speedBB + {t('app.brand')} @@ -46,19 +61,21 @@ function Navigation() { } function AppShell() { + const { t } = useTranslation() + return (
} /> - } /> + } /> } /> } /> } />
- speedBB forum. Powered by API Platform and React-Bootstrap. + {t('footer.copy')}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 3cef76a..6173c28 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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}`, }), }) } diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js new file mode 100644 index 0000000..48bb622 --- /dev/null +++ b/frontend/src/i18n.js @@ -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 diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index a08ba09..2ef6b29 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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( diff --git a/frontend/src/pages/CategoryView.jsx b/frontend/src/pages/CategoryView.jsx deleted file mode 100644 index 39f4406..0000000 --- a/frontend/src/pages/CategoryView.jsx +++ /dev/null @@ -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 ( - - {loading &&

Loading category...

} - {error &&

{error}

} - {category && ( - <> -
-

Category

-

{category.name}

-

- {category.description || 'No description added yet.'} -

-
- - - -

Threads

- {threads.length === 0 && ( -

No threads here yet. Start one below.

- )} - {threads.map((thread) => ( - - - {thread.title} - - {thread.body.length > 160 ? `${thread.body.slice(0, 160)}...` : thread.body} - - - View thread - - - - ))} - - -

Start a thread

-
- {!token && ( -

Log in to create a new thread.

- )} -
- - Title - setTitle(event.target.value)} - disabled={!token || saving} - required - /> - - - Body - setBody(event.target.value)} - disabled={!token || saving} - required - /> - - -
-
- -
- - )} -
- ) -} diff --git a/frontend/src/pages/ForumView.jsx b/frontend/src/pages/ForumView.jsx new file mode 100644 index 0000000..3bec306 --- /dev/null +++ b/frontend/src/pages/ForumView.jsx @@ -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 ( + + {loading &&

{t('forum.loading')}

} + {error &&

{error}

} + {forum && ( + <> +
+

+ {forum.type === 'forum' ? t('forum.type_forum') : t('forum.type_category')} +

+

{forum.name}

+

+ {forum.description || t('forum.no_description')} +

+
+ + + +

{t('forum.children')}

+ {children.length === 0 && ( +

{t('forum.empty_children')}

+ )} + {children.map((child) => ( + + + {child.name} + + {child.description || t('forum.no_description')} + + + {t('forum.open')} + + + + ))} + + {forum.type === 'forum' && ( + <> +

{t('forum.threads')}

+ {threads.length === 0 && ( +

{t('forum.empty_threads')}

+ )} + {threads.map((thread) => ( + + + {thread.title} + + {thread.body.length > 160 + ? `${thread.body.slice(0, 160)}...` + : thread.body} + + + {t('thread.view')} + + + + ))} + + )} + + +

{t('forum.start_thread')}

+
+ {forum.type !== 'forum' && ( +

{t('forum.only_forums')}

+ )} + {forum.type === 'forum' && !token && ( +

{t('forum.login_hint')}

+ )} +
+ + {t('form.title')} + setTitle(event.target.value)} + disabled={!token || saving || forum.type !== 'forum'} + required + /> + + + {t('form.body')} + setBody(event.target.value)} + disabled={!token || saving || forum.type !== 'forum'} + required + /> + + +
+
+ +
+ + )} +
+ ) +} diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 0b38dd9..90ea2a1 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -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 (
-

speedBB

-

Forum categories

+

{t('app.brand')}

+

{t('home.hero_title')}

- Explore conversations, ask questions, and share ideas. Start in a category - that matches your topic. + {t('home.hero_body')}

-

Browse categories

- {loading &&

Loading categories...

} +

{t('home.browse')}

+ {loading &&

{t('home.loading')}

} {error &&

{error}

} - {!loading && categories.length === 0 && ( -

No categories yet. Create the first one in the API.

+ {!loading && forums.length === 0 && ( +

{t('home.empty')}

)} - {categories.map((category) => ( - + {forums.map((forum) => ( + - {category.name} + {forum.name} - {category.description || 'No description yet.'} + {forum.description || t('forum.no_description')} - - Open category + + {t('forum.open')} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index feadc0c..51c3058 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -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() { - Log in - - Access your account to start new threads and reply. - + {t('auth.login_title')} + {t('auth.login_hint')} {error &&

{error}

}
- Email + {t('form.email')} - Password + {t('form.password')}
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 31b69ae..e6ce4d3 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -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() { - Create account - - Register with an email and a unique username. - + {t('auth.register_title')} + {t('auth.register_hint')} {error &&

{error}

}
- Email + {t('form.email')} - Username + {t('form.username')} - Password + {t('form.password')}
diff --git a/frontend/src/pages/ThreadView.jsx b/frontend/src/pages/ThreadView.jsx index 76549bc..147636c 100644 --- a/frontend/src/pages/ThreadView.jsx +++ b/frontend/src/pages/ThreadView.jsx @@ -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 ( - {loading &&

Loading thread...

} + {loading &&

{t('thread.loading')}

} {error &&

{error}

} {thread && ( <>
-

Thread

+

{t('thread.label')}

{thread.title}

{thread.body}

- {thread.category && ( + {thread.forum && (

- Category:{' '} - - {thread.category.name || 'Back to category'} + {t('thread.category')}{' '} + + {thread.forum.name || t('thread.back_to_category')}

)} @@ -63,32 +65,34 @@ export default function ThreadView() { -

Replies

- {posts.length === 0 &&

Be the first to reply.

} +

{t('thread.replies')}

+ {posts.length === 0 && ( +

{t('thread.empty')}

+ )} {posts.map((post) => ( {post.body} - {post.author?.username || 'Anonymous'} + {post.author?.username || t('thread.anonymous')} ))} -

Reply

+

{t('thread.reply')}

{!token && ( -

Log in to reply to this thread.

+

{t('thread.login_hint')}

)}
- Message + {t('form.message')} setBody(event.target.value)} disabled={!token || saving} @@ -96,7 +100,7 @@ export default function ThreadView() { />