diff --git a/.env b/.env index 3e68a82..85be481 100644 --- a/.env +++ b/.env @@ -55,3 +55,7 @@ OPENAI_API_KEY=!ChangeMe! # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###< symfony/messenger ### + +###> symfony/line-notify-notifier ### +# LINE_NOTIFY_DSN=linenotify://TOKEN@default +###< symfony/line-notify-notifier ### diff --git a/.idea/app-sf.iml b/.idea/app-sf.iml index 1397076..5c192a4 100644 --- a/.idea/app-sf.iml +++ b/.idea/app-sf.iml @@ -194,6 +194,7 @@ + diff --git a/.idea/php.xml b/.idea/php.xml index 13fd26b..12b6459 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -214,6 +214,7 @@ + diff --git a/assets/styles/app.scss b/assets/styles/app.scss index dbf1e79..94c0a5c 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -30,6 +30,36 @@ html { font-size: 14px; } +.app-footer { + @extend .mt-5; + + &__links { + display: inline; + list-style: none; + padding: 0; + margin: 0; + + li { + display: inline; + } + + li:not(:last-child)::after { + content: "|"; + } + + li > a { + color: inherit; + text-decoration: none; + } + + li > a:hover { + span { + text-decoration: underline; + } + } + } +} + // utils .v-center { height: 100vh; diff --git a/composer.json b/composer.json index 42e62ac..a4e1278 100644 --- a/composer.json +++ b/composer.json @@ -41,11 +41,13 @@ "symfony/framework-bundle": "7.2.*", "symfony/http-client": "7.2.*", "symfony/intl": "7.2.*", + "symfony/line-notify-notifier": "7.2.*", "symfony/lock": "7.2.*", "symfony/mailer": "7.2.*", "symfony/messenger": "7.2.*", "symfony/mime": "7.2.*", "symfony/monolog-bundle": "^3.0", + "symfony/notifier": "7.2.*", "symfony/password-hasher": "7.2.*", "symfony/process": "7.2.*", "symfony/runtime": "7.2.*", diff --git a/composer.lock b/composer.lock index 5177e06..e5c425b 100644 --- a/composer.lock +++ b/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": "2b3fb4708f599b2570d89c0e421ce016", + "content-hash": "7f33ef08e509d9be4156a8737776f09f", "packages": [ { "name": "composer/semver", @@ -5222,6 +5222,77 @@ ], "time": "2024-09-27T08:52:18+00:00" }, + { + "name": "symfony/line-notify-notifier", + "version": "7.2.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/line-notify-notifier.git", + "reference": "53382886f0d05eef8285fdbff32d98f239809ee8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/line-notify-notifier/zipball/53382886f0d05eef8285fdbff32d98f239809ee8", + "reference": "53382886f0d05eef8285fdbff32d98f239809ee8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/http-client": "^6.4|^7.0", + "symfony/notifier": "^7.2" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0" + }, + "type": "symfony-notifier-bridge", + "autoload": { + "psr-4": { + "Symfony\\Component\\Notifier\\Bridge\\LineNotify\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Akira Kurozumi", + "email": "info@a-zumi.net" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides LINE Notify integration for Symfony Notifier.", + "homepage": "https://symfony.com", + "keywords": [ + "chat", + "line", + "notifier" + ], + "support": { + "source": "https://github.com/symfony/line-notify-notifier/tree/7.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-27T08:38:14+00:00" + }, { "name": "symfony/lock", "version": "7.2.x-dev", @@ -5711,6 +5782,84 @@ ], "time": "2024-08-06T10:03:39+00:00" }, + { + "name": "symfony/notifier", + "version": "7.2.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/notifier.git", + "reference": "b26a9ea112767f339130b7fbf28a6bf437bc3838" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/notifier/zipball/b26a9ea112767f339130b7fbf28a6bf437bc3838", + "reference": "b26a9ea112767f339130b7fbf28a6bf437bc3838", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/event-dispatcher-contracts": "^2.5|^3", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Notifier\\": "" + }, + "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": "Sends notifications via one or more channels (email, SMS, ...)", + "homepage": "https://symfony.com", + "keywords": [ + "notification", + "notifier" + ], + "support": { + "source": "https://github.com/symfony/notifier/tree/7.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-27T08:38:14+00:00" + }, { "name": "symfony/options-resolver", "version": "7.2.x-dev", diff --git a/config/packages/notifier.yaml b/config/packages/notifier.yaml new file mode 100644 index 0000000..8d4eff7 --- /dev/null +++ b/config/packages/notifier.yaml @@ -0,0 +1,12 @@ +framework: + notifier: + chatter_transports: + linenotify: "%env(LINE_NOTIFY_DSN)%" + texter_transports: + channel_policy: + urgent: ["chat/linenotify"] + high: ["chat/linenotify"] + medium: ["chat/linenotify"] + low: ["chat/linenotify"] + admin_recipients: + - { email: dbplay@pan93.com } diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b89bf28..1feba76 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -39,6 +39,11 @@ security: # login page - { route: app_login, roles: PUBLIC_ACCESS } + # feedback page + # Note that we provide the feedback form in login page, + # so we need to allow public access to this page. + - { route: app_feedback, roles: PUBLIC_ACCESS } + # admin - { path: ^/admin, roles: ROLE_ADMIN } diff --git a/migrations/Version20241006071505.php b/migrations/Version20241006071505.php new file mode 100644 index 0000000..a4854bc --- /dev/null +++ b/migrations/Version20241006071505.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE feedback (id UUID NOT NULL, sender_id INT DEFAULT NULL, title TEXT NOT NULL, description TEXT NOT NULL, type VARCHAR(255) NOT NULL, metadata JSON NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, contact TEXT DEFAULT NULL, status VARCHAR(255) NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_D2294458F624B39D ON feedback (sender_id)'); + $this->addSql('CREATE INDEX IDX_D22944588CDE5729 ON feedback (type)'); + $this->addSql('CREATE INDEX IDX_D2294458F624B39D8CDE5729 ON feedback (sender_id, type)'); + $this->addSql('CREATE INDEX IDX_D22944587B00651C ON feedback (status)'); + $this->addSql('COMMENT ON COLUMN feedback.id IS \'(DC2Type:ulid)\''); + $this->addSql('COMMENT ON COLUMN feedback.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN feedback.updated_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE feedback ADD CONSTRAINT FK_D2294458F624B39D FOREIGN KEY (sender_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE feedback DROP CONSTRAINT FK_D2294458F624B39D'); + $this->addSql('DROP TABLE feedback'); + } +} diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index c6c8eb1..df4afb8 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -6,6 +6,7 @@ use App\Entity\Comment; use App\Entity\CommentLikeEvent; +use App\Entity\Feedback; use App\Entity\Group; use App\Entity\HintOpenEvent; use App\Entity\LoginEvent; @@ -56,5 +57,8 @@ public function configureMenuItems(): iterable yield MenuItem::linkToCrud('SolutionVideoEvent', 'fa fa-video', SolutionVideoEvent::class); yield MenuItem::linkToCrud('HintOpenEvent', 'fa fa-lightbulb', HintOpenEvent::class); yield MenuItem::linkToCrud('LoginEvent', 'fa fa-sign-in', LoginEvent::class); + + yield MenuItem::section('Feedback'); + yield MenuItem::linkToCrud('Feedback', 'fa fa-comments', Feedback::class); } } diff --git a/src/Controller/Admin/FeedbackCrudController.php b/src/Controller/Admin/FeedbackCrudController.php new file mode 100644 index 0000000..8733ba1 --- /dev/null +++ b/src/Controller/Admin/FeedbackCrudController.php @@ -0,0 +1,119 @@ +setDisabled(), + AssociationField::new('sender')->hideWhenUpdating(), + TextField::new('title')->hideWhenUpdating(), + TextEditorField::new('description')->hideWhenUpdating(), + ChoiceField::new('type')->hideWhenUpdating(), + TextField::new('contact')->hideWhenUpdating(), + ArrayField::new('metadata')->hideWhenUpdating(), + ChoiceField::new('status'), + DateTimeField::new('createdAt', 'Created at')->setDisabled(), + DateTimeField::new('updatedAt', 'Updated at')->setDisabled(), + ]; + } + + public function configureFilters(Filters $filters): Filters + { + return $filters->add('sender')->add('type')->add('status'); + } + + public function configureActions(Actions $actions): Actions + { + $actions->add(Crud::PAGE_INDEX, Action::DETAIL); + $actions->addBatchAction( + Action::new('mark_resolved', icon: 'fa fa-check') + ->linkToUrl( + $this + ->adminUrlGenerator + ->unsetAll() + ->setController(self::class) + ->setAction('markStatus') + ->set('status', FeedbackStatus::Resolved->value) + ->generateUrl() + ) + ); + $actions->addBatchAction( + Action::new('mark_closed', icon: 'fa fa-xmark') + ->linkToUrl( + $this + ->adminUrlGenerator + ->unsetAll() + ->setController(self::class) + ->setAction('markStatus') + ->set('status', FeedbackStatus::Closed->value) + ->generateUrl() + ) + ); + + return $actions; + } + + public function markStatus( + BatchActionDto $batchActionDto, + EntityManagerInterface $entityManager, + #[MapQueryParameter] FeedbackStatus $status, + ): Response { + $repository = $entityManager->getRepository(Feedback::class); + + foreach ($batchActionDto->getEntityIds() as $entityId) { + $feedback = $repository->find($entityId); + if (null === $feedback) { + continue; + } + + $feedback->setStatus($status); + $entityManager->persist($feedback); + } + + $entityManager->flush(); + $this->addFlash('success', t('feedback.marked', ['%status%' => $status])); + + return $this->redirect($batchActionDto->getReferrerUrl()); + } + + public function configureCrud(Crud $crud): Crud + { + return $crud->setDefaultSort(['createdAt' => 'DESC']); + } +} diff --git a/src/Controller/Admin/QuestionCrudController.php b/src/Controller/Admin/QuestionCrudController.php index 5cd41af..7f742a5 100644 --- a/src/Controller/Admin/QuestionCrudController.php +++ b/src/Controller/Admin/QuestionCrudController.php @@ -61,9 +61,6 @@ public function reindex( $questionRepository->reindex($searchService); $this->addFlash('success', t('questions.reindex.success')); - return $this->redirect($adminUrlGenerator - ->setController(self::class) - ->setAction(Action::INDEX) - ->generateUrl()); + return $this->redirect($adminUrlGenerator->setAction(Crud::PAGE_INDEX)->generateUrl()); } } diff --git a/src/Controller/FeedbackController.php b/src/Controller/FeedbackController.php new file mode 100644 index 0000000..9a8b7b5 --- /dev/null +++ b/src/Controller/FeedbackController.php @@ -0,0 +1,58 @@ +setSender($user) + ->setMetadata([ + 'url' => $url, + ]); + + $form = $this->createForm(FeedbackFormType::class, $feedback); + $form = $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // add more metadata that does not affect by requests (e.g. user agent) + $feedback->setMetadata(array_merge( + $feedback->getMetadata(), + [ + 'user_agent' => $request->headers->get('User-Agent'), + 'user' => $user?->getUserIdentifier(), + ], + )); + + $entityManager->persist($feedback); + $entityManager->flush(); + + return $this->render('feedback/index.html.twig', [ + 'form' => null, + ]); + } + + return $this->render('feedback/index.html.twig', [ + 'form' => $form, + ]); + } +} diff --git a/src/Entity/BaseEvent.php b/src/Entity/BaseEvent.php index c39c91c..fc2e64d 100644 --- a/src/Entity/BaseEvent.php +++ b/src/Entity/BaseEvent.php @@ -6,6 +6,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator; +use Symfony\Bridge\Doctrine\Types\UlidType; use Symfony\Component\Uid\Ulid; #[ORM\MappedSuperclass] @@ -15,7 +16,7 @@ abstract class BaseEvent #[ORM\Id] #[ORM\CustomIdGenerator(class: UlidGenerator::class)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] - #[ORM\Column(type: 'ulid', unique: true)] + #[ORM\Column(type: UlidType::NAME, unique: true)] protected ?Ulid $id = null; #[ORM\Column] diff --git a/src/Entity/Feedback.php b/src/Entity/Feedback.php new file mode 100644 index 0000000..d347980 --- /dev/null +++ b/src/Entity/Feedback.php @@ -0,0 +1,194 @@ + $metadata the metadata for the feedback + */ + #[ORM\Column(type: 'json')] + private array $metadata = []; + + #[ORM\Column] + private \DateTimeImmutable $createdAt; + + #[ORM\ManyToOne(inversedBy: 'feedback')] + #[ORM\JoinColumn(nullable: true)] + private ?User $sender; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $contact = null; + + #[ORM\Column(enumType: FeedbackStatus::class)] + private FeedbackStatus $status = FeedbackStatus::New; + + #[ORM\Column] + private \DateTimeImmutable $updated_at; + + public function getId(): ?Ulid + { + return $this->id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $description): static + { + $this->description = $description; + + return $this; + } + + public function getType(): ?FeedbackType + { + return $this->type; + } + + public function setType(FeedbackType $type): static + { + $this->type = $type; + + return $this; + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * @param array $metadata + * + * @return $this + */ + public function setMetadata(array $metadata): static + { + $this->metadata = $metadata; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getSender(): ?User + { + return $this->sender; + } + + public function setSender(?User $sender): static + { + $this->sender = $sender; + + return $this; + } + + public function getContact(): ?string + { + return $this->contact; + } + + public function setContact(?string $contact): static + { + $this->contact = $contact; + + return $this; + } + + public function getStatus(): FeedbackStatus + { + return $this->status; + } + + public function setStatus(FeedbackStatus $status): static + { + $this->status = $status; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updated_at; + } + + public function setUpdatedAt(\DateTimeImmutable $updated_at): static + { + $this->updated_at = $updated_at; + + return $this; + } + + #[ORM\PrePersist] + public function updateCreatedAtValue(): void + { + $this->createdAt = new \DateTimeImmutable(); + $this->updateUpdatedAtValue(); + } + + #[ORM\PreUpdate] + public function updateUpdatedAtValue(): void + { + $this->updated_at = new \DateTimeImmutable(); + } +} diff --git a/src/Entity/FeedbackStatus.php b/src/Entity/FeedbackStatus.php new file mode 100644 index 0000000..d6d6bbd --- /dev/null +++ b/src/Entity/FeedbackStatus.php @@ -0,0 +1,28 @@ + $translator->trans('feedback.status.backlog', locale: $locale), + self::New => $translator->trans('feedback.status.new', locale: $locale), + self::InProgress => $translator->trans('feedback.status.in_progress', locale: $locale), + self::Resolved => $translator->trans('feedback.status.resolved', locale: $locale), + self::Closed => $translator->trans('feedback.status.closed', locale: $locale), + }; + } +} diff --git a/src/Entity/FeedbackType.php b/src/Entity/FeedbackType.php new file mode 100644 index 0000000..d1fa85a --- /dev/null +++ b/src/Entity/FeedbackType.php @@ -0,0 +1,24 @@ + $translator->trans('feedback.type.bugs', locale: $locale), + self::Improvements => $translator->trans('feedback.type.improvements', locale: $locale), + self::Others => $translator->trans('feedback.type.others', locale: $locale), + }; + } +} diff --git a/src/Entity/Form/MetadataDto.php b/src/Entity/Form/MetadataDto.php new file mode 100644 index 0000000..6178ee8 --- /dev/null +++ b/src/Entity/Form/MetadataDto.php @@ -0,0 +1,33 @@ + + */ + public array $metadata; + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * @param array $metadata + * + * @return $this + */ + public function setMetadata(array $metadata): self + { + $this->metadata = $metadata; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index eb705a8..3d2c1e6 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -84,6 +84,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\OneToMany(targetEntity: LoginEvent::class, mappedBy: 'account', orphanRemoval: true)] private Collection $loginEvents; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Feedback::class, mappedBy: 'sender')] + private Collection $feedback; + public function __construct() { $this->solutionEvents = new ArrayCollection(); @@ -92,6 +98,7 @@ public function __construct() $this->commentLikeEvents = new ArrayCollection(); $this->hintOpenEvents = new ArrayCollection(); $this->loginEvents = new ArrayCollection(); + $this->feedback = new ArrayCollection(); } public function getId(): ?int @@ -382,4 +389,34 @@ public function removeLoginEvent(LoginEvent $loginEvent): static return $this; } + + /** + * @return Collection + */ + public function getFeedback(): Collection + { + return $this->feedback; + } + + public function addFeedback(Feedback $feedback): static + { + if (!$this->feedback->contains($feedback)) { + $this->feedback->add($feedback); + $feedback->setSender($this); + } + + return $this; + } + + public function removeFeedback(Feedback $feedback): static + { + if ($this->feedback->removeElement($feedback)) { + // set the owning side to null + if ($feedback->getSender() === $this) { + $feedback->setSender(null); + } + } + + return $this; + } } diff --git a/src/EventSubscriber/FeedbackCreatedListenerSubscriber.php b/src/EventSubscriber/FeedbackCreatedListenerSubscriber.php new file mode 100644 index 0000000..b39f72a --- /dev/null +++ b/src/EventSubscriber/FeedbackCreatedListenerSubscriber.php @@ -0,0 +1,45 @@ +translator->trans('notification.on-feedback-created.content', [ + '%id%' => $feedback->getId(), + '%account%' => $feedback->getSender()?->getUserIdentifier() + ?? $this->translator->trans('notification.on-feedback-created.anonymous'), + '%subject%' => $feedback->getTitle(), + '%link%' => $this->adminUrlGenerator->unsetAll() + ->setController(FeedbackCrudController::class) + ->setAction(Action::DETAIL) + ->setEntityId($feedback->getId()) + ->generateUrl(), + ]); + + $this->notifier->send((new Notification($notificationContent))->channels(['chat/linenotify'])); + } +} diff --git a/src/Form/FeedbackFormType.php b/src/Form/FeedbackFormType.php new file mode 100644 index 0000000..2311471 --- /dev/null +++ b/src/Form/FeedbackFormType.php @@ -0,0 +1,67 @@ +add('sender', TextType::class, [ + 'label' => t('feedback.form.account'), + 'disabled' => true, + ]) + ->add('type', EnumType::class, [ + 'class' => FeedbackType::class, + 'label' => t('feedback.form.type'), + ]) + ->add('title', TextType::class, [ + 'label' => t('feedback.form.subject'), + ]) + ->add('description', TextEditorType::class, [ + 'label' => t('feedback.form.description'), + 'help' => t('feedback.form.description_help'), + 'help_html' => true, + ]) + ->add('contact', TextType::class, [ + 'label' => t('feedback.form.contact'), + 'help' => t('feedback.form.contact_help'), + 'required' => false, + ]) + ->add('metadata', HiddenType::class) + ->add('submit', SubmitType::class, [ + 'label' => t('feedback.form.submit'), + ]) + ; + + $builder->get('metadata') + ->addModelTransformer($this->metadataModelTransformer); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Feedback::class, + ]); + } +} diff --git a/src/Form/FeedbackMetadataModelTransformer.php b/src/Form/FeedbackMetadataModelTransformer.php new file mode 100644 index 0000000..0523b35 --- /dev/null +++ b/src/Form/FeedbackMetadataModelTransformer.php @@ -0,0 +1,40 @@ +, string> + */ +final readonly class FeedbackMetadataModelTransformer implements DataTransformerInterface +{ + public function __construct( + private SerializerInterface $serializer, + ) { + } + + public function transform(mixed $value): string + { + \assert(\is_array($value)); + + return $this->serializer->serialize( + (new MetadataDto())->setMetadata($value), + 'json' + ); + } + + /** + * @return array + */ + public function reverseTransform(mixed $value): array + { + $deserialized = $this->serializer->deserialize($value, MetadataDto::class, 'json'); + + return $deserialized->getMetadata(); + } +} diff --git a/src/Repository/FeedbackRepository.php b/src/Repository/FeedbackRepository.php new file mode 100644 index 0000000..26035c9 --- /dev/null +++ b/src/Repository/FeedbackRepository.php @@ -0,0 +1,20 @@ + + */ +class FeedbackRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Feedback::class); + } +} diff --git a/symfony.lock b/symfony.lock index b2f4716..5e6dd95 100644 --- a/symfony.lock +++ b/symfony.lock @@ -149,6 +149,15 @@ "src/Kernel.php" ] }, + "symfony/line-notify-notifier": { + "version": "7.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "217c816e9079cbdf633b482ca341295be9367cc4" + } + }, "symfony/lock": { "version": "7.2", "recipe": { @@ -206,6 +215,18 @@ "config/packages/monolog.yaml" ] }, + "symfony/notifier": { + "version": "7.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.0", + "ref": "178877daf79d2dbd62129dd03612cb1a2cb407cc" + }, + "files": [ + "config/packages/notifier.yaml" + ] + }, "symfony/phpunit-bridge": { "version": "7.1", "recipe": { diff --git a/templates/app.html.twig b/templates/app.html.twig index 27fe9ec..02c4133 100644 --- a/templates/app.html.twig +++ b/templates/app.html.twig @@ -1,17 +1,21 @@ {% extends 'base.html.twig' %} {% block body %} - {% block header %} -
- {% block nav %} - - {% endblock %} -
- {% endblock %} +
+ {% block header %} +
+ {% block nav %} + + {% endblock %} +
+ {% endblock %} -
- {% block app %} +
+ {% block app %} - {% endblock %} + {% endblock %} +
+ +
{% endblock %} diff --git a/templates/complementary/index.html.twig b/templates/complementary/index.html.twig index 80415c3..60d0b95 100644 --- a/templates/complementary/index.html.twig +++ b/templates/complementary/index.html.twig @@ -42,5 +42,4 @@
- {% endblock %} diff --git a/templates/components/AppFooter.html.twig b/templates/components/AppFooter.html.twig new file mode 100644 index 0000000..8a64333 --- /dev/null +++ b/templates/components/AppFooter.html.twig @@ -0,0 +1,21 @@ + +
+
+ © 2024 資料庫練功房 +
+ +
+ diff --git a/templates/components/Navbar.html.twig b/templates/components/Navbar.html.twig index d11a03a..92168e6 100644 --- a/templates/components/Navbar.html.twig +++ b/templates/components/Navbar.html.twig @@ -49,6 +49,7 @@ {% endfor %} + {% if app.user %}
+ {% endif %} diff --git a/templates/feedback/index.html.twig b/templates/feedback/index.html.twig new file mode 100644 index 0000000..3631ff2 --- /dev/null +++ b/templates/feedback/index.html.twig @@ -0,0 +1,31 @@ +{% extends 'app.html.twig' %} + +{% block nav %}{% endblock %} +{% block title %}留言一覽{% endblock %} + +{% block app %} +
+

意見回饋

+ +

感謝您提出的意見回饋!請填寫下方的意見回饋表單,我們會在 24 小時內回覆您。

+

注意這份表單已經附上您目前的瀏覽器及環境資訊、回報的連結和使用者資訊,不過您也可以補充上其他您覺得有必要的資訊。

+ + + {% if form is not null %} + {{ form(form) }} + + + {% else %} + + {% endif %} + +
+{% endblock %} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index 22bd60b..db6ce09 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -59,7 +59,9 @@ diff --git a/translations/messages.zh_TW.yaml b/translations/messages.zh_TW.yaml index 56fd815..9b1fe31 100644 --- a/translations/messages.zh_TW.yaml +++ b/translations/messages.zh_TW.yaml @@ -46,6 +46,12 @@ HintOpenEvent: 提示打開事件 Response: 回應 LoginEvent: 登入事件 Account: 帳號 +Feedback: 回饋 +Sender: 寄件者 +Contact: 聯絡方式 +Metadata: 中繼資料 +Mark Resolved: 標記為已解決 +Mark Closed: 標記為已關閉 challenge.error-type.user: 輸入錯誤 challenge.error-type.server: 伺服器錯誤 @@ -93,3 +99,46 @@ charts: instruction: hint: no_hint: 沒有提示。 + +feedback: + type: + bugs: 錯誤報告 + improvements: 功能建議 + others: 其他意見 + + form: + account: 意見回饋帳號 + subject: 主旨 + description: 詳細說明 + type: 回饋類型 + description_help: | +

「詳細說明」應該包括:

+
    +
  1. 問題的清楚描述,比如「錯誤的 SQL 語法會導致伺服器錯誤」。
  2. +
  3. 如何重現問題,比如「在答案欄輸入 'SELECT * FROM' 後按下提交」。
  4. +
  5. 預期的行為,比如「應該顯示『語法錯誤』」。
  6. +
  7. 實際的行為,比如「顯示『伺服器錯誤』」。
  8. +
+

「重現問題」最好包括螢幕截圖和螢幕錄影。你可以上傳到雲端空間,然後將連結複製給我們。

+ contact: 聯絡方式 + contact_help: | + 如果有其他需要確認,或者是你想要收到問題的回應進度,請留下你的聯絡方式。 + 如果不填寫,則使用你帳號的電子信箱進行回覆(如果沒有登入則不會回覆)。 + submit: 送出回饋 + + status: + backlog: 待處理 + new: 新問題 + in_progress: 處理中 + resolved: 已解決 + closed: 已關閉 + + marked: 已經將選擇的回饋標記為「%status%」。 + +notification: + on-feedback-created: + content: | + 使用者 %account% 提交了一份意見回饋,主旨是「%subject%」。 + 閱讀意見回饋 → %link% + + anonymous: <匿名>