Storyline: per-chapter DM notes

Each chapter page gets a collapsible "Chapter notes" card at the top — a
free-text log the DM fills during play (the story the party tells after
the Crooked Goose, names given to NPCs, threads for next time). Saved
per chapter slug with a "saved <timestamp>" marker, same shape as the
Enemy/NPC game-notes field.

New: ChapterNote entity (slug-keyed, unique) + repository findOrCreate(),
ChapterNoteType form, chapter_note table migration (ENGINE = InnoDB
pinned). The storyline chapter route now also accepts POST to save.

Chapters stay hand-written Twig; only the mutable notes live in the DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-08 18:51:53 +02:00
co-authored by Claude Sonnet 5
parent 56f4641564
commit 5c821bd7ad
8 changed files with 257 additions and 2 deletions
+22 -2
View File
@@ -2,7 +2,11 @@
namespace App\Controller;
use App\Form\ChapterNoteType;
use App\Repository\ChapterNoteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -46,13 +50,27 @@ class StorylineController extends AbstractController
]);
}
#[Route('/storyline/{slug}', name: 'app_storyline_chapter', requirements: ['slug' => '[a-z0-9-]+'], methods: ['GET'])]
public function chapter(string $slug): Response
#[Route('/storyline/{slug}', name: 'app_storyline_chapter', requirements: ['slug' => '[a-z0-9-]+'], methods: ['GET', 'POST'])]
public function chapter(string $slug, Request $request, ChapterNoteRepository $notes, EntityManagerInterface $em): Response
{
if (!isset(self::CHAPTERS[$slug])) {
throw $this->createNotFoundException('No such storyline chapter.');
}
$note = $notes->findOrCreate($slug);
$form = $this->createForm(ChapterNoteType::class, $note);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (null === $note->getId()) {
$em->persist($note);
}
$em->flush();
$this->addFlash('success', 'Chapter notes saved.');
return $this->redirectToRoute('app_storyline_chapter', ['slug' => $slug]);
}
$slugs = array_keys(self::CHAPTERS);
$pos = array_search($slug, $slugs, true);
@@ -60,6 +78,8 @@ class StorylineController extends AbstractController
'chapters' => self::CHAPTERS,
'slug' => $slug,
'chapter' => self::CHAPTERS[$slug],
'note' => $note,
'form' => $form,
'prev' => $pos > 0 ? $slugs[$pos - 1] : null,
'next' => $pos < \count($slugs) - 1 ? $slugs[$pos + 1] : null,
]);
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Entity;
use App\Repository\ChapterNoteRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* The DM's running notes for one storyline chapter. Chapters themselves are
* hand-written Twig (see StorylineController::CHAPTERS); this table only holds
* the mutable play notes, keyed by the chapter slug.
*/
#[ORM\Entity(repositoryClass: ChapterNoteRepository::class)]
#[ORM\Table(name: 'chapter_note')]
#[ORM\UniqueConstraint(name: 'uniq_chapter_note_slug', columns: ['slug'])]
#[ORM\HasLifecycleCallbacks]
class ChapterNote
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 120)]
private string $slug;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $notes = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct(string $slug)
{
$this->slug = $slug;
}
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function touch(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getSlug(): string
{
return $this->slug;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): static
{
$this->notes = $notes;
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Form;
use App\Entity\ChapterNote;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The DM's running log for a storyline chapter.
*/
class ChapterNoteType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('notes', TextareaType::class, [
'required' => false,
'label' => false,
'attr' => [
'rows' => 12,
'placeholder' => "What happened at the table: the story the party told about the cottage, names they gave NPCs, promises made, threads to pick up next time…",
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => ChapterNote::class]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Repository;
use App\Entity\ChapterNote;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ChapterNote>
*/
class ChapterNoteRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ChapterNote::class);
}
/**
* The note row for a chapter, or a fresh unsaved one. The caller persists
* it only if something is actually written.
*/
public function findOrCreate(string $slug): ChapterNote
{
return $this->findOneBy(['slug' => $slug]) ?? new ChapterNote($slug);
}
}