@illuma-ai/code-sandbox 1.4.0 → 1.4.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.
- package/LICENSE +18 -12
- package/README.md +1 -1
- package/package.json +3 -4
- package/src/components/BootOverlay.tsx +0 -145
- package/src/components/CodeEditor.tsx +0 -298
- package/src/components/FileTree.tsx +0 -678
- package/src/components/Preview.tsx +0 -262
- package/src/components/Terminal.tsx +0 -111
- package/src/components/ViewSlider.tsx +0 -87
- package/src/components/Workbench.tsx +0 -382
- package/src/hooks/useRuntime.ts +0 -637
- package/src/index.ts +0 -51
- package/src/services/runtime.ts +0 -775
- package/src/styles.css +0 -178
- package/src/templates/fullstack-starter.ts +0 -3507
- package/src/templates/index.ts +0 -607
- package/src/types.ts +0 -375
package/src/templates/index.ts
DELETED
|
@@ -1,607 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Template registry — built-in project templates.
|
|
3
|
-
*
|
|
4
|
-
* The "express-react" template is a proper full-stack app:
|
|
5
|
-
* - Express backend with API routes that proxy JSONPlaceholder
|
|
6
|
-
* - React frontend as separate component files under public/
|
|
7
|
-
* - Express serves static files via fs.readFileSync (Nodepod doesn't support
|
|
8
|
-
* res.sendFile or express.static)
|
|
9
|
-
* - React + Tailwind loaded from CDN — no build step needed
|
|
10
|
-
*
|
|
11
|
-
* WHY THIS ARCHITECTURE:
|
|
12
|
-
* Nodepod can't run Vite/Next.js/Webpack. But Express works perfectly.
|
|
13
|
-
* By loading React/Tailwind from CDN and splitting the React app into
|
|
14
|
-
* separate script files, we get a realistic full-stack project structure
|
|
15
|
-
* that users can explore and edit in the code sandbox.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type { FileMap } from "../types";
|
|
19
|
-
import { fullstackStarterTemplate } from "./fullstack-starter";
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// server.js — Express backend
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
const SERVER_JS = `const express = require('express');
|
|
26
|
-
const fs = require('fs');
|
|
27
|
-
const path = require('path');
|
|
28
|
-
const app = express();
|
|
29
|
-
const PORT = 3000;
|
|
30
|
-
|
|
31
|
-
const API_BASE = 'https://jsonplaceholder.typicode.com';
|
|
32
|
-
|
|
33
|
-
// Middleware
|
|
34
|
-
app.use(express.json());
|
|
35
|
-
|
|
36
|
-
// -----------------------------------------------------------------------
|
|
37
|
-
// Static file serving
|
|
38
|
-
// Nodepod doesn't support express.static() or res.sendFile(), so we
|
|
39
|
-
// serve files manually via fs.readFileSync + res.send.
|
|
40
|
-
// -----------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
const MIME_TYPES = {
|
|
43
|
-
'.html': 'text/html',
|
|
44
|
-
'.js': 'application/javascript',
|
|
45
|
-
'.css': 'text/css',
|
|
46
|
-
'.json': 'application/json',
|
|
47
|
-
'.svg': 'image/svg+xml',
|
|
48
|
-
'.png': 'image/png',
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
function serveStatic(req, res, next) {
|
|
52
|
-
// Only serve GET requests for paths with extensions
|
|
53
|
-
if (req.method !== 'GET') return next();
|
|
54
|
-
|
|
55
|
-
var filePath = path.join(__dirname, 'public', req.path === '/' ? 'index.html' : req.path);
|
|
56
|
-
var ext = path.extname(filePath);
|
|
57
|
-
|
|
58
|
-
// If no extension, try .html
|
|
59
|
-
if (!ext) {
|
|
60
|
-
filePath += '.html';
|
|
61
|
-
ext = '.html';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
var data = fs.readFileSync(filePath, ext === '.png' ? undefined : 'utf-8');
|
|
66
|
-
res.setHeader('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
|
|
67
|
-
res.send(data);
|
|
68
|
-
} catch (e) {
|
|
69
|
-
next();
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// -----------------------------------------------------------------------
|
|
74
|
-
// API proxy routes — proxy to JSONPlaceholder
|
|
75
|
-
// -----------------------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
app.get('/api/posts', async function(req, res) {
|
|
78
|
-
try {
|
|
79
|
-
var userId = req.query.userId;
|
|
80
|
-
var url = userId ? API_BASE + '/posts?userId=' + userId : API_BASE + '/posts';
|
|
81
|
-
var resp = await fetch(url);
|
|
82
|
-
var data = await resp.json();
|
|
83
|
-
res.json(data);
|
|
84
|
-
} catch (err) {
|
|
85
|
-
res.status(500).json({ error: 'Failed to fetch posts', details: err.message });
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
app.get('/api/posts/:id', async function(req, res) {
|
|
90
|
-
try {
|
|
91
|
-
var resp = await fetch(API_BASE + '/posts/' + req.params.id);
|
|
92
|
-
var data = await resp.json();
|
|
93
|
-
res.json(data);
|
|
94
|
-
} catch (err) {
|
|
95
|
-
res.status(500).json({ error: 'Failed to fetch post', details: err.message });
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
app.get('/api/posts/:id/comments', async function(req, res) {
|
|
100
|
-
try {
|
|
101
|
-
var resp = await fetch(API_BASE + '/posts/' + req.params.id + '/comments');
|
|
102
|
-
var data = await resp.json();
|
|
103
|
-
res.json(data);
|
|
104
|
-
} catch (err) {
|
|
105
|
-
res.status(500).json({ error: 'Failed to fetch comments', details: err.message });
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
app.get('/api/users', async function(req, res) {
|
|
110
|
-
try {
|
|
111
|
-
var resp = await fetch(API_BASE + '/users');
|
|
112
|
-
var data = await resp.json();
|
|
113
|
-
res.json(data);
|
|
114
|
-
} catch (err) {
|
|
115
|
-
res.status(500).json({ error: 'Failed to fetch users', details: err.message });
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
app.get('/api/users/:id', async function(req, res) {
|
|
120
|
-
try {
|
|
121
|
-
var resp = await fetch(API_BASE + '/users/' + req.params.id);
|
|
122
|
-
var data = await resp.json();
|
|
123
|
-
res.json(data);
|
|
124
|
-
} catch (err) {
|
|
125
|
-
res.status(500).json({ error: 'Failed to fetch user', details: err.message });
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// -----------------------------------------------------------------------
|
|
130
|
-
// Catch-all: serve static files (must be after API routes)
|
|
131
|
-
// -----------------------------------------------------------------------
|
|
132
|
-
app.use(serveStatic);
|
|
133
|
-
|
|
134
|
-
app.listen(PORT, function() {
|
|
135
|
-
console.log('Server running on port ' + PORT);
|
|
136
|
-
});
|
|
137
|
-
`;
|
|
138
|
-
|
|
139
|
-
// ---------------------------------------------------------------------------
|
|
140
|
-
// public/index.html — HTML shell that loads React app
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
|
|
143
|
-
const INDEX_HTML = `<!DOCTYPE html>
|
|
144
|
-
<html lang="en">
|
|
145
|
-
<head>
|
|
146
|
-
<meta charset="UTF-8">
|
|
147
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
148
|
-
<title>JSONPlaceholder Explorer</title>
|
|
149
|
-
<script src="https://cdn.tailwindcss.com"><\/script>
|
|
150
|
-
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"><\/script>
|
|
151
|
-
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"><\/script>
|
|
152
|
-
<link rel="stylesheet" href="/styles.css">
|
|
153
|
-
</head>
|
|
154
|
-
<body class="bg-gray-50 min-h-screen">
|
|
155
|
-
<div id="root"></div>
|
|
156
|
-
<!-- Load components in dependency order -->
|
|
157
|
-
<script src="/components/PostCard.js"><\/script>
|
|
158
|
-
<script src="/components/UserCard.js"><\/script>
|
|
159
|
-
<script src="/components/CommentCard.js"><\/script>
|
|
160
|
-
<script src="/components/PostDetail.js"><\/script>
|
|
161
|
-
<script src="/components/UserDetail.js"><\/script>
|
|
162
|
-
<script src="/App.js"><\/script>
|
|
163
|
-
</body>
|
|
164
|
-
</html>`;
|
|
165
|
-
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
// public/styles.css — App styles
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
|
|
170
|
-
const STYLES_CSS = `.tab-active {
|
|
171
|
-
border-bottom: 2px solid #3b82f6;
|
|
172
|
-
color: #3b82f6;
|
|
173
|
-
}
|
|
174
|
-
.card-hover:hover {
|
|
175
|
-
transform: translateY(-2px);
|
|
176
|
-
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
|
177
|
-
}
|
|
178
|
-
.fade-in {
|
|
179
|
-
animation: fadeIn 0.3s ease-in;
|
|
180
|
-
}
|
|
181
|
-
@keyframes fadeIn {
|
|
182
|
-
from { opacity: 0; transform: translateY(8px); }
|
|
183
|
-
to { opacity: 1; transform: translateY(0); }
|
|
184
|
-
}
|
|
185
|
-
`;
|
|
186
|
-
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
// public/components/PostCard.js
|
|
189
|
-
// ---------------------------------------------------------------------------
|
|
190
|
-
|
|
191
|
-
const POST_CARD_JS = `var e = React.createElement;
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* PostCard — Renders a single post as a clickable card.
|
|
195
|
-
*/
|
|
196
|
-
window.PostCard = function PostCard(props) {
|
|
197
|
-
var post = props.post;
|
|
198
|
-
var onSelect = props.onSelect;
|
|
199
|
-
|
|
200
|
-
return e('div', {
|
|
201
|
-
className: 'bg-white rounded-lg p-5 border border-gray-200 card-hover transition-all duration-200 cursor-pointer fade-in',
|
|
202
|
-
onClick: function() { onSelect(post); }
|
|
203
|
-
},
|
|
204
|
-
e('div', { className: 'flex items-start justify-between mb-2' },
|
|
205
|
-
e('span', { className: 'text-xs font-medium text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full' },
|
|
206
|
-
'Post #' + post.id),
|
|
207
|
-
e('span', { className: 'text-xs text-gray-400' }, 'User ' + post.userId)
|
|
208
|
-
),
|
|
209
|
-
e('h3', { className: 'font-semibold text-gray-800 mb-2 line-clamp-1' }, post.title),
|
|
210
|
-
e('p', { className: 'text-sm text-gray-500 line-clamp-2' }, post.body)
|
|
211
|
-
);
|
|
212
|
-
};
|
|
213
|
-
`;
|
|
214
|
-
|
|
215
|
-
// ---------------------------------------------------------------------------
|
|
216
|
-
// public/components/UserCard.js
|
|
217
|
-
// ---------------------------------------------------------------------------
|
|
218
|
-
|
|
219
|
-
const USER_CARD_JS = `var e = React.createElement;
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* UserCard — Renders a user profile card with avatar.
|
|
223
|
-
*/
|
|
224
|
-
window.UserCard = function UserCard(props) {
|
|
225
|
-
var user = props.user;
|
|
226
|
-
var onSelect = props.onSelect;
|
|
227
|
-
|
|
228
|
-
return e('div', {
|
|
229
|
-
className: 'bg-white rounded-lg p-5 border border-gray-200 card-hover transition-all duration-200 cursor-pointer fade-in',
|
|
230
|
-
onClick: function() { onSelect(user); }
|
|
231
|
-
},
|
|
232
|
-
e('div', { className: 'flex items-center gap-3 mb-3' },
|
|
233
|
-
e('div', {
|
|
234
|
-
className: 'w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-bold text-sm'
|
|
235
|
-
}, user.name.charAt(0)),
|
|
236
|
-
e('div', null,
|
|
237
|
-
e('h3', { className: 'font-semibold text-gray-800' }, user.name),
|
|
238
|
-
e('p', { className: 'text-xs text-gray-400' }, '@' + user.username)
|
|
239
|
-
)
|
|
240
|
-
),
|
|
241
|
-
e('div', { className: 'space-y-1 text-sm text-gray-500' },
|
|
242
|
-
e('p', null, user.email),
|
|
243
|
-
e('p', null, user.company ? user.company.name : '')
|
|
244
|
-
)
|
|
245
|
-
);
|
|
246
|
-
};
|
|
247
|
-
`;
|
|
248
|
-
|
|
249
|
-
// ---------------------------------------------------------------------------
|
|
250
|
-
// public/components/CommentCard.js
|
|
251
|
-
// ---------------------------------------------------------------------------
|
|
252
|
-
|
|
253
|
-
const COMMENT_CARD_JS = `var e = React.createElement;
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* CommentCard — Renders a single comment.
|
|
257
|
-
*/
|
|
258
|
-
window.CommentCard = function CommentCard(props) {
|
|
259
|
-
var c = props.comment;
|
|
260
|
-
|
|
261
|
-
return e('div', { className: 'bg-gray-50 rounded-lg p-4 border border-gray-100 fade-in' },
|
|
262
|
-
e('div', { className: 'flex items-center gap-2 mb-2' },
|
|
263
|
-
e('div', {
|
|
264
|
-
className: 'w-6 h-6 rounded-full bg-gray-300 flex items-center justify-center text-[10px] font-bold text-gray-600'
|
|
265
|
-
}, c.name.charAt(0).toUpperCase()),
|
|
266
|
-
e('span', { className: 'text-xs font-medium text-gray-600 truncate flex-1' }, c.name),
|
|
267
|
-
e('span', { className: 'text-xs text-gray-400 truncate' }, c.email)
|
|
268
|
-
),
|
|
269
|
-
e('p', { className: 'text-sm text-gray-600' }, c.body)
|
|
270
|
-
);
|
|
271
|
-
};
|
|
272
|
-
`;
|
|
273
|
-
|
|
274
|
-
// ---------------------------------------------------------------------------
|
|
275
|
-
// public/components/PostDetail.js
|
|
276
|
-
// ---------------------------------------------------------------------------
|
|
277
|
-
|
|
278
|
-
const POST_DETAIL_JS = `var e = React.createElement;
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* PostDetail — Full post view with comments.
|
|
282
|
-
*/
|
|
283
|
-
window.PostDetail = function PostDetail(props) {
|
|
284
|
-
var post = props.post;
|
|
285
|
-
var onBack = props.onBack;
|
|
286
|
-
var comments = props.comments;
|
|
287
|
-
var loading = props.loading;
|
|
288
|
-
|
|
289
|
-
return e('div', { className: 'fade-in' },
|
|
290
|
-
e('button', {
|
|
291
|
-
onClick: onBack,
|
|
292
|
-
className: 'text-sm text-blue-600 hover:text-blue-700 mb-4 flex items-center gap-1'
|
|
293
|
-
}, '\\u2190 Back to posts'),
|
|
294
|
-
|
|
295
|
-
e('div', { className: 'bg-white rounded-lg p-6 border border-gray-200 mb-6' },
|
|
296
|
-
e('div', { className: 'flex items-center gap-2 mb-3' },
|
|
297
|
-
e('span', { className: 'text-xs font-medium text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full' },
|
|
298
|
-
'Post #' + post.id),
|
|
299
|
-
e('span', { className: 'text-xs text-gray-400' }, 'User ' + post.userId)
|
|
300
|
-
),
|
|
301
|
-
e('h2', { className: 'text-xl font-bold text-gray-800 mb-3' }, post.title),
|
|
302
|
-
e('p', { className: 'text-gray-600 leading-relaxed' }, post.body)
|
|
303
|
-
),
|
|
304
|
-
|
|
305
|
-
e('h3', { className: 'text-lg font-semibold text-gray-700 mb-3' },
|
|
306
|
-
'Comments (' + (comments ? comments.length : '...') + ')'
|
|
307
|
-
),
|
|
308
|
-
|
|
309
|
-
loading
|
|
310
|
-
? e('div', { className: 'text-center py-8 text-gray-400' }, 'Loading comments...')
|
|
311
|
-
: comments && comments.length > 0
|
|
312
|
-
? e('div', { className: 'space-y-3' },
|
|
313
|
-
comments.map(function(c) {
|
|
314
|
-
return e(window.CommentCard, { key: c.id, comment: c });
|
|
315
|
-
})
|
|
316
|
-
)
|
|
317
|
-
: e('p', { className: 'text-gray-400 text-sm' }, 'No comments found.')
|
|
318
|
-
);
|
|
319
|
-
};
|
|
320
|
-
`;
|
|
321
|
-
|
|
322
|
-
// ---------------------------------------------------------------------------
|
|
323
|
-
// public/components/UserDetail.js
|
|
324
|
-
// ---------------------------------------------------------------------------
|
|
325
|
-
|
|
326
|
-
const USER_DETAIL_JS = `var e = React.createElement;
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* UserDetail — Full user profile with their posts.
|
|
330
|
-
*/
|
|
331
|
-
window.UserDetail = function UserDetail(props) {
|
|
332
|
-
var user = props.user;
|
|
333
|
-
var posts = props.posts;
|
|
334
|
-
var onBack = props.onBack;
|
|
335
|
-
var onSelectPost = props.onSelectPost;
|
|
336
|
-
var loading = props.loading;
|
|
337
|
-
|
|
338
|
-
return e('div', { className: 'fade-in' },
|
|
339
|
-
e('button', {
|
|
340
|
-
onClick: onBack,
|
|
341
|
-
className: 'text-sm text-blue-600 hover:text-blue-700 mb-4 flex items-center gap-1'
|
|
342
|
-
}, '\\u2190 Back to users'),
|
|
343
|
-
|
|
344
|
-
e('div', { className: 'bg-white rounded-lg p-6 border border-gray-200 mb-6' },
|
|
345
|
-
e('div', { className: 'flex items-center gap-4 mb-4' },
|
|
346
|
-
e('div', {
|
|
347
|
-
className: 'w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-bold text-xl'
|
|
348
|
-
}, user.name.charAt(0)),
|
|
349
|
-
e('div', null,
|
|
350
|
-
e('h2', { className: 'text-xl font-bold text-gray-800' }, user.name),
|
|
351
|
-
e('p', { className: 'text-sm text-gray-400' }, '@' + user.username)
|
|
352
|
-
)
|
|
353
|
-
),
|
|
354
|
-
e('div', { className: 'grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm' },
|
|
355
|
-
e('div', null,
|
|
356
|
-
e('span', { className: 'text-gray-400' }, 'Email'),
|
|
357
|
-
e('p', { className: 'text-gray-700' }, user.email)
|
|
358
|
-
),
|
|
359
|
-
e('div', null,
|
|
360
|
-
e('span', { className: 'text-gray-400' }, 'Phone'),
|
|
361
|
-
e('p', { className: 'text-gray-700' }, user.phone || 'N/A')
|
|
362
|
-
),
|
|
363
|
-
e('div', null,
|
|
364
|
-
e('span', { className: 'text-gray-400' }, 'Website'),
|
|
365
|
-
e('p', { className: 'text-gray-700' }, user.website || 'N/A')
|
|
366
|
-
),
|
|
367
|
-
e('div', null,
|
|
368
|
-
e('span', { className: 'text-gray-400' }, 'Company'),
|
|
369
|
-
e('p', { className: 'text-gray-700' }, user.company ? user.company.name : 'N/A')
|
|
370
|
-
)
|
|
371
|
-
)
|
|
372
|
-
),
|
|
373
|
-
|
|
374
|
-
e('h3', { className: 'text-lg font-semibold text-gray-700 mb-3' },
|
|
375
|
-
'Posts by ' + user.name + ' (' + (posts ? posts.length : '...') + ')'
|
|
376
|
-
),
|
|
377
|
-
|
|
378
|
-
loading
|
|
379
|
-
? e('div', { className: 'text-center py-8 text-gray-400' }, 'Loading posts...')
|
|
380
|
-
: posts && posts.length > 0
|
|
381
|
-
? e('div', { className: 'grid gap-3' },
|
|
382
|
-
posts.map(function(p) {
|
|
383
|
-
return e(window.PostCard, { key: p.id, post: p, onSelect: onSelectPost });
|
|
384
|
-
})
|
|
385
|
-
)
|
|
386
|
-
: e('p', { className: 'text-gray-400 text-sm' }, 'No posts found.')
|
|
387
|
-
);
|
|
388
|
-
};
|
|
389
|
-
`;
|
|
390
|
-
|
|
391
|
-
// ---------------------------------------------------------------------------
|
|
392
|
-
// public/App.js — Main application component + mount
|
|
393
|
-
// ---------------------------------------------------------------------------
|
|
394
|
-
|
|
395
|
-
const APP_JS = `var e = React.createElement;
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* App — Main application component.
|
|
399
|
-
* Manages tabs (Posts/Users), fetches data from the Express API,
|
|
400
|
-
* and renders detail views when items are selected.
|
|
401
|
-
*/
|
|
402
|
-
function App() {
|
|
403
|
-
var TAB_POSTS = 'posts';
|
|
404
|
-
var TAB_USERS = 'users';
|
|
405
|
-
|
|
406
|
-
var tabState = React.useState(TAB_POSTS);
|
|
407
|
-
var activeTab = tabState[0], setActiveTab = tabState[1];
|
|
408
|
-
|
|
409
|
-
var postsState = React.useState(null);
|
|
410
|
-
var posts = postsState[0], setPosts = postsState[1];
|
|
411
|
-
|
|
412
|
-
var usersState = React.useState(null);
|
|
413
|
-
var users = usersState[0], setUsers = usersState[1];
|
|
414
|
-
|
|
415
|
-
var loadingState = React.useState(true);
|
|
416
|
-
var loading = loadingState[0], setLoading = loadingState[1];
|
|
417
|
-
|
|
418
|
-
var selectedPostState = React.useState(null);
|
|
419
|
-
var selectedPost = selectedPostState[0], setSelectedPost = selectedPostState[1];
|
|
420
|
-
|
|
421
|
-
var selectedUserState = React.useState(null);
|
|
422
|
-
var selectedUser = selectedUserState[0], setSelectedUser = selectedUserState[1];
|
|
423
|
-
|
|
424
|
-
var commentsState = React.useState(null);
|
|
425
|
-
var comments = commentsState[0], setComments = commentsState[1];
|
|
426
|
-
|
|
427
|
-
var userPostsState = React.useState(null);
|
|
428
|
-
var userPosts = userPostsState[0], setUserPosts = userPostsState[1];
|
|
429
|
-
|
|
430
|
-
var detailLoadingState = React.useState(false);
|
|
431
|
-
var detailLoading = detailLoadingState[0], setDetailLoading = detailLoadingState[1];
|
|
432
|
-
|
|
433
|
-
// Fetch posts on mount
|
|
434
|
-
React.useEffect(function() {
|
|
435
|
-
setLoading(true);
|
|
436
|
-
fetch('/api/posts')
|
|
437
|
-
.then(function(r) { return r.json(); })
|
|
438
|
-
.then(function(data) { setPosts(data); setLoading(false); })
|
|
439
|
-
.catch(function() { setLoading(false); });
|
|
440
|
-
}, []);
|
|
441
|
-
|
|
442
|
-
// Fetch users when tab switches
|
|
443
|
-
React.useEffect(function() {
|
|
444
|
-
if (activeTab === TAB_USERS && !users) {
|
|
445
|
-
setLoading(true);
|
|
446
|
-
fetch('/api/users')
|
|
447
|
-
.then(function(r) { return r.json(); })
|
|
448
|
-
.then(function(data) { setUsers(data); setLoading(false); })
|
|
449
|
-
.catch(function() { setLoading(false); });
|
|
450
|
-
}
|
|
451
|
-
}, [activeTab]);
|
|
452
|
-
|
|
453
|
-
function handleSelectPost(post) {
|
|
454
|
-
setSelectedPost(post);
|
|
455
|
-
setSelectedUser(null);
|
|
456
|
-
setComments(null);
|
|
457
|
-
setDetailLoading(true);
|
|
458
|
-
fetch('/api/posts/' + post.id + '/comments')
|
|
459
|
-
.then(function(r) { return r.json(); })
|
|
460
|
-
.then(function(data) { setComments(data); setDetailLoading(false); })
|
|
461
|
-
.catch(function() { setDetailLoading(false); });
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function handleSelectUser(user) {
|
|
465
|
-
setSelectedUser(user);
|
|
466
|
-
setSelectedPost(null);
|
|
467
|
-
setUserPosts(null);
|
|
468
|
-
setDetailLoading(true);
|
|
469
|
-
fetch('/api/posts?userId=' + user.id)
|
|
470
|
-
.then(function(r) { return r.json(); })
|
|
471
|
-
.then(function(data) { setUserPosts(data); setDetailLoading(false); })
|
|
472
|
-
.catch(function() { setDetailLoading(false); });
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function handleBackFromPost() { setSelectedPost(null); setComments(null); }
|
|
476
|
-
function handleBackFromUser() { setSelectedUser(null); setUserPosts(null); }
|
|
477
|
-
function switchTab(tab) { setActiveTab(tab); setSelectedPost(null); setSelectedUser(null); }
|
|
478
|
-
|
|
479
|
-
// Loading spinner
|
|
480
|
-
function Spinner() {
|
|
481
|
-
return e('div', { className: 'text-center py-12' },
|
|
482
|
-
e('div', { className: 'inline-block w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-2' }),
|
|
483
|
-
e('p', { className: 'text-gray-400 text-sm' }, 'Loading...')
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Render
|
|
488
|
-
return e('div', { className: 'min-h-screen bg-gray-50' },
|
|
489
|
-
// Header
|
|
490
|
-
e('header', { className: 'bg-white border-b border-gray-200 sticky top-0 z-10' },
|
|
491
|
-
e('div', { className: 'max-w-4xl mx-auto px-4 py-4' },
|
|
492
|
-
e('div', { className: 'flex items-center justify-between mb-3' },
|
|
493
|
-
e('div', null,
|
|
494
|
-
e('h1', { className: 'text-xl font-bold text-gray-800' }, 'JSONPlaceholder Explorer'),
|
|
495
|
-
e('p', { className: 'text-xs text-gray-400' }, 'Full-stack Express + React')
|
|
496
|
-
),
|
|
497
|
-
e('span', {
|
|
498
|
-
className: 'text-xs text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full font-medium'
|
|
499
|
-
}, '\\u2022 Live')
|
|
500
|
-
),
|
|
501
|
-
// Tab bar
|
|
502
|
-
e('div', { className: 'flex gap-0 border-b border-gray-200 -mb-[1px]' },
|
|
503
|
-
e('button', {
|
|
504
|
-
onClick: function() { switchTab(TAB_POSTS); },
|
|
505
|
-
className: 'px-4 py-2 text-sm font-medium transition-colors ' +
|
|
506
|
-
(activeTab === TAB_POSTS ? 'tab-active' : 'text-gray-500 hover:text-gray-700')
|
|
507
|
-
}, 'Posts'),
|
|
508
|
-
e('button', {
|
|
509
|
-
onClick: function() { switchTab(TAB_USERS); },
|
|
510
|
-
className: 'px-4 py-2 text-sm font-medium transition-colors ' +
|
|
511
|
-
(activeTab === TAB_USERS ? 'tab-active' : 'text-gray-500 hover:text-gray-700')
|
|
512
|
-
}, 'Users')
|
|
513
|
-
)
|
|
514
|
-
)
|
|
515
|
-
),
|
|
516
|
-
|
|
517
|
-
// Content
|
|
518
|
-
e('main', { className: 'max-w-4xl mx-auto px-4 py-6' },
|
|
519
|
-
selectedPost
|
|
520
|
-
? e(window.PostDetail, { post: selectedPost, comments: comments, loading: detailLoading, onBack: handleBackFromPost })
|
|
521
|
-
: selectedUser
|
|
522
|
-
? e(window.UserDetail, { user: selectedUser, posts: userPosts, loading: detailLoading, onBack: handleBackFromUser, onSelectPost: handleSelectPost })
|
|
523
|
-
: activeTab === TAB_POSTS
|
|
524
|
-
? (loading ? e(Spinner) : posts && posts.length > 0
|
|
525
|
-
? e('div', { className: 'grid gap-3 sm:grid-cols-2' },
|
|
526
|
-
posts.slice(0, 20).map(function(p) {
|
|
527
|
-
return e(window.PostCard, { key: p.id, post: p, onSelect: handleSelectPost });
|
|
528
|
-
})
|
|
529
|
-
)
|
|
530
|
-
: e('p', { className: 'text-gray-400 text-center py-12' }, 'No posts found.'))
|
|
531
|
-
: activeTab === TAB_USERS
|
|
532
|
-
? (loading ? e(Spinner) : users && users.length > 0
|
|
533
|
-
? e('div', { className: 'grid gap-3 sm:grid-cols-2' },
|
|
534
|
-
users.map(function(u) {
|
|
535
|
-
return e(window.UserCard, { key: u.id, user: u, onSelect: handleSelectUser });
|
|
536
|
-
})
|
|
537
|
-
)
|
|
538
|
-
: e('p', { className: 'text-gray-400 text-center py-12' }, 'No users found.'))
|
|
539
|
-
: null
|
|
540
|
-
)
|
|
541
|
-
);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// Mount
|
|
545
|
-
ReactDOM.createRoot(document.getElementById('root')).render(e(App));
|
|
546
|
-
`;
|
|
547
|
-
|
|
548
|
-
// ---------------------------------------------------------------------------
|
|
549
|
-
// Template registry
|
|
550
|
-
// ---------------------------------------------------------------------------
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* Template registry — maps template names to file maps.
|
|
554
|
-
*/
|
|
555
|
-
export const templates: Record<
|
|
556
|
-
string,
|
|
557
|
-
{ files: FileMap; entryCommand: string; port: number }
|
|
558
|
-
> = {
|
|
559
|
-
"express-react": {
|
|
560
|
-
entryCommand: "node server.js",
|
|
561
|
-
port: 3000,
|
|
562
|
-
files: {
|
|
563
|
-
"package.json": JSON.stringify(
|
|
564
|
-
{
|
|
565
|
-
name: "sandbox-app",
|
|
566
|
-
version: "1.0.0",
|
|
567
|
-
dependencies: {
|
|
568
|
-
express: "^4.18.0",
|
|
569
|
-
},
|
|
570
|
-
},
|
|
571
|
-
null,
|
|
572
|
-
2,
|
|
573
|
-
),
|
|
574
|
-
|
|
575
|
-
"server.js": SERVER_JS,
|
|
576
|
-
|
|
577
|
-
// Frontend files — served by Express's static middleware
|
|
578
|
-
"public/index.html": INDEX_HTML,
|
|
579
|
-
"public/styles.css": STYLES_CSS,
|
|
580
|
-
"public/App.js": APP_JS,
|
|
581
|
-
"public/components/PostCard.js": POST_CARD_JS,
|
|
582
|
-
"public/components/UserCard.js": USER_CARD_JS,
|
|
583
|
-
"public/components/CommentCard.js": COMMENT_CARD_JS,
|
|
584
|
-
"public/components/PostDetail.js": POST_DETAIL_JS,
|
|
585
|
-
"public/components/UserDetail.js": USER_DETAIL_JS,
|
|
586
|
-
},
|
|
587
|
-
},
|
|
588
|
-
|
|
589
|
-
"fullstack-starter": fullstackStarterTemplate,
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* Get a project template by name.
|
|
594
|
-
*
|
|
595
|
-
* @param name - Template name (e.g., 'express-react')
|
|
596
|
-
* @returns Template config or null if not found
|
|
597
|
-
*/
|
|
598
|
-
export function getTemplate(
|
|
599
|
-
name: string,
|
|
600
|
-
): { files: FileMap; entryCommand: string; port: number } | null {
|
|
601
|
-
return templates[name] || null;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
/** List available template names */
|
|
605
|
-
export function listTemplates(): string[] {
|
|
606
|
-
return Object.keys(templates);
|
|
607
|
-
}
|