693 lines
20 KiB
JavaScript
693 lines
20 KiB
JavaScript
import { createReadStream, existsSync, statSync } from 'node:fs'
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
import { createServer } from 'node:http'
|
|
import { extname, join, normalize } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
const rootDir = normalize(join(__dirname, '..'))
|
|
const publicDir = join(rootDir, 'public')
|
|
const assetDir = join(publicDir, 'action-assets')
|
|
const host = process.env.HOST ?? '127.0.0.1'
|
|
const port = Number(process.env.PORT ?? 4175)
|
|
|
|
const assetSlots = [
|
|
{
|
|
id: 'dungeon-bulldrome',
|
|
category: 'Dungeon Icons',
|
|
label: 'Bulldrome Hunting Grounds',
|
|
path: 'dungeons/bulldrome.svg',
|
|
preview: 'dungeon',
|
|
},
|
|
{
|
|
id: 'dungeon-yian-kut-ku',
|
|
category: 'Dungeon Icons',
|
|
label: 'Yian Kut-Ku Roost',
|
|
path: 'dungeons/yian-kut-ku.svg',
|
|
preview: 'dungeon',
|
|
},
|
|
{
|
|
id: 'dungeon-cyber-dragon',
|
|
category: 'Dungeon Icons',
|
|
label: 'Cyber Dragon Core',
|
|
path: 'dungeons/cyber-dragon.svg',
|
|
preview: 'dungeon',
|
|
},
|
|
{
|
|
id: 'enemy-cyber-dragon',
|
|
category: 'Boss and Mob Sprites',
|
|
label: 'Cyber Dragon',
|
|
path: 'enemies/cyber-dragon.svg',
|
|
preview: 'enemy',
|
|
},
|
|
{
|
|
id: 'enemy-bulldrome',
|
|
category: 'Boss and Mob Sprites',
|
|
label: 'Bulldrome',
|
|
path: 'enemies/bulldrome.svg',
|
|
preview: 'enemy',
|
|
},
|
|
{
|
|
id: 'enemy-bullfango',
|
|
category: 'Boss and Mob Sprites',
|
|
label: 'Bullfango',
|
|
path: 'enemies/bullfango.svg',
|
|
preview: 'enemy',
|
|
},
|
|
{
|
|
id: 'enemy-yian-kut-ku',
|
|
category: 'Boss and Mob Sprites',
|
|
label: 'Yian Kut-Ku',
|
|
path: 'enemies/yian-kut-ku.svg',
|
|
preview: 'enemy',
|
|
},
|
|
{
|
|
id: 'enemy-bird',
|
|
category: 'Boss and Mob Sprites',
|
|
label: 'Bird',
|
|
path: 'enemies/bird.svg',
|
|
preview: 'enemy',
|
|
},
|
|
]
|
|
|
|
const mimeTypes = {
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.png': 'image/png',
|
|
'.svg': 'image/svg+xml; charset=utf-8',
|
|
'.webp': 'image/webp',
|
|
}
|
|
|
|
const server = createServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`)
|
|
|
|
if (request.method === 'GET' && url.pathname === '/') {
|
|
sendHtml(response, adminHtml())
|
|
return
|
|
}
|
|
|
|
if (request.method === 'GET' && url.pathname === '/api/assets') {
|
|
sendJson(response, 200, { slots: assetSlots.map(slotPayload) })
|
|
return
|
|
}
|
|
|
|
if (request.method === 'POST' && url.pathname.startsWith('/api/assets/')) {
|
|
const slotId = decodeURIComponent(url.pathname.replace('/api/assets/', ''))
|
|
const slot = assetSlots.find((item) => item.id === slotId)
|
|
if (!slot) throw statusError(404, 'Unknown asset slot.')
|
|
|
|
const body = await readJson(request, 16 * 1024 * 1024)
|
|
const svg = normalizeUploadToSvg(body)
|
|
const targetPath = join(assetDir, slot.path)
|
|
await mkdir(join(targetPath, '..'), { recursive: true })
|
|
await writeFile(targetPath, svg)
|
|
sendJson(response, 200, { slot: slotPayload(slot) })
|
|
return
|
|
}
|
|
|
|
if (request.method === 'GET' && url.pathname.startsWith('/action-assets/')) {
|
|
await servePublicAsset(response, url.pathname)
|
|
return
|
|
}
|
|
|
|
sendJson(response, 404, { error: 'not_found' })
|
|
} catch (error) {
|
|
const status = Number(error?.status) || 500
|
|
if (status >= 500) console.error(error)
|
|
sendJson(response, status, {
|
|
error: error instanceof Error ? error.message : 'Unable to process request.',
|
|
})
|
|
}
|
|
})
|
|
|
|
server.listen(port, host, () => {
|
|
console.log(`Action asset admin listening on http://${host}:${port}`)
|
|
})
|
|
|
|
function slotPayload(slot) {
|
|
const filePath = join(assetDir, slot.path)
|
|
const stat = existsSync(filePath) ? statSync(filePath) : null
|
|
return {
|
|
...slot,
|
|
size: stat?.size ?? 0,
|
|
updatedAt: stat?.mtime.toISOString() ?? null,
|
|
url: `/action-assets/${slot.path}`,
|
|
}
|
|
}
|
|
|
|
function normalizeUploadToSvg(body) {
|
|
const dataUrl = String(body?.dataUrl ?? '')
|
|
const width = clampDimension(Number(body?.width), 256)
|
|
const height = clampDimension(Number(body?.height), 256)
|
|
const originalName = escapeXml(String(body?.name ?? 'uploaded-image').slice(0, 120))
|
|
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|svg\+xml));base64,([A-Za-z0-9+/=]+)$/)
|
|
if (!match) throw statusError(400, 'Upload must be PNG, JPG, WebP, or SVG.')
|
|
|
|
const decodedBytes = Buffer.byteLength(match[2], 'base64')
|
|
if (decodedBytes > 10 * 1024 * 1024) throw statusError(400, 'Upload must be 10 MB or smaller.')
|
|
|
|
return [
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="${originalName}">`,
|
|
` <image href="${dataUrl}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>`,
|
|
'</svg>',
|
|
'',
|
|
].join('\n')
|
|
}
|
|
|
|
function clampDimension(value, fallback) {
|
|
if (!Number.isFinite(value) || value < 1) return fallback
|
|
return Math.min(4096, Math.round(value))
|
|
}
|
|
|
|
async function readJson(request, maxSize) {
|
|
const chunks = []
|
|
let size = 0
|
|
for await (const chunk of request) {
|
|
size += chunk.length
|
|
if (size > maxSize) throw statusError(400, 'Request body is too large.')
|
|
chunks.push(chunk)
|
|
}
|
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
}
|
|
|
|
async function servePublicAsset(response, pathname) {
|
|
const safePath = normalize(pathname).replace(/^(\.\.[/\\])+/, '')
|
|
const filePath = join(publicDir, safePath)
|
|
if (!filePath.startsWith(publicDir) || !existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
sendJson(response, 404, { error: 'not_found' })
|
|
return
|
|
}
|
|
|
|
response.writeHead(200, {
|
|
'Cache-Control': 'no-store',
|
|
'Content-Type': mimeTypes[extname(filePath)] ?? 'application/octet-stream',
|
|
})
|
|
createReadStream(filePath).pipe(response)
|
|
}
|
|
|
|
function sendHtml(response, html) {
|
|
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
response.end(html)
|
|
}
|
|
|
|
function sendJson(response, status, payload) {
|
|
response.writeHead(status, {
|
|
'Cache-Control': 'no-store',
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
})
|
|
response.end(JSON.stringify(payload))
|
|
}
|
|
|
|
function statusError(status, message) {
|
|
const error = new Error(message)
|
|
error.status = status
|
|
return error
|
|
}
|
|
|
|
function escapeXml(value) {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
}
|
|
|
|
function adminHtml() {
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Action Asset Admin</title>
|
|
<style>
|
|
:root {
|
|
--ink: #f4eed8;
|
|
--muted: #a89f87;
|
|
--panel: #191b22;
|
|
--panel-light: #242630;
|
|
--edge: #565066;
|
|
--gold: #e5b95f;
|
|
--red: #dc5162;
|
|
--green: #76d39a;
|
|
--blue: #5ec7ff;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
background: #07080b;
|
|
color: var(--ink);
|
|
font-family: Arial, sans-serif;
|
|
margin: 0;
|
|
}
|
|
button, input { font: inherit; }
|
|
.admin-shell {
|
|
display: grid;
|
|
gap: 14px;
|
|
grid-template-columns: minmax(320px, 0.85fr) minmax(520px, 1.15fr);
|
|
min-height: 100vh;
|
|
padding: 14px;
|
|
}
|
|
.panel {
|
|
background: var(--panel);
|
|
border: 2px solid #090a0d;
|
|
outline: 2px solid var(--edge);
|
|
min-width: 0;
|
|
}
|
|
.asset-panel {
|
|
overflow: auto;
|
|
padding: 14px;
|
|
}
|
|
h1, h2, h3, p { margin: 0; }
|
|
h1, h2, h3, .eyebrow, .asset-card strong, .pixel {
|
|
font-family: "Courier New", monospace;
|
|
font-weight: 800;
|
|
letter-spacing: 0;
|
|
text-transform: uppercase;
|
|
}
|
|
h1 { font-size: 24px; line-height: 1.2; }
|
|
h2 { font-size: 15px; margin: 18px 0 8px; }
|
|
.eyebrow {
|
|
color: var(--gold);
|
|
font-size: 11px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.copy {
|
|
color: var(--muted);
|
|
line-height: 1.35;
|
|
margin-top: 8px;
|
|
}
|
|
.asset-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
.asset-card {
|
|
align-items: center;
|
|
background: #111319;
|
|
border: 2px solid #090a0d;
|
|
display: grid;
|
|
gap: 10px;
|
|
grid-template-columns: 72px minmax(0, 1fr);
|
|
outline: 2px solid #41404a;
|
|
padding: 10px;
|
|
}
|
|
.asset-card.selected {
|
|
outline-color: var(--gold);
|
|
}
|
|
.asset-card img {
|
|
background: #08090c;
|
|
border: 2px solid #090a0d;
|
|
display: block;
|
|
height: 72px;
|
|
object-fit: contain;
|
|
width: 72px;
|
|
}
|
|
.asset-card strong {
|
|
display: block;
|
|
font-size: 12px;
|
|
line-height: 1.25;
|
|
}
|
|
.asset-card small {
|
|
color: var(--muted);
|
|
display: block;
|
|
font-size: 12px;
|
|
margin-top: 4px;
|
|
word-break: break-all;
|
|
}
|
|
.asset-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
.asset-actions input {
|
|
color: var(--muted);
|
|
max-width: 100%;
|
|
}
|
|
.asset-actions button, .toolbar a {
|
|
background: var(--gold);
|
|
border: 2px solid #08090c;
|
|
color: #19150e;
|
|
cursor: pointer;
|
|
outline: 2px solid #816630;
|
|
padding: 8px 10px;
|
|
text-decoration: none;
|
|
}
|
|
.asset-actions button:disabled {
|
|
cursor: wait;
|
|
opacity: 0.55;
|
|
}
|
|
.toolbar {
|
|
align-items: center;
|
|
display: flex;
|
|
gap: 10px;
|
|
justify-content: space-between;
|
|
margin-top: 12px;
|
|
}
|
|
.status {
|
|
color: var(--green);
|
|
min-height: 20px;
|
|
}
|
|
.status.error {
|
|
color: #ff8190;
|
|
}
|
|
.preview-panel {
|
|
display: grid;
|
|
gap: 14px;
|
|
grid-template-rows: auto auto minmax(0, 1fr);
|
|
overflow: hidden;
|
|
padding: 14px;
|
|
}
|
|
.dungeon-preview-grid {
|
|
display: grid;
|
|
gap: 10px;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
.dungeon-card {
|
|
align-items: center;
|
|
background: #111319;
|
|
border: 2px solid #090a0d;
|
|
display: grid;
|
|
gap: 8px;
|
|
grid-template-columns: 56px minmax(0, 1fr);
|
|
min-height: 80px;
|
|
outline: 2px solid #41404a;
|
|
padding: 8px;
|
|
}
|
|
.dungeon-card img {
|
|
background: #171922;
|
|
border: 2px solid #090a0d;
|
|
display: block;
|
|
height: 56px;
|
|
object-fit: cover;
|
|
width: 56px;
|
|
}
|
|
.dungeon-card strong {
|
|
display: block;
|
|
font-size: 11px;
|
|
line-height: 1.2;
|
|
}
|
|
.dungeon-card small {
|
|
color: var(--muted);
|
|
display: block;
|
|
line-height: 1.2;
|
|
margin-top: 4px;
|
|
}
|
|
.fight-preview-shell {
|
|
align-items: center;
|
|
background: #08090c;
|
|
border: 2px solid #090a0d;
|
|
display: flex;
|
|
justify-content: center;
|
|
min-height: 340px;
|
|
outline: 2px solid #41404a;
|
|
overflow: hidden;
|
|
padding: 12px;
|
|
}
|
|
.fight-preview {
|
|
aspect-ratio: 16 / 9;
|
|
background: #11151c;
|
|
max-height: 100%;
|
|
max-width: 100%;
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
.arena {
|
|
background:
|
|
linear-gradient(rgba(37,43,53,.65) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(37,43,53,.65) 1px, transparent 1px),
|
|
#18202a;
|
|
background-size: 48px 48px;
|
|
border: 3px solid var(--edge);
|
|
inset: 13% 4% 7%;
|
|
position: absolute;
|
|
}
|
|
.bossbar {
|
|
left: 50%;
|
|
position: absolute;
|
|
top: 12px;
|
|
transform: translateX(-50%);
|
|
width: min(440px, calc(100% - 40px));
|
|
z-index: 3;
|
|
}
|
|
.bossbar strong, .bossbar span {
|
|
display: block;
|
|
font-family: "Courier New", monospace;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
text-align: center;
|
|
}
|
|
.bossbar span {
|
|
color: var(--muted);
|
|
margin-top: 2px;
|
|
}
|
|
.bossbar i {
|
|
background: #090a0d;
|
|
border: 2px solid #090a0d;
|
|
display: block;
|
|
height: 16px;
|
|
margin-top: 4px;
|
|
}
|
|
.bossbar b {
|
|
background: var(--red);
|
|
display: block;
|
|
height: 100%;
|
|
width: 82%;
|
|
}
|
|
.unit {
|
|
position: absolute;
|
|
transform: translate(-50%, -50%);
|
|
z-index: 2;
|
|
}
|
|
.unit img {
|
|
display: block;
|
|
height: 78px;
|
|
object-fit: contain;
|
|
width: 78px;
|
|
}
|
|
.unit.bulldrome img, .unit.yian-kut-ku img {
|
|
height: 116px;
|
|
width: 116px;
|
|
}
|
|
.player-dot {
|
|
background: #30ff7a;
|
|
border: 3px solid #090a0d;
|
|
border-radius: 50%;
|
|
height: 28px;
|
|
left: 50%;
|
|
position: absolute;
|
|
top: 74%;
|
|
transform: translate(-50%, -50%);
|
|
width: 28px;
|
|
z-index: 2;
|
|
}
|
|
@media (max-width: 920px) {
|
|
.admin-shell {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="admin-shell">
|
|
<section class="panel asset-panel">
|
|
<p class="eyebrow">Local Only</p>
|
|
<h1>Action Asset Admin</h1>
|
|
<p class="copy">Uploads write directly into <span class="pixel">public/action-assets</span>. Next web or mobile build uses them automatically.</p>
|
|
<div class="toolbar">
|
|
<p id="status" class="status" aria-live="polite"></p>
|
|
<a href="http://127.0.0.1:5173/" target="_blank" rel="noreferrer">Open Game</a>
|
|
</div>
|
|
<div id="assetRoot"></div>
|
|
</section>
|
|
|
|
<section class="panel preview-panel">
|
|
<div>
|
|
<p class="eyebrow">In-Game Preview</p>
|
|
<h1>Dungeon Cards</h1>
|
|
</div>
|
|
<div class="dungeon-preview-grid" id="dungeonPreview"></div>
|
|
<div>
|
|
<p class="eyebrow">Combat Preview</p>
|
|
<div class="fight-preview-shell">
|
|
<div class="fight-preview">
|
|
<div class="arena"></div>
|
|
<div class="bossbar">
|
|
<strong>Boss Pack</strong>
|
|
<span>364 / 375</span>
|
|
<i><b></b></i>
|
|
</div>
|
|
<div id="enemyPreview"></div>
|
|
<div class="player-dot" title="Player"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
const state = {
|
|
slots: [],
|
|
selectedId: '',
|
|
pending: new Map(),
|
|
}
|
|
|
|
const positions = {
|
|
'enemy-bulldrome': ['50%', '31%', 'bulldrome'],
|
|
'enemy-bullfango': ['50%', '33%', 'bullfango'],
|
|
'enemy-yian-kut-ku': ['50%', '31%', 'yian-kut-ku'],
|
|
'enemy-bird': ['64%', '45%', 'bird'],
|
|
'enemy-cyber-dragon': ['50%', '31%', 'cyber-dragon'],
|
|
}
|
|
|
|
async function loadSlots() {
|
|
const response = await fetch('/api/assets')
|
|
const body = await response.json()
|
|
state.slots = body.slots
|
|
state.selectedId = state.selectedId || state.slots[0]?.id || ''
|
|
render()
|
|
}
|
|
|
|
function assetUrl(slot) {
|
|
const pending = state.pending.get(slot.id)
|
|
if (pending) return pending.dataUrl
|
|
return slot.url + '?v=' + encodeURIComponent(slot.updatedAt || Date.now())
|
|
}
|
|
|
|
function render() {
|
|
renderAssetList()
|
|
renderDungeonPreview()
|
|
renderEnemyPreview()
|
|
}
|
|
|
|
function renderAssetList() {
|
|
const root = document.getElementById('assetRoot')
|
|
const groups = state.slots.reduce((map, slot) => {
|
|
if (!map.has(slot.category)) map.set(slot.category, [])
|
|
map.get(slot.category).push(slot)
|
|
return map
|
|
}, new Map())
|
|
root.innerHTML = Array.from(groups, ([category, slots]) => \`
|
|
<h2>\${category}</h2>
|
|
<div class="asset-list">
|
|
\${slots.map(slot => assetCard(slot)).join('')}
|
|
</div>
|
|
\`).join('')
|
|
|
|
for (const slot of state.slots) {
|
|
document.getElementById('file-' + slot.id).addEventListener('change', event => selectFile(slot, event))
|
|
document.getElementById('save-' + slot.id).addEventListener('click', () => saveSlot(slot))
|
|
}
|
|
}
|
|
|
|
function assetCard(slot) {
|
|
const pending = state.pending.get(slot.id)
|
|
return \`
|
|
<article class="asset-card \${slot.id === state.selectedId ? 'selected' : ''}" data-slot="\${slot.id}">
|
|
<img src="\${assetUrl(slot)}" alt="">
|
|
<div>
|
|
<strong>\${slot.label}</strong>
|
|
<small>\${slot.path}</small>
|
|
<small>\${slot.updatedAt ? 'Updated ' + new Date(slot.updatedAt).toLocaleString() : 'Missing file'}\${pending ? ' - pending' : ''}</small>
|
|
<div class="asset-actions">
|
|
<input id="file-\${slot.id}" type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml">
|
|
<button id="save-\${slot.id}" type="button" \${pending ? '' : 'disabled'}>Save</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
\`
|
|
}
|
|
|
|
function renderDungeonPreview() {
|
|
const root = document.getElementById('dungeonPreview')
|
|
const dungeons = state.slots.filter(slot => slot.preview === 'dungeon')
|
|
root.innerHTML = dungeons.map(slot => \`
|
|
<article class="dungeon-card">
|
|
<img src="\${assetUrl(slot)}" alt="">
|
|
<div>
|
|
<strong>\${slot.label}</strong>
|
|
<small>Action dungeon icon preview.</small>
|
|
</div>
|
|
</article>
|
|
\`).join('')
|
|
}
|
|
|
|
function renderEnemyPreview() {
|
|
const root = document.getElementById('enemyPreview')
|
|
const enemies = state.slots.filter(slot => slot.preview === 'enemy')
|
|
root.innerHTML = enemies.map(slot => {
|
|
const [left, top, className] = positions[slot.id] || ['50%', '50%', '']
|
|
return \`
|
|
<div class="unit \${className}" style="left: \${left}; top: \${top}">
|
|
<img src="\${assetUrl(slot)}" alt="\${slot.label}">
|
|
</div>
|
|
\`
|
|
}).join('')
|
|
}
|
|
|
|
function selectFile(slot, event) {
|
|
const file = event.target.files?.[0]
|
|
if (!file) return
|
|
if (!/^image\\/(png|jpeg|webp|svg\\+xml)$/.test(file.type)) {
|
|
setStatus('Use PNG, JPG, WebP, or SVG.', true)
|
|
return
|
|
}
|
|
|
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
|
const dataUrl = String(reader.result)
|
|
const image = new Image()
|
|
image.onload = () => {
|
|
state.pending.set(slot.id, {
|
|
dataUrl,
|
|
height: image.naturalHeight || 256,
|
|
name: file.name,
|
|
width: image.naturalWidth || 256,
|
|
})
|
|
state.selectedId = slot.id
|
|
setStatus('Preview updated. Save to write file.', false)
|
|
render()
|
|
}
|
|
image.onerror = () => setStatus('Could not read image.', true)
|
|
image.src = dataUrl
|
|
}
|
|
reader.readAsDataURL(file)
|
|
}
|
|
|
|
async function saveSlot(slot) {
|
|
const pending = state.pending.get(slot.id)
|
|
if (!pending) return
|
|
setStatus('Saving...', false)
|
|
const response = await fetch('/api/assets/' + encodeURIComponent(slot.id), {
|
|
body: JSON.stringify(pending),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
method: 'POST',
|
|
})
|
|
const body = await response.json()
|
|
if (!response.ok) {
|
|
setStatus(body.error || 'Save failed.', true)
|
|
return
|
|
}
|
|
state.pending.delete(slot.id)
|
|
const index = state.slots.findIndex(item => item.id === slot.id)
|
|
if (index >= 0) state.slots[index] = body.slot
|
|
setStatus('Saved. Next build will include this asset.', false)
|
|
render()
|
|
}
|
|
|
|
function setStatus(message, error) {
|
|
const element = document.getElementById('status')
|
|
element.textContent = message
|
|
element.classList.toggle('error', Boolean(error))
|
|
}
|
|
|
|
loadSlots().catch(error => setStatus(error.message, true))
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
}
|