@workadventure/map-starter-kit-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,333 @@
1
+ import express from 'express';
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { exec } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+
7
+ const execAsync = promisify(exec);
8
+
9
+ /** Metadata returned by map-storage for each map (WAM file). */
10
+ export interface MapStorageMetadata {
11
+ copyright?: string;
12
+ description?: string;
13
+ name?: string;
14
+ thumbnail?: string;
15
+ }
16
+
17
+ /** Single map entry returned by map-storage GET /maps/ (value in the map). */
18
+ export interface MapStorageEntry {
19
+ mapUrl: string;
20
+ wamFileUrl?: string;
21
+ metadata?: MapStorageMetadata;
22
+ }
23
+
24
+ /** Response shape: Map<wamfile, { mapUrl, metadata }>. In JSON this is a plain object. */
25
+ export interface MapStorageListResponse {
26
+ [wamFile: string]: MapStorageEntry;
27
+ }
28
+
29
+ /** Hydrated map item returned by our API to the frontend. */
30
+ export interface MapListItem {
31
+ wamFileUrl: string;
32
+ filename: string;
33
+ mapName: string | null;
34
+ mapDescription?: string;
35
+ mapCopyright?: string;
36
+ mapImage: string | null;
37
+ mapUrl: string;
38
+ }
39
+
40
+ /**
41
+ * Converts the map-storage response (Map<wamfile, entry>) into a list of MapListItem.
42
+ */
43
+ export function hydrateMapStorageList(
44
+ raw: MapStorageListResponse,
45
+ mapStorageBaseUrl: string
46
+ ): MapListItem[] {
47
+ const list: MapListItem[] = [];
48
+ const baseUrl = mapStorageBaseUrl.replace(/\/$/, '');
49
+
50
+ for (const [wamFile, entry] of Object.entries(raw)) {
51
+ if (!entry || typeof entry !== 'object') continue;
52
+
53
+ const rawWamFileUrl = entry.wamFileUrl ?? wamFile;
54
+ const wamFileUrlNormalized = rawWamFileUrl.startsWith('http')
55
+ ? new URL(rawWamFileUrl).pathname.replace(/^\//, '')
56
+ : rawWamFileUrl.replace(/^\//, '');
57
+
58
+ const filename = wamFileUrlNormalized.split('/').pop() ?? wamFileUrlNormalized;
59
+ const nameFromFile = filename.replace(/\.(tmj|wam)$/i, '');
60
+
61
+ const meta = entry.metadata ?? {};
62
+ const mapUrl = entry.mapUrl ?? (entry as unknown as { mapUrl?: string }).mapUrl ?? '';
63
+
64
+ let mapImage: string | null = null;
65
+ if (meta.thumbnail) {
66
+ if (meta.thumbnail.startsWith('http')) {
67
+ mapImage = meta.thumbnail;
68
+ } else {
69
+ const thumbFile = meta.thumbnail.replace(/^\//, '');
70
+ const wamDir = wamFileUrlNormalized.includes('/')
71
+ ? wamFileUrlNormalized.replace(/\/[^/]*$/, '')
72
+ : '';
73
+ const thumbPath = wamDir ? `${wamDir}/${thumbFile}` : thumbFile;
74
+ mapImage = `${baseUrl}/${thumbPath}`;
75
+ }
76
+ }
77
+
78
+ list.push({
79
+ wamFileUrl: wamFileUrlNormalized,
80
+ filename,
81
+ mapName: meta.name ?? nameFromFile,
82
+ mapDescription: meta.description,
83
+ mapCopyright: meta.copyright,
84
+ mapImage,
85
+ mapUrl: mapUrl.startsWith('http') ? mapUrl : `${baseUrl}/${mapUrl.replace(/^\//, '')}`,
86
+ });
87
+ }
88
+
89
+ return list;
90
+ }
91
+
92
+ export class UploaderController {
93
+ private router: express.Router;
94
+ private app: express.Application;
95
+
96
+ constructor(app: express.Application) {
97
+ this.app = app;
98
+ this.router = express.Router();
99
+ this.setupMiddleware();
100
+ this.setupRoutes();
101
+ // Register the router on the application with the "/uploader" prefix
102
+ this.app.use('/uploader', this.router);
103
+ }
104
+
105
+ private setupMiddleware() {
106
+ // Middleware to parse JSON bodies
107
+ this.router.use(express.json());
108
+ }
109
+
110
+ private setupRoutes() {
111
+ // Route to configure MAP_STORAGE mode
112
+ this.router.post('/configure', async (req, res) => {
113
+ try {
114
+ const { mapStorageUrl, mapStorageApiKey, uploadDirectory } = req.body;
115
+
116
+ // Validate required fields
117
+ if (!mapStorageUrl || !mapStorageApiKey || !uploadDirectory) {
118
+ return res.status(400).json({
119
+ error: 'Missing required fields',
120
+ required: ['mapStorageUrl', 'mapStorageApiKey', 'uploadDirectory']
121
+ });
122
+ }
123
+
124
+ // Create or update .env.secret file
125
+ await this.createEnvSecretFile({
126
+ mapStorageUrl,
127
+ mapStorageApiKey,
128
+ uploadDirectory
129
+ });
130
+
131
+ return res.json({
132
+ success: true,
133
+ message: 'Configuration updated successfully'
134
+ });
135
+ } catch (error) {
136
+ console.error('Error configuring uploader:', error);
137
+ return res.status(500).json({
138
+ error: 'Error configuring uploader',
139
+ message: error instanceof Error ? error.message : 'Unknown error'
140
+ });
141
+ }
142
+ });
143
+
144
+ // Route to get current configuration status
145
+ this.router.get('/status', async (_, res) => {
146
+ try {
147
+ const envSecretPath = path.join(process.cwd(), 'src/.env.secret');
148
+
149
+ const hasSecretFile = await fs.promises.access(envSecretPath)
150
+ .then(() => true)
151
+ .catch(() => false);
152
+
153
+ let secretConfig: {
154
+ mapStorageUrl: string | null;
155
+ mapStorageApiKey: string | null;
156
+ uploadDirectory: string | null;
157
+ } | null = null;
158
+ if (hasSecretFile) {
159
+ const secretContent = await fs.promises.readFile(envSecretPath, 'utf-8');
160
+ const mapStorageUrl = secretContent.match(/MAP_STORAGE_URL=(.+)/)?.[1]?.trim();
161
+ const mapStorageApiKey = secretContent.match(/MAP_STORAGE_API_KEY=(.+)/)?.[1]?.trim();
162
+ const uploadDirectory = secretContent.match(/UPLOAD_DIRECTORY=(.+)/)?.[1]?.trim();
163
+
164
+ secretConfig = {
165
+ mapStorageUrl: mapStorageUrl || null,
166
+ mapStorageApiKey: mapStorageApiKey || null, // Hide the actual key
167
+ uploadDirectory: uploadDirectory || null
168
+ };
169
+ }
170
+
171
+ res.json({
172
+ hasSecretFile,
173
+ secretConfig
174
+ });
175
+ } catch (error) {
176
+ console.error('Error getting uploader status:', error);
177
+ res.status(500).json({
178
+ error: 'Error getting uploader status',
179
+ message: error instanceof Error ? error.message : 'Unknown error'
180
+ });
181
+ }
182
+ });
183
+
184
+ // Route to get list of maps from map-storage (for self-hosted step4)
185
+ this.router.get('/maps-storage-list', async (_, res) => {
186
+ try {
187
+ const envSecretPath = path.join(process.cwd(), '.env.secret');
188
+ const hasSecretFile = await fs.promises.access(envSecretPath)
189
+ .then(() => true)
190
+ .catch(() => false);
191
+
192
+ if (!hasSecretFile) {
193
+ return res.status(400).json({
194
+ error: 'Configuration not found',
195
+ message: 'Please configure the upload settings first.'
196
+ });
197
+ }
198
+
199
+ const secretContent = await fs.promises.readFile(envSecretPath, 'utf-8');
200
+ const mapStorageUrl = secretContent.match(/MAP_STORAGE_URL=(.+)/)?.[1]?.trim();
201
+ const mapStorageApiKey = secretContent.match(/MAP_STORAGE_API_KEY=(.+)/)?.[1]?.trim();
202
+
203
+ if (!mapStorageUrl || !mapStorageApiKey) {
204
+ return res.status(400).json({
205
+ error: 'Missing map-storage configuration',
206
+ message: 'MAP_STORAGE_URL and MAP_STORAGE_API_KEY must be set in .env.secret.'
207
+ });
208
+ }
209
+
210
+ const baseUrl = mapStorageUrl.replace(/\/$/, '');
211
+ const listUrl = `${baseUrl}/maps/`;
212
+
213
+ const response = await fetch(listUrl, {
214
+ method: 'GET',
215
+ headers: {
216
+ 'Authorization': `Bearer ${mapStorageApiKey}`,
217
+ 'Content-Type': 'application/json'
218
+ }
219
+ });
220
+
221
+ if (!response.ok) {
222
+ const text = await response.text();
223
+ console.error('Map-storage list error:', response.status, text);
224
+ return res.status(response.status).json({
225
+ error: 'Map-storage request failed',
226
+ message: response.status === 401
227
+ ? 'Invalid API key. Check MAP_STORAGE_API_KEY in your .env.secret.'
228
+ : `Map-storage returned ${response.status}: ${text.slice(0, 200)}`
229
+ });
230
+ }
231
+
232
+ const data = await response.json();
233
+ // Map-storage returns Map<wamfile, { mapUrl, metadata }> (as a plain object in JSON)
234
+ const raw = typeof data === 'object' && data !== null ? data : {};
235
+ const rawMap: MapStorageListResponse = !Array.isArray(raw) && typeof raw.maps === 'object' && raw.maps !== null
236
+ ? (raw.maps as MapStorageListResponse)
237
+ : !Array.isArray(raw)
238
+ ? (raw as MapStorageListResponse)
239
+ : {};
240
+ const maps = hydrateMapStorageList(rawMap, baseUrl);
241
+ const playBaseUrl = secretContent.match(/PLAY_BASE_URL=(.+)/)?.[1]?.trim() || null;
242
+ return res.json({ maps, mapStorageUrl: baseUrl, playBaseUrl });
243
+ } catch (error) {
244
+ console.error('Error fetching maps from map-storage:', error);
245
+ return res.status(500).json({
246
+ error: 'Error fetching maps list',
247
+ message: error instanceof Error ? error.message : 'Unknown error'
248
+ });
249
+ }
250
+ });
251
+
252
+ // Route to upload map
253
+ this.router.post('/upload', async (_, res) => {
254
+ try {
255
+ // Verify that configuration exists
256
+ const envSecretPath = path.join(process.cwd(), '.env.secret');
257
+
258
+ // Check if .env.secret exists
259
+ const hasSecretFile = await fs.promises.access(envSecretPath)
260
+ .then(() => true)
261
+ .catch(() => false);
262
+
263
+ if (!hasSecretFile) {
264
+ return res.status(400).json({
265
+ error: 'Configuration not found',
266
+ message: 'Please configure the upload settings first using /uploader/configure'
267
+ });
268
+ }
269
+
270
+ // Execute upload
271
+ await this.runUpload();
272
+
273
+ return res.json({
274
+ success: true,
275
+ message: 'Map uploaded successfully'
276
+ });
277
+ } catch (error) {
278
+ console.error('Error uploading map:', error);
279
+ return res.status(500).json({
280
+ error: 'Error uploading map',
281
+ message: error instanceof Error ? error.message : 'Unknown error'
282
+ });
283
+ }
284
+ });
285
+ }
286
+
287
+ private async runUpload(): Promise<void> {
288
+ const projectRoot = process.cwd();
289
+
290
+ try {
291
+ // Execute npm run upload-only
292
+ // The command will read environment variables from .env and .env.secret
293
+ const { stdout, stderr } = await execAsync('npm run upload', {
294
+ cwd: projectRoot,
295
+ env: {
296
+ ...process.env,
297
+ // Ensure we're using the current process environment
298
+ NODE_ENV: process.env.NODE_ENV || 'development'
299
+ },
300
+ maxBuffer: 10 * 1024 * 1024 // 10MB buffer for output
301
+ });
302
+
303
+ if (stderr && !stderr.includes('warning')) {
304
+ console.warn('Upload stderr:', stderr);
305
+ }
306
+
307
+ console.info('Upload stdout:', stdout);
308
+ console.info('Upload completed successfully');
309
+ } catch (error) {
310
+ console.error('Error executing upload-only:', error);
311
+ throw error;
312
+ }
313
+ }
314
+
315
+ private async createEnvSecretFile(config: {
316
+ mapStorageUrl: string;
317
+ mapStorageApiKey: string;
318
+ uploadDirectory: string;
319
+ }): Promise<void> {
320
+ const envSecretPath = path.join(process.cwd(), '.env.secret');
321
+
322
+ // Create the .env.secret file with the provided configuration
323
+ const secretContent = `# Secret configuration file for MAP_STORAGE upload mode
324
+ # This file is not committed to git (see .gitignore)
325
+
326
+ MAP_STORAGE_URL=${config.mapStorageUrl}
327
+ MAP_STORAGE_API_KEY=${config.mapStorageApiKey}
328
+ UPLOAD_DIRECTORY=${config.uploadDirectory}
329
+ `;
330
+
331
+ await fs.promises.writeFile(envSecretPath, secretContent, 'utf-8');
332
+ }
333
+ }
@@ -0,0 +1,40 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ let coreRoot: string | null = null;
6
+
7
+ /**
8
+ * Returns the root directory of the "core" package (app + templates + public).
9
+ * - When running from the project root: returns process.cwd().
10
+ * - When running from packages/map-starter-kit-core or node_modules: returns the package directory.
11
+ * Allows updating the core package without touching user files (maps, .env, tilesets).
12
+ */
13
+ export function getCoreRoot(): string {
14
+ if (coreRoot !== null) {
15
+ return coreRoot;
16
+ }
17
+ try {
18
+ const dir = path.dirname(fileURLToPath(import.meta.url));
19
+ const candidate = path.dirname(dir);
20
+ const packagePath = path.join(candidate, "package.json");
21
+ if (fs.existsSync(packagePath)) {
22
+ const pkg = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
23
+ if (pkg.name === "@workadventure/map-starter-kit-core") {
24
+ coreRoot = candidate;
25
+ return coreRoot;
26
+ }
27
+ }
28
+ } catch {
29
+ // ignore
30
+ }
31
+ coreRoot = process.cwd();
32
+ return coreRoot;
33
+ }
34
+
35
+ /**
36
+ * Override the core root (e.g. for tests or custom layout).
37
+ */
38
+ export function setCoreRoot(root: string): void {
39
+ coreRoot = root;
40
+ }
package/src/server.ts ADDED
@@ -0,0 +1,124 @@
1
+ import express from 'express';
2
+ import * as path from "node:path";
3
+ import * as fs from "node:fs";
4
+ import cors from 'cors';
5
+ import { getCoreRoot } from './getCoreRoot.js';
6
+ import { FrontController } from './controllers/FrontController.js';
7
+ import { MapController } from './controllers/MapController.js';
8
+ import { UploaderController } from './controllers/UploaderController.js';
9
+
10
+ const app = express();
11
+
12
+ const corsOptions = {
13
+ credentials: true, // Allow sending cookies
14
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
15
+ allowedHeaders: [
16
+ 'Content-Type',
17
+ 'Authorization',
18
+ 'X-Requested-With',
19
+ 'Accept',
20
+ 'Origin',
21
+ 'Access-Control-Request-Method',
22
+ 'Access-Control-Request-Headers'
23
+ ],
24
+ exposedHeaders: ['Content-Length', 'Content-Type'],
25
+ maxAge: 86400, // Cache the OPTIONS requests for 24 hours
26
+ };
27
+
28
+ // Apply the CORS middleware
29
+ // The CORS middleware automatically handles the OPTIONS requests (preflight)
30
+ app.use(cors(corsOptions));
31
+
32
+ // Parse JSON bodies
33
+ app.use(express.json());
34
+
35
+ // Configure the static assets for Express
36
+ const staticOptions = {
37
+ maxAge: '1d', // Cache the files for 1 day
38
+ etag: true, // Enable ETag for cache validation
39
+ lastModified: true, // Enable Last-Modified header
40
+ };
41
+
42
+ // Serve dist/assets FIRST with explicit MIME type configuration
43
+ // This ensures compiled JavaScript files from getMapsScripts are served correctly
44
+ // This route must be before express.static('.') to take precedence
45
+ app.use('/assets', express.static(path.join(process.cwd(), 'dist', 'assets'), staticOptions));
46
+ // Serve the public folder from core (project root or package root for easy updates)
47
+ app.use('/public', express.static(path.join(getCoreRoot(), 'public'), staticOptions));
48
+ // Serve the tilesets folder with a longer cache (rarely modified)
49
+ app.use('/tilesets', express.static(path.join(process.cwd(), 'tilesets'), {
50
+ maxAge: '7d',
51
+ etag: true,
52
+ lastModified: true,
53
+ }));
54
+
55
+ // Middleware to exclude /src from express.static - let Vite handle TypeScript transformation
56
+ // VitePluginNode will automatically add Vite middleware that transforms TypeScript files
57
+ const staticMiddleware = express.static('.', staticOptions);
58
+
59
+ // Middleware to transform and serve TypeScript files as JavaScript
60
+ // This bundles the file with its dependencies to resolve npm imports
61
+ app.use('/src', async (req, res, next) => {
62
+ // Only handle .ts and .tsx files - transform them to JavaScript
63
+ if (req.path.endsWith('.ts') || req.path.endsWith('.tsx')) {
64
+ try {
65
+ // req.path includes /src/, so we need to join it correctly
66
+ const filePath = path.join(process.cwd(), 'src', req.path.startsWith('/') ? req.path.slice(1) : req.path);
67
+
68
+ // Check if file exists
69
+ if (!fs.existsSync(filePath)) {
70
+ return res.status(404).send('File not found');
71
+ }
72
+
73
+ // Use dynamic import to get esbuild (available via Vite)
74
+ const esbuild = await import('esbuild');
75
+
76
+ // Bundle the TypeScript file with its dependencies
77
+ // This resolves npm imports like @workadventure/scripting-api-extra
78
+ const result = await esbuild.build({
79
+ entryPoints: [filePath],
80
+ bundle: true,
81
+ format: 'esm',
82
+ target: 'esnext',
83
+ write: false,
84
+ platform: 'browser',
85
+ sourcemap: false,
86
+ // Externalize WorkAdventure global API (available in the browser)
87
+ external: ['WA'],
88
+ });
89
+
90
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
91
+ return res.send(result.outputFiles[0].text);
92
+ } catch (error) {
93
+ console.error('Error transforming TypeScript file:', error);
94
+ return next(error);
95
+ }
96
+ }
97
+ // For non-TypeScript files in /src, pass to next middleware
98
+ next();
99
+ });
100
+
101
+ // Serve static files, but skip /src (handled above)
102
+ app.use((req, res, next) => {
103
+ // Skip /src requests - they are handled by the transformation middleware above
104
+ if (req.path.startsWith('/src/')) {
105
+ return next(); // Let the transformation middleware handle it or pass to Vite
106
+ }
107
+ // For other files, use express.static
108
+ staticMiddleware(req, res, next);
109
+ });
110
+
111
+ const controllers = [
112
+ new MapController(app),
113
+ new FrontController(app),
114
+ new UploaderController(app),
115
+ ];
116
+
117
+ // Verify and log all controllers created
118
+ controllers.forEach(controller => {
119
+ console.info(`Controller started: ${controller.constructor.name}`);
120
+ });
121
+
122
+ export default app;
123
+ // Export for VitePluginNode compatibility
124
+ export const viteNodeApp = app;
@@ -0,0 +1,169 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <meta name="robots" content="noindex">
9
+ <meta name="title" content="WorkAdventure Starter Kit">
10
+
11
+ <link href="public/styles/styles.css" rel="stylesheet">
12
+
13
+ <title>WorkAdventure test map</title>
14
+ <link rel="icon" href="/images/favicon.svg" type="image/svg+xml">
15
+ <script src="https://cdn.jsdelivr.net/npm/mustache@4.2.0/mustache.min.js"></script>
16
+ <script type="module">
17
+ document.addEventListener("DOMContentLoaded", (event) => {
18
+ // Load index.js to have access to getMapsList
19
+ import('/public/assets/index.js').then(() => {
20
+ loadTMJ();
21
+ });
22
+ });
23
+
24
+ async function loadTMJ() {
25
+ try {
26
+ // Get the list of maps from the API
27
+ const maps = await window.getMapsList();
28
+
29
+ // Retrieve map images for background fade
30
+ const mapImages = maps
31
+ .map(map => {
32
+ if (map.mapImage) {
33
+ return map.mapImage.startsWith('http') ? map.mapImage : `/${map.mapImage}`;
34
+ }
35
+ return null;
36
+ })
37
+ .filter(img => img !== null);
38
+
39
+ // Create background image fade
40
+ if (mapImages.length > 0) {
41
+ await window.createBackgroundImageFade(mapImages);
42
+ }
43
+
44
+ // Mustache template for a map card
45
+ const cardTemplate = `
46
+ <div class="map-cover" style="background-image: url('{{mapImageUrl}}');"></div>
47
+ <div class="map-date">
48
+ Last edit: {{lastModifiedFormatted}}
49
+ </div>
50
+ <div class="map-name">
51
+ {{mapName}}
52
+ </div>
53
+ <div class="map-detail">
54
+ <div class="map-file">
55
+ <strong>{{filename}}</strong>.tmj
56
+ </div>
57
+ <div class="map-weight">
58
+ <strong>{{size}}</strong>
59
+ <span style="opacity: .5">Mo</span>
60
+ </div>
61
+ </div>
62
+ <div class="map-desc">
63
+ {{mapDescription}}
64
+ </div>
65
+ <div class="map-testurl">
66
+ <a href="#" class="btn" data-map-path="{{path}}">Test my map</a>
67
+ </div>
68
+ `;
69
+
70
+ // Prepare data for Mustache
71
+ const mapsData = maps.map(map => {
72
+ // Build the image URL - use the first available image or a default image
73
+ const mapImageUrl = map.mapImage
74
+ ? (map.mapImage.startsWith('http') ? map.mapImage : `/${map.mapImage}`)
75
+ : (mapImages.length > 0 ? mapImages[0] : 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="1620" height="1024"><rect fill="%231b2a41" width="100%" height="100%"/></svg>');
76
+
77
+ return {
78
+ ...map,
79
+ mapImageUrl: mapImageUrl,
80
+ mapDescription: map.mapDescription || 'No description available'
81
+ };
82
+ });
83
+
84
+ // Render each map with Mustache and inject them into the container
85
+ const mainElement = document.querySelector('main');
86
+ if (mainElement) {
87
+ // Clear existing content
88
+ mainElement.innerHTML = '';
89
+
90
+ // Create a section for each map
91
+ mapsData.forEach(map => {
92
+ const section = document.createElement('section');
93
+ section.className = 'card-map';
94
+ section.innerHTML = Mustache.render(cardTemplate, map);
95
+
96
+ // Add an event handler for the "Test my map" button
97
+ const testBtn = section.querySelector('.map-testurl a');
98
+ if (testBtn) {
99
+ testBtn.addEventListener('click', (e) => {
100
+ e.preventDefault();
101
+ const host = window.location.host;
102
+ let path = window.location.pathname;
103
+ if (path.endsWith('index.html')) {
104
+ path = path.substr(0, path.length - 'index.html'.length);
105
+ }
106
+ const instanceId = Math.random().toString(36).substring(2, 15);
107
+ const url = `https://play.workadventu.re/_/${instanceId}/${host}${path}${map.path}`;
108
+ window.open(url, '_blank');
109
+ });
110
+ }
111
+
112
+ mainElement.appendChild(section);
113
+ });
114
+ }
115
+ } catch (error) {
116
+ console.error('Error loading maps:', error);
117
+ }
118
+ }
119
+ </script>
120
+ </head>
121
+
122
+ <body>
123
+ <div class="content">
124
+ <header>
125
+ <div class="logo">
126
+ <a href="https://workadventu.re/" target="_blank" title="Workadventure">
127
+ <img src="public/images/logo.svg" alt="Workadventure logo" height="36" />
128
+ </a>
129
+ </div>
130
+ <div style="flex-grow: 1;"></div>
131
+ <div class="socials">
132
+ <a href="https://discord.gg/G6Xh9ZM9aR" target="_blank" title="discord">
133
+ <img src="/public/images/brand-discord.svg" alt="discord">
134
+ </a>
135
+ <a href="https://github.com/thecodingmachine/workadventure" target="_blank" title="github">
136
+ <img src="/public/images/brand-github.svg" alt="github">
137
+ </a>
138
+ <a href="https://www.youtube.com/channel/UCXJ9igV-kb9gw1ftR33y5tA" target="_blank" title="youtube">
139
+ <img src="/public/images/brand-youtube.svg" alt="youtube">
140
+ </a>
141
+ <a href="https://twitter.com/Workadventure_" target="_blank" title="twitter">
142
+ <img src="/public/images/brand-x.svg" alt="X">
143
+ </a>
144
+ <a href="https://www.linkedin.com/company/workadventu-re" target="_blank" title="linkedin">
145
+ <img src="/public/images/brand-linkedin.svg" alt="linkedin">
146
+ </a>
147
+ </div>
148
+ <div class="btn-header-wrapper">
149
+ <a href="https://discord.gg/G6Xh9ZM9aR" target="_blank" class="btn btn-light">Talk to the community</a>
150
+ <a href="https://docs.workadventu.re/map-building/" target="_blank" class="btn">Documentation</a>
151
+ </div>
152
+ </header>
153
+ <main>
154
+ <!-- Map cards will be injected here dynamically by Mustache -->
155
+ </main>
156
+ <div class="button-wrapper">
157
+ <div style="flex-grow: 1;">
158
+ </div>
159
+ <div>
160
+ <a href="step1-git">
161
+ Publish
162
+ </a>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ <div class="bg"></div>
167
+ </body>
168
+
169
+ </html>