tangled
alpha
login
or
join now
nekomimi.pet
/
wisp.place-monorepo
88
fork
atom
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
88
fork
atom
overview
issues
9
pulls
pipelines
fix onboarding
nekomimi.pet
1 month ago
f4359de0
68ac9c75
+435
-1
3 changed files
expand all
collapse all
unified
split
apps
main-app
public
editor
onboarding.html
landingpage.html
src
index.ts
+426
apps/main-app/public/editor/onboarding.html
···
1
1
+
<!DOCTYPE html>
2
2
+
<html lang="en">
3
3
+
<head>
4
4
+
<meta charset="UTF-8">
5
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
+
<title>Get Started - wisp.place</title>
7
7
+
<link rel="icon" type="image/x-icon" href="../favicon.ico">
8
8
+
<link rel="preconnect" href="https://fonts.googleapis.com">
9
9
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
10
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
11
11
+
<style>
12
12
+
* { margin: 0; padding: 0; box-sizing: border-box; }
13
13
+
14
14
+
:root {
15
15
+
--bg: oklch(0.92 0.012 35);
16
16
+
--bg-alt: oklch(0.88 0.01 35);
17
17
+
--text: oklch(0.15 0.015 30);
18
18
+
--text-muted: oklch(0.35 0.02 30);
19
19
+
--text-subtle: oklch(0.50 0.02 30);
20
20
+
--border: oklch(0.30 0.025 35);
21
21
+
--border-light: oklch(0.65 0.02 30);
22
22
+
--accent: oklch(0.65 0.18 345);
23
23
+
--cta-bg: oklch(0.30 0.025 35);
24
24
+
--cta-text: oklch(0.96 0.008 35);
25
25
+
--code-bg: oklch(0.95 0.008 35);
26
26
+
--success: oklch(0.65 0.20 145);
27
27
+
}
28
28
+
29
29
+
@media (prefers-color-scheme: dark) {
30
30
+
:root {
31
31
+
--bg: oklch(0.23 0.015 285);
32
32
+
--bg-alt: oklch(0.20 0.015 285);
33
33
+
--text: oklch(0.90 0.005 285);
34
34
+
--text-muted: oklch(0.72 0.01 285);
35
35
+
--text-subtle: oklch(0.55 0.01 285);
36
36
+
--border: oklch(0.90 0.005 285);
37
37
+
--border-light: oklch(0.38 0.02 285);
38
38
+
--accent: oklch(0.85 0.08 5);
39
39
+
--cta-bg: oklch(0.70 0.10 295);
40
40
+
--cta-text: oklch(0.23 0.015 285);
41
41
+
--code-bg: oklch(0.28 0.015 285);
42
42
+
}
43
43
+
}
44
44
+
45
45
+
body {
46
46
+
font-family: "JetBrains Mono", monospace;
47
47
+
background: var(--bg);
48
48
+
color: var(--text);
49
49
+
line-height: 1.6;
50
50
+
}
51
51
+
.container { max-width: 600px; margin: 0 auto; padding: 3rem 1rem; }
52
52
+
header {
53
53
+
border-bottom: 1px solid var(--border-light);
54
54
+
padding: 1rem 2rem;
55
55
+
background: var(--bg);
56
56
+
position: sticky;
57
57
+
top: 0;
58
58
+
z-index: 100;
59
59
+
}
60
60
+
.logo { font-size: 1.125rem; font-weight: 700; color: var(--text); letter-spacing: -0.02em; }
61
61
+
.progress { display: flex; justify-content: center; align-items: center; gap: 1rem; margin-bottom: 2rem; }
62
62
+
.step-indicator {
63
63
+
width: 2rem; height: 2rem; border-radius: 50%;
64
64
+
display: flex; align-items: center; justify-content: center;
65
65
+
background: var(--bg-alt); color: var(--text-subtle);
66
66
+
font-weight: 600;
67
67
+
}
68
68
+
.step-indicator.active { background: var(--cta-bg); color: var(--cta-text); }
69
69
+
.step-indicator.complete { background: var(--success); color: var(--bg); }
70
70
+
.progress-line { width: 4rem; height: 2px; background: var(--border-light); }
71
71
+
.card {
72
72
+
background: var(--bg-alt);
73
73
+
border: 1px solid var(--border-light);
74
74
+
border-radius: 0.5rem;
75
75
+
padding: 1.5rem;
76
76
+
}
77
77
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; text-align: center; color: var(--text); }
78
78
+
h2 { font-size: 1.25rem; margin-bottom: 1rem; color: var(--text); }
79
79
+
p { color: var(--text-muted); margin-bottom: 1rem; }
80
80
+
.text-center { text-align: center; }
81
81
+
.text-muted { color: var(--text-muted); font-size: 0.875rem; }
82
82
+
label { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: 500; color: var(--text); }
83
83
+
input {
84
84
+
width: 100%;
85
85
+
padding: 0.5rem 0.75rem;
86
86
+
background: var(--bg);
87
87
+
border: 1px solid var(--border-light);
88
88
+
border-radius: 0.375rem;
89
89
+
color: var(--text);
90
90
+
font-size: 0.875rem;
91
91
+
font-family: "JetBrains Mono", monospace;
92
92
+
}
93
93
+
input:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
94
94
+
button {
95
95
+
width: 100%;
96
96
+
padding: 0.5rem 1rem;
97
97
+
background: var(--cta-bg);
98
98
+
color: var(--cta-text);
99
99
+
border: none;
100
100
+
border-radius: 0.375rem;
101
101
+
font-weight: 600;
102
102
+
cursor: pointer;
103
103
+
font-size: 0.875rem;
104
104
+
font-family: "JetBrains Mono", monospace;
105
105
+
}
106
106
+
button:hover { opacity: 0.9; }
107
107
+
button:disabled { opacity: 0.5; cursor: not-allowed; }
108
108
+
button.outline {
109
109
+
background: transparent;
110
110
+
color: var(--text);
111
111
+
border: 1px solid var(--border-light);
112
112
+
}
113
113
+
.input-wrapper { position: relative; margin-bottom: 1rem; }
114
114
+
.input-icon {
115
115
+
position: absolute;
116
116
+
right: 0.75rem;
117
117
+
top: 50%;
118
118
+
transform: translateY(-50%);
119
119
+
}
120
120
+
.spinner {
121
121
+
display: inline-block;
122
122
+
width: 1rem; height: 1rem;
123
123
+
border: 2px solid var(--border-light);
124
124
+
border-top-color: var(--accent);
125
125
+
border-radius: 50%;
126
126
+
animation: spin 0.6s linear infinite;
127
127
+
}
128
128
+
@keyframes spin { to { transform: rotate(360deg); } }
129
129
+
.success { color: var(--success); }
130
130
+
.error { color: var(--accent); }
131
131
+
.hidden { display: none; }
132
132
+
.mb-1 { margin-bottom: 0.5rem; }
133
133
+
.mb-2 { margin-bottom: 1rem; }
134
134
+
.mb-4 { margin-bottom: 1.5rem; }
135
135
+
.mt-4 { margin-top: 1.5rem; }
136
136
+
.flex { display: flex; }
137
137
+
.gap-2 { gap: 0.5rem; }
138
138
+
.upload-zone {
139
139
+
border: 2px dashed var(--border-light);
140
140
+
border-radius: 0.5rem;
141
141
+
padding: 2rem;
142
142
+
text-align: center;
143
143
+
cursor: pointer;
144
144
+
transition: border-color 0.2s;
145
145
+
}
146
146
+
.upload-zone:hover { border-color: var(--accent); }
147
147
+
.alert {
148
148
+
padding: 1rem;
149
149
+
border-radius: 0.5rem;
150
150
+
margin-bottom: 1rem;
151
151
+
}
152
152
+
.alert-success { background: var(--code-bg); border: 1px solid var(--border-light); color: var(--success); }
153
153
+
.alert-info { background: var(--code-bg); color: var(--text-muted); }
154
154
+
</style>
155
155
+
</head>
156
156
+
<body>
157
157
+
<header>
158
158
+
<div class="logo">wisp.place</div>
159
159
+
</header>
160
160
+
161
161
+
<div class="container">
162
162
+
<div class="progress mb-4">
163
163
+
<div class="step-indicator" id="step1">1</div>
164
164
+
<div class="progress-line"></div>
165
165
+
<div class="step-indicator" id="step2">2</div>
166
166
+
</div>
167
167
+
168
168
+
<h1 id="stepTitle">Claim Your Free Domain</h1>
169
169
+
<p class="text-center text-muted mb-4" id="stepDesc">Choose a subdomain on wisp.place</p>
170
170
+
171
171
+
<!-- Step 1: Domain -->
172
172
+
<div id="domainStep" class="card">
173
173
+
<h2>Choose Your Domain</h2>
174
174
+
<p class="text-muted mb-2">Pick a unique handle for your free *.wisp.place subdomain</p>
175
175
+
176
176
+
<label for="handle">Your Handle</label>
177
177
+
<div class="input-wrapper">
178
178
+
<input type="text" id="handle" placeholder="my-awesome-site">
179
179
+
<span class="input-icon" id="availabilityIcon"></span>
180
180
+
</div>
181
181
+
<p class="text-muted mb-2 hidden" id="domainPreview"></p>
182
182
+
<p class="text-muted error mb-2 hidden" id="domainError"></p>
183
183
+
184
184
+
<button id="claimBtn" disabled>Claim Domain</button>
185
185
+
</div>
186
186
+
187
187
+
<!-- Step 2: Upload -->
188
188
+
<div id="uploadStep" class="card hidden">
189
189
+
<h2>Deploy Your Site</h2>
190
190
+
<p class="text-muted mb-4">Upload your static site files or start with an empty site</p>
191
191
+
192
192
+
<div class="alert alert-success mb-4">
193
193
+
<strong>✓ Domain claimed:</strong> <span id="claimedDomain"></span>
194
194
+
</div>
195
195
+
196
196
+
<label for="siteName">Site Name</label>
197
197
+
<input type="text" id="siteName" placeholder="my-site" class="mb-1">
198
198
+
<p class="text-muted mb-4">A unique identifier for this site in your account</p>
199
199
+
200
200
+
<label>Upload Files (Optional)</label>
201
201
+
<div class="upload-zone mb-1" id="uploadZone">
202
202
+
<p>📤</p>
203
203
+
<p class="mb-2">Choose Folder</p>
204
204
+
<p class="text-muted" id="fileCount"></p>
205
205
+
</div>
206
206
+
<input type="file" id="fileInput" multiple webkitdirectory directory class="hidden">
207
207
+
<p class="text-muted mb-2">Supported: HTML, CSS, JS, images, fonts, and more</p>
208
208
+
<p class="text-muted mb-4">Limits: 100MB per file, 300MB total</p>
209
209
+
210
210
+
<div id="uploadProgress" class="alert alert-info mb-4 hidden">
211
211
+
<div class="flex gap-2">
212
212
+
<span class="spinner"></span>
213
213
+
<span id="progressText"></span>
214
214
+
</div>
215
215
+
</div>
216
216
+
217
217
+
<div class="flex gap-2">
218
218
+
<button id="skipBtn" class="outline">Skip for Now</button>
219
219
+
<button id="uploadBtn" disabled>Create Empty Site</button>
220
220
+
</div>
221
221
+
</div>
222
222
+
</div>
223
223
+
224
224
+
<script>
225
225
+
let state = {
226
226
+
step: 'domain',
227
227
+
handle: '',
228
228
+
isAvailable: null,
229
229
+
domain: '',
230
230
+
claimedDomain: '',
231
231
+
siteName: '',
232
232
+
files: null,
233
233
+
checkTimeout: null
234
234
+
};
235
235
+
236
236
+
// DOM elements
237
237
+
const step1 = document.getElementById('step1');
238
238
+
const step2 = document.getElementById('step2');
239
239
+
const stepTitle = document.getElementById('stepTitle');
240
240
+
const stepDesc = document.getElementById('stepDesc');
241
241
+
const domainStep = document.getElementById('domainStep');
242
242
+
const uploadStep = document.getElementById('uploadStep');
243
243
+
const handleInput = document.getElementById('handle');
244
244
+
const availabilityIcon = document.getElementById('availabilityIcon');
245
245
+
const domainPreview = document.getElementById('domainPreview');
246
246
+
const domainError = document.getElementById('domainError');
247
247
+
const claimBtn = document.getElementById('claimBtn');
248
248
+
const claimedDomainEl = document.getElementById('claimedDomain');
249
249
+
const siteNameInput = document.getElementById('siteName');
250
250
+
const uploadZone = document.getElementById('uploadZone');
251
251
+
const fileInput = document.getElementById('fileInput');
252
252
+
const fileCount = document.getElementById('fileCount');
253
253
+
const uploadProgress = document.getElementById('uploadProgress');
254
254
+
const progressText = document.getElementById('progressText');
255
255
+
const skipBtn = document.getElementById('skipBtn');
256
256
+
const uploadBtn = document.getElementById('uploadBtn');
257
257
+
258
258
+
// Handle input
259
259
+
handleInput.addEventListener('input', (e) => {
260
260
+
state.handle = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
261
261
+
handleInput.value = state.handle;
262
262
+
263
263
+
clearTimeout(state.checkTimeout);
264
264
+
265
265
+
if (state.handle.length < 3) {
266
266
+
state.isAvailable = null;
267
267
+
availabilityIcon.innerHTML = '';
268
268
+
domainPreview.classList.add('hidden');
269
269
+
domainError.classList.add('hidden');
270
270
+
claimBtn.disabled = true;
271
271
+
return;
272
272
+
}
273
273
+
274
274
+
availabilityIcon.innerHTML = '<span class="spinner"></span>';
275
275
+
state.checkTimeout = setTimeout(checkAvailability, 500);
276
276
+
});
277
277
+
278
278
+
async function checkAvailability() {
279
279
+
try {
280
280
+
const res = await fetch(`/api/domain/check?handle=${encodeURIComponent(state.handle)}`);
281
281
+
const data = await res.json();
282
282
+
state.isAvailable = data.available;
283
283
+
state.domain = data.domain || '';
284
284
+
285
285
+
if (state.isAvailable) {
286
286
+
availabilityIcon.innerHTML = '<span class="success">✓</span>';
287
287
+
domainPreview.textContent = `Your domain will be: ${state.domain}`;
288
288
+
domainPreview.classList.remove('hidden');
289
289
+
domainError.classList.add('hidden');
290
290
+
claimBtn.disabled = false;
291
291
+
} else {
292
292
+
availabilityIcon.innerHTML = '<span class="error">✗</span>';
293
293
+
domainError.textContent = 'This handle is not available or invalid';
294
294
+
domainError.classList.remove('hidden');
295
295
+
domainPreview.classList.add('hidden');
296
296
+
claimBtn.disabled = true;
297
297
+
}
298
298
+
} catch (err) {
299
299
+
console.error(err);
300
300
+
state.isAvailable = false;
301
301
+
availabilityIcon.innerHTML = '<span class="error">✗</span>';
302
302
+
claimBtn.disabled = true;
303
303
+
}
304
304
+
}
305
305
+
306
306
+
// Claim domain
307
307
+
claimBtn.addEventListener('click', async () => {
308
308
+
if (!state.isAvailable) return;
309
309
+
310
310
+
claimBtn.disabled = true;
311
311
+
claimBtn.textContent = 'Claiming...';
312
312
+
313
313
+
try {
314
314
+
const res = await fetch('/api/domain/claim', {
315
315
+
method: 'POST',
316
316
+
headers: { 'Content-Type': 'application/json' },
317
317
+
body: JSON.stringify({ handle: state.handle })
318
318
+
});
319
319
+
const data = await res.json();
320
320
+
321
321
+
if (data.success) {
322
322
+
state.claimedDomain = data.domain;
323
323
+
state.step = 'upload';
324
324
+
renderStep();
325
325
+
} else {
326
326
+
throw new Error(data.error || 'Failed to claim domain');
327
327
+
}
328
328
+
} catch (err) {
329
329
+
const msg = err.message;
330
330
+
if (msg.includes('Already claimed')) {
331
331
+
alert('You have already claimed a wisp.place subdomain. Redirecting to editor...');
332
332
+
window.location.href = '/editor';
333
333
+
} else {
334
334
+
alert(`Failed to claim domain: ${msg}`);
335
335
+
claimBtn.disabled = false;
336
336
+
claimBtn.textContent = 'Claim Domain';
337
337
+
}
338
338
+
}
339
339
+
});
340
340
+
341
341
+
// Upload zone
342
342
+
uploadZone.addEventListener('click', () => fileInput.click());
343
343
+
fileInput.addEventListener('change', (e) => {
344
344
+
state.files = e.target.files;
345
345
+
if (state.files && state.files.length > 0) {
346
346
+
fileCount.textContent = `${state.files.length} files selected`;
347
347
+
uploadBtn.textContent = 'Upload & Deploy';
348
348
+
}
349
349
+
});
350
350
+
351
351
+
// Site name
352
352
+
siteNameInput.addEventListener('input', (e) => {
353
353
+
state.siteName = e.target.value;
354
354
+
uploadBtn.disabled = !state.siteName;
355
355
+
});
356
356
+
357
357
+
// Skip
358
358
+
skipBtn.addEventListener('click', () => {
359
359
+
window.location.href = '/editor';
360
360
+
});
361
361
+
362
362
+
// Upload
363
363
+
uploadBtn.addEventListener('click', async () => {
364
364
+
if (!state.siteName) return;
365
365
+
366
366
+
uploadBtn.disabled = true;
367
367
+
skipBtn.disabled = true;
368
368
+
uploadProgress.classList.remove('hidden');
369
369
+
progressText.textContent = 'Preparing files...';
370
370
+
371
371
+
try {
372
372
+
const formData = new FormData();
373
373
+
formData.append('siteName', state.siteName);
374
374
+
375
375
+
if (state.files) {
376
376
+
for (let i = 0; i < state.files.length; i++) {
377
377
+
formData.append('files', state.files[i]);
378
378
+
}
379
379
+
}
380
380
+
381
381
+
progressText.textContent = 'Uploading to AT Protocol...';
382
382
+
const res = await fetch('/wisp/upload-files', {
383
383
+
method: 'POST',
384
384
+
body: formData
385
385
+
});
386
386
+
const data = await res.json();
387
387
+
388
388
+
if (data.success) {
389
389
+
progressText.textContent = 'Upload complete!';
390
390
+
setTimeout(() => {
391
391
+
window.location.href = `https://${state.claimedDomain}`;
392
392
+
}, 1500);
393
393
+
} else {
394
394
+
throw new Error(data.error || 'Upload failed');
395
395
+
}
396
396
+
} catch (err) {
397
397
+
alert(`Upload failed: ${err.message}`);
398
398
+
uploadBtn.disabled = false;
399
399
+
skipBtn.disabled = false;
400
400
+
uploadProgress.classList.add('hidden');
401
401
+
}
402
402
+
});
403
403
+
404
404
+
function renderStep() {
405
405
+
if (state.step === 'domain') {
406
406
+
step1.classList.add('active');
407
407
+
step2.classList.remove('active', 'complete');
408
408
+
stepTitle.textContent = 'Claim Your Free Domain';
409
409
+
stepDesc.textContent = 'Choose a subdomain on wisp.place';
410
410
+
domainStep.classList.remove('hidden');
411
411
+
uploadStep.classList.add('hidden');
412
412
+
} else {
413
413
+
step1.classList.remove('active');
414
414
+
step1.classList.add('complete');
415
415
+
step1.textContent = '✓';
416
416
+
step2.classList.add('active');
417
417
+
stepTitle.textContent = 'Deploy Your First Site';
418
418
+
stepDesc.textContent = 'Upload your site or start with an empty one';
419
419
+
domainStep.classList.add('hidden');
420
420
+
uploadStep.classList.remove('hidden');
421
421
+
claimedDomainEl.textContent = state.claimedDomain;
422
422
+
}
423
423
+
}
424
424
+
</script>
425
425
+
</body>
426
426
+
</html>
+1
-1
apps/main-app/public/landingpage.html
···
874
874
<ul>
875
875
<li><a href="https://docs.wisp.place/cli/" target="_blank">CLI Guide</a></li>
876
876
<li><a href="https://docs.wisp.place/api/" target="_blank">API Reference</a></li>
877
877
-
<li><a href="https://tangled.org/nekomimi.pet/wisp.place-monorepo" target="_blank">GitHub</a></li>
877
877
+
<li><a href="https://tangled.org/nekomimi.pet/wisp.place-monorepo" target="_blank">Tangled</a></li>
878
878
</ul>
879
879
</div>
880
880
<div class="footer-col">
+8
apps/main-app/src/index.ts
···
229
229
.get('/acceptable-use', ({ set }) => {
230
230
set.redirect = '/editor/acceptable-use'
231
231
})
232
232
+
.get('/onboarding', async ({ set }) => {
233
233
+
set.headers['Content-Type'] = 'text/html; charset=utf-8'
234
234
+
return await Bun.file('./apps/main-app/public/editor/onboarding.html').text()
235
235
+
})
236
236
+
.get('/editor/onboarding', async ({ set }) => {
237
237
+
set.headers['Content-Type'] = 'text/html; charset=utf-8'
238
238
+
return await Bun.file('./apps/main-app/public/editor/onboarding.html').text()
239
239
+
})
232
240
.get('/oauth-client-metadata.json', () => {
233
241
return createClientMetadata(config)
234
242
})