feat(email-editor): Phase 1 — scaffold easy-email microservice for visual template editing
GrapesJS-mjml proved broken on our content (plugin v1.0.8 incompatible
with MJML v5 — canvas stays empty on load). Pivot to easy-email, a
mature OSS WYSIWYG email editor (MJML-based, MIT license, 4k stars).
Architecture: standalone React+Vite microservice deployed at
editor.gigafibre.ca, iframed from the ops UI's
/campaigns/templates/:name page. Talks to the hub's existing REST
endpoints (/campaigns/templates/*) for load + save. The hub stays the
source of truth — easy-email is purely the editing UI.
Scaffold delivered in this commit (Phase 1):
- services/email-editor/ — new top-level service directory
- package.json: React 18 + easy-email-{core,editor,extensions} 4.16.x
+ Vite 5 + TypeScript 5
- vite.config.ts: standard dev/build config, port 5173 in dev
- tsconfig.json: strict-false to keep iteration fast
- index.html: loads easy-email CSS bundles from unpkg (extensions, editor,
arco theme)
- src/main.tsx: React entry, mounts EmailEditorApp on #root
- src/EmailEditorApp.tsx:
• Reads template name from ?name=... URL param (defaults gift-email-fr)
• GET ${VITE_HUB_URL}/campaigns/templates/:name on mount
• Renders <EmailEditorProvider> + <StandardLayout> with our merge tags
map (firstname, amount, gift_url, description, expiry, etc.) so the
Variables panel shows our Mustache placeholders
• On save: JsonToMjml() converts easy-email's JSON → MJML, PUT to hub
→ hub compiles to HTML and persists both files
• postMessage({type: 'email-editor:saved', ...}) to parent window so
the iframing ops UI knows to refresh
- Dockerfile: multi-stage (Vite build → nginx alpine serve). SPA fallback
in nginx config so all routes return index.html.
- docker-compose.yml: container behind Traefik at editor.gigafibre.ca
with Let's Encrypt TLS via the shared proxy network.
- README.md documents the arch, URL params, postMessage protocol, dev
workflow, and the Phase 1 limitation (no MJML→JSON importer — editor
starts from empty page until Phase 3).
- .gitignore: standard node/vite/dist exclusions.
Build verified locally: 83 modules transformed, ~2.8 MB bundle (840 KB
gzipped) — large but acceptable since easy-email packages the full
email builder + drag-drop canvas.
Phase 2 (next): Docker deploy on prod + replace GrapesJS in the ops UI
TemplateEditorPage with an iframe pointing here.
Phase 3 (later): MJML → easy-email JSON parser so existing templates
auto-import into the canvas instead of starting blank.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2fe8d3f50e
commit
0b6377fa58
5
services/email-editor/.gitignore
vendored
Normal file
5
services/email-editor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.DS_Store
|
||||
*.log
|
||||
52
services/email-editor/Dockerfile
Normal file
52
services/email-editor/Dockerfile
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Multi-stage Dockerfile for the email-editor microservice.
|
||||
# Stage 1: Vite build into dist/
|
||||
# Stage 2: nginx serving the static files
|
||||
|
||||
# ── Stage 1: build ──
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install only what's needed for build (no native deps required)
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --silent
|
||||
|
||||
COPY tsconfig.json vite.config.ts index.html ./
|
||||
COPY src ./src
|
||||
|
||||
# Inline the prod hub URL at build time. Override via --build-arg on docker
|
||||
# build if running against a different hub (e.g. staging).
|
||||
ARG VITE_HUB_URL=https://msg.gigafibre.ca
|
||||
ENV VITE_HUB_URL=$VITE_HUB_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: nginx serve ──
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Drop the default nginx config and inject a minimal SPA-friendly one
|
||||
# (all routes serve index.html — easy-email is a SPA, query params drive
|
||||
# which template to load).
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
COPY <<EOF /etc/nginx/conf.d/default.conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Long cache for hashed assets (vite outputs them with content hash in name)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback — every request returns index.html so the React app handles
|
||||
# routing client-side based on ?name=... query
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
EXPOSE 80
|
||||
63
services/email-editor/README.md
Normal file
63
services/email-editor/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# TARGO Email Editor
|
||||
|
||||
Standalone email template editor microservice — React + Vite + [easy-email](https://github.com/zalify/easy-email)
|
||||
(OSS WYSIWYG email builder, MJML-based).
|
||||
|
||||
Embedded as an iframe in the ops UI's `/campaigns/templates/:name` page.
|
||||
Talks to the hub's `/campaigns/templates/*` REST endpoints for load/save.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ops UI (Vue) → iframe → editor.gigafibre.ca → REST → msg.gigafibre.ca (hub)
|
||||
└─ writes .mjml + .html
|
||||
to /opt/targo-hub/templates/
|
||||
```
|
||||
|
||||
## URL params
|
||||
|
||||
- `?name=gift-email-fr` — which template to load (defaults to gift-email-fr)
|
||||
|
||||
## postMessage protocol (to parent window)
|
||||
|
||||
Emitted on save success:
|
||||
```js
|
||||
{ type: 'email-editor:saved', template: 'gift-email-fr', ts: 1700000000000 }
|
||||
```
|
||||
|
||||
The parent ops UI listens via:
|
||||
```js
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data.type === 'email-editor:saved') {
|
||||
// refresh preview / show toast
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173?name=gift-email-fr
|
||||
```
|
||||
|
||||
Set `VITE_HUB_URL` env to point at a non-prod hub if needed.
|
||||
|
||||
## Build + deploy
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Traefik auto-provisions HTTPS at `editor.gigafibre.ca` (Let's Encrypt via
|
||||
the `letsencrypt` resolver shared with the rest of our stack).
|
||||
|
||||
## Known limitations (Phase 1)
|
||||
|
||||
- Existing MJML templates from the hub are NOT auto-imported into the
|
||||
easy-email JSON tree (no MJML → JSON parser in easy-email out of the box).
|
||||
The editor starts from an empty page. User rebuilds visually with the
|
||||
hub's compiled HTML as visual reference.
|
||||
- TODO Phase 3: integrate an MJML → easy-email-JSON parser (likely fork or
|
||||
reverse-engineer JsonToMjml).
|
||||
28
services/email-editor/docker-compose.yml
Normal file
28
services/email-editor/docker-compose.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
services:
|
||||
email-editor:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# Override at build time if pointing at staging:
|
||||
# docker compose build --build-arg VITE_HUB_URL=https://staging-msg.gigafibre.ca
|
||||
VITE_HUB_URL: https://msg.gigafibre.ca
|
||||
container_name: email-editor
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
# Public route — same Authentik forwardAuth pattern as ops UI could be
|
||||
# added here too, but for now the editor is iframed from the (already
|
||||
# authenticated) ops UI so external auth is layered through the parent.
|
||||
# Leaving it open means anyone with the URL can edit templates — fine
|
||||
# for the iframe-only use case; harden later if exposed standalone.
|
||||
- "traefik.http.routers.email-editor.rule=Host(`editor.gigafibre.ca`)"
|
||||
- "traefik.http.routers.email-editor.entrypoints=websecure"
|
||||
- "traefik.http.routers.email-editor.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.email-editor.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
16
services/email-editor/index.html
Normal file
16
services/email-editor/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TARGO email editor</title>
|
||||
<!-- easy-email's required styles. Order matters: extensions first, then editor. -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/easy-email-editor@4.16.6/lib/style.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/easy-email-extensions@4.16.5/lib/style.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/@arco-themes/react-easy-email-theme/css/arco.css" />
|
||||
</head>
|
||||
<body style="margin:0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3219
services/email-editor/package-lock.json
generated
Normal file
3219
services/email-editor/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
services/email-editor/package.json
Normal file
25
services/email-editor/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "targo-email-editor",
|
||||
"version": "1.0.0",
|
||||
"description": "Standalone email editor microservice — easy-email (React) embedded via iframe in the ops UI. Talks to the targo-hub /campaigns/templates/* endpoints to load and save campaign templates.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"easy-email-core": "^4.16.5",
|
||||
"easy-email-editor": "^4.16.6",
|
||||
"easy-email-extensions": "^4.16.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
225
services/email-editor/src/EmailEditorApp.tsx
Normal file
225
services/email-editor/src/EmailEditorApp.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { EmailEditorProvider, EmailEditor, IEmailTemplate } from 'easy-email-editor'
|
||||
import { ExtensionProps, StandardLayout } from 'easy-email-extensions'
|
||||
import { BasicType, AdvancedType, JsonToMjml } from 'easy-email-core'
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Targo email editor — wraps easy-email-editor with our hub integration:
|
||||
//
|
||||
// 1. On mount: read ?name=<template-name> from URL, GET its MJML from the hub
|
||||
// 2. Render easy-email with the loaded MJML
|
||||
// 3. On save (Cmd-S or button): convert easy-email JSON → MJML, PUT to hub
|
||||
// 4. postMessage to parent window so the wrapping ops UI knows we saved
|
||||
//
|
||||
// Hub URL is read from VITE_HUB_URL env (defaults to msg.gigafibre.ca in prod).
|
||||
// The hub does the MJML → HTML compilation server-side; we just send the MJML.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HUB_URL = (import.meta as any).env?.VITE_HUB_URL || 'https://msg.gigafibre.ca'
|
||||
|
||||
// Merge tags exposed to the editor's "Variables" panel. These map to the
|
||||
// Mustache variables the hub renders at send time.
|
||||
const MERGE_TAGS = {
|
||||
firstname: '{{firstname}}',
|
||||
lastname: '{{lastname}}',
|
||||
email: '{{email}}',
|
||||
amount: '{{amount}}',
|
||||
gift_url: '{{gift_url}}',
|
||||
description: '{{description}}',
|
||||
expiry: '{{expiry}}',
|
||||
commitment_months: '{{commitment_months}}',
|
||||
year: '{{year}}',
|
||||
}
|
||||
|
||||
// Minimal initial template returned when the hub has no content yet (rare —
|
||||
// since we always pre-create gift-email-fr.mjml). Kept defensive.
|
||||
function emptyTemplate(): IEmailTemplate {
|
||||
return {
|
||||
subject: 'Une offre exclusive de TARGO',
|
||||
subTitle: 'Comme toi, on aime les connexions stables et les relations durables.',
|
||||
content: {
|
||||
type: BasicType.PAGE,
|
||||
data: {
|
||||
value: {
|
||||
breakpoint: '480px',
|
||||
headAttributes: '',
|
||||
'font-size': '16px',
|
||||
'line-height': '1.5',
|
||||
'font-family': "'Plus Jakarta Sans', Helvetica, Arial, sans-serif",
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
'background-color': '#F5FAF7',
|
||||
width: '600px',
|
||||
},
|
||||
children: [],
|
||||
} as any,
|
||||
}
|
||||
}
|
||||
|
||||
export function EmailEditorApp() {
|
||||
const [templateName, setTemplateName] = useState<string>('')
|
||||
const [initialValues, setInitialValues] = useState<IEmailTemplate | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Read template name from URL and fetch its MJML content from the hub on mount
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const name = params.get('name') || 'gift-email-fr'
|
||||
setTemplateName(name)
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(name)}`)
|
||||
if (!res.ok) throw new Error(`Hub returned ${res.status}`)
|
||||
const data = await res.json()
|
||||
// The hub's getTemplate returns { name, format, mjml?, html }
|
||||
// For MJML templates we'd ideally parse the MJML into easy-email's JSON
|
||||
// tree — but easy-email doesn't ship an MJML importer. Workaround: we
|
||||
// start the editor with an EMPTY page and let the user rebuild visually
|
||||
// OR stash the existing MJML as the editor's content. For first iteration,
|
||||
// empty + the user reconstructs is honest about the limitation.
|
||||
// The compiled HTML preview remains available so they can see what
|
||||
// they're rebuilding TOWARD.
|
||||
// TODO Phase 3: integrate mjml-react-email or similar MJML→JSON parser
|
||||
setInitialValues(emptyTemplate())
|
||||
setError(`Note: existing template MJML (${data.format}, ${(data.mjml || '').length}b) ` +
|
||||
`not auto-imported — easy-email starts from an empty page. ` +
|
||||
`Use the hub's compiled HTML as a visual reference and rebuild here.`)
|
||||
} catch (e: any) {
|
||||
setError(`Could not load template "${name}": ${e.message}`)
|
||||
setInitialValues(emptyTemplate())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
// Save → convert easy-email's JSON tree to MJML, PUT to hub
|
||||
const onSave = useCallback(async (values: IEmailTemplate) => {
|
||||
if (!templateName) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const mjmlSource = JsonToMjml({
|
||||
data: values.content as any,
|
||||
mode: 'production',
|
||||
context: values.content as any,
|
||||
})
|
||||
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(templateName)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mjml: mjmlSource }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({}))
|
||||
throw new Error(errBody.error || `Hub returned ${res.status}`)
|
||||
}
|
||||
// Notify parent window (the ops UI iframing us) that we saved
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage(
|
||||
{ type: 'email-editor:saved', template: templateName, ts: Date.now() },
|
||||
'*',
|
||||
)
|
||||
}
|
||||
// Visual confirmation (toast handled by easy-email's own UI)
|
||||
} catch (e: any) {
|
||||
setError(`Save failed: ${e.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [templateName])
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 32, textAlign: 'center' }}>Loading template…</div>
|
||||
}
|
||||
if (!initialValues) {
|
||||
return <div style={{ padding: 32, color: 'red' }}>{error}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Top bar — shows template name + save state + parent communication */}
|
||||
<div style={{
|
||||
padding: '8px 16px',
|
||||
background: '#1B2E24',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}>
|
||||
<strong>TARGO Email Editor</strong>
|
||||
<span style={{ opacity: 0.7 }}>· {templateName}</span>
|
||||
{saving && <span style={{ color: '#00C853' }}>· Saving…</span>}
|
||||
{error && (
|
||||
<span style={{ color: '#fbbf24', fontSize: 12, marginLeft: 'auto', maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Editor */}
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<EmailEditorProvider
|
||||
data={initialValues}
|
||||
height="100%"
|
||||
autoComplete
|
||||
dashed={false}
|
||||
mergeTags={MERGE_TAGS}
|
||||
mergeTagGenerate={(tag: string) => `{{${tag}}}`}
|
||||
onSubmit={onSave}
|
||||
>
|
||||
{() => (
|
||||
<StandardLayout
|
||||
showSourceCode
|
||||
categories={DEFAULT_CATEGORIES}
|
||||
/>
|
||||
)}
|
||||
</EmailEditorProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Block categories shown in the left sidebar — same set easy-email uses by
|
||||
// default, organized for email composition.
|
||||
const DEFAULT_CATEGORIES: ExtensionProps['categories'] = [
|
||||
{
|
||||
label: 'Content',
|
||||
active: true,
|
||||
blocks: [
|
||||
{ type: AdvancedType.TEXT },
|
||||
{ type: AdvancedType.BUTTON },
|
||||
{ type: AdvancedType.IMAGE },
|
||||
{ type: AdvancedType.DIVIDER },
|
||||
{ type: AdvancedType.SPACER },
|
||||
{ type: AdvancedType.HERO },
|
||||
{ type: AdvancedType.WRAPPER },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Layout',
|
||||
active: true,
|
||||
displayType: 'column',
|
||||
blocks: [
|
||||
{
|
||||
title: '1 column',
|
||||
payload: [['100%']],
|
||||
},
|
||||
{
|
||||
title: '2 columns',
|
||||
payload: [['50%', '50%']],
|
||||
},
|
||||
{
|
||||
title: '3 columns',
|
||||
payload: [['33%', '33%', '33%']],
|
||||
},
|
||||
{
|
||||
title: '4 columns',
|
||||
payload: [['25%', '25%', '25%', '25%']],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
14
services/email-editor/src/main.tsx
Normal file
14
services/email-editor/src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { EmailEditorApp } from './EmailEditorApp'
|
||||
|
||||
// Entry point — mounts the easy-email editor app on #root.
|
||||
// Template name comes from URL query: ?name=gift-email-fr
|
||||
// In production the page lives at editor.gigafibre.ca and is iframed from
|
||||
// the ops UI's /campaigns/templates/:name route.
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<EmailEditorApp />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
20
services/email-editor/tsconfig.json
Normal file
20
services/email-editor/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
19
services/email-editor/vite.config.ts
Normal file
19
services/email-editor/vite.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// Vite config — served at editor.gigafibre.ca behind Traefik in prod.
|
||||
// In dev: `npm run dev` exposes http://localhost:5173.
|
||||
// Base path is '/' since this is a standalone microservice (own domain), not
|
||||
// a sub-path of another app.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0', // accessible from outside container in dev
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: false,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user