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:
parent
0b6377fa58
commit
f9971e9113
|
|
@ -1,35 +1,55 @@
|
|||
<template>
|
||||
<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">
|
||||
<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>
|
||||
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined style="min-width:240px" @update:model-value="loadTemplate" />
|
||||
<q-btn flat dense icon="add" label="Vide" class="q-ml-sm" @click="startBlank">
|
||||
<q-tooltip>Repartir d'un canevas vide (pour composer une nouvelle template depuis zéro)</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense icon="restore" label="Réinitialiser" class="q-ml-sm" @click="reloadFromDisk">
|
||||
<q-tooltip>Recharger la dernière version sauvegardée sur disque</q-tooltip>
|
||||
</q-btn>
|
||||
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined
|
||||
style="min-width:240px" @update:model-value="onSelectTemplate" />
|
||||
<q-chip dense color="grey-2" text-color="grey-9" class="q-ml-sm" size="sm" icon="cloud_done"
|
||||
v-if="lastSaved">
|
||||
Sauvegardé · {{ lastSavedAt }}
|
||||
</q-chip>
|
||||
<q-space />
|
||||
<q-btn-toggle v-model="viewMode" :options="[
|
||||
{ label: 'Visuel', value: 'visual' },
|
||||
{ label: 'HTML', value: 'html' },
|
||||
{ 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 flat color="primary" icon="visibility" label="Aperçu inbox" class="q-mr-sm"
|
||||
@click="openPreview">
|
||||
<q-tooltip>Voir le rendu compilé avec données de test (HTML de l'inbox)</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat icon="undo" label="Annuler" class="q-ml-sm" :disable="!dirty" @click="discardChanges">
|
||||
<q-tooltip>Annuler les changements non sauvegardés</q-tooltip>
|
||||
<q-btn flat color="orange-9" icon="send" label="Envoyer un test" class="q-mr-sm"
|
||||
@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 unelevated color="primary" icon="save" label="Enregistrer" class="q-ml-sm"
|
||||
:loading="saving" :disable="!dirty" @click="save" />
|
||||
</div>
|
||||
|
||||
<!-- Test-send dialog: lets you fire ONE rendered email to any address
|
||||
with custom variable values. Always saves first if dirty (otherwise
|
||||
the test would render the OLD template — confusing). -->
|
||||
<!-- The editor itself — full-page iframe to the standalone email-editor
|
||||
microservice at editor.gigafibre.ca. Communicates back via postMessage
|
||||
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-card style="min-width: 500px; max-width: 90vw">
|
||||
<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-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
|
||||
type="email" autofocus class="q-mb-md"
|
||||
hint="L'email de test sera envoyé via Mailjet à cette adresse" />
|
||||
type="email" autofocus 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">
|
||||
<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.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.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.expiry" label="expiry (optionnel)" 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" outlined dense class="col-12" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="bg-grey-2">
|
||||
<div class="text-caption text-grey-8">
|
||||
Envoyé via Mailjet depuis <code>TARGO <support@targointernet.com></code> (sender validé).
|
||||
Le rendu utilisera le HTML compilé actuellement sauvegardé sur disque (pas les modifs non-sauvegardées).
|
||||
Envoyé via Mailjet depuis <code>TARGO <support@targointernet.com></code>.
|
||||
Utilise la dernière version sauvegardée (sauvegarde dans l'éditeur d'abord pour tester les modifs récentes).
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<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" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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 { useQuasar } from 'quasar'
|
||||
import { listTemplates, getTemplate, saveTemplate, previewTemplate,
|
||||
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'
|
||||
import { listTemplates, previewTemplate, testSendTemplate } from 'src/api/campaigns'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const $q = useQuasar()
|
||||
|
||||
const grapesContainer = ref(null)
|
||||
const templates = ref([])
|
||||
// ── Editor iframe — standalone microservice at editor.gigafibre.ca.
|
||||
// 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 html = ref('')
|
||||
const previewHtml = ref('')
|
||||
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
|
||||
const iframeKey = ref(0)
|
||||
const editorIframe = ref(null)
|
||||
|
||||
// ── 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 testSending = ref(false)
|
||||
const testSendForm = ref({
|
||||
|
|
@ -170,320 +200,12 @@ async function doTestSend () {
|
|||
subject: testSendForm.value.subject,
|
||||
vars: testSendForm.value.vars,
|
||||
})
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: `Test envoyé à ${r.to}`,
|
||||
caption: `${r.bytes} octets via ${r.from}`,
|
||||
timeout: 5000,
|
||||
})
|
||||
$q.notify({ type: 'positive', message: `Test envoyé à ${r.to}`, caption: `${r.bytes} octets`, timeout: 5000 })
|
||||
testSendOpen.value = false
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Échec envoi test: ' + e.message, timeout: 6000 })
|
||||
$q.notify({ type: 'negative', message: 'Échec envoi: ' + e.message })
|
||||
} finally {
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user