feat(ops/campaigns): Phase 2 — switch editor page to easy-email iframe

Replace the broken GrapesJS-mjml integration with an iframe pointing to
the standalone email-editor microservice at editor.gigafibre.ca (created
in Phase 1).

What changed:

- Dropped all grapesjs* imports and ~250 lines of editor init/save/preview
  glue code. That logic now lives in the React app on the other side of
  the iframe.
- Page becomes a thin wrapper:
  • Top bar: back button, template selector, "saved" chip,
    "Aperçu inbox" button, "Envoyer un test" button, reload button.
  • Below: full-height iframe to editor.gigafibre.ca/?name=<template-name>.
- Template switching: bumping iframeKey forces a fresh iframe load so the
  new ?name= param takes effect. Route is updated via router.replace.
- postMessage listener: receives { type: 'email-editor:saved', ts }
  from the editor iframe and shows a positive toast + updates the
  "Sauvegardé · il y a Xs" chip. Origin-checked against EDITOR_BASE.
- Preview dialog: unchanged — fetches compiled HTML from hub's preview
  endpoint and renders in srcdoc iframe.
- Test-send dialog: unchanged from previous version.

Removed (now handled inside the iframe):
- Visual / HTML / Aperçu view-mode toggle (editor.gigafibre.ca handles
  all editing modes natively)
- "Vide" / "Réinitialiser" buttons (editor has its own)
- "Annuler" / "Enregistrer" buttons (editor saves itself on Cmd-S /
  toolbar button)
- spell-check on textarea (editor handles it)
- GrapesJS asset manager wiring (editor will use its own image picker
  in Phase 3)

DNS prerequisite handled separately: editor.gigafibre.ca → 96.125.196.67
created via Cloudflare API (proxied=false to match the existing pattern
that lets Traefik handle Let's Encrypt directly).

Container running on prod via /opt/email-editor/docker-compose.yml,
Traefik routing to Host(`editor.gigafibre.ca`). HTTPS verified live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-22 06:01:28 -04:00
parent 0b6377fa58
commit f9971e9113

View File

@ -1,35 +1,55 @@
<template> <template>
<q-page> <q-page>
<!-- Top bar --> <!-- Top bar: thin wrapper. All editing happens inside the iframe; the
parent app's job is template selection + test-send + saved-toast. -->
<div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb"> <div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb">
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" /> <q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
<div class="text-h6 q-mr-md">Éditeur de template</div> <div class="text-h6 q-mr-md">Éditeur de template</div>
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined style="min-width:240px" @update:model-value="loadTemplate" /> <q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
<q-btn flat dense icon="add" label="Vide" class="q-ml-sm" @click="startBlank"> style="min-width:240px" @update:model-value="onSelectTemplate" />
<q-tooltip>Repartir d'un canevas vide (pour composer une nouvelle template depuis zéro)</q-tooltip> <q-chip dense color="grey-2" text-color="grey-9" class="q-ml-sm" size="sm" icon="cloud_done"
</q-btn> v-if="lastSaved">
<q-btn flat dense icon="restore" label="Réinitialiser" class="q-ml-sm" @click="reloadFromDisk"> Sauvegardé · {{ lastSavedAt }}
<q-tooltip>Recharger la dernière version sauvegardée sur disque</q-tooltip> </q-chip>
</q-btn>
<q-space /> <q-space />
<q-btn-toggle v-model="viewMode" :options="[ <q-btn flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm"
{ label: 'Visuel', value: 'visual' }, @click="openPreview">
{ label: 'HTML', value: 'html' }, <q-tooltip>Voir le rendu compilé avec données de test (HTML de l'inbox)</q-tooltip>
{ label: 'Aperçu', value: 'preview' },
]" dense unelevated toggle-color="primary" />
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-ml-sm" @click="testSendOpen = true">
<q-tooltip>Envoie un courriel réel à une adresse de test avec des variables personnalisées</q-tooltip>
</q-btn> </q-btn>
<q-btn flat icon="undo" label="Annuler" class="q-ml-sm" :disable="!dirty" @click="discardChanges"> <q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm"
<q-tooltip>Annuler les changements non sauvegardés</q-tooltip> @click="testSendOpen = true">
<q-tooltip>Envoie un courriel réel à une adresse de test avec variables custom</q-tooltip>
</q-btn>
<q-btn flat dense icon="refresh" @click="reloadEditor">
<q-tooltip>Recharger l'éditeur (en cas de bug)</q-tooltip>
</q-btn> </q-btn>
<q-btn unelevated color="primary" icon="save" label="Enregistrer" class="q-ml-sm"
:loading="saving" :disable="!dirty" @click="save" />
</div> </div>
<!-- Test-send dialog: lets you fire ONE rendered email to any address <!-- The editor itself full-page iframe to the standalone email-editor
with custom variable values. Always saves first if dirty (otherwise microservice at editor.gigafibre.ca. Communicates back via postMessage
the test would render the OLD template confusing). --> when the user saves (we listen + show a toast + update lastSaved). -->
<iframe v-if="iframeUrl" :src="iframeUrl" :key="iframeKey"
style="width:100%; height:calc(100vh - 60px); border:0; display:block;"
ref="editorIframe" />
<!-- Aperçu dialog renders the LATEST saved compiled HTML in a clean
viewer. Useful before sending a test, to see what the inbox version
actually looks like with merge tags substituted. -->
<q-dialog v-model="previewOpen" maximized persistent>
<q-card class="bg-grey-2">
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
<q-icon name="visibility" class="q-mr-sm" />
<q-toolbar-title>Aperçu inbox · {{ currentName }}</q-toolbar-title>
<q-btn flat dense round icon="close" @click="previewOpen = false" />
</q-toolbar>
<q-banner v-if="previewLoading" class="bg-blue-1 text-blue-9"><q-spinner size="sm" /> Rendu en cours</q-banner>
<q-card-section style="height: calc(100vh - 60px); overflow:hidden">
<iframe :srcdoc="previewHtmlContent" style="width:100%; height:100%; border:1px solid #e5e7eb; background:#fff;" />
</q-card-section>
</q-card>
</q-dialog>
<!-- Test-send dialog (unchanged from previous version) -->
<q-dialog v-model="testSendOpen" persistent> <q-dialog v-model="testSendOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw"> <q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-orange-1 text-orange-9"> <q-card-section class="row items-center bg-orange-1 text-orange-9">
@ -39,113 +59,123 @@
<q-btn flat dense round icon="close" v-close-popup /> <q-btn flat dense round icon="close" v-close-popup />
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
<q-banner v-if="dirty" class="bg-amber-1 text-amber-9 q-mb-md" rounded dense>
<q-icon name="warning" class="q-mr-xs" />
Tu as des changements non sauvegardés. Sauvegarde avant d'envoyer pour tester la dernière version.
</q-banner>
<q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense <q-input v-model="testSendForm.to" label="Adresse de destination" outlined dense
type="email" autofocus class="q-mb-md" type="email" autofocus class="q-mb-md" />
hint="L'email de test sera envoyé via Mailjet à cette adresse" />
<q-input v-model="testSendForm.subject" label="Sujet" outlined dense class="q-mb-md" /> <q-input v-model="testSendForm.subject" label="Sujet" outlined dense class="q-mb-md" />
<div class="text-subtitle2 q-mb-sm">Variables (pour le rendu)</div> <div class="text-subtitle2 q-mb-sm">Variables</div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<q-input v-model="testSendForm.vars.firstname" label="firstname" outlined dense class="col-6" /> <q-input v-model="testSendForm.vars.firstname" label="firstname" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.lastname" label="lastname" outlined dense class="col-6" /> <q-input v-model="testSendForm.vars.lastname" label="lastname" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.amount" label="amount" outlined dense class="col-6" /> <q-input v-model="testSendForm.vars.amount" label="amount" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.commitment_months" label="commitment_months" outlined dense class="col-6" /> <q-input v-model="testSendForm.vars.commitment_months" label="commitment_months" outlined dense class="col-6" />
<q-input v-model="testSendForm.vars.gift_url" label="gift_url" outlined dense class="col-12" /> <q-input v-model="testSendForm.vars.gift_url" label="gift_url" outlined dense class="col-12" />
<q-input v-model="testSendForm.vars.description" label="description (adresse)" outlined dense class="col-12" /> <q-input v-model="testSendForm.vars.description" label="description" outlined dense class="col-12" />
<q-input v-model="testSendForm.vars.expiry" label="expiry (optionnel)" outlined dense class="col-12" /> <q-input v-model="testSendForm.vars.expiry" label="expiry" outlined dense class="col-12" />
</div> </div>
</q-card-section> </q-card-section>
<q-card-section class="bg-grey-2"> <q-card-section class="bg-grey-2">
<div class="text-caption text-grey-8"> <div class="text-caption text-grey-8">
Envoyé via Mailjet depuis <code>TARGO &lt;support@targointernet.com&gt;</code> (sender validé). Envoyé via Mailjet depuis <code>TARGO &lt;support@targointernet.com&gt;</code>.
Le rendu utilisera le HTML compilé actuellement sauvegardé sur disque (pas les modifs non-sauvegardées). Utilise la dernière version sauvegardée (sauvegarde dans l'éditeur d'abord pour tester les modifs récentes).
</div> </div>
</q-card-section> </q-card-section>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup /> <q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="primary" icon-right="send" label="Envoyer le test maintenant" <q-btn unelevated color="primary" icon-right="send" label="Envoyer le test"
:loading="testSending" :disable="!testSendForm.to" @click="doTestSend" /> :loading="testSending" :disable="!testSendForm.to" @click="doTestSend" />
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Hint banner: visual mode is best for new templates, HTML for existing -->
<q-banner v-if="viewMode === 'visual' && !blankCanvas" class="bg-amber-1 text-amber-9 q-px-md q-py-xs" style="border-bottom:1px solid #fde68a">
<q-icon name="lightbulb" class="q-mr-xs" />
Le mode Visuel est idéal pour <strong>composer une nouvelle template depuis un canevas vide</strong>
(clique "Vide" ). Pour éditer la template existante (tables imbriquées hand-crafted),
<strong>passe en mode HTML</strong> GrapesJS ne parse pas toujours les structures complexes.
</q-banner>
<!-- Editor surface (one of three modes) -->
<div v-show="viewMode === 'visual'" ref="grapesContainer" style="height:calc(100vh - 110px)"></div>
<div v-show="viewMode === 'html'" class="q-pa-md" style="height:calc(100vh - 110px)">
<q-banner class="bg-blue-1 text-blue-9 q-mb-sm" rounded>
<template v-slot:avatar><q-icon name="info" /></template>
Édition HTML brute. Les changements sauvegardés ici écrasent ceux de l'éditeur visuel.
<!-- v-pre tells Vue NOT to compile child interpolations, so the {{var}}
text below is rendered literally instead of being parsed as Vue
template expressions. Without v-pre, Vue's parser chokes on the
nested curly braces inside `{{ '{{firstname}}' }}`. -->
<span v-pre>
Variables disponibles : <code>{{firstname}}</code>, <code>{{amount}}</code>,
<code>{{gift_url}}</code>, <code>{{description}}</code>,
<code>{{expiry}}</code>, <code>{{commitment_months}}</code>.
Blocs conditionnels : <code>{{#expiry}}...{{/expiry}}</code>.
</span>
</q-banner>
<!-- lang+spellcheck attributes turn on the browser's native spell-checker.
Red squiggles under typos in real time. Language follows the current
template suffix (-fr fr, -en en) so Chrome/Safari use the right
dictionary. For AI-grade rewriting (grammar, tone) plug the future
/campaigns/ai/rewrite endpoint into a separate button. -->
<q-input v-model="html" type="textarea" outlined dense filled
:input-attrs="{ spellcheck: 'true', lang: editorLang, autocorrect: 'on', autocapitalize: 'sentences' }"
input-style="font-family:monospace; font-size:0.85rem; line-height:1.4; min-height:calc(100vh - 220px)"
@update:model-value="dirty = true" />
</div>
<div v-show="viewMode === 'preview'" style="height:calc(100vh - 110px); overflow:auto; background:#f7f8f7;">
<iframe :srcdoc="previewHtml" frameborder="0" style="width:100%; height:100%; display:block;"></iframe>
</div>
</q-page> </q-page>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { listTemplates, getTemplate, saveTemplate, previewTemplate, import { listTemplates, previewTemplate, testSendTemplate } from 'src/api/campaigns'
testSendTemplate, listAssets, uploadAsset } from 'src/api/campaigns'
import grapesjs from 'grapesjs'
import 'grapesjs/dist/css/grapes.min.css'
import presetNewsletter from 'grapesjs-preset-newsletter'
// MJML plugin used when the template's format is 'mjml'. Provides proper
// drag-drop email blocks (mj-section, mj-column, mj-text, mj-button,
// mj-image, etc.) that compile to email-safe HTML on save server-side.
import mjmlPlugin from 'grapesjs-mjml'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const $q = useQuasar() const $q = useQuasar()
const grapesContainer = ref(null) // Editor iframe standalone microservice at editor.gigafibre.ca.
const templates = ref([]) // URL params drive which template loads. iframeKey is bumped to force a
// fresh load when the user switches templates (otherwise the iframe
// caches the previous template's state).
const EDITOR_BASE = 'https://editor.gigafibre.ca'
const currentName = ref(route.params.name || 'gift-email-fr') const currentName = ref(route.params.name || 'gift-email-fr')
const html = ref('') const iframeKey = ref(0)
const previewHtml = ref('') const editorIframe = ref(null)
const dirty = ref(false)
const saving = ref(false)
const viewMode = ref('visual') // 'visual' | 'html' | 'preview'
const blankCanvas = ref(false) // true after clicking "Vide" hides the "use HTML" hint
const templateFormat = ref('html') // 'mjml' | 'html' drives editor plugin choice + save body shape
const mjmlSource = ref('') // when format=mjml, this holds the MJML source separately from the rendered HTML
// Test-send dialog state const iframeUrl = computed(() =>
`${EDITOR_BASE}/?name=${encodeURIComponent(currentName.value)}`
)
const templates = ref([])
const templateOptions = computed(() => templates.value.map(t => ({
label: `${t.name}.${t.format || 'html'} (${Math.round(t.size / 1024)} KB)`,
value: t.name,
})))
async function loadAvailableTemplates () {
templates.value = await listTemplates()
}
function onSelectTemplate (name) {
currentName.value = name
iframeKey.value++ // force iframe reload with new ?name=
router.replace({ path: `/campaigns/templates/${name}` })
}
function reloadEditor () { iframeKey.value++ }
// Listen for postMessage from the editor iframe
const lastSaved = ref(null)
const lastSavedAt = computed(() => {
if (!lastSaved.value) return ''
const diff = Math.floor((Date.now() - lastSaved.value) / 1000)
if (diff < 60) return `il y a ${diff}s`
if (diff < 3600) return `il y a ${Math.floor(diff / 60)}min`
return new Date(lastSaved.value).toLocaleTimeString('fr-CA')
})
function handleMessage (event) {
// Only accept messages from our editor domain
if (event.origin !== EDITOR_BASE) return
if (event.data?.type === 'email-editor:saved') {
lastSaved.value = event.data.ts || Date.now()
$q.notify({ type: 'positive', message: 'Template enregistré ✓', timeout: 2500 })
}
}
onMounted(async () => {
await loadAvailableTemplates()
window.addEventListener('message', handleMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage)
})
// Preview dialog (compiled HTML from hub)
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewHtmlContent = ref('')
async function openPreview () {
previewOpen.value = true
previewLoading.value = true
try {
const r = await previewTemplate(currentName.value)
previewHtmlContent.value = r.rendered || ''
} catch (e) {
$q.notify({ type: 'negative', message: 'Aperçu impossible: ' + e.message })
} finally {
previewLoading.value = false
}
}
// Test-send dialog
const testSendOpen = ref(false) const testSendOpen = ref(false)
const testSending = ref(false) const testSending = ref(false)
const testSendForm = ref({ const testSendForm = ref({
@ -170,320 +200,12 @@ async function doTestSend () {
subject: testSendForm.value.subject, subject: testSendForm.value.subject,
vars: testSendForm.value.vars, vars: testSendForm.value.vars,
}) })
$q.notify({ $q.notify({ type: 'positive', message: `Test envoyé à ${r.to}`, caption: `${r.bytes} octets`, timeout: 5000 })
type: 'positive',
message: `Test envoyé à ${r.to}`,
caption: `${r.bytes} octets via ${r.from}`,
timeout: 5000,
})
testSendOpen.value = false testSendOpen.value = false
} catch (e) { } catch (e) {
$q.notify({ type: 'negative', message: 'Échec envoi test: ' + e.message, timeout: 6000 }) $q.notify({ type: 'negative', message: 'Échec envoi: ' + e.message })
} finally { } finally {
testSending.value = false testSending.value = false
} }
} }
// Derive the editor's spell-check language from the template name suffix.
// gift-email-fr fr, gift-email-en en, etc. Browser's built-in dictionary
// (Chrome/Safari/Firefox) uses this to flag typos in the correct language.
const editorLang = computed(() => {
const m = (currentName.value || '').match(/-([a-z]{2})$/)
return m ? m[1] : 'fr'
})
let editor = null
let originalHtml = ''
const templateOptions = ref([])
async function loadAvailableTemplates () {
templates.value = await listTemplates()
templateOptions.value = templates.value.map(t => ({
label: `${t.name}.html (${Math.round(t.size/1024)} KB)`,
value: t.name,
}))
}
async function loadTemplate (name) {
const data = await getTemplate(name || currentName.value)
templateFormat.value = data.format || 'html'
// For MJML templates, the editor's source is the .mjml file; the .html
// shown alongside is just the latest compiled output (used for preview).
if (data.format === 'mjml') {
mjmlSource.value = data.mjml || ''
html.value = data.html || '' // compiled output (for HTML tab fallback)
originalHtml = data.mjml || '' // dirty comparison is on the mjml source
} else {
mjmlSource.value = ''
html.value = data.html
originalHtml = data.html
}
dirty.value = false
// GrapesJS editor needs to be re-initialized when switching between mjml/html
// formats because the plugins are different. Tear down + rebuild.
if (editor) {
try { editor.destroy() } catch {}
editor = null
}
await nextTick()
initGrapes()
// Push the loaded source into the GrapesJS canvas
if (editor) {
const source = data.format === 'mjml' ? data.mjml : data.html
editor.setComponents(source || '')
setTimeout(() => { dirty.value = false }, 100)
}
router.replace({ path: `/campaigns/templates/${name || currentName.value}` })
}
function initGrapes () {
// Plugin selection by template format:
// mjml grapesjs-mjml (proper email-focused visual builder)
// html grapesjs-preset-newsletter (legacy fallback for raw HTML templates)
const isMjml = templateFormat.value === 'mjml'
const plugin = isMjml ? mjmlPlugin : presetNewsletter
const pluginOpts = isMjml
? {
// grapesjs-mjml options see https://github.com/GrapesJS/mjml
fonts: {
'Plus Jakarta Sans': 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap',
'Space Grotesk': 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&display=swap',
},
}
: {
modalLabelImport: 'Coller votre HTML ici',
modalLabelExport: 'Copier le HTML',
codeViewerTheme: 'hopscotch',
importPlaceholder: '<table>...</table>',
cellStyle: { 'font-size': '12px', 'font-weight': 300, 'vertical-align': 'top' },
}
editor = grapesjs.init({
container: grapesContainer.value,
height: '100%',
width: 'auto',
storageManager: false, // we manage save ourselves via REST
fromElement: false,
plugins: [plugin],
pluginsOpts: {
[plugin]: pluginOpts,
},
// Asset Manager self-hosted upload via our /campaigns/assets/upload
// endpoint. Custom uploadFile handler bypasses GrapesJS' built-in
// multipart uploader to use our base64-JSON convention.
assetManager: {
// We override the default uploader, so leave `upload` empty here
upload: false,
uploadName: 'file',
// Triggered when user picks file(s) via the AssetManager dialog or
// drag-drops onto an image component. Reads each file, POSTs to hub,
// adds the returned URL to the AssetManager's library so the user
// can drag it from the sidebar.
uploadFile: async (e) => {
const files = e.dataTransfer ? e.dataTransfer.files : e.target.files
if (!files || !files.length) return
for (const file of files) {
try {
const res = await uploadAsset(file)
if (res?.url) {
editor.AssetManager.add({ type: 'image', src: res.url })
$q.notify({ type: 'positive', message: `Image téléversée : ${file.name}` })
}
} catch (err) {
$q.notify({ type: 'negative', message: `Échec téléversement: ${err.message}` })
}
}
},
},
// Custom blocks for merge-variable insertion
blockManager: {
blocks: [
{
id: 'var-firstname',
label: '{{firstname}}',
category: 'Variables',
content: '<span>{{firstname}}</span>',
attributes: { class: 'fa fa-user' },
},
{
id: 'var-amount',
label: '{{amount}}',
category: 'Variables',
content: '<strong>{{amount}}</strong>',
},
{
id: 'var-gift-url',
label: '{{gift_url}}',
category: 'Variables',
content: '<a href="{{gift_url}}" style="color:#019547; font-weight:700;">Lien cadeau</a>',
},
{
id: 'var-description',
label: '{{description}}',
category: 'Variables',
content: '<span>{{description}}</span>',
},
{
id: 'var-expiry',
label: '{{expiry}}',
category: 'Variables',
content: '<span>{{expiry}}</span>',
},
{
id: 'var-commitment',
label: '{{commitment_months}}',
category: 'Variables',
content: '<span>{{commitment_months}}</span>',
},
],
},
})
// Track changes
editor.on('component:add component:remove component:update', () => {
if (!editor.getHtml) return
const next = editor.getHtml({ component: editor.getWrapper() }) + '<style>' + editor.getCss() + '</style>'
if (next !== originalHtml) dirty.value = true
})
// Sync HTML on every change so the HTML tab stays accurate
editor.on('update', () => {
try {
html.value = editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : '')
} catch (e) { /* during init the editor may not have a canvas ready */ }
})
}
// When viewMode changes to 'preview', call the hub's preview endpoint with
// sample data so {{vars}} get substituted live.
watch(viewMode, async (mode) => {
if (mode === 'preview') {
// If user has been editing in HTML mode, use their current html.value;
// otherwise pull from the grapes canvas.
const sourceHtml = viewMode.value === 'html'
? html.value
: (editor ? editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : '') : html.value)
try {
const r = await previewTemplate(currentName.value, { html: sourceHtml })
previewHtml.value = r.rendered
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur prévisualisation: ' + e.message })
}
}
// When switching from HTML mode back to Visual, sync grapes canvas from textarea
if (mode === 'visual' && editor && html.value && html.value !== editor.getHtml()) {
editor.setComponents(html.value)
}
})
async function save () {
saving.value = true
try {
if (templateFormat.value === 'mjml') {
// MJML path: the editor's getHtml() returns MJML source (the mjml
// plugin overrides the default behaviour). We POST that the hub
// compiles to email-safe HTML server-side and saves both files.
const mjmlSrc = editor.getHtml()
await saveTemplate(currentName.value, mjmlSrc, { format: 'mjml' })
mjmlSource.value = mjmlSrc
originalHtml = mjmlSrc
} else {
// HTML legacy path
const finalHtml = viewMode.value === 'html'
? html.value
: (editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : ''))
await saveTemplate(currentName.value, finalHtml)
originalHtml = finalHtml
html.value = finalHtml
}
dirty.value = false
$q.notify({ type: 'positive', message: 'Template enregistré ✓' })
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
}
function discardChanges () {
html.value = originalHtml
if (editor) editor.setComponents(originalHtml)
dirty.value = false
blankCanvas.value = false
}
// Start from an empty canvas useful when composing a brand-new template
// without inheriting any structure from the existing on-disk template. The
// "saved" baseline is what's on disk, so clicking Annuler restores that.
// Saving while in blank mode OVERWRITES the disk template with the new
// composition (with a backup taken by the hub, see lib/campaigns.js).
function startBlank () {
$q.dialog({
title: 'Repartir d\'un canevas vide ?',
message: `Le canevas sera réinitialisé. Tes changements actuels sont conservés
sur disque tant que tu ne cliques pas "Enregistrer".`,
cancel: true, persistent: true,
}).onOk(() => {
const blank = `<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f7f8f7;font-family:-apple-system,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;background:#fff;border-radius:14px;">
<tr><td style="padding:32px;">
<p>Bonjour {{firstname}},</p>
<p>Ton message ici</p>
</td></tr>
</table>
</td></tr></table>
</body></html>`
html.value = blank
if (editor) editor.setComponents(blank)
dirty.value = true
blankCanvas.value = true
})
}
// Force-reload the on-disk version of the current template, discarding any
// unsaved edits. Useful after experimenting in the canvas to get back to
// known-good state without leaving the editor.
async function reloadFromDisk () {
if (dirty.value) {
const ok = await new Promise(resolve => {
$q.dialog({
title: 'Réinitialiser ?',
message: 'Les changements non sauvegardés seront perdus.',
cancel: true, persistent: true,
}).onOk(() => resolve(true)).onCancel(() => resolve(false))
})
if (!ok) return
}
await loadTemplate(currentName.value)
blankCanvas.value = false
}
onMounted(async () => {
await loadAvailableTemplates()
await nextTick()
initGrapes()
await loadTemplate(currentName.value)
// Preload previously-uploaded assets into the AssetManager so the user
// sees their existing image library in the sidebar (no need to re-upload
// an image they used in a previous campaign).
try {
const assets = await listAssets()
if (assets.length && editor) {
editor.AssetManager.add(assets.map(a => ({ type: 'image', src: a.url })))
}
} catch (e) { /* non-fatal — editor still works without preloaded assets */ }
})
onBeforeUnmount(() => {
if (editor) try { editor.destroy() } catch {}
})
</script> </script>
<style>
/* GrapesJS UI is darkish by default — tone down to match Quasar light theme */
.gjs-one-bg { background: #f3f4f3 !important; }
.gjs-three-bg { background: #019547 !important; }
.gjs-four-color, .gjs-four-color-h:hover { color: #019547 !important; }
</style>