start compose feature

This commit is contained in:
Paul Lützner committed 2026-07-04 20:29:51 +02:00
1 parent 9218f5e627
commit 678b33e0cb
25 files changed
+914 -21

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ import './stimulus_bootstrap.js';
* which should already be in your base.html.twig.
*/
import './styles/app.css';
import '@fortawesome/fontawesome-free/css/all.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
+197
View File
@@ -0,0 +1,197 @@
// assets/controllers/compose_controller.js
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['ccField', 'bccField', 'body', 'saveStatus', 'toCollection', 'collapsible', 'minimizeIcon', 'expandIcon']
static values = {
draftUrl: String,
autosaveDelay: { type: Number, default: 2000 },
minimized: { type: Boolean, default: false },
expanded: { type: Boolean, default: false },
}
#autosaveTimer = null
connect() {
const toCollection = this.element.querySelector('.compose-to[data-prototype]')
this._ensureEntry(toCollection)
this._boundAutosave = this._scheduleAutosave.bind(this)
this.element.querySelector('form')
?.addEventListener('input', this._boundAutosave)
// Auto-expand on mobile
if (window.innerWidth < 768) {
this.expandedValue = true
}
}
disconnect() {
clearTimeout(this.#autosaveTimer)
this.element.querySelector('form')
?.removeEventListener('input', this._boundAutosave)
// Make sure we restore body scroll if unmounted while expanded
document.body.style.overflow = ''
}
// ── Minimize ──────────────────────────────────────────────────────
toggleMinimize() {
// Can't minimize while expanded — collapse first
if (this.expandedValue) {
this.expandedValue = false
return
}
this.minimizedValue = !this.minimizedValue
}
minimizedValueChanged() {
const minimized = this.minimizedValue
if (this.hasCollapsibleTarget) {
this.collapsibleTarget.style.display = minimized ? 'none' : ''
}
if (this.hasMinimizeIconTarget) {
this.minimizeIconTarget.className = minimized
? 'fa-solid fa-chevron-up text-xs'
: 'fa-solid fa-minus text-xs'
}
// Minimized: fully rounded pill. Normal: rounded top only via rounded-2xl + no bottom radius override.
this.element.classList.toggle('rounded-b-2xl', minimized)
}
// ── Expand / fullscreen ───────────────────────────────────────────
toggleExpand() {
this.expandedValue = !this.expandedValue
}
expandedValueChanged() {
const expanded = this.expandedValue
const el = this.element
if (expanded) {
this.minimizedValue = false
el.style.cssText = `
position: fixed;
inset: 1rem;
width: auto;
max-width: none;
margin: 0;
z-index: 50;
`
if (this.hasBodyTarget) {
this.bodyTarget.style.flex = '1'
this.bodyTarget.style.height = '0' // flex-1 needs a 0-basis to expand correctly
}
document.body.style.overflow = 'hidden'
} else {
el.style.cssText = ''
if (this.hasBodyTarget) {
this.bodyTarget.style.flex = ''
this.bodyTarget.style.height = ''
}
document.body.style.overflow = ''
}
if (this.hasExpandIconTarget) {
this.expandIconTarget.className = expanded
? 'fa-solid fa-down-left-and-up-right-to-center text-[10px]'
: 'fa-solid fa-up-right-and-down-left-from-center text-[10px]'
}
}
// ── Close ─────────────────────────────────────────────────────────
close() {
document.body.style.overflow = ''
this.element.closest('turbo-frame').innerHTML = ''
}
// ── Save draft ────────────────────────────────────────────────────
_scheduleAutosave() {
clearTimeout(this.#autosaveTimer)
this.#autosaveTimer = setTimeout(
() => this.saveDraft(),
this.autosaveDelayValue,
)
}
async saveDraft(event = null) {
event?.preventDefault()
const form = this.element.querySelector('form')
if (!form) return
const url = this.hasDraftUrlValue ? this.draftUrlValue : form.action
const status = this.hasSaveStatusTarget ? this.saveStatusTarget : null
if (status) {
status.textContent = 'Saving…'
status.classList.remove('text-red-500', 'text-green-600')
status.classList.add('text-gray-400')
}
try {
const response = await fetch(url, {
method: 'POST',
body: new FormData(form),
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
})
if (response.ok) {
if (status) {
status.textContent = 'Draft saved'
status.classList.remove('text-gray-400', 'text-red-500')
status.classList.add('text-green-600')
}
} else {
throw new Error('Server error')
}
} catch {
if (status) {
status.textContent = 'Failed to save'
status.classList.remove('text-gray-400', 'text-green-600')
status.classList.add('text-red-500')
}
}
}
// ── Cc / Bcc ──────────────────────────────────────────────────────
showCc() {
this.ccFieldTarget.classList.remove('hidden')
this._ensureEntry(this.ccFieldTarget.querySelector('[data-prototype]'))
}
showBcc() {
this.bccFieldTarget.classList.remove('hidden')
this._ensureEntry(this.bccFieldTarget.querySelector('[data-prototype]'))
}
// ── Helpers ───────────────────────────────────────────────────────
_ensureEntry(collection) {
if (!collection || collection.children.length > 0) return
const index = collection.dataset.index ?? 0
const prototype = collection.dataset.prototype
.replace(/__cc__|__bcc__|__to__/g, index)
collection.dataset.index = parseInt(index) + 1
collection.insertAdjacentHTML('beforeend', prototype)
collection.querySelector('input')?.focus()
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
twig:
file_name_pattern: '*.twig'
form_themes:
- '_partials/modal_form_theme.html.twig'
- '_layout/form/modal_form_theme.html.twig'
globals:
accounts: '@App\Twig\AccountsGlobal'
+2 -2
View File
@@ -1508,8 +1508,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }
* @psalm-type MercureConfig = array{
* hubs?: array<string, array{ // Default: []
* url?: scalar|Param|null, // URL of the hub's publish endpoint
* public_url?: scalar|Param|null, // URL of the hub's public endpoint // Default: null
* url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null
* public_url?: scalar|Param|null, // URL of the hub's public endpoint
* jwt?: string|array{ // JSON Web Token configuration.
* value?: scalar|Param|null, // JSON Web Token to use to publish to this hub.
* provider?: scalar|Param|null, // The ID of a service to call to provide the JSON Web Token.
+1
View File
@@ -27,4 +27,5 @@ return [
'@hotwired/stimulus' => ['version' => '3.2.2'],
'@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
'@hotwired/turbo' => ['version' => '8.0.23'],
'@fortawesome/fontawesome-free/css/all.css' => ['version' => '7.3.0', 'type' => 'css'],
];
+36
View File
@@ -0,0 +1,36 @@
<?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 Version20260703192500 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('ALTER TABLE account ADD is_primary BOOLEAN NOT NULL DEFAULT false');
$this->addSql('ALTER TABLE account ADD email VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE account RENAME COLUMN label TO name');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account ADD label VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE account DROP name');
$this->addSql('ALTER TABLE account DROP is_primary');
$this->addSql('ALTER TABLE account DROP email');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?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 Version20260704135906 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('ALTER TABLE account ALTER is_primary DROP DEFAULT');
$this->addSql('ALTER TABLE message ALTER imap_uid DROP NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE account ALTER is_primary SET DEFAULT false');
$this->addSql('ALTER TABLE message ALTER imap_uid SET NOT NULL');
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ class ImapIdleCommand extends Command
$io->info(sprintf(
'Starting IDLE on mailbox "%s" for account "%s"',
$mailbox->getName(),
$mailbox->getAccount()->getLabel(),
$mailbox->getAccount()->getEmail(),
));
$retries = 0;
+1 -1
View File
@@ -41,7 +41,7 @@ class SyncMailboxesCommand extends Command
}
foreach ($accounts as $account) {
$io->section('Syncing mailboxes for: ' . $account->getLabel());
$io->section('Syncing mailboxes for: ' . $account->getEmail());
try {
$result = $this->mailboxSyncer->syncForAccount($account);
+1 -1
View File
@@ -44,7 +44,7 @@ class SyncMessagesCommand extends Command
}
foreach ($accounts as $account) {
$io->section('Syncing messages for: ' . $account->getLabel());
$io->section('Syncing messages for: ' . $account->getEmail());
try {
$connection = ImapConnectionFactory::connect($account);
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Controller;
use App\Entity\Account;
use App\Entity\Mailbox;
use App\Entity\Message;
use App\Form\ComposeType;
use App\Repository\MailboxRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/compose', name: 'app_compose_')]
class ComposeController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly MailboxRepository $mailboxRepository,
) {}
#[Route('/new', name: 'new', methods: ['GET'])]
public function new(): Response
{
$message = new Message();
$form = $this->createForm(ComposeType::class, $message, [
'user' => $this->getUser(),
'validation_groups' => ['Default'],
]);
return $this->render('compose/_window.html.twig', [
'form' => $form,
'message' => $message,
]);
}
#[Route('/draft', name: 'draft', methods: ['POST'])]
public function draft(Request $request): Response
{
$message = new Message();
$form = $this->createForm(ComposeType::class, $message, [
'user' => $this->getUser(),
'validation_groups' => ['Default'], // no 'send' group — To is optional
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->persistDraft($form, $message);
return $this->render('compose/_window.html.twig', [
'form' => $form,
'message' => $message,
'saved' => true,
]);
}
return $this->render('compose/_window.html.twig', [
'form' => $form,
'message' => $message,
]);
}
#[Route('/send', name: 'send', methods: ['POST'])]
public function send(Request $request): Response
{
$message = new Message();
$form = $this->createForm(ComposeType::class, $message, [
'user' => $this->getUser(),
'validation_groups' => ['Default', 'send'], // To is required here
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->persistDraft($form, $message);
// TODO: dispatch SendMailMessage
$this->addFlash('success', 'Message sent.');
return $this->redirectToRoute('app_mail_sent');
}
return $this->render('compose/_window.html.twig', [
'form' => $form,
'message' => $message,
]);
}
private function persistDraft(FormInterface $form, Message $message): void
{
/** @var Account $account */
$account = $form->get('account')->getData();
$draftsMailbox = $this->mailboxRepository->findDraftMailboxForAccount($account);
if (!$draftsMailbox instanceof Mailbox) {
throw new \RuntimeException('No Drafts mailbox found for the selected account.');
}
$now = new \DateTimeImmutable();
$message
->setMailbox($draftsMailbox)
->setFromAddress($account->getEmail() ?? $account->getUsername())
->setFromName($account->getName())
->setHasAttachments(false)
->setCreatedAt($now)
->setUpdatedAt($now)
;
$this->em->persist($message);
$this->em->flush();
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Domain\Model;
use App\Entity\Account;
class AccountModel
{
public function getFromHeader(): string
{
if ($this instanceof Account) {
if (null !== $this->getName()) {
return sprintf('%s <%s>', $this->getName(), $this->getEmail() ?? $this->getUsername());
}
return $this->getEmail();
}
throw new \LogicException('Not an Account');
}
public function __toString(): string
{
return $this->getFromHeader();
}
}
+35 -6
View File
@@ -2,6 +2,7 @@
namespace App\Entity;
use App\Domain\Model\AccountModel;
use App\Repository\AccountRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -11,19 +12,25 @@ use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: AccountRepository::class)]
//#[Broadcast]
class Account
class Account extends AccountModel
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column]
private bool $isPrimary = false;
#[ORM\ManyToOne(inversedBy: 'accounts')]
#[ORM\JoinColumn(nullable: false)]
private ?User $usr = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $label = null;
private ?string $email = null;
#[ORM\Column(length: 255)]
private ?string $imapHost = null;
@@ -113,14 +120,25 @@ class Account
return $this;
}
public function getLabel(): ?string
public function getName(): ?string
{
return $this->label;
return $this->name;
}
public function setLabel(?string $label): static
public function setName(?string $name): static
{
$this->label = $label;
$this->name = $name;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
@@ -329,6 +347,17 @@ class Account
return $this;
}
public function isPrimary(): bool
{
return $this->isPrimary;
}
public function setIsPrimary(bool $isPrimary): static
{
$this->isPrimary = $isPrimary;
return $this;
}
/**
* @return Collection<int, Mailbox>
*/
+1 -1
View File
@@ -21,7 +21,7 @@ class Message
#[ORM\JoinColumn(nullable: false)]
private ?Mailbox $mailbox = null;
#[ORM\Column]
#[ORM\Column(nullable: true)]
private ?int $imapUid = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
+2 -2
View File
@@ -18,9 +18,9 @@ class AccountType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('label', TextType::class, [
->add('email', TextType::class, [
'attr' => [
'placeholder' => 'Work, Personal…',
'placeholder' => 'you@example.com',
'class' => 'form-input',
],
'label' => 'Account label',
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* Represents a single recipient entry: ['name' => '...', 'address' => '...']
*
* Used as the entry_type in CollectionType for toAddresses, ccAddresses, bccAddresses.
*/
class AddressEntryType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('address', EmailType::class, [
'label' => false,
'row_attr' => ['class' => 'contents'],
'attr' => [
'placeholder' => 'Email address',
'class' => 'flex-1 text-sm text-gray-800 outline-none bg-transparent placeholder-gray-400 min-w-0',
'autocomplete' => 'email',
],
'constraints' => [
new NotBlank(groups: ['send']),
new Email(groups: ['send']),
],
])
->add('name', TextType::class, [
'label' => false,
'required' => false,
'row_attr' => ['class' => 'contents'],
'attr' => [
'placeholder' => 'Name',
'class' => 'w-28 text-sm text-gray-500 outline-none bg-transparent placeholder-gray-300 border-l border-gray-200 pl-2 min-w-0',
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
// Each entry maps to a plain array, not an entity
'data_class' => null,
]);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Form;
use App\Entity\Account;
use App\Entity\Message;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
class ComposeType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var UserInterface $user */
$user = $options['user'];
$builder
// From — EntityType scoped to the current user's accounts
->add('account', EntityType::class, [
'label' => false,
'class' => Account::class,
'choice_label' => fn(Account $a) => $a->getFromHeader(),
'query_builder' => fn(EntityRepository $er) => $er
->createQueryBuilder('a')
->where('a.usr = :user')
->andWhere('a.isActive = true')
->orderBy('a.isPrimary', 'DESC')
->addOrderBy('a.email', 'ASC')
->setParameter('user', $user),
// Pre-select the primary account
'preferred_choices' => fn(Account $a) => $a->isPrimary(),
'mapped' => false, // Controller resolves mailbox from this and sets it on Message
'attr' => ['class' => 'compose-from-select'],
])
// To — always visible, at least one entry required
->add('toAddresses', CollectionType::class, [
'label' => false,
'entry_type' => AddressEntryType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
'prototype_name' => '__to__',
'constraints' => [
new NotBlank(
message: 'At least one recipient is required.',
groups: ['send'],
),
],
'attr' => ['class' => 'compose-collection compose-to'],
])
// Cc — hidden until the Cc button is clicked (Stimulus handles visibility)
->add('ccAddresses', CollectionType::class, [
'label' => false,
'entry_type' => AddressEntryType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
'prototype_name' => '__cc__',
'required' => false,
'attr' => [
'class' => 'compose-collection compose-cc',
'data-compose-target' => 'ccField', // Stimulus target
],
])
// Bcc — hidden until the Bcc button is clicked
->add('bccAddresses', CollectionType::class, [
'label' => false,
'entry_type' => AddressEntryType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
'prototype_name' => '__bcc__',
'required' => false,
'attr' => [
'class' => 'compose-collection compose-bcc',
'data-compose-target' => 'bccField', // Stimulus target
],
])
->add('subject', TextType::class, [
'label' => false,
'required' => false,
'attr' => ['placeholder' => 'Subject'],
])
->add('bodyHtml', TextareaType::class, [
'label' => false,
'required' => false,
'attr' => [
'placeholder' => '',
'rows' => 10,
'data-compose-target' => 'body',
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Message::class,
'action' => '/compose/send',
'method' => 'POST',
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'compose',
'validation_groups' => ['Default'],
'user' => null,
]);
$resolver->setRequired('user');
}
}
+5
View File
@@ -31,6 +31,11 @@ class MailboxRepository extends ServiceEntityRepository
return $indexed;
}
public function findDraftMailboxForAccount(Account $account): ?Mailbox
{
return $this->findOneBy(['account' => $account, 'specialUse' => '\\Drafts']);
}
public function findIdleEnabledAndSyncEnabled(): array
{
$queryBuilder = $this->createQueryBuilder('mailbox');
+1
View File
@@ -38,6 +38,7 @@
{% block body %}{% endblock %}
{% block javascripts_body %}{% endblock %}
{% include '_partials/_modal.html.twig' %}
<turbo-frame id="compose_dock" class="fixed bottom-4 right-6 flex items-end gap-3"></turbo-frame>
<div id="toast-region" class="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 items-end"></div>
</body>
</html>
@@ -0,0 +1,36 @@
{# templates/form/compose_theme.html.twig #}
{% block address_entry_widget %}
<div class="flex items-center gap-2 w-full">
{{- form_widget(form.address) -}}
</div>
{% endblock %}
{% block address_entry_row %}
{{- block('address_entry_widget') -}}
{% endblock %}
{# Bare email input — no wrapper, no label, no error box #}
{% block email_widget %}
<input
type="email"
{{ block('widget_attributes') }}
{% if value is not empty %}value="{{ value }}"{% endif %}
class="flex-1 bg-transparent outline-none text-sm
text-gray-800 dark:text-gray-100
placeholder-gray-400 dark:placeholder-gray-500
min-w-0"
/>
{% endblock %}
{% block text_widget %}
<input
type="text"
{{ block('widget_attributes') }}
{% if value is not empty %}value="{{ value }}"{% endif %}
class="flex-1 bg-transparent outline-none text-sm
text-gray-800 dark:text-gray-100
placeholder-gray-400 dark:placeholder-gray-500
min-w-0"
/>
{% endblock %}
+1 -1
View File
@@ -12,7 +12,7 @@
class="flex items-center gap-2 px-3 py-2 rounded-lg transition-colors flex-1 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<span class="w-2 h-2 rounded-full flex-shrink-0 {{ account.isActive ? 'bg-green-400' : 'bg-gray-300 dark:bg-gray-700' }}"></span>
<span class="truncate text-sm">{{ account.label ?: account.username }}</span>
<span class="truncate text-sm">{{ account.email ?: account.username }}</span>
</a>
<button
type="button"
+5 -3
View File
@@ -2,12 +2,14 @@
{# Compose #}
<div class="px-3 py-3 border-b border-gray-100 dark:border-gray-800">
<button class="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg px-3 py-2 transition-colors">
<a href="{{ path('app_compose_new') }}"
data-turbo-frame="compose_dock"
class="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg px-3 py-2 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Compose
</button>
</a>
</div>
{# Nav #}
@@ -16,7 +18,7 @@
{% set current = app.request.attributes.get('_route') %}
<a href="{{ path('app_mail_inbox') }}"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-lg transition-colors {{ current == 'app_mail_inbox' ? 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800' }}">
class="flex items-center justify-between gap-2 px-3 py-2 rounded-lg transition-colors{{ current == 'app_mail_inbox' ? 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800' }}">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V7a2 2 0 00-2-2H6a2 2 0 00-2 2v6m16 0l-8 5-8-5m16 0v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4"/>
+2 -2
View File
@@ -61,8 +61,8 @@ Controller sets up:
{# Account basics #}
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
{{ form_row(form.label, { label: 'Account label', attr: { placeholder: 'Work, Personal…' } }) }}
{{ form_row(form.username, { label: 'Email address', attr: { placeholder: 'you@example.com' } }) }}
{{ form_row(form.email, { label: 'Email address', attr: { placeholder: 'you@example.com' } }) }}
{{ form_row(form.username, { label: 'Username', attr: { placeholder: 'you@example.com' } }) }}
</div>
{{ form_row(form.password, { label: 'Password', attr: { placeholder: '••••••••' } }) }}
</div>
+223
View File
@@ -0,0 +1,223 @@
{# templates/compose/_window.html.twig #}
{% form_theme form '_layout/form/compose_theme.html.twig' %}
<turbo-frame id="compose_dock">
<div data-controller="compose"
data-compose-draft-url-value="{{ path('app_compose_draft') }}"
class="w-[520px] rounded-2xl shadow-2xl shadow-black/20 dark:shadow-black/60
bg-white/80 dark:bg-gray-900/80
backdrop-blur-xl
border border-white/60 dark:border-white/10
flex flex-col overflow-hidden mb-4">
{{ form_start(form, { attr: { novalidate: 'novalidate' } }) }}
{# ── Header ───────────────────────────────────────────────── #}
<div class="flex items-center justify-between px-4 py-2.5
bg-gray-800 dark:bg-gray-950
rounded-t-2xl select-none shrink-0"
data-action="dblclick->compose#toggleMinimize">
<span class="text-sm font-semibold text-white cursor-pointer"
data-action="click->compose#toggleMinimize">New Message</span>
<div class="flex items-center gap-1">
<span data-compose-target="saveStatus"
class="text-xs text-gray-400 mr-2 transition-colors"></span>
<button type="button" title="Minimize"
data-action="click->compose#toggleMinimize"
class="w-7 h-7 flex items-center justify-center rounded-lg
text-gray-400 hover:text-white hover:bg-white/10 transition-colors">
<i data-compose-target="minimizeIcon" class="fa-solid fa-minus text-xs"></i>
</button>
<button type="button" title="Expand"
data-action="click->compose#toggleExpand"
class="w-7 h-7 flex items-center justify-center rounded-lg
text-gray-400 hover:text-white hover:bg-white/10 transition-colors">
<i data-compose-target="expandIcon"
class="fa-solid fa-up-right-and-down-left-from-center text-[10px]"></i>
</button>
<button type="button" title="Save draft and close"
data-action="compose#saveDraft compose#close"
class="w-7 h-7 flex items-center justify-center rounded-lg
text-gray-400 hover:text-white hover:bg-white/10 transition-colors">
<i class="fa-solid fa-xmark text-sm"></i>
</button>
</div>
</div>
{# ── Collapsible body ─────────────────────────────────────── #}
<div data-compose-target="collapsible" class="flex flex-col flex-1 min-h-0">
{# ── From ─────────────────────────────────────────────────── #}
<div class="flex items-center gap-3 px-4 border-b border-gray-100 dark:border-gray-800 min-h-[38px]">
<span class="text-xs font-medium text-gray-400 dark:text-gray-500 w-8 shrink-0">From</span>
<select id="{{ form.account.vars.id }}"
name="{{ form.account.vars.full_name }}"
class="flex-1 bg-transparent outline-none text-sm
text-gray-700 dark:text-gray-300 cursor-pointer py-1">
{% for choice in form.account.vars.choices %}
<option value="{{ choice.value }}"
{% if form.account.vars.value == choice.value %}selected{% endif %}>
{{ choice.label }}
</option>
{% endfor %}
</select>
</div>
{# ── To ───────────────────────────────────────────────────── #}
<div class="flex items-center gap-3 px-4 border-b border-gray-100 dark:border-gray-800 min-h-[38px]">
<span class="text-xs font-medium text-gray-400 dark:text-gray-500 w-8 shrink-0">To</span>
<div class="flex-1 py-1 compose-collection compose-to"
id="{{ form.toAddresses.vars.id }}"
data-prototype="{{ form_widget(form.toAddresses.vars.prototype)|e('html_attr') }}"
data-index="{{ form.toAddresses|length }}">
{% for entry in form.toAddresses %}
{{ form_widget(entry) }}
{% endfor %}
</div>
<div class="flex gap-2 text-xs font-medium shrink-0">
<span class="cursor-pointer text-gray-400 dark:text-gray-500 hover:text-blue-500 transition-colors"
data-action="click->compose#showCc">Cc</span>
<span class="cursor-pointer text-gray-400 dark:text-gray-500 hover:text-blue-500 transition-colors"
data-action="click->compose#showBcc">Bcc</span>
</div>
</div>
{# ── Cc ───────────────────────────────────────────────────── #}
<div data-compose-target="ccField"
class="hidden items-center gap-3 px-4 border-b border-gray-100 dark:border-gray-800 min-h-[38px]">
<span class="text-xs font-medium text-gray-400 dark:text-gray-500 w-8 shrink-0">Cc</span>
<div class="flex-1 py-1 compose-collection compose-cc"
id="{{ form.ccAddresses.vars.id }}"
data-prototype="{{ form_widget(form.ccAddresses.vars.prototype)|e('html_attr') }}"
data-index="{{ form.ccAddresses|length }}">
{% for entry in form.ccAddresses %}
{{ form_widget(entry) }}
{% endfor %}
</div>
</div>
{# ── Bcc ──────────────────────────────────────────────────── #}
<div data-compose-target="bccField"
class="hidden items-center gap-3 px-4 border-b border-gray-100 dark:border-gray-800 min-h-[38px]">
<span class="text-xs font-medium text-gray-400 dark:text-gray-500 w-8 shrink-0">Bcc</span>
<div class="flex-1 py-1 compose-collection compose-bcc"
id="{{ form.bccAddresses.vars.id }}"
data-prototype="{{ form_widget(form.bccAddresses.vars.prototype)|e('html_attr') }}"
data-index="{{ form.bccAddresses|length }}">
{% for entry in form.bccAddresses %}
{{ form_widget(entry) }}
{% endfor %}
</div>
</div>
{# ── Subject ──────────────────────────────────────────────── #}
<div class="flex items-center gap-3 px-4 border-b border-gray-100 dark:border-gray-800 min-h-[38px]">
<input type="text"
id="{{ form.subject.vars.id }}"
name="{{ form.subject.vars.full_name }}"
value="{{ form.subject.vars.value ?: '' }}"
placeholder="Subject"
class="flex-1 bg-transparent outline-none text-sm font-medium py-1
text-gray-800 dark:text-gray-100
placeholder-gray-400 dark:placeholder-gray-600"/>
</div>
{# ── Body ─────────────────────────────────────────────────── #}
<div class="flex-1 flex flex-col min-h-[200px] px-4 py-3">
<textarea id="{{ form.bodyHtml.vars.id }}"
name="{{ form.bodyHtml.vars.full_name }}"
data-compose-target="body"
placeholder="Write your message…"
class="flex-1 w-full min-h-[190px] bg-transparent outline-none resize-none
text-sm leading-relaxed
text-gray-800 dark:text-gray-100
placeholder-gray-400 dark:placeholder-gray-600">{{ form.bodyHtml.vars.value ?: '' }}</textarea>
</div>
{# ── Toolbar ──────────────────────────────────────────────── #}
<div class="px-3 pb-3 pt-1 border-t border-gray-100 dark:border-gray-800 shrink-0">
{# Formatting bubble #}
<div class="flex items-center gap-0.5 mb-2 flex-wrap
bg-gray-100/80 dark:bg-gray-800/60
rounded-full px-2 py-1">
<select class="bg-transparent border-none outline-none text-xs cursor-pointer px-1 py-0.5 rounded
text-gray-600 dark:text-gray-400
hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<option>Sans Serif</option>
<option>Serif</option>
<option>Monospace</option>
</select>
<div class="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-1 shrink-0"></div>
<button type="button" class="flex items-center gap-0.5 px-1.5 py-0.5 rounded transition-colors
text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700">
<span class="text-base font-bold leading-none">T</span><span class="text-[10px] font-bold leading-none">T</span>
<i class="fa-solid fa-chevron-down text-[8px] ml-0.5"></i>
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-1 shrink-0"></div>
<button type="button" class="px-1.5 py-0.5 rounded font-bold text-sm transition-colors
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700">B</button>
<button type="button" class="px-1.5 py-0.5 rounded italic text-sm transition-colors
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700">I</button>
<button type="button" class="px-1.5 py-0.5 rounded underline text-sm transition-colors
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700">U</button>
<button type="button" class="px-1.5 py-0.5 rounded text-sm flex flex-col items-center leading-none gap-0.5 transition-colors
text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700">
<span class="font-semibold">A</span>
<span class="block w-3.5 h-[3px] bg-blue-500 rounded-sm"></span>
</button>
<div class="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-1 shrink-0"></div>
<button type="button" class="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-sm transition-colors
text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700">
<i class="fa-solid fa-align-left"></i><i class="fa-solid fa-chevron-down text-[8px]"></i>
</button>
<button type="button" class="px-1.5 py-0.5 rounded text-sm transition-colors text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"><i class="fa-solid fa-list-ol"></i></button>
<button type="button" class="px-1.5 py-0.5 rounded text-sm transition-colors text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"><i class="fa-solid fa-list-ul"></i></button>
<button type="button" class="px-1.5 py-0.5 rounded text-sm transition-colors text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"><i class="fa-solid fa-outdent"></i></button>
<button type="button" class="px-1.5 py-0.5 rounded text-sm transition-colors text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"><i class="fa-solid fa-indent"></i></button>
<div class="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-1 shrink-0"></div>
<button type="button" class="px-1.5 py-0.5 rounded text-sm transition-colors text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"><i class="fa-solid fa-ellipsis-vertical"></i></button>
</div>
{# Action bar #}
<div class="flex items-center gap-1">
<div class="flex items-center rounded-full overflow-hidden bg-blue-600 mr-2 shrink-0">
<button type="submit"
class="text-white text-sm font-semibold px-4 py-1.5 hover:bg-white/10 transition-colors">
Send
</button>
<button type="button"
class="text-white border-l border-white/30 px-2.5 py-1.5 hover:bg-white/10 transition-colors text-xs">
<i class="fa-solid fa-chevron-down"></i>
</button>
</div>
{% set ic = "w-8 h-8 flex items-center justify-center rounded-full transition-colors text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800" %}
<button type="button" class="{{ ic }} text-sm font-bold tracking-tighter">Aa</button>
<button type="button" class="{{ ic }}"><i class="fa-solid fa-paperclip"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-solid fa-link"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-regular fa-face-smile"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-brands fa-google-drive"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-regular fa-image"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-solid fa-lock"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-solid fa-pen-nib"></i></button>
<button type="button" class="{{ ic }}"><i class="fa-solid fa-ellipsis-vertical"></i></button>
<div class="flex-1"></div>
<button type="button"
data-action="compose#close"
title="Discard draft"
class="{{ ic }} hover:text-red-500 dark:hover:text-red-400">
<i class="fa-regular fa-trash-can"></i>
</button>
</div>
</div>
</div>{# end collapsible #}
<input type="hidden" name="compose[_token]" value="{{ csrf_token('compose') }}">
{{ form_end(form, { render_rest: false }) }}
</div>
</turbo-frame>