feat(campaigns): create new templates from UI + enable Unlayer template library

Two improvements to the template editor:

1. "+ Nouveau" button + creation dialog
   Users can now create new templates from the editor UI without us
   re-deploying the hub. Click "Nouveau" next to the template selector,
   pick a name + prefix + starter (blank or copy from existing), submit.
   The hub PUTs the new template (existing endpoint, no new code needed
   on the backend — just relaxed validation).

   Form:
     • Type (prefix): gift-email / newsletter / transactional
     • Name suffix: lowercase letters/digits/dashes (e.g. summer-2026)
     • Starter: "Vide" or "Copier depuis <existing template>"

   On submit:
     • If starter != blank: GET source template's html + design
     • PUT new template name with that content
     • Refresh templates list + switch editor to the new one

2. Backend: replace hardcoded EDITABLE_TEMPLATES allow-list with
   regex-validated prefix matching + disk scan
   • EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-',
     'transactional-'] — bounds what categories users can create
   • TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ — prevents path traversal
   • isValidTemplateName() validates both regex + prefix membership
   • scanEditableTemplates() returns all matching .html/.mjml files
     currently on disk (excludes .bak-* and .legacy-* variants)
   • listEditableTemplates() now scans disk instead of a static list,
     so newly-created templates appear automatically in the dropdown

3. Enable Unlayer's built-in panels
   • templates: true — exposes Unlayer's template library (limited
     free-tier selection but ~10-20 starters available without a
     projectId)
   • stockImages: true — Unsplash search built into image picker
   • imageEditor: true — basic crop/resize on inserted images
   • undoRedo: true — history navigation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-22 06:39:26 -04:00
parent 448e62177e
commit 73e4118901
2 changed files with 138 additions and 11 deletions

View File

@ -7,6 +7,9 @@
<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="onSelectTemplate" />
<q-btn flat dense icon="add" label="Nouveau" color="primary" class="q-ml-sm" @click="newTemplateOpen = true">
<q-tooltip>Créer un nouveau template (vide ou copié depuis un existant)</q-tooltip>
</q-btn>
<q-chip v-if="lastSavedTs" dense size="sm" color="grey-2" text-color="grey-9" class="q-ml-sm" icon="cloud_done">
Sauvegardé · {{ lastSavedLabel }}
</q-chip>
@ -60,6 +63,40 @@
</q-card>
</q-dialog>
<!-- New template dialog name + starter -->
<q-dialog v-model="newTemplateOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
<q-card-section class="row items-center bg-blue-1 text-blue-9">
<q-icon name="add" class="q-mr-sm" />
<div class="text-h6">Nouveau template</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<q-input v-model="newTemplateForm.suffix" outlined dense autofocus
label="Nom (suffixe après gift-email-)" :prefix="newTemplateForm.prefix + '-'"
:rules="[v => /^[a-z0-9-]+$/.test(v) || 'Lettres minuscules, chiffres et tirets seulement']"
hint="Exemple: summer-2026, automne-promo, anniversaire" class="q-mb-md" />
<q-select v-model="newTemplateForm.prefix" :options="['gift-email','newsletter','transactional']"
label="Type" outlined dense class="q-mb-md" />
<q-select v-model="newTemplateForm.starter" :options="starterOptions" emit-value map-options
label="Démarrer depuis" outlined dense class="q-mb-md" />
<q-banner v-if="newTemplateFinal" class="bg-grey-2 text-grey-8 q-mt-sm" rounded dense>
<q-icon name="info" class="q-mr-xs" />
Le template sera créé sous le nom <strong>{{ newTemplateFinal }}</strong>
<span v-if="newTemplateForm.starter !== 'blank'">
· contenu copié depuis <em>{{ newTemplateForm.starter }}</em>
</span>
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="primary" icon="add" label="Créer le template"
:loading="creating" :disable="!newTemplateValid" @click="createTemplate" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Test-send dialog -->
<q-dialog v-model="testSendOpen" persistent>
<q-card style="min-width: 500px; max-width: 90vw">
@ -165,6 +202,20 @@ const editorOptions = {
displayMode: 'email',
// Locale for built-in strings
locale: 'fr-CA',
// Enable optional sidebar features (free tier limits some see Unlayer docs)
features: {
// Built-in Unlayer template library limited selection without projectId
// but still gives the user some pre-built starts to pick from.
templates: true,
// Unsplash image search panel
stockImages: true,
// Image upload (uses Unlayer's CDN by default; can be wired to our hub
// /campaigns/assets/upload endpoint via customJS later if we want to
// self-host uploads for now their CDN is fine for ad-hoc images)
imageEditor: true,
// Undo/redo + history
undoRedo: true,
},
// Use Unlayer's free CDN. For paid users this would carry a projectId.
// Without a projectId the "Powered by Unlayer" badge shows in the sidebar.
}
@ -180,6 +231,60 @@ async function loadAvailableTemplates () {
templates.value = await listTemplates()
}
// New template creation
const newTemplateOpen = ref(false)
const creating = ref(false)
const newTemplateForm = ref({
prefix: 'gift-email',
suffix: '',
starter: 'blank', // 'blank' | template name to copy from
})
const newTemplateFinal = computed(() => {
const { prefix, suffix } = newTemplateForm.value
if (!suffix || !/^[a-z0-9-]+$/.test(suffix)) return ''
return `${prefix}-${suffix}`
})
const newTemplateValid = computed(() =>
!!newTemplateFinal.value &&
!templates.value.find(t => t.name === newTemplateFinal.value),
)
const starterOptions = computed(() => [
{ label: 'Vide (canevas blanc)', value: 'blank' },
...templates.value.map(t => ({ label: `Copier depuis ${t.name}`, value: t.name })),
])
async function createTemplate () {
if (!newTemplateValid.value) return
creating.value = true
try {
const newName = newTemplateFinal.value
let html = '<div style="padding:32px;text-align:center;color:#64748B;font-family:sans-serif;">Nouveau template — drag des blocs depuis la sidebar pour commencer.</div>'
let design = null
if (newTemplateForm.value.starter !== 'blank') {
// Copy html + design from the chosen source template
const src = await getTemplate(newTemplateForm.value.starter)
html = src.html || html
design = src.design || null
}
await saveTemplateApi(newName, html, { design })
await loadAvailableTemplates()
newTemplateOpen.value = false
// Switch to the new template
currentName.value = newName
router.replace({ path: `/campaigns/templates/${newName}` })
loadTemplateIntoEditor(newName)
newTemplateForm.value.suffix = '' // reset for next time
$q.notify({ type: 'positive', message: `Template "${newName}" créé`, timeout: 3000 })
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur création: ' + e.message })
} finally {
creating.value = false
}
}
// Load template into the Unlayer canvas on editor ready + on switch
async function loadTemplateIntoEditor (name) {
if (!editorReady.value || !editor.value) return

View File

@ -652,10 +652,32 @@ const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
// gets used at send time. `-simple` variants are flat-structured copies
// designed to parse cleanly in GrapesJS visual editor (no nested tables).
// Adding a new lang or variant = drop a new .html here + add to this list.
// Canonical templates — both now backed by .mjml source (compiled to .html
// on save for the send-worker). Legacy HTML-only variants were dropped
// after the MJML migration (see git history for rollback).
const EDITABLE_TEMPLATES = ['gift-email-fr', 'gift-email-en']
// Templates allow-list — moved from a hardcoded array to a regex-validated
// + disk-scan approach so the user can create new templates from the editor
// UI without us re-deploying the hub. The prefix list bounds what names are
// allowed (prevents arbitrary file writes outside the campaign domain).
const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-']
const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only
function isValidTemplateName (name) {
if (typeof name !== 'string' || !TEMPLATE_NAME_RE.test(name)) return false
return EDITABLE_TEMPLATE_PREFIXES.some(p => name.startsWith(p))
}
// Scan the templates dir and return every file matching our allow-list
// prefixes (.html OR .mjml — either format makes it editable). The .json
// design file alone doesn't count (it's a companion).
function scanEditableTemplates () {
if (!fs.existsSync(TEMPLATES_DIR)) return []
const seen = new Set()
for (const f of fs.readdirSync(TEMPLATES_DIR)) {
const m = f.match(/^([a-z0-9-]+)\.(html|mjml)$/)
if (!m) continue
if (m[1].includes('.bak-') || m[1].includes('.legacy-')) continue // skip backups
if (isValidTemplateName(m[1])) seen.add(m[1])
}
return [...seen].sort()
}
// Resolve the template path for a given recipient language. Falls back to
// the French version (most of the customer base) if the language-specific
@ -669,8 +691,8 @@ function templateForLanguage (lang) {
}
function templatePath (name) {
if (!EDITABLE_TEMPLATES.includes(name)) {
throw new Error(`template not editable: ${name}`)
if (!isValidTemplateName(name)) {
throw new Error(`template name invalid or not allowed: ${name}`)
}
return path.join(TEMPLATES_DIR, name + '.html')
}
@ -679,8 +701,8 @@ function templatePath (name) {
// is in MJML mode — the editor loads the .mjml source and on save we
// recompile to .html. If only the .html exists, classic HTML mode.
function templateMjmlPath (name) {
if (!EDITABLE_TEMPLATES.includes(name)) {
throw new Error(`template not editable: ${name}`)
if (!isValidTemplateName(name)) {
throw new Error(`template name invalid or not allowed: ${name}`)
}
return path.join(TEMPLATES_DIR, name + '.mjml')
}
@ -690,8 +712,8 @@ function templateMjmlPath (name) {
// present, the editor uses it as the source of truth on load. Generated
// client-side by the editor on save; we just persist it as-is.
function templateJsonPath (name) {
if (!EDITABLE_TEMPLATES.includes(name)) {
throw new Error(`template not editable: ${name}`)
if (!isValidTemplateName(name)) {
throw new Error(`template name invalid or not allowed: ${name}`)
}
return path.join(TEMPLATES_DIR, name + '.json')
}
@ -704,7 +726,7 @@ function templateFormat (name) {
}
function listEditableTemplates () {
return EDITABLE_TEMPLATES.map(name => {
return scanEditableTemplates().map(name => {
const format = templateFormat(name)
const p = format === 'mjml' ? templateMjmlPath(name) : path.join(TEMPLATES_DIR, name + '.html')
let size = 0, modified = null