Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

fix onboarding

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