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

View File

@@ -26,6 +26,7 @@
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/translation": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"

95
api/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7c78a734d525250702b4a298784f81dd",
"content-hash": "678b66a22585eb57ff21a981f084502f",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -6233,6 +6233,99 @@
],
"time": "2025-12-01T09:13:36+00:00"
},
{
"name": "symfony/translation",
"version": "v8.0.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
"reference": "770e3b8b0ba8360958abedcabacd4203467333ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation/zipball/770e3b8b0ba8360958abedcabacd4203467333ca",
"reference": "770e3b8b0ba8360958abedcabacd4203467333ca",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-mbstring": "^1.0",
"symfony/translation-contracts": "^3.6.1"
},
"conflict": {
"nikic/php-parser": "<5.0",
"symfony/http-client-contracts": "<2.5",
"symfony/service-contracts": "<2.5"
},
"provide": {
"symfony/translation-implementation": "2.3|3.0"
},
"require-dev": {
"nikic/php-parser": "^5.0",
"psr/log": "^1|^2|^3",
"symfony/config": "^7.4|^8.0",
"symfony/console": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/finder": "^7.4|^8.0",
"symfony/http-client-contracts": "^2.5|^3.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/intl": "^7.4|^8.0",
"symfony/polyfill-intl-icu": "^1.21",
"symfony/routing": "^7.4|^8.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/yaml": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"files": [
"Resources/functions.php"
],
"psr-4": {
"Symfony\\Component\\Translation\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/translation/tree/v8.0.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-01T09:13:36+00:00"
},
{
"name": "symfony/translation-contracts",
"version": "v3.6.1",

View File

@@ -1,6 +1,9 @@
api_platform:
title: speedBB API
version: 1.0.0
formats:
json: ['application/json']
jsonld: ['application/ld+json']
defaults:
stateless: true
cache_headers:

View File

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

View File

@@ -297,7 +297,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator;
* },
* },
* translator?: bool|array{ // Translator configuration
* enabled?: bool, // Default: false
* enabled?: bool, // Default: true
* fallbacks?: list<scalar|null>,
* logging?: bool, // Default: false
* formatter?: scalar|null, // Default: "translator.formatter.default"

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251224115331 extends AbstractMigration
{
public function getDescription(): string
{
return 'Initial schema (executed previously).';
}
public function up(Schema $schema): void
{
}
public function down(Schema $schema): void
{
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251224120510 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE forum (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL, description LONGTEXT DEFAULT NULL, type VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, parent_id INT DEFAULT NULL, INDEX IDX_852BBECD727ACA70 (parent_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE forum ADD CONSTRAINT FK_852BBECD727ACA70 FOREIGN KEY (parent_id) REFERENCES forum (id)');
$this->addSql('ALTER TABLE thread DROP FOREIGN KEY `FK_31204C8312469DE2`');
$this->addSql('DROP INDEX IDX_31204C8312469DE2 ON thread');
$this->addSql('INSERT INTO forum (id, name, description, type, created_at, updated_at) SELECT id, name, description, \'forum\', created_at, updated_at FROM category');
$this->addSql('ALTER TABLE thread CHANGE category_id forum_id INT NOT NULL');
$this->addSql('ALTER TABLE thread ADD CONSTRAINT FK_31204C8329CCBAD0 FOREIGN KEY (forum_id) REFERENCES forum (id)');
$this->addSql('CREATE INDEX IDX_31204C8329CCBAD0 ON thread (forum_id)');
$this->addSql('DROP TABLE category');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE category (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_general_ci`, description LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB COMMENT = \'\' ');
$this->addSql('ALTER TABLE forum DROP FOREIGN KEY FK_852BBECD727ACA70');
$this->addSql('DROP TABLE forum');
$this->addSql('ALTER TABLE thread DROP FOREIGN KEY FK_31204C8329CCBAD0');
$this->addSql('DROP INDEX IDX_31204C8329CCBAD0 ON thread');
$this->addSql('ALTER TABLE thread CHANGE forum_id category_id INT NOT NULL');
$this->addSql('ALTER TABLE thread ADD CONSTRAINT `FK_31204C8312469DE2` FOREIGN KEY (category_id) REFERENCES category (id)');
$this->addSql('CREATE INDEX IDX_31204C8312469DE2 ON thread (category_id)');
}
}

11
api/public/.htaccess Normal file
View File

@@ -0,0 +1,11 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^ - [L]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^ index.php [L]
</IfModule>

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Controller;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
class I18nController
{
public function __construct(
private TranslatorInterface $translator,
#[Autowire('%kernel.default_locale%')]
private string $defaultLocale
) {
}
#[Route(
'/api/i18n/{locale}',
name: 'api_i18n',
methods: ['GET'],
requirements: ['locale' => '[A-Za-z0-9_-]+']
)]
public function __invoke(string $locale): JsonResponse
{
$messages = $this->getMessagesForLocale($locale);
if (!$messages && $locale !== $this->defaultLocale) {
$messages = $this->getMessagesForLocale($this->defaultLocale);
}
return new JsonResponse($messages);
}
private function getMessagesForLocale(string $locale): array
{
$catalogue = $this->translator->getCatalogue($locale);
$messages = $catalogue->all('messages');
$fallback = $catalogue->getFallbackCatalogue();
while ($fallback) {
foreach ($fallback->all('messages') as $key => $value) {
if (!array_key_exists($key, $messages)) {
$messages[$key] = $value;
}
}
$fallback = $fallback->getFallbackCatalogue();
}
ksort($messages);
return $messages;
}
}

View File

@@ -2,6 +2,9 @@
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -16,48 +19,67 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['parent' => 'exact', 'type' => 'exact'])]
#[ApiFilter(ExistsFilter::class, properties: ['parent'])]
#[ApiResource(
operations : [
normalizationContext: ['groups' => ['forum:read']],
denormalizationContext: ['groups' => ['forum:write']],
operations: [
new Get(),
new GetCollection(),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')")
],
normalizationContext : ['groups' => ['category:read']],
denormalizationContext: ['groups' => ['category:write']]
]
)]
class Category
class Forum
{
public const TYPE_CATEGORY = 'category';
public const TYPE_FORUM = 'forum';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['category:read', 'thread:read'])]
#[Groups(['forum:read', 'thread:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[Assert\NotBlank]
#[Groups(['category:read', 'category:write', 'thread:read'])]
#[Groups(['forum:read', 'forum:write', 'thread:read'])]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['category:read', 'category:write'])]
#[Groups(['forum:read', 'forum:write'])]
private ?string $description = null;
#[ORM\Column(length: 20)]
#[Assert\Choice(choices: [self::TYPE_CATEGORY, self::TYPE_FORUM])]
#[Groups(['forum:read', 'forum:write'])]
private string $type = self::TYPE_CATEGORY;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[Groups(['forum:read', 'forum:write'])]
private ?self $parent = null;
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
#[Groups(['forum:read'])]
private Collection $children;
#[ORM\OneToMany(mappedBy: 'forum', targetEntity: Thread::class)]
#[Groups(['forum:read'])]
private Collection $threads;
#[ORM\Column]
#[Groups(['category:read'])]
#[Groups(['forum:read'])]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column]
#[Groups(['category:read'])]
#[Groups(['forum:read'])]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\OneToMany(mappedBy: 'category', targetEntity: Thread::class)]
#[Groups(['category:read'])]
private Collection $threads;
public function __construct()
{
$this->children = new ArrayCollection();
$this->threads = new ArrayCollection();
}
@@ -104,14 +126,36 @@ class Category
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
public function getType(): string
{
return $this->createdAt;
return $this->type;
}
public function getUpdatedAt(): ?\DateTimeImmutable
public function setType(string $type): self
{
return $this->updatedAt;
$this->type = $type;
return $this;
}
public function getParent(): ?self
{
return $this->parent;
}
public function setParent(?self $parent): self
{
$this->parent = $parent;
return $this;
}
/**
* @return Collection<int, Forum>
*/
public function getChildren(): Collection
{
return $this->children;
}
/**
@@ -121,4 +165,14 @@ class Category
{
return $this->threads;
}
public function isCategory(): bool
{
return $this->type === self::TYPE_CATEGORY;
}
public function isForum(): bool
{
return $this->type === self::TYPE_FORUM;
}
}

View File

@@ -9,7 +9,7 @@ use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Post as PostOperation;
use App\State\ThreadOwnerProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -19,14 +19,14 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['category' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['forum' => 'exact'])]
#[ApiResource(
normalizationContext: ['groups' => ['thread:read']],
denormalizationContext: ['groups' => ['thread:write']],
operations: [
new Get(),
new GetCollection(),
new Post(
new PostOperation(
security: "is_granted('ROLE_USER')",
processor: ThreadOwnerProcessor::class
),
@@ -39,12 +39,12 @@ class Thread
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['thread:read', 'category:read', 'post:read'])]
#[Groups(['thread:read', 'forum:read', 'post:read'])]
private ?int $id = null;
#[ORM\Column(length: 200)]
#[Assert\NotBlank]
#[Groups(['thread:read', 'thread:write', 'category:read', 'post:read'])]
#[Groups(['thread:read', 'thread:write', 'forum:read', 'post:read'])]
private ?string $title = null;
#[ORM\Column(type: 'text')]
@@ -52,11 +52,12 @@ class Thread
#[Groups(['thread:read', 'thread:write'])]
private ?string $body = null;
#[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'threads')]
#[ORM\ManyToOne(targetEntity: Forum::class, inversedBy: 'threads')]
#[ORM\JoinColumn(nullable: false)]
#[Assert\NotNull]
#[Assert\Expression("this.getForum() and this.getForum().isForum()", message: "Thread must belong to a forum.")]
#[Groups(['thread:read', 'thread:write'])]
private ?Category $category = null;
private ?Forum $forum = null;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'threads')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
@@ -123,14 +124,14 @@ class Thread
return $this;
}
public function getCategory(): ?Category
public function getForum(): ?Forum
{
return $this->category;
return $this->forum;
}
public function setCategory(?Category $category): self
public function setForum(?Forum $forum): self
{
$this->category = $category;
$this->forum = $forum;
return $this;
}

View File

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

View File

@@ -144,6 +144,19 @@
"config/routes/security.yaml"
]
},
"symfony/translation": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": {
"version": "8.0",
"recipe": {

0
api/translations/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,166 @@
msgid ""
msgstr ""
"Language: de\n"
"Content-Type: text/plain; charset=UTF-8\n"
msgid "app.brand"
msgstr "speedBB"
msgid "nav.forums"
msgstr "Foren"
msgid "nav.login"
msgstr "Anmelden"
msgid "nav.register"
msgstr "Registrieren"
msgid "nav.logout"
msgstr "Abmelden"
msgid "nav.language"
msgstr "Sprache"
msgid "home.hero_title"
msgstr "Foren"
msgid "home.hero_body"
msgstr "Entdecke Diskussionen, stelle Fragen und teile Ideen in Kategorien und Foren."
msgid "home.browse"
msgstr "Foren durchsuchen"
msgid "home.loading"
msgstr "Foren werden geladen..."
msgid "home.empty"
msgstr "Noch keine Foren vorhanden. Lege das erste Forum in der API an."
msgid "forum.threads"
msgstr "Threads"
msgid "forum.start_thread"
msgstr "Thread starten"
msgid "forum.loading"
msgstr "Forum wird geladen..."
msgid "forum.type_category"
msgstr "Kategorie"
msgid "forum.type_forum"
msgstr "Forum"
msgid "forum.no_description"
msgstr "Noch keine Beschreibung vorhanden."
msgid "forum.empty_threads"
msgstr "Noch keine Threads vorhanden. Starte unten einen."
msgid "forum.login_hint"
msgstr "Melde dich an, um einen neuen Thread zu erstellen."
msgid "forum.open"
msgstr "Forum öffnen"
msgid "forum.children"
msgstr "Unterforen"
msgid "forum.empty_children"
msgstr "Noch keine Unterforen vorhanden."
msgid "forum.only_forums"
msgstr "Threads können nur in Foren erstellt werden."
msgid "thread.replies"
msgstr "Antworten"
msgid "thread.reply"
msgstr "Antworten"
msgid "thread.loading"
msgstr "Thread wird geladen..."
msgid "thread.label"
msgstr "Thread"
msgid "thread.category"
msgstr "Forum:"
msgid "thread.back_to_category"
msgstr "Zurück zum Forum"
msgid "thread.empty"
msgstr "Sei die erste Person, die antwortet."
msgid "thread.anonymous"
msgstr "Anonym"
msgid "thread.login_hint"
msgstr "Melde dich an, um auf diesen Thread zu antworten."
msgid "thread.view"
msgstr "Thread ansehen"
msgid "auth.login_title"
msgstr "Anmelden"
msgid "auth.login_hint"
msgstr "Melde dich an, um neue Threads zu starten und zu antworten."
msgid "auth.register_title"
msgstr "Konto erstellen"
msgid "auth.register_hint"
msgstr "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen."
msgid "footer.copy"
msgstr "speedBB Forum. Powered by API Platform und React-Bootstrap."
msgid "form.title"
msgstr "Titel"
msgid "form.body"
msgstr "Inhalt"
msgid "form.message"
msgstr "Nachricht"
msgid "form.email"
msgstr "E-Mail"
msgid "form.username"
msgstr "Benutzername"
msgid "form.password"
msgstr "Passwort"
msgid "form.thread_title_placeholder"
msgstr "Thema"
msgid "form.thread_body_placeholder"
msgstr "Teile den Kontext und deine Frage."
msgid "form.reply_placeholder"
msgstr "Schreibe deine Antwort."
msgid "form.posting"
msgstr "Wird gesendet..."
msgid "form.create_thread"
msgstr "Thread erstellen"
msgid "form.post_reply"
msgstr "Antwort posten"
msgid "form.signing_in"
msgstr "Anmeldung läuft..."
msgid "form.sign_in"
msgstr "Anmelden"
msgid "form.registering"
msgstr "Registrierung läuft..."
msgid "form.create_account"
msgstr "Konto erstellen"

View File

@@ -0,0 +1,166 @@
msgid ""
msgstr ""
"Language: en\n"
"Content-Type: text/plain; charset=UTF-8\n"
msgid "app.brand"
msgstr "speedBB"
msgid "nav.forums"
msgstr "Forums"
msgid "nav.login"
msgstr "Login"
msgid "nav.register"
msgstr "Register"
msgid "nav.logout"
msgstr "Logout"
msgid "nav.language"
msgstr "Language"
msgid "home.hero_title"
msgstr "Forums"
msgid "home.hero_body"
msgstr "Explore conversations, ask questions, and share ideas across categories and forums."
msgid "home.browse"
msgstr "Browse forums"
msgid "home.loading"
msgstr "Loading forums..."
msgid "home.empty"
msgstr "No forums yet. Create the first one in the API."
msgid "forum.threads"
msgstr "Threads"
msgid "forum.start_thread"
msgstr "Start a thread"
msgid "forum.loading"
msgstr "Loading forum..."
msgid "forum.type_category"
msgstr "Category"
msgid "forum.type_forum"
msgstr "Forum"
msgid "forum.no_description"
msgstr "No description added yet."
msgid "forum.empty_threads"
msgstr "No threads here yet. Start one below."
msgid "forum.login_hint"
msgstr "Log in to create a new thread."
msgid "forum.open"
msgstr "Open forum"
msgid "forum.children"
msgstr "Sub-forums"
msgid "forum.empty_children"
msgstr "No sub-forums yet."
msgid "forum.only_forums"
msgstr "Threads can only be created in forums."
msgid "thread.replies"
msgstr "Replies"
msgid "thread.reply"
msgstr "Reply"
msgid "thread.loading"
msgstr "Loading thread..."
msgid "thread.label"
msgstr "Thread"
msgid "thread.category"
msgstr "Forum:"
msgid "thread.back_to_category"
msgstr "Back to forum"
msgid "thread.empty"
msgstr "Be the first to reply."
msgid "thread.anonymous"
msgstr "Anonymous"
msgid "thread.login_hint"
msgstr "Log in to reply to this thread."
msgid "thread.view"
msgstr "View thread"
msgid "auth.login_title"
msgstr "Log in"
msgid "auth.login_hint"
msgstr "Access your account to start new threads and reply."
msgid "auth.register_title"
msgstr "Create account"
msgid "auth.register_hint"
msgstr "Register with an email and a unique username."
msgid "footer.copy"
msgstr "speedBB forum. Powered by API Platform and React-Bootstrap."
msgid "form.title"
msgstr "Title"
msgid "form.body"
msgstr "Body"
msgid "form.message"
msgstr "Message"
msgid "form.email"
msgstr "Email"
msgid "form.username"
msgstr "Username"
msgid "form.password"
msgstr "Password"
msgid "form.thread_title_placeholder"
msgstr "Topic headline"
msgid "form.thread_body_placeholder"
msgstr "Share the context and your question."
msgid "form.reply_placeholder"
msgstr "Share your reply."
msgid "form.posting"
msgstr "Posting..."
msgid "form.create_thread"
msgstr "Create thread"
msgid "form.post_reply"
msgstr "Post reply"
msgid "form.signing_in"
msgstr "Signing in..."
msgid "form.sign_in"
msgstr "Sign in"
msgid "form.registering"
msgstr "Registering..."
msgid "form.create_account"
msgstr "Create account"