feat: add template studio service foundation
This commit is contained in:
parent
0aad601ba5
commit
6d50f9d42f
7
.env.example
Normal file
7
.env.example
Normal file
@ -0,0 +1,7 @@
|
||||
PORT=3000
|
||||
DATABASE_URL=postgresql://template_studio:change-me@localhost:5432/template_studio
|
||||
TEMPLATE_TENANT_ID=default
|
||||
AUTH_REQUIRED=false
|
||||
KEYCLOAK_ISSUER=https://auth.example.com/realms/dev
|
||||
KEYCLOAK_CLIENT_ID=template-studio
|
||||
CHROMIUM_PATH=/usr/bin/chromium
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
*.log
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run typecheck && npm run test && npm run build
|
||||
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends chromium ca-certificates fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server ./server
|
||||
ENV PORT=3000
|
||||
ENV CHROMIUM_PATH=/usr/bin/chromium
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/api/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
CMD ["node", "server/index.js"]
|
||||
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "template-studio",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "Mobile-first email template and report builder for Arrowstack.",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"node --watch server/index.js\" \"vite --host 0.0.0.0\"",
|
||||
"build": "vite build",
|
||||
"start": "node server/index.js",
|
||||
"test": "node --test server/*.test.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"jose": "^6.1.0",
|
||||
"keycloak-js": "^26.2.0",
|
||||
"mjml": "^4.15.3",
|
||||
"pg": "^8.16.3",
|
||||
"playwright-core": "^1.55.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.0.3"
|
||||
}
|
||||
}
|
||||
45
server/auth.js
Normal file
45
server/auth.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
||||
|
||||
let jwks
|
||||
|
||||
function authConfig() {
|
||||
const issuer = process.env.KEYCLOAK_ISSUER?.replace(/\/$/, '')
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID
|
||||
return { issuer, clientId }
|
||||
}
|
||||
|
||||
export function publicAuthConfig() {
|
||||
const { issuer, clientId } = authConfig()
|
||||
return {
|
||||
authRequired: process.env.AUTH_REQUIRED !== 'false',
|
||||
issuer: issuer || '',
|
||||
clientId: clientId || ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(req, res, next) {
|
||||
if (process.env.AUTH_REQUIRED === 'false') {
|
||||
req.user = { sub: 'local-dev', preferred_username: 'local-dev' }
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
const { issuer, clientId } = authConfig()
|
||||
if (!issuer || !clientId) throw new Error('Keycloak is not configured')
|
||||
const header = req.headers.authorization || ''
|
||||
if (!header.startsWith('Bearer ')) return res.status(401).json({ error: 'missing_bearer_token' })
|
||||
|
||||
jwks ||= createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`))
|
||||
const { payload } = await jwtVerify(header.slice(7), jwks, { issuer })
|
||||
|
||||
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud].filter(Boolean)
|
||||
if (payload.azp !== clientId && !aud.includes(clientId)) {
|
||||
return res.status(403).json({ error: 'wrong_client' })
|
||||
}
|
||||
|
||||
req.user = payload
|
||||
next()
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'invalid_token', detail: error.message })
|
||||
}
|
||||
}
|
||||
52
server/db.js
Normal file
52
server/db.js
Normal file
@ -0,0 +1,52 @@
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
let pool
|
||||
|
||||
export function getPool() {
|
||||
if (!pool) {
|
||||
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required')
|
||||
pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 10,
|
||||
ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : undefined
|
||||
})
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
export async function initDb() {
|
||||
const db = getPool()
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id text PRIMARY KEY,
|
||||
tenant_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL CHECK (type IN ('email', 'report')),
|
||||
status text NOT NULL DEFAULT 'draft',
|
||||
draft_document jsonb NOT NULL,
|
||||
sample_data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
published_version integer,
|
||||
created_by text,
|
||||
updated_by text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`)
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS template_versions (
|
||||
id text PRIMARY KEY,
|
||||
template_id text NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
|
||||
version integer NOT NULL,
|
||||
document jsonb NOT NULL,
|
||||
sample_data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_by text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(template_id, version)
|
||||
)
|
||||
`)
|
||||
await db.query(`
|
||||
CREATE INDEX IF NOT EXISTS templates_tenant_updated_idx
|
||||
ON templates (tenant_id, updated_at DESC)
|
||||
`)
|
||||
}
|
||||
147
server/index.js
Normal file
147
server/index.js
Normal file
@ -0,0 +1,147 @@
|
||||
import crypto from 'node:crypto'
|
||||
import express from 'express'
|
||||
import { chromium } from 'playwright-core'
|
||||
import { initDb, getPool } from './db.js'
|
||||
import { publicAuthConfig, requireAuth } from './auth.js'
|
||||
import { renderEmailHtml, renderReportHtml } from './templateEngine.js'
|
||||
|
||||
const app = express()
|
||||
const port = Number(process.env.PORT || 3000)
|
||||
const tenantId = process.env.TEMPLATE_TENANT_ID || 'default'
|
||||
|
||||
app.use(express.json({ limit: '2mb' }))
|
||||
app.get('/api/health/ready', async (_req, res) => {
|
||||
try {
|
||||
await getPool().query('SELECT 1')
|
||||
res.json({ ok: true })
|
||||
} catch (error) {
|
||||
res.status(503).json({ ok: false, error: error.message })
|
||||
}
|
||||
})
|
||||
app.get('/api/config', (_req, res) => res.json(publicAuthConfig()))
|
||||
app.use('/api', requireAuth)
|
||||
|
||||
function userId(req) {
|
||||
return req.user?.preferred_username || req.user?.sub || 'unknown'
|
||||
}
|
||||
|
||||
function defaultDocument(type) {
|
||||
return {
|
||||
blocks: [
|
||||
{ id: crypto.randomUUID(), type: 'heading', props: { text: type === 'email' ? 'Hello {{customer.name}}' : 'Report — {{report.date}}' } },
|
||||
{ id: crypto.randomUUID(), type: 'text', props: { text: type === 'email' ? 'Add your message here.' : 'Add report context here.' } },
|
||||
{ id: crypto.randomUUID(), type: 'kpi', props: { label: 'Total', value: '{{summary.total}}' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/templates', async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
'SELECT id,name,type,status,published_version,updated_at FROM templates WHERE tenant_id=$1 ORDER BY updated_at DESC',
|
||||
[tenantId]
|
||||
)
|
||||
res.json(rows)
|
||||
})
|
||||
|
||||
app.post('/api/templates', async (req, res) => {
|
||||
const name = String(req.body?.name || '').trim()
|
||||
const type = req.body?.type === 'email' ? 'email' : 'report'
|
||||
if (!name) return res.status(400).json({ error: 'name_required' })
|
||||
const id = crypto.randomUUID()
|
||||
const document = defaultDocument(type)
|
||||
const sampleData = { customer: { name: 'Example Customer' }, report: { date: new Date().toISOString().slice(0, 10) }, summary: { total: 125 } }
|
||||
const { rows } = await getPool().query(
|
||||
`INSERT INTO templates (id,tenant_id,name,type,draft_document,sample_data,created_by,updated_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$7) RETURNING *`,
|
||||
[id, tenantId, name, type, document, sampleData, userId(req)]
|
||||
)
|
||||
res.status(201).json(rows[0])
|
||||
})
|
||||
|
||||
app.get('/api/templates/:id', async (req, res) => {
|
||||
const { rows } = await getPool().query('SELECT * FROM templates WHERE id=$1 AND tenant_id=$2', [req.params.id, tenantId])
|
||||
if (!rows[0]) return res.status(404).json({ error: 'not_found' })
|
||||
res.json(rows[0])
|
||||
})
|
||||
|
||||
app.put('/api/templates/:id/draft', async (req, res) => {
|
||||
const document = req.body?.document
|
||||
const sampleData = req.body?.sampleData ?? {}
|
||||
if (!document || !Array.isArray(document.blocks)) return res.status(400).json({ error: 'invalid_document' })
|
||||
const { rows } = await getPool().query(
|
||||
`UPDATE templates SET draft_document=$1,sample_data=$2,updated_by=$3,updated_at=now()
|
||||
WHERE id=$4 AND tenant_id=$5 RETURNING *`,
|
||||
[document, sampleData, userId(req), req.params.id, tenantId]
|
||||
)
|
||||
if (!rows[0]) return res.status(404).json({ error: 'not_found' })
|
||||
res.json(rows[0])
|
||||
})
|
||||
|
||||
app.post('/api/templates/:id/publish', async (req, res) => {
|
||||
const db = await getPool().connect()
|
||||
try {
|
||||
await db.query('BEGIN')
|
||||
const current = await db.query('SELECT * FROM templates WHERE id=$1 AND tenant_id=$2 FOR UPDATE', [req.params.id, tenantId])
|
||||
const template = current.rows[0]
|
||||
if (!template) {
|
||||
await db.query('ROLLBACK')
|
||||
return res.status(404).json({ error: 'not_found' })
|
||||
}
|
||||
const nextVersion = (template.published_version || 0) + 1
|
||||
await db.query(
|
||||
`INSERT INTO template_versions (id,template_id,version,document,sample_data,created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[crypto.randomUUID(), template.id, nextVersion, template.draft_document, template.sample_data, userId(req)]
|
||||
)
|
||||
const updated = await db.query(
|
||||
`UPDATE templates SET status='published',published_version=$1,updated_at=now(),updated_by=$2
|
||||
WHERE id=$3 RETURNING *`,
|
||||
[nextVersion, userId(req), template.id]
|
||||
)
|
||||
await db.query('COMMIT')
|
||||
res.json(updated.rows[0])
|
||||
} catch (error) {
|
||||
await db.query('ROLLBACK')
|
||||
throw error
|
||||
} finally {
|
||||
db.release()
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/templates/:id/render', async (req, res) => {
|
||||
const { rows } = await getPool().query('SELECT * FROM templates WHERE id=$1 AND tenant_id=$2', [req.params.id, tenantId])
|
||||
const template = rows[0]
|
||||
if (!template) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const data = req.body?.data ?? template.sample_data ?? {}
|
||||
const format = req.body?.format || (template.type === 'email' ? 'email' : 'html')
|
||||
const document = template.draft_document
|
||||
|
||||
if (format === 'email') return res.json({ html: renderEmailHtml(document, data) })
|
||||
const html = renderReportHtml(document, data)
|
||||
if (format === 'html') return res.json({ html })
|
||||
if (format !== 'pdf') return res.status(400).json({ error: 'unsupported_format' })
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
||||
})
|
||||
try {
|
||||
const page = await browser.newPage()
|
||||
await page.setContent(html, { waitUntil: 'networkidle' })
|
||||
const pdf = await page.pdf({ format: 'A4', printBackground: true })
|
||||
res.type('application/pdf').send(pdf)
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
})
|
||||
|
||||
app.use(express.static('dist'))
|
||||
app.get(/^(?!\/api).*/, (_req, res) => res.sendFile('index.html', { root: 'dist' }))
|
||||
app.use((error, _req, res, _next) => {
|
||||
console.error(error)
|
||||
res.status(500).json({ error: 'internal_error' })
|
||||
})
|
||||
|
||||
await initDb()
|
||||
app.listen(port, '0.0.0.0', () => console.log(`Template Studio listening on :${port}`))
|
||||
93
server/templateEngine.js
Normal file
93
server/templateEngine.js
Normal file
@ -0,0 +1,93 @@
|
||||
import mjml2html from 'mjml'
|
||||
|
||||
function getPath(data, path) {
|
||||
return String(path || '').split('.').filter(Boolean).reduce((value, key) => value?.[key], data)
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
export function interpolate(input, data) {
|
||||
return String(input ?? '').replace(/{{\s*([\w.[\]-]+)\s*}}/g, (_, path) => {
|
||||
const normalized = path.replace(/\[(\d+)\]/g, '.$1')
|
||||
const value = getPath(data, normalized)
|
||||
return value == null ? '' : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
function safeUrl(input, data) {
|
||||
const url = interpolate(input, data).trim()
|
||||
return /^(https?:|mailto:)/i.test(url) ? url : '#'
|
||||
}
|
||||
|
||||
function renderTable(block, data) {
|
||||
const rows = getPath(data, block.props?.rowsPath) || []
|
||||
const columns = Array.isArray(block.props?.columns) ? block.props.columns : []
|
||||
if (!Array.isArray(rows)) return ''
|
||||
return `<div class="table-wrap"><table><thead><tr>${columns.map(c => `<th>${escapeHtml(c.label)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${columns.map(c => `<td>${escapeHtml(getPath(row, c.key))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`
|
||||
}
|
||||
|
||||
function blockHtml(block, data) {
|
||||
const p = block.props || {}
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
return `<h2>${escapeHtml(interpolate(p.text, data))}</h2>`
|
||||
case 'text':
|
||||
return `<p>${escapeHtml(interpolate(p.text, data))}</p>`
|
||||
case 'kpi':
|
||||
return `<div class="kpi"><span>${escapeHtml(interpolate(p.label, data))}</span><strong>${escapeHtml(interpolate(p.value, data))}</strong></div>`
|
||||
case 'button':
|
||||
return `<p><a class="button" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</a></p>`
|
||||
case 'image':
|
||||
return `<img class="image" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
||||
case 'divider':
|
||||
return '<hr />'
|
||||
case 'table':
|
||||
return renderTable(block, data)
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function renderReportHtml(template, data = {}) {
|
||||
const blocks = template?.blocks || []
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
@page{size:A4;margin:18mm}*{box-sizing:border-box}body{font:15px/1.5 system-ui,-apple-system,sans-serif;color:#172033;margin:0}h2{font-size:24px;margin:0 0 16px}p{margin:0 0 14px}.kpi{border:1px solid #dbe2ea;border-radius:12px;padding:14px;margin:0 0 12px;display:flex;justify-content:space-between;gap:16px}.kpi span{color:#64748b}.kpi strong{font-size:18px}.button{display:inline-block;background:#111827;color:white;text-decoration:none;padding:10px 16px;border-radius:10px}.image{max-width:100%;height:auto;border-radius:10px}hr{border:0;border-top:1px solid #dbe2ea;margin:18px 0}.table-wrap{overflow:hidden;border:1px solid #dbe2ea;border-radius:10px;margin:0 0 16px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{padding:9px 10px;text-align:left;border-bottom:1px solid #eef2f7}th{background:#f8fafc}
|
||||
</style></head><body>${blocks.map(block => blockHtml(block, data)).join('\n')}</body></html>`
|
||||
}
|
||||
|
||||
function emailBlock(block, data) {
|
||||
const p = block.props || {}
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
return `<mj-text font-size="24px" font-weight="700">${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
||||
case 'text':
|
||||
return `<mj-text>${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
||||
case 'kpi':
|
||||
return `<mj-text color="#64748b">${escapeHtml(interpolate(p.label, data))}</mj-text><mj-text font-size="22px" font-weight="700">${escapeHtml(interpolate(p.value, data))}</mj-text>`
|
||||
case 'button':
|
||||
return `<mj-button background-color="#111827" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</mj-button>`
|
||||
case 'image':
|
||||
return `<mj-image src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
||||
case 'divider':
|
||||
return '<mj-divider border-color="#dbe2ea" />'
|
||||
default:
|
||||
return '<mj-text color="#64748b">This block is available in report output only.</mj-text>'
|
||||
}
|
||||
}
|
||||
|
||||
export function renderEmailHtml(template, data = {}) {
|
||||
const body = (template?.blocks || []).map(block => emailBlock(block, data)).join('\n')
|
||||
const mjml = `<mjml><mj-head><mj-attributes><mj-all font-family="Arial, sans-serif" /><mj-text font-size="15px" line-height="1.5" color="#172033" /></mj-attributes></mj-head><mj-body background-color="#f1f5f9"><mj-section background-color="#ffffff" padding="24px"><mj-column>${body}</mj-column></mj-section></mj-body></mjml>`
|
||||
const result = mjml2html(mjml, { validationLevel: 'soft' })
|
||||
if (result.errors?.length) console.warn('MJML warnings', result.errors)
|
||||
return result.html
|
||||
}
|
||||
19
server/templateEngine.test.js
Normal file
19
server/templateEngine.test.js
Normal file
@ -0,0 +1,19 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { interpolate, renderReportHtml, renderEmailHtml } from './templateEngine.js'
|
||||
|
||||
test('interpolates nested values', () => {
|
||||
assert.equal(interpolate('Hello {{customer.name}}', { customer: { name: 'Kauri' } }), 'Hello Kauri')
|
||||
})
|
||||
|
||||
test('report output escapes injected markup', () => {
|
||||
const html = renderReportHtml({ blocks: [{ type: 'text', props: { text: '{{value}}' } }] }, { value: '<script>x</script>' })
|
||||
assert.match(html, /<script>/)
|
||||
assert.doesNotMatch(html, /<script>x/)
|
||||
})
|
||||
|
||||
test('email renderer produces HTML', () => {
|
||||
const html = renderEmailHtml({ blocks: [{ type: 'heading', props: { text: 'Hello' } }] }, {})
|
||||
assert.match(html, /Hello/)
|
||||
assert.match(html, /doctype/i)
|
||||
})
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
29
vite.config.ts
Normal file
29
vite.config.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Arrowstack Template Studio',
|
||||
short_name: 'Templates',
|
||||
description: 'Mobile-first email and report template builder',
|
||||
theme_color: '#111827',
|
||||
background_color: '#f8fafc',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000'
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user