your personal website on atproto - mirror
blento.app
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>