<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Get Started - wisp.place</title>
<link rel="icon" type="image/x-icon" href="../favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/wisp.css">
<style>
.container { max-width: 600px; }
.progress { display: flex; justify-content: center; align-items: center; gap: 1rem; margin-bottom: 2rem; }
.step-indicator {
width: 2rem; height: 2rem; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: var(--bg-alt); color: var(--text-subtle);
font-weight: 600;
}
.step-indicator.active { background: var(--cta-bg); color: var(--cta-text); }
.step-indicator.complete { background: var(--success); color: var(--bg); }
.progress-line { width: 4rem; height: 2px; background: var(--border-light); }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; text-align: center; }
h2 { font-size: 1.25rem; margin-bottom: 1rem; }
button { width: 100%; }
.input-wrapper { position: relative; margin-bottom: 1rem; }
.input-icon {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
}
.upload-zone {
border: 2px dashed var(--border-light);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s;
}
.upload-zone:hover { border-color: var(--accent); }
</style>
</head>
<body>
<header>
<div class="logo">wisp.place</div>
</header>
<div class="container">
<div class="progress mb-4">
<div class="step-indicator" id="step1">1</div>
<div class="progress-line"></div>
<div class="step-indicator" id="step2">2</div>
</div>
<h1 id="stepTitle">Claim Your Free Domain</h1>
<p class="text-center text-muted mb-4" id="stepDesc">Choose a subdomain on wisp.place</p>
<!-- Step 1: Domain -->
<div id="domainStep" class="card">
<h2>Choose Your Domain</h2>
<p class="text-muted mb-2">Pick a unique handle for your free *.wisp.place subdomain</p>
<label for="handle">Your Handle</label>
<div class="input-wrapper">
<input type="text" id="handle" placeholder="my-awesome-site">
<span class="input-icon" id="availabilityIcon"></span>
</div>
<p class="text-muted mb-2 hidden" id="domainPreview"></p>
<p class="text-muted error mb-2 hidden" id="domainError"></p>
<button id="claimBtn" disabled>Claim Domain</button>
</div>
<!-- Step 2: Upload -->
<div id="uploadStep" class="card hidden">
<h2>Deploy Your Site</h2>
<p class="text-muted mb-4">Upload your static site files or start with an empty site</p>
<div class="alert alert-success mb-4">
<strong>✓ Domain claimed:</strong> <span id="claimedDomain"></span>
</div>
<label for="siteName">Site Name</label>
<input type="text" id="siteName" placeholder="my-site" class="mb-1">
<p class="text-muted mb-4">A unique identifier for this site in your account</p>
<label>Upload Files (Optional)</label>
<div class="upload-zone mb-1" id="uploadZone">
<p>📤</p>
<p class="mb-2">Choose Folder</p>
<p class="text-muted" id="fileCount"></p>
</div>
<input type="file" id="fileInput" multiple webkitdirectory directory class="hidden">
<p class="text-muted mb-2">Supported: HTML, CSS, JS, images, fonts, and more</p>
<p class="text-muted mb-4">Limits: 200MB per file, 300MB total</p>
<div id="uploadProgress" class="alert alert-info mb-4 hidden">
<div class="flex gap-2">
<span class="spinner"></span>
<span id="progressText"></span>
</div>
</div>
<div class="flex gap-2">
<button id="skipBtn" class="outline">Skip for Now</button>
<button id="uploadBtn" disabled>Create Empty Site</button>
</div>
</div>
</div>
<script>
let state = {
step: 'domain',
handle: '',
isAvailable: null,
domain: '',
claimedDomain: '',
siteName: '',
files: null,
checkTimeout: null
};
// DOM elements
const step1 = document.getElementById('step1');
const step2 = document.getElementById('step2');
const stepTitle = document.getElementById('stepTitle');
const stepDesc = document.getElementById('stepDesc');
const domainStep = document.getElementById('domainStep');
const uploadStep = document.getElementById('uploadStep');
const handleInput = document.getElementById('handle');
const availabilityIcon = document.getElementById('availabilityIcon');
const domainPreview = document.getElementById('domainPreview');
const domainError = document.getElementById('domainError');
const claimBtn = document.getElementById('claimBtn');
const claimedDomainEl = document.getElementById('claimedDomain');
const siteNameInput = document.getElementById('siteName');
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const fileCount = document.getElementById('fileCount');
const uploadProgress = document.getElementById('uploadProgress');
const progressText = document.getElementById('progressText');
const skipBtn = document.getElementById('skipBtn');
const uploadBtn = document.getElementById('uploadBtn');
// Handle input
handleInput.addEventListener('input', (e) => {
state.handle = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
handleInput.value = state.handle;
clearTimeout(state.checkTimeout);
if (state.handle.length < 3) {
state.isAvailable = null;
availabilityIcon.innerHTML = '';
domainPreview.classList.add('hidden');
domainError.classList.add('hidden');
claimBtn.disabled = true;
return;
}
availabilityIcon.innerHTML = '<span class="spinner"></span>';
state.checkTimeout = setTimeout(checkAvailability, 500);
});
async function checkAvailability() {
try {
const res = await fetch(`/api/domain/check?handle=${encodeURIComponent(state.handle)}`);
const data = await res.json();
state.isAvailable = data.available;
state.domain = data.domain || '';
if (state.isAvailable) {
availabilityIcon.innerHTML = '<span class="success">✓</span>';
domainPreview.textContent = `Your domain will be: ${state.domain}`;
domainPreview.classList.remove('hidden');
domainError.classList.add('hidden');
claimBtn.disabled = false;
} else {
availabilityIcon.innerHTML = '<span class="error">✗</span>';
domainError.textContent = 'This handle is not available or invalid';
domainError.classList.remove('hidden');
domainPreview.classList.add('hidden');
claimBtn.disabled = true;
}
} catch (err) {
console.error(err);
state.isAvailable = false;
availabilityIcon.innerHTML = '<span class="error">✗</span>';
claimBtn.disabled = true;
}
}
// Claim domain
claimBtn.addEventListener('click', async () => {
if (!state.isAvailable) return;
claimBtn.disabled = true;
claimBtn.textContent = 'Claiming...';
try {
const res = await fetch('/api/domain/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handle: state.handle })
});
const data = await res.json();
if (data.success) {
state.claimedDomain = data.domain;
state.step = 'upload';
renderStep();
} else {
throw new Error(data.error || 'Failed to claim domain');
}
} catch (err) {
const msg = err.message;
if (msg.includes('Already claimed')) {
alert('You have already claimed a wisp.place subdomain. Redirecting to editor...');
window.location.href = '/editor';
} else {
alert(`Failed to claim domain: ${msg}`);
claimBtn.disabled = false;
claimBtn.textContent = 'Claim Domain';
}
}
});
// Upload zone
uploadZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
state.files = e.target.files;
if (state.files && state.files.length > 0) {
fileCount.textContent = `${state.files.length} files selected`;
uploadBtn.textContent = 'Upload & Deploy';
}
});
// Site name
siteNameInput.addEventListener('input', (e) => {
state.siteName = e.target.value;
uploadBtn.disabled = !state.siteName;
});
// Skip
skipBtn.addEventListener('click', () => {
window.location.href = '/editor';
});
function waitForUploadJob(jobId) {
return new Promise((resolve, reject) => {
const es = new EventSource(`/wisp/upload-progress/${jobId}`);
let settled = false;
const finish = (fn, val) => { if (settled) return; settled = true; es.close(); fn(val); };
es.addEventListener('progress', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.status === 'completed') finish(resolve);
else if (d.status === 'failed') finish(reject, new Error(d.error || 'Upload failed'));
} catch (_) {}
});
es.addEventListener('done', () => finish(resolve));
es.addEventListener('error', (ev) => {
if (ev && ev.data) {
try {
const d = JSON.parse(ev.data);
finish(reject, new Error(d.error || 'Upload failed'));
return;
} catch (_) {}
}
if (es.readyState === EventSource.CLOSED) {
finish(reject, new Error('Upload connection closed'));
}
});
});
}
// Upload
uploadBtn.addEventListener('click', async () => {
if (!state.siteName) return;
uploadBtn.disabled = true;
skipBtn.disabled = true;
uploadProgress.classList.remove('hidden');
progressText.textContent = 'Preparing files...';
try {
const formData = new FormData();
formData.append('siteName', state.siteName);
if (state.files) {
for (let i = 0; i < state.files.length; i++) {
formData.append('files', state.files[i]);
}
}
progressText.textContent = 'Uploading to AT Protocol...';
const res = await fetch('/wisp/upload-files', {
method: 'POST',
body: formData
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Upload failed');
}
if (data.jobId) {
progressText.textContent = 'Uploading files...';
await waitForUploadJob(data.jobId);
}
progressText.textContent = 'Linking domain to site...';
const mapRes = await fetch('/api/domain/wisp/map-site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: state.claimedDomain, siteRkey: state.siteName })
});
const mapData = await mapRes.json();
if (!mapData.success) {
throw new Error('Failed to link domain to site');
}
progressText.textContent = 'All set! Redirecting...';
setTimeout(() => {
window.location.href = `https://${state.claimedDomain}`;
}, 1500);
} catch (err) {
alert(`Upload failed: ${err.message}`);
uploadBtn.disabled = false;
skipBtn.disabled = false;
uploadProgress.classList.add('hidden');
}
});
function renderStep() {
if (state.step === 'domain') {
step1.classList.add('active');
step2.classList.remove('active', 'complete');
stepTitle.textContent = 'Claim Your Free Domain';
stepDesc.textContent = 'Choose a subdomain on wisp.place';
domainStep.classList.remove('hidden');
uploadStep.classList.add('hidden');
} else {
step1.classList.remove('active');
step1.classList.add('complete');
step1.textContent = '✓';
step2.classList.add('active');
stepTitle.textContent = 'Deploy Your First Site';
stepDesc.textContent = 'Upload your site or start with an empty one';
domainStep.classList.add('hidden');
uploadStep.classList.remove('hidden');
claimedDomainEl.textContent = state.claimedDomain;
}
}
</script>
</body>
</html>