mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-17 17:51:24 +01:00
Add external API integration for wiki/web UI synchronization
This commit adds a comprehensive external API system that allows external tools (wikis, web UIs, etc.) to control and synchronize with Fantasy Map Generator. New Features: - External API Bridge (modules/external-api.js) - Event-driven architecture with EventEmitter - Map lifecycle control (create, load, save) - Data access methods (rivers, cultures, states, burgs) - Data mutation methods with auto-redraw - Export support (SVG, PNG, JSON) - Change detection with automatic event emission - PostMessage Communication Layer - Auto-enables when FMG is embedded in iframe - Bidirectional message passing - Request/response pattern with promise support - Automatic event forwarding to parent window - REST API Server (api-server/) - Express.js server with full CRUD operations - WebSocket support via Socket.IO for real-time updates - File upload support for map and CSV import - In-memory storage (can be replaced with database) - CORS enabled for cross-origin requests - Comprehensive endpoints for all map data - Client Library (api-server/client.js) - Simple JavaScript client for REST API - Promise-based async methods - Works in browser and Node.js - Demo Pages (demos/) - PostMessage integration demo with full UI - REST API demo with interactive testing - WebSocket demo for real-time events - Documentation - Comprehensive integration guide (EXTERNAL_API_INTEGRATION.md) - API reference with TypeScript interfaces - Multiple integration examples - Troubleshooting guide Integration Methods: 1. PostMessage Bridge - For iframe embedding 2. REST API - For server-side integration 3. Direct JavaScript API - For same-origin apps Use Cases: - Wiki pages that need to display and control maps - Web UIs that want to edit map data - External tools that need to sync with FMG - Real-time collaborative map editing - Batch operations and automation Technical Details: - Zero dependencies for external-api.js (pure JS) - Auto-initializes on DOMContentLoaded - Throttled change detection (500ms debounce) - Deep cloning for data access (prevents mutations) - Error handling throughout - Version tagged (v1.0.0) Updated Files: - index.html: Added script tag to load external-api module All APIs are backward compatible and don't affect existing functionality.
This commit is contained in:
parent
dede314c94
commit
20458e39e2
10 changed files with 4699 additions and 0 deletions
599
demos/postmessage-demo.html
Normal file
599
demos/postmessage-demo.html
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FMG PostMessage Integration Demo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 350px;
|
||||
background: white;
|
||||
border-right: 1px solid #ddd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #95a5a6;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #ecf0f1;
|
||||
}
|
||||
|
||||
#mapFrame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.log {
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #3d3d3d;
|
||||
}
|
||||
|
||||
.log-entry.event {
|
||||
color: #50fa7b;
|
||||
}
|
||||
|
||||
.log-entry.response {
|
||||
color: #8be9fd;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #7f8c8d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Fantasy Map Generator - PostMessage Integration Demo</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Sidebar Controls -->
|
||||
<div class="sidebar">
|
||||
<!-- Connection Status -->
|
||||
<div class="panel">
|
||||
<h2>Connection Status</h2>
|
||||
<span id="status" class="status disconnected">Disconnected</span>
|
||||
</div>
|
||||
|
||||
<!-- Map Controls -->
|
||||
<div class="panel">
|
||||
<h2>Map Controls</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Seed (optional)</label>
|
||||
<input type="text" id="seedInput" placeholder="e.g., 1234567890">
|
||||
</div>
|
||||
|
||||
<button onclick="createMap()">Create New Map</button>
|
||||
<button onclick="saveMap()" class="secondary">Save Map</button>
|
||||
<button onclick="getMapState()" class="secondary">Get Map State</button>
|
||||
</div>
|
||||
|
||||
<!-- Data Management -->
|
||||
<div class="panel">
|
||||
<h2>Data Management</h2>
|
||||
|
||||
<button onclick="getRivers()">Get Rivers</button>
|
||||
<button onclick="getCultures()">Get Cultures</button>
|
||||
<button onclick="getStates()">Get States</button>
|
||||
<button onclick="getBurgs()">Get Burgs</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom Data Update -->
|
||||
<div class="panel">
|
||||
<h2>Update Rivers</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Rivers JSON</label>
|
||||
<textarea id="riversInput" placeholder='[{"i":1,"name":"River Name",...}]'></textarea>
|
||||
</div>
|
||||
|
||||
<button onclick="updateRivers()">Update Rivers</button>
|
||||
</div>
|
||||
|
||||
<!-- Export -->
|
||||
<div class="panel">
|
||||
<h2>Export</h2>
|
||||
|
||||
<button onclick="exportSVG()">Export SVG</button>
|
||||
<button onclick="exportPNG()">Export PNG</button>
|
||||
<button onclick="exportJSON()">Export JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="panel">
|
||||
<h2>Statistics</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Rivers</div>
|
||||
<div class="stat-value" id="riverCount">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Cultures</div>
|
||||
<div class="stat-value" id="cultureCount">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">States</div>
|
||||
<div class="stat-value" id="stateCount">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Burgs</div>
|
||||
<div class="stat-value" id="burgCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Log -->
|
||||
<div class="panel">
|
||||
<h2>Event Log</h2>
|
||||
<div class="log" id="eventLog"></div>
|
||||
<button onclick="clearLog()" class="secondary" style="margin-top: 10px;">Clear Log</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Frame -->
|
||||
<div class="map-container">
|
||||
<iframe id="mapFrame" src="../index.html"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const mapFrame = document.getElementById('mapFrame');
|
||||
const statusEl = document.getElementById('status');
|
||||
const eventLog = document.getElementById('eventLog');
|
||||
let requestId = 0;
|
||||
const pendingRequests = new Map();
|
||||
|
||||
// Initialize when iframe loads
|
||||
mapFrame.addEventListener('load', () => {
|
||||
log('Iframe loaded', 'event');
|
||||
statusEl.textContent = 'Connected';
|
||||
statusEl.className = 'status connected';
|
||||
|
||||
// Request initial state
|
||||
setTimeout(() => {
|
||||
sendMessage('GET_STATE');
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Listen for messages from iframe
|
||||
window.addEventListener('message', (event) => {
|
||||
const {type, payload, requestId: respId, timestamp} = event.data;
|
||||
|
||||
if (!type) return;
|
||||
|
||||
log(`← ${type}`, type === 'ERROR' ? 'error' : type === 'RESPONSE' ? 'response' : 'event');
|
||||
|
||||
// Handle responses
|
||||
if (type === 'RESPONSE' && respId && pendingRequests.has(respId)) {
|
||||
const resolver = pendingRequests.get(respId);
|
||||
resolver(payload);
|
||||
pendingRequests.delete(respId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle events
|
||||
if (type === 'EVENT') {
|
||||
const {event, data} = payload;
|
||||
log(`Event: ${event}`, 'event');
|
||||
|
||||
// Update statistics based on events
|
||||
if (event === 'map:changed' || event === 'map:loaded' || event === 'map:created') {
|
||||
updateStatistics(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (type === 'ERROR') {
|
||||
console.error('Error from iframe:', payload);
|
||||
}
|
||||
});
|
||||
|
||||
// Send message to iframe
|
||||
function sendMessage(type, payload = null) {
|
||||
const reqId = ++requestId;
|
||||
|
||||
log(`→ ${type}`, 'response');
|
||||
|
||||
mapFrame.contentWindow.postMessage({
|
||||
type,
|
||||
payload,
|
||||
requestId: reqId
|
||||
}, '*');
|
||||
|
||||
// Return promise that resolves when response is received
|
||||
return new Promise((resolve) => {
|
||||
pendingRequests.set(reqId, resolve);
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(reqId)) {
|
||||
pendingRequests.delete(reqId);
|
||||
resolve({success: false, error: 'Request timeout'});
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// Map controls
|
||||
async function createMap() {
|
||||
const seed = document.getElementById('seedInput').value.trim();
|
||||
const options = seed ? {seed} : {};
|
||||
|
||||
log('Creating new map...', 'event');
|
||||
const result = await sendMessage('CREATE_MAP', options);
|
||||
|
||||
if (result.success) {
|
||||
log('Map created successfully', 'event');
|
||||
updateStatistics(result.state);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMap() {
|
||||
log('Saving map...', 'event');
|
||||
const result = await sendMessage('SAVE_MAP', {format: 'data'});
|
||||
|
||||
if (result.success) {
|
||||
log('Map saved', 'event');
|
||||
|
||||
// Download the map file
|
||||
const blob = new Blob([result.data], {type: 'text/plain'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename || 'map.map';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function getMapState() {
|
||||
log('Getting map state...', 'event');
|
||||
const result = await sendMessage('GET_STATE');
|
||||
|
||||
if (result.success) {
|
||||
log('Map state retrieved', 'event');
|
||||
console.log('Map state:', result.data);
|
||||
updateStatistics(result.data);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Data retrieval
|
||||
async function getRivers() {
|
||||
log('Getting rivers...', 'event');
|
||||
const result = await sendMessage('GET_RIVERS');
|
||||
|
||||
if (result.success) {
|
||||
log(`Retrieved ${result.data.length} rivers`, 'event');
|
||||
console.log('Rivers:', result.data);
|
||||
document.getElementById('riverCount').textContent = result.data.length;
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function getCultures() {
|
||||
log('Getting cultures...', 'event');
|
||||
const result = await sendMessage('GET_CULTURES');
|
||||
|
||||
if (result.success) {
|
||||
log(`Retrieved ${result.data.length} cultures`, 'event');
|
||||
console.log('Cultures:', result.data);
|
||||
document.getElementById('cultureCount').textContent = result.data.length;
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function getStates() {
|
||||
log('Getting states...', 'event');
|
||||
const result = await sendMessage('GET_STATES');
|
||||
|
||||
if (result.success) {
|
||||
log(`Retrieved ${result.data.length} states`, 'event');
|
||||
console.log('States:', result.data);
|
||||
document.getElementById('stateCount').textContent = result.data.length;
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function getBurgs() {
|
||||
log('Getting burgs...', 'event');
|
||||
const result = await sendMessage('GET_BURGS');
|
||||
|
||||
if (result.success) {
|
||||
log(`Retrieved ${result.data.length} burgs`, 'event');
|
||||
console.log('Burgs:', result.data);
|
||||
document.getElementById('burgCount').textContent = result.data.length;
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Data updates
|
||||
async function updateRivers() {
|
||||
const riversJSON = document.getElementById('riversInput').value.trim();
|
||||
|
||||
if (!riversJSON) {
|
||||
log('Error: No rivers data provided', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rivers = JSON.parse(riversJSON);
|
||||
log('Updating rivers...', 'event');
|
||||
|
||||
const result = await sendMessage('UPDATE_RIVERS', rivers);
|
||||
|
||||
if (result.success) {
|
||||
log('Rivers updated successfully', 'event');
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Error: Invalid JSON - ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions
|
||||
async function exportSVG() {
|
||||
log('Exporting SVG...', 'event');
|
||||
const result = await sendMessage('EXPORT_SVG');
|
||||
|
||||
if (result.success) {
|
||||
log('SVG exported', 'event');
|
||||
|
||||
// Download SVG
|
||||
const blob = new Blob([result.data], {type: 'image/svg+xml'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'map.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPNG() {
|
||||
log('Exporting PNG (this may take a while)...', 'event');
|
||||
const result = await sendMessage('EXPORT_PNG', {width: 2048, height: 2048});
|
||||
|
||||
if (result.success) {
|
||||
log('PNG exported', 'event');
|
||||
|
||||
// result.data is a data URL
|
||||
const a = document.createElement('a');
|
||||
a.href = result.data;
|
||||
a.download = 'map.png';
|
||||
a.click();
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportJSON() {
|
||||
log('Exporting JSON...', 'event');
|
||||
const result = await sendMessage('EXPORT_JSON');
|
||||
|
||||
if (result.success) {
|
||||
log('JSON exported', 'event');
|
||||
|
||||
// Download JSON
|
||||
const blob = new Blob([result.data], {type: 'application/json'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'map.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics display
|
||||
function updateStatistics(state) {
|
||||
if (!state || !state.pack) return;
|
||||
|
||||
document.getElementById('riverCount').textContent = state.pack.rivers?.length || 0;
|
||||
document.getElementById('cultureCount').textContent = state.pack.cultures?.length || 0;
|
||||
document.getElementById('stateCount').textContent = state.pack.states?.length || 0;
|
||||
document.getElementById('burgCount').textContent = state.pack.burgs?.length || 0;
|
||||
}
|
||||
|
||||
// Logging
|
||||
function log(message, type = 'event') {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||
eventLog.appendChild(entry);
|
||||
eventLog.scrollTop = eventLog.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
eventLog.innerHTML = '';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
619
demos/rest-api-demo.html
Normal file
619
demos/rest-api-demo.html
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FMG REST API Demo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-right: 10px;
|
||||
margin-top: 10px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #95a5a6;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.response-area {
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.status.online {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.offline {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.map-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.map-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.map-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.map-id {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.map-date {
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.map-actions button {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #3498db;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Fantasy Map Generator - REST API Demo</h1>
|
||||
<p class="subtitle">Interactive demo for testing the REST API endpoints</p>
|
||||
|
||||
<!-- Server Status -->
|
||||
<div class="panel">
|
||||
<h2>Server Status</h2>
|
||||
<div class="control-group">
|
||||
<label>API Base URL</label>
|
||||
<input type="text" id="apiBaseUrl" value="http://localhost:3000/api">
|
||||
</div>
|
||||
<button onclick="checkHealth()">Check Health</button>
|
||||
<div id="serverStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- Create Map -->
|
||||
<div class="panel">
|
||||
<h2>Create New Map</h2>
|
||||
<div class="control-group">
|
||||
<label>Seed (optional)</label>
|
||||
<input type="text" id="createSeed" placeholder="e.g., myseed123">
|
||||
</div>
|
||||
<button onclick="createMap()">Create Map</button>
|
||||
<div id="createResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Map List -->
|
||||
<div class="panel">
|
||||
<h2>Maps</h2>
|
||||
<button onclick="listMaps()">Refresh List</button>
|
||||
<ul id="mapList" class="map-list" style="margin-top: 15px;"></ul>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Get Map Data -->
|
||||
<div class="panel">
|
||||
<h2>Get Map Data</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getMap()">Get Map</button>
|
||||
<div id="getMapResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Get Rivers -->
|
||||
<div class="panel">
|
||||
<h2>Get Rivers</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getRiversMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getRivers()">Get Rivers</button>
|
||||
<div id="getRiversResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Get Cultures -->
|
||||
<div class="panel">
|
||||
<h2>Get Cultures</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getCulturesMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getCultures()">Get Cultures</button>
|
||||
<div id="getCulturesResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Get States -->
|
||||
<div class="panel">
|
||||
<h2>Get States</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getStatesMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getStates()">Get States</button>
|
||||
<div id="getStatesResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Get Burgs -->
|
||||
<div class="panel">
|
||||
<h2>Get Burgs</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getBurgsMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getBurgs()">Get Burgs</button>
|
||||
<div id="getBurgsResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Rivers -->
|
||||
<div class="panel">
|
||||
<h2>Update Rivers</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="updateRiversMapId" placeholder="map_...">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Rivers JSON</label>
|
||||
<textarea id="riversData" placeholder='[{"i":1,"name":"New River","type":"River",...}]'></textarea>
|
||||
</div>
|
||||
<button onclick="updateRivers()">Update Rivers</button>
|
||||
<div id="updateRiversResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Import Rivers CSV -->
|
||||
<div class="panel">
|
||||
<h2>Import Rivers from CSV</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="importRiversMapId" placeholder="map_...">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>CSV File</label>
|
||||
<input type="file" id="csvFile" accept=".csv">
|
||||
</div>
|
||||
<button onclick="importRiversCSV()">Import Rivers</button>
|
||||
<div id="importRiversResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add Burg -->
|
||||
<div class="panel">
|
||||
<h2>Add New Burg</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="addBurgMapId" placeholder="map_...">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Burg Data (JSON)</label>
|
||||
<textarea id="burgData" placeholder='{"name":"New City","x":100,"y":100,"cell":500,"population":10,"type":"city"}'></textarea>
|
||||
</div>
|
||||
<button onclick="addBurg()">Add Burg</button>
|
||||
<div id="addBurgResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const getBaseUrl = () => document.getElementById('apiBaseUrl').value.trim();
|
||||
|
||||
// Helper to make API calls
|
||||
async function apiCall(endpoint, method = 'GET', body = null) {
|
||||
const url = `${getBaseUrl()}${endpoint}`;
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: {}
|
||||
};
|
||||
|
||||
if (body) {
|
||||
if (body instanceof FormData) {
|
||||
options.body = body;
|
||||
} else {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
return {response, data};
|
||||
} catch (error) {
|
||||
return {error: error.message};
|
||||
}
|
||||
}
|
||||
|
||||
// Display response
|
||||
function displayResponse(elementId, data) {
|
||||
const el = document.getElementById(elementId);
|
||||
el.style.display = 'block';
|
||||
el.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
// Server health check
|
||||
async function checkHealth() {
|
||||
const statusEl = document.getElementById('serverStatus');
|
||||
statusEl.innerHTML = '<div class="loading"></div> Checking...';
|
||||
|
||||
const {data, error} = await apiCall('/health');
|
||||
|
||||
if (error) {
|
||||
statusEl.innerHTML = `<div class="status offline">Offline</div><p>Error: ${error}</p>`;
|
||||
} else {
|
||||
statusEl.innerHTML = `<div class="status online">Online</div><pre>${JSON.stringify(data, null, 2)}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create map
|
||||
async function createMap() {
|
||||
const seed = document.getElementById('createSeed').value.trim();
|
||||
const body = seed ? {seed} : {};
|
||||
|
||||
const {data, error} = await apiCall('/maps', 'POST', body);
|
||||
|
||||
if (error) {
|
||||
displayResponse('createResponse', {error});
|
||||
} else {
|
||||
displayResponse('createResponse', data);
|
||||
if (data.mapId) {
|
||||
// Auto-fill map IDs in other forms
|
||||
document.getElementById('getMapId').value = data.mapId;
|
||||
document.getElementById('getRiversMapId').value = data.mapId;
|
||||
document.getElementById('getCulturesMapId').value = data.mapId;
|
||||
document.getElementById('getStatesMapId').value = data.mapId;
|
||||
document.getElementById('getBurgsMapId').value = data.mapId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List maps
|
||||
async function listMaps() {
|
||||
const {data, error} = await apiCall('/maps');
|
||||
const listEl = document.getElementById('mapList');
|
||||
|
||||
if (error) {
|
||||
listEl.innerHTML = `<li>Error: ${error}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.maps || data.maps.length === 0) {
|
||||
listEl.innerHTML = '<li>No maps found</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = data.maps.map(map => `
|
||||
<li class="map-item">
|
||||
<div class="map-info">
|
||||
<div class="map-id">${map.id}</div>
|
||||
<div class="map-date">Created: ${map.createdAt}</div>
|
||||
</div>
|
||||
<div class="map-actions">
|
||||
<button onclick="selectMap('${map.id}')" class="secondary">Select</button>
|
||||
<button onclick="deleteMap('${map.id}')" class="danger">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Select map (fill in all forms)
|
||||
function selectMap(mapId) {
|
||||
document.getElementById('getMapId').value = mapId;
|
||||
document.getElementById('getRiversMapId').value = mapId;
|
||||
document.getElementById('getCulturesMapId').value = mapId;
|
||||
document.getElementById('getStatesMapId').value = mapId;
|
||||
document.getElementById('getBurgsMapId').value = mapId;
|
||||
document.getElementById('updateRiversMapId').value = mapId;
|
||||
document.getElementById('importRiversMapId').value = mapId;
|
||||
document.getElementById('addBurgMapId').value = mapId;
|
||||
}
|
||||
|
||||
// Delete map
|
||||
async function deleteMap(mapId) {
|
||||
if (!confirm(`Delete map ${mapId}?`)) return;
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}`, 'DELETE');
|
||||
|
||||
if (error) {
|
||||
alert(`Error: ${error}`);
|
||||
} else {
|
||||
alert('Map deleted');
|
||||
listMaps();
|
||||
}
|
||||
}
|
||||
|
||||
// Get map
|
||||
async function getMap() {
|
||||
const mapId = document.getElementById('getMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}`);
|
||||
displayResponse('getMapResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Get rivers
|
||||
async function getRivers() {
|
||||
const mapId = document.getElementById('getRiversMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/rivers`);
|
||||
displayResponse('getRiversResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Get cultures
|
||||
async function getCultures() {
|
||||
const mapId = document.getElementById('getCulturesMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/cultures`);
|
||||
displayResponse('getCulturesResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Get states
|
||||
async function getStates() {
|
||||
const mapId = document.getElementById('getStatesMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/states`);
|
||||
displayResponse('getStatesResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Get burgs
|
||||
async function getBurgs() {
|
||||
const mapId = document.getElementById('getBurgsMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/burgs`);
|
||||
displayResponse('getBurgsResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Update rivers
|
||||
async function updateRivers() {
|
||||
const mapId = document.getElementById('updateRiversMapId').value.trim();
|
||||
const riversJSON = document.getElementById('riversData').value.trim();
|
||||
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!riversJSON) {
|
||||
alert('Please enter rivers data');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rivers = JSON.parse(riversJSON);
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/rivers`, 'PUT', {rivers});
|
||||
displayResponse('updateRiversResponse', error ? {error} : data);
|
||||
} catch (e) {
|
||||
displayResponse('updateRiversResponse', {error: `Invalid JSON: ${e.message}`});
|
||||
}
|
||||
}
|
||||
|
||||
// Import rivers from CSV
|
||||
async function importRiversCSV() {
|
||||
const mapId = document.getElementById('importRiversMapId').value.trim();
|
||||
const fileInput = document.getElementById('csvFile');
|
||||
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileInput.files[0]) {
|
||||
alert('Please select a CSV file');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/rivers/import`, 'POST', formData);
|
||||
displayResponse('importRiversResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Add burg
|
||||
async function addBurg() {
|
||||
const mapId = document.getElementById('addBurgMapId').value.trim();
|
||||
const burgJSON = document.getElementById('burgData').value.trim();
|
||||
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!burgJSON) {
|
||||
alert('Please enter burg data');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const burgData = JSON.parse(burgJSON);
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/burgs`, 'POST', burgData);
|
||||
displayResponse('addBurgResponse', error ? {error} : data);
|
||||
} catch (e) {
|
||||
displayResponse('addBurgResponse', {error: `Invalid JSON: ${e.message}`});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check health on load
|
||||
window.addEventListener('load', () => {
|
||||
checkHealth();
|
||||
listMaps();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
617
demos/websocket-demo.html
Normal file
617
demos/websocket-demo.html
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FMG WebSocket Demo</title>
|
||||
<script src="https://cdn.socket.io/4.6.1/socket.io.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 350px;
|
||||
background: white;
|
||||
border-right: 1px solid #ddd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #95a5a6;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
flex: 1;
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 8px;
|
||||
border-left: 3px solid transparent;
|
||||
margin-bottom: 5px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-entry.event {
|
||||
border-left-color: #50fa7b;
|
||||
}
|
||||
|
||||
.log-entry.send {
|
||||
border-left-color: #8be9fd;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
border-left-color: #ff5555;
|
||||
}
|
||||
|
||||
.log-entry.info {
|
||||
border-left-color: #f1fa8c;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #6272a4;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.log-type {
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.event.log-type {
|
||||
color: #50fa7b;
|
||||
}
|
||||
|
||||
.send.log-type {
|
||||
color: #8be9fd;
|
||||
}
|
||||
|
||||
.error.log-type {
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.info.log-type {
|
||||
color: #f1fa8c;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status.connecting {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #7f8c8d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
list-style: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 8px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-name {
|
||||
font-family: monospace;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-count {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Fantasy Map Generator - WebSocket Real-Time Demo</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Sidebar Controls -->
|
||||
<div class="sidebar">
|
||||
<!-- Connection -->
|
||||
<div class="panel">
|
||||
<h2>WebSocket Connection</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Server URL</label>
|
||||
<input type="text" id="serverUrl" value="http://localhost:3000">
|
||||
</div>
|
||||
|
||||
<button onclick="connect()" id="connectBtn">Connect</button>
|
||||
<button onclick="disconnect()" id="disconnectBtn" class="secondary" disabled>Disconnect</button>
|
||||
|
||||
<div style="margin-top: 15px;">
|
||||
<span id="connectionStatus" class="status disconnected">Disconnected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Custom Event -->
|
||||
<div class="panel">
|
||||
<h2>Send Custom Event</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Event Name</label>
|
||||
<input type="text" id="eventName" placeholder="e.g., map:update">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Event Data (JSON)</label>
|
||||
<textarea id="eventData" placeholder='{"mapId":"map_123","updates":{...}}'></textarea>
|
||||
</div>
|
||||
|
||||
<button onclick="sendEvent()" id="sendBtn" disabled>Send Event</button>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="panel">
|
||||
<h2>Statistics</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Events Sent</div>
|
||||
<div class="stat-value" id="sentCount">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Events Received</div>
|
||||
<div class="stat-value" id="receivedCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Types -->
|
||||
<div class="panel">
|
||||
<h2>Event Types Received</h2>
|
||||
<ul id="eventTypes" class="event-list"></ul>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="panel">
|
||||
<h2>Quick Actions</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="quickMapId" placeholder="map_...">
|
||||
</div>
|
||||
|
||||
<button onclick="simulateMapCreated()" id="actionBtn1" disabled>Simulate: Map Created</button>
|
||||
<button onclick="simulateRiversUpdated()" id="actionBtn2" disabled>Simulate: Rivers Updated</button>
|
||||
<button onclick="simulateBurgAdded()" id="actionBtn3" disabled>Simulate: Burg Added</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="color: #2c3e50;">Real-Time Event Log</h2>
|
||||
<button onclick="clearEventLog()" class="secondary">Clear Log</button>
|
||||
</div>
|
||||
<div id="eventLog" class="event-log"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let socket = null;
|
||||
let sentCount = 0;
|
||||
let receivedCount = 0;
|
||||
let eventTypeCounts = {};
|
||||
|
||||
// Connect to WebSocket server
|
||||
function connect() {
|
||||
const serverUrl = document.getElementById('serverUrl').value.trim();
|
||||
|
||||
if (socket && socket.connected) {
|
||||
logEvent('Already connected', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
logEvent(`Connecting to ${serverUrl}...`, 'info');
|
||||
|
||||
document.getElementById('connectionStatus').textContent = 'Connecting...';
|
||||
document.getElementById('connectionStatus').className = 'status connecting';
|
||||
|
||||
socket = io(serverUrl);
|
||||
|
||||
// Connection events
|
||||
socket.on('connect', () => {
|
||||
logEvent(`Connected! Socket ID: ${socket.id}`, 'event');
|
||||
document.getElementById('connectionStatus').textContent = 'Connected';
|
||||
document.getElementById('connectionStatus').className = 'status connected';
|
||||
|
||||
document.getElementById('connectBtn').disabled = true;
|
||||
document.getElementById('disconnectBtn').disabled = false;
|
||||
document.getElementById('sendBtn').disabled = false;
|
||||
document.getElementById('actionBtn1').disabled = false;
|
||||
document.getElementById('actionBtn2').disabled = false;
|
||||
document.getElementById('actionBtn3').disabled = false;
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
logEvent('Disconnected from server', 'error');
|
||||
document.getElementById('connectionStatus').textContent = 'Disconnected';
|
||||
document.getElementById('connectionStatus').className = 'status disconnected';
|
||||
|
||||
document.getElementById('connectBtn').disabled = false;
|
||||
document.getElementById('disconnectBtn').disabled = true;
|
||||
document.getElementById('sendBtn').disabled = true;
|
||||
document.getElementById('actionBtn1').disabled = true;
|
||||
document.getElementById('actionBtn2').disabled = true;
|
||||
document.getElementById('actionBtn3').disabled = true;
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
logEvent(`Connection error: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
// Listen for all map-related events
|
||||
const eventNames = [
|
||||
'map:creating',
|
||||
'map:created',
|
||||
'map:updated',
|
||||
'map:deleted',
|
||||
'map:loaded',
|
||||
'rivers:updated',
|
||||
'rivers:imported',
|
||||
'cultures:updated',
|
||||
'states:updated',
|
||||
'burgs:updated',
|
||||
'burg:added',
|
||||
'export:request',
|
||||
'export:completed'
|
||||
];
|
||||
|
||||
eventNames.forEach(eventName => {
|
||||
socket.on(eventName, (data) => {
|
||||
receivedCount++;
|
||||
document.getElementById('receivedCount').textContent = receivedCount;
|
||||
|
||||
if (!eventTypeCounts[eventName]) {
|
||||
eventTypeCounts[eventName] = 0;
|
||||
}
|
||||
eventTypeCounts[eventName]++;
|
||||
updateEventTypes();
|
||||
|
||||
logEvent(`${eventName}: ${JSON.stringify(data, null, 2)}`, 'event');
|
||||
});
|
||||
});
|
||||
|
||||
// Catch-all for any other events
|
||||
socket.onAny((eventName, ...args) => {
|
||||
if (!eventNames.includes(eventName)) {
|
||||
receivedCount++;
|
||||
document.getElementById('receivedCount').textContent = receivedCount;
|
||||
|
||||
if (!eventTypeCounts[eventName]) {
|
||||
eventTypeCounts[eventName] = 0;
|
||||
}
|
||||
eventTypeCounts[eventName]++;
|
||||
updateEventTypes();
|
||||
|
||||
logEvent(`${eventName}: ${JSON.stringify(args, null, 2)}`, 'event');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect from WebSocket server
|
||||
function disconnect() {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
logEvent('Disconnecting...', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// Send custom event
|
||||
function sendEvent() {
|
||||
if (!socket || !socket.connected) {
|
||||
logEvent('Not connected!', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const eventName = document.getElementById('eventName').value.trim();
|
||||
const eventDataStr = document.getElementById('eventData').value.trim();
|
||||
|
||||
if (!eventName) {
|
||||
logEvent('Event name is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventData = eventDataStr ? JSON.parse(eventDataStr) : {};
|
||||
|
||||
socket.emit(eventName, eventData);
|
||||
|
||||
sentCount++;
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
|
||||
logEvent(`Sent ${eventName}: ${JSON.stringify(eventData, null, 2)}`, 'send');
|
||||
} catch (error) {
|
||||
logEvent(`Error: Invalid JSON - ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Quick actions
|
||||
function simulateMapCreated() {
|
||||
const mapId = document.getElementById('quickMapId').value.trim() || 'map_' + Date.now();
|
||||
|
||||
const data = {
|
||||
mapId,
|
||||
mapData: {
|
||||
seed: 'test-seed',
|
||||
pack: {
|
||||
cultures: [{i: 0, name: 'Test Culture'}],
|
||||
states: [{i: 0, name: 'Test State'}],
|
||||
burgs: [{i: 0, name: 'Test City'}],
|
||||
rivers: [{i: 0, name: 'Test River'}]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.emit('map:created', data);
|
||||
|
||||
sentCount++;
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
logEvent(`Sent map:created: ${JSON.stringify(data, null, 2)}`, 'send');
|
||||
}
|
||||
|
||||
function simulateRiversUpdated() {
|
||||
const mapId = document.getElementById('quickMapId').value.trim() || 'map_' + Date.now();
|
||||
|
||||
const data = {
|
||||
mapId,
|
||||
rivers: [
|
||||
{i: 1, name: 'Mystic River', type: 'River', discharge: 100},
|
||||
{i: 2, name: 'Crystal Brook', type: 'River', discharge: 50}
|
||||
]
|
||||
};
|
||||
|
||||
socket.emit('rivers:updated', data);
|
||||
|
||||
sentCount++;
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
logEvent(`Sent rivers:updated: ${JSON.stringify(data, null, 2)}`, 'send');
|
||||
}
|
||||
|
||||
function simulateBurgAdded() {
|
||||
const mapId = document.getElementById('quickMapId').value.trim() || 'map_' + Date.now();
|
||||
|
||||
const data = {
|
||||
mapId,
|
||||
burg: {
|
||||
i: 10,
|
||||
name: 'New Settlement',
|
||||
x: 500,
|
||||
y: 400,
|
||||
cell: 1234,
|
||||
population: 5,
|
||||
type: 'town'
|
||||
}
|
||||
};
|
||||
|
||||
socket.emit('burg:added', data);
|
||||
|
||||
sentCount++;
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
logEvent(`Sent burg:added: ${JSON.stringify(data, null, 2)}`, 'send');
|
||||
}
|
||||
|
||||
// Update event types list
|
||||
function updateEventTypes() {
|
||||
const listEl = document.getElementById('eventTypes');
|
||||
const sorted = Object.entries(eventTypeCounts)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
listEl.innerHTML = sorted.map(([name, count]) => `
|
||||
<li class="event-item">
|
||||
<span class="event-name">${name}</span>
|
||||
<span class="event-count">${count}</span>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Logging
|
||||
function logEvent(message, type = 'info') {
|
||||
const logEl = document.getElementById('eventLog');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
|
||||
const time = new Date().toLocaleTimeString();
|
||||
entry.innerHTML = `
|
||||
<span class="log-time">${time}</span>
|
||||
<span class="log-type ${type}">${type.toUpperCase()}</span>
|
||||
<span>${escapeHtml(message)}</span>
|
||||
`;
|
||||
|
||||
logEl.appendChild(entry);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
|
||||
// Keep only last 100 entries
|
||||
while (logEl.children.length > 100) {
|
||||
logEl.removeChild(logEl.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function clearEventLog() {
|
||||
document.getElementById('eventLog').innerHTML = '';
|
||||
sentCount = 0;
|
||||
receivedCount = 0;
|
||||
eventTypeCounts = {};
|
||||
document.getElementById('sentCount').textContent = '0';
|
||||
document.getElementById('receivedCount').textContent = '0';
|
||||
updateEventTypes();
|
||||
logEvent('Log cleared', 'info');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Auto-connect on load
|
||||
window.addEventListener('load', () => {
|
||||
logEvent('WebSocket demo loaded. Click "Connect" to start.', 'info');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue