your personal website on atproto - mirror blento.app
at update-link-card 285 lines 8.6 kB view raw
1<script lang="ts" module> 2 export const customDomainModalState = $state({ 3 visible: false, 4 show: () => (customDomainModalState.visible = true), 5 hide: () => (customDomainModalState.visible = false) 6 }); 7</script> 8 9<script lang="ts"> 10 import { putRecord, getRecord, getHandleOrDid } from '$lib/atproto/methods'; 11 import { user } from '$lib/atproto'; 12 import { Button, Input } from '@foxui/core'; 13 import Modal from '$lib/components/modal/Modal.svelte'; 14 import { launchConfetti } from '@foxui/visual'; 15 16 let { publicationUrl }: { publicationUrl?: string } = $props(); 17 18 let currentDomain = $derived( 19 publicationUrl?.startsWith('https://') && !publicationUrl.includes('blento.app') 20 ? publicationUrl.replace('https://', '') 21 : '' 22 ); 23 24 let step: 'current' | 'input' | 'instructions' | 'verifying' | 'removing' | 'success' | 'error' = 25 $state('input'); 26 let rawDomain = $state(''); 27 let domain = $derived(rawDomain.replace(/^https?:\/\//, '').replace(/\/+$/, '')); 28 let errorMessage = $state(''); 29 let errorHint = $state(''); 30 31 $effect(() => { 32 if (customDomainModalState.visible) { 33 step = currentDomain ? 'current' : 'input'; 34 } else { 35 step = 'input'; 36 rawDomain = ''; 37 errorMessage = ''; 38 errorHint = ''; 39 } 40 }); 41 42 async function removeDomain() { 43 step = 'removing'; 44 try { 45 const existing = await getRecord({ 46 collection: 'site.standard.publication', 47 rkey: 'blento.self' 48 }); 49 50 if (existing?.value) { 51 const { url: _url, ...rest } = existing.value as Record<string, unknown>; 52 await putRecord({ 53 collection: 'site.standard.publication', 54 rkey: 'blento.self', 55 record: rest 56 }); 57 } 58 59 step = 'input'; 60 } catch (err: unknown) { 61 errorMessage = err instanceof Error ? err.message : String(err); 62 step = 'error'; 63 } 64 } 65 66 function goToInstructions() { 67 if (!domain.trim()) return; 68 step = 'instructions'; 69 } 70 71 async function verify() { 72 step = 'verifying'; 73 try { 74 // Step 1: Verify DNS records 75 const dnsRes = await fetch('/api/verify-domain', { 76 method: 'POST', 77 headers: { 'Content-Type': 'application/json' }, 78 body: JSON.stringify({ domain }) 79 }); 80 81 const dnsData = await dnsRes.json(); 82 83 if (!dnsRes.ok || dnsData.error) { 84 errorMessage = dnsData.error; 85 errorHint = dnsData.hint || ''; 86 step = 'error'; 87 return; 88 } 89 90 // Step 2: Write URL to ATProto profile 91 const existing = await getRecord({ 92 collection: 'site.standard.publication', 93 rkey: 'blento.self' 94 }); 95 96 await putRecord({ 97 collection: 'site.standard.publication', 98 rkey: 'blento.self', 99 record: { 100 ...(existing?.value || {}), 101 url: 'https://' + domain 102 } 103 }); 104 105 // Step 3: Activate domain in KV (server verifies profile has the URL) 106 const activateRes = await fetch('/api/activate-domain', { 107 method: 'POST', 108 headers: { 'Content-Type': 'application/json' }, 109 body: JSON.stringify({ did: user.did, domain }) 110 }); 111 112 const activateData = await activateRes.json(); 113 114 if (!activateRes.ok || activateData.error) { 115 errorMessage = activateData.error; 116 errorHint = ''; 117 step = 'error'; 118 return; 119 } 120 121 // Refresh cached profile 122 if (user.profile) { 123 fetch(`/${getHandleOrDid(user.profile)}/api/refresh`).catch(() => {}); 124 } 125 126 launchConfetti(); 127 step = 'success'; 128 } catch (err: unknown) { 129 errorMessage = err instanceof Error ? err.message : String(err); 130 step = 'error'; 131 } 132 } 133 134 async function copyToClipboard(text: string) { 135 await navigator.clipboard.writeText(text); 136 } 137</script> 138 139<Modal bind:open={customDomainModalState.visible}> 140 {#if step === 'current'} 141 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 142 Custom Domain 143 </h3> 144 145 <div 146 class="bg-base-200 dark:bg-base-700 mt-2 flex items-center justify-between rounded-2xl px-3 py-2 font-mono text-sm" 147 > 148 <span>{currentDomain}</span> 149 </div> 150 151 <div class="mt-4 flex gap-2"> 152 <Button variant="ghost" onclick={removeDomain}>Remove</Button> 153 <Button variant="ghost" onclick={() => (step = 'input')}>Change</Button> 154 <Button onclick={() => customDomainModalState.hide()}>Close</Button> 155 </div> 156 {:else if step === 'input'} 157 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 158 Custom Domain 159 </h3> 160 161 <Input type="text" bind:value={rawDomain} placeholder="mydomain.com" /> 162 163 <div class="mt-4 flex gap-2"> 164 <Button variant="ghost" onclick={() => customDomainModalState.hide()}>Cancel</Button> 165 <Button onclick={goToInstructions} disabled={!domain.trim()}>Next</Button> 166 </div> 167 {:else if step === 'instructions'} 168 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 169 Set up your domain 170 </h3> 171 172 <p class="text-base-800 dark:text-base-200 mt-2 text-sm"> 173 Add a CNAME record for your domain pointing to: 174 </p> 175 176 <div 177 class="bg-base-200 dark:bg-base-700 mt-2 flex items-center justify-between rounded-2xl px-3 py-2 font-mono text-sm" 178 > 179 <span>blento-proxy.fly.dev</span> 180 <button 181 class="text-base-600 hover:text-base-900 dark:text-base-400 dark:hover:text-base-100 ml-2 cursor-pointer" 182 onclick={() => copyToClipboard('blento-proxy.fly.dev')} 183 > 184 <svg 185 xmlns="http://www.w3.org/2000/svg" 186 fill="none" 187 viewBox="0 0 24 24" 188 stroke-width="1.5" 189 stroke="currentColor" 190 class="size-4" 191 > 192 <path 193 stroke-linecap="round" 194 stroke-linejoin="round" 195 d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" 196 /> 197 </svg> 198 <span class="sr-only">Copy to clipboard</span> 199 </button> 200 </div> 201 202 <div class="mt-4 flex gap-2"> 203 <Button variant="ghost" onclick={() => (step = 'input')}>Back</Button> 204 <Button onclick={verify}>Verify</Button> 205 </div> 206 {:else if step === 'verifying'} 207 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 208 Verifying... 209 </h3> 210 211 <p class="text-base-800 dark:text-base-200 mt-2 text-sm"> 212 Checking DNS records and verifying your domain. 213 </p> 214 215 <div class="mt-4 flex items-center gap-2"> 216 <svg 217 class="text-base-500 size-5 animate-spin" 218 xmlns="http://www.w3.org/2000/svg" 219 fill="none" 220 viewBox="0 0 24 24" 221 > 222 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" 223 ></circle> 224 <path 225 class="opacity-75" 226 fill="currentColor" 227 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 228 ></path> 229 </svg> 230 <span class="text-base-600 dark:text-base-400 text-sm">Verifying...</span> 231 </div> 232 {:else if step === 'removing'} 233 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 234 Removing... 235 </h3> 236 237 <div class="mt-4 flex items-center gap-2"> 238 <svg 239 class="text-base-500 size-5 animate-spin" 240 xmlns="http://www.w3.org/2000/svg" 241 fill="none" 242 viewBox="0 0 24 24" 243 > 244 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" 245 ></circle> 246 <path 247 class="opacity-75" 248 fill="currentColor" 249 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 250 ></path> 251 </svg> 252 <span class="text-base-600 dark:text-base-400 text-sm">Removing domain...</span> 253 </div> 254 {:else if step === 'success'} 255 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 256 Domain verified! 257 </h3> 258 259 <p class="text-base-800 dark:text-base-200 mt-2 text-sm"> 260 Your custom domain {domain} has been set up successfully. 261 </p> 262 263 <div class="mt-4"> 264 <Button onclick={() => customDomainModalState.hide()}>Close</Button> 265 </div> 266 {:else if step === 'error'} 267 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 268 Verification failed 269 </h3> 270 271 <p class="mt-2 text-sm text-red-500 dark:text-red-400"> 272 {errorMessage} 273 </p> 274 {#if errorHint} 275 <p class="mt-1 text-sm font-bold text-red-500 dark:text-red-400"> 276 {errorHint} 277 </p> 278 {/if} 279 280 <div class="mt-4 flex gap-2"> 281 <Button variant="ghost" onclick={() => customDomainModalState.hide()}>Close</Button> 282 <Button onclick={verify}>Retry</Button> 283 </div> 284 {/if} 285</Modal>