mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
start compose feature
This commit is contained in:
1 parent
9218f5e627
commit
678b33e0cb
25 files changed
+914
-21
No files matched your search
@@ -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! 🎉');
|
||||
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 %}
|
||||
File renamed without changes.
@@ -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"
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user