Files
i-want-to-heal-2/server/action-assets-admin.mjs

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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
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>`
}