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:
@@ -429,6 +429,23 @@ table.dataTable {
|
||||
}
|
||||
.storyline-pager-title { font-weight: 600; }
|
||||
|
||||
/* Chapter notes: collapsible card above the chapter body */
|
||||
.storyline-notes > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.storyline-notes > summary::-webkit-details-marker { display: none; }
|
||||
.storyline-notes > summary::before {
|
||||
content: "\25B8"; /* right-pointing triangle */
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.storyline-notes[open] > summary::before { transform: rotate(90deg); }
|
||||
.storyline-notes textarea { font-size: 0.95rem; line-height: 1.6; }
|
||||
|
||||
/* Chapter list: kicker line above each title */
|
||||
.storyline-chapter-kicker {
|
||||
display: block;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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 Version20260908164955 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return "Add chapter_note table for the DM's per-chapter storyline notes";
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ENGINE = InnoDB is pinned explicitly: the local WAMP MariaDB defaults
|
||||
// to MyISAM, which would silently drop the unique index behaviour.
|
||||
$this->addSql('CREATE TABLE chapter_note (id INT AUTO_INCREMENT NOT NULL, slug VARCHAR(120) NOT NULL, notes LONGTEXT DEFAULT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX uniq_chapter_note_slug (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE chapter_note');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,24 @@
|
||||
</nav>
|
||||
|
||||
<div class="col-lg-9 order-lg-1">
|
||||
<details class="card shadow-sm mb-4 storyline-notes" open>
|
||||
<summary class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>📝 Chapter notes</span>
|
||||
{% if note.updatedAt %}
|
||||
<span class="text-body-secondary small fw-normal">saved {{ note.updatedAt|date('Y-m-d H:i') }}</span>
|
||||
{% endif %}
|
||||
</summary>
|
||||
<div class="card-body">
|
||||
<p class="text-body-secondary small mb-2">
|
||||
Your running log for this chapter — what actually happened at the table.
|
||||
</p>
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.notes) }}
|
||||
<button class="btn btn-primary mt-2">Save notes</button>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<article class="storyline-doc">
|
||||
<header class="storyline-doc-head">
|
||||
<div class="storyline-eyebrow">Storyline · {{ chapter.eyebrow }}</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\ChapterNote;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ChapterNoteTest extends TestCase
|
||||
{
|
||||
public function testStartsEmptyForItsSlug(): void
|
||||
{
|
||||
$note = new ChapterNote('prologue-the-long-road');
|
||||
|
||||
self::assertSame('prologue-the-long-road', $note->getSlug());
|
||||
self::assertNull($note->getId());
|
||||
self::assertNull($note->getNotes());
|
||||
self::assertNull($note->getUpdatedAt());
|
||||
}
|
||||
|
||||
public function testTouchStampsUpdatedAt(): void
|
||||
{
|
||||
$note = new ChapterNote('overview');
|
||||
$note->setNotes("The party told Maud the cottage was haunted.");
|
||||
$note->touch();
|
||||
|
||||
self::assertNotNull($note->getUpdatedAt());
|
||||
self::assertSame("The party told Maud the cottage was haunted.", $note->getNotes());
|
||||
}
|
||||
|
||||
public function testNotesCanBeClearedBackToNull(): void
|
||||
{
|
||||
$note = (new ChapterNote('overview'))->setNotes('something');
|
||||
|
||||
$note->setNotes(null);
|
||||
|
||||
self::assertNull($note->getNotes());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user