diff --git a/assets/styles/app.css b/assets/styles/app.css index bdeb0dd..f505d64 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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; diff --git a/migrations/Version20260908164955.php b/migrations/Version20260908164955.php new file mode 100644 index 0000000..ec7b676 --- /dev/null +++ b/migrations/Version20260908164955.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/src/Controller/StorylineController.php b/src/Controller/StorylineController.php index 2c01f11..2448a9c 100644 --- a/src/Controller/StorylineController.php +++ b/src/Controller/StorylineController.php @@ -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, ]); diff --git a/src/Entity/ChapterNote.php b/src/Entity/ChapterNote.php new file mode 100644 index 0000000..dfe7876 --- /dev/null +++ b/src/Entity/ChapterNote.php @@ -0,0 +1,72 @@ +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; + } +} diff --git a/src/Form/ChapterNoteType.php b/src/Form/ChapterNoteType.php new file mode 100644 index 0000000..bfd7236 --- /dev/null +++ b/src/Form/ChapterNoteType.php @@ -0,0 +1,32 @@ +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]); + } +} diff --git a/src/Repository/ChapterNoteRepository.php b/src/Repository/ChapterNoteRepository.php new file mode 100644 index 0000000..4af0b69 --- /dev/null +++ b/src/Repository/ChapterNoteRepository.php @@ -0,0 +1,27 @@ + + */ +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); + } +} diff --git a/templates/storyline/chapter.html.twig b/templates/storyline/chapter.html.twig index c99afb4..f522e9c 100644 --- a/templates/storyline/chapter.html.twig +++ b/templates/storyline/chapter.html.twig @@ -22,6 +22,24 @@
+
+ + 📝 Chapter notes + {% if note.updatedAt %} + saved {{ note.updatedAt|date('Y-m-d H:i') }} + {% endif %} + +
+

+ Your running log for this chapter — what actually happened at the table. +

+ {{ form_start(form) }} + {{ form_row(form.notes) }} + + {{ form_end(form) }} +
+
+
Storyline · {{ chapter.eyebrow }}
diff --git a/tests/Entity/ChapterNoteTest.php b/tests/Entity/ChapterNoteTest.php new file mode 100644 index 0000000..928aaad --- /dev/null +++ b/tests/Entity/ChapterNoteTest.php @@ -0,0 +1,38 @@ +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()); + } +}