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 domain = $state('');
27 let errorMessage = $state('');
28 let errorHint = $state('');
29
30 $effect(() => {
31 if (customDomainModalState.visible) {
32 step = currentDomain ? 'current' : 'input';
33 } else {
34 step = 'input';
35 domain = '';
36 errorMessage = '';
37 errorHint = '';
38 }
39 });
40
41 async function removeDomain() {
42 step = 'removing';
43 try {
44 const existing = await getRecord({
45 collection: 'site.standard.publication',
46 rkey: 'blento.self'
47 });
48
49 if (existing?.value) {
50 const { url: _url, ...rest } = existing.value as Record<string, unknown>;
51 await putRecord({
52 collection: 'site.standard.publication',
53 rkey: 'blento.self',
54 record: rest
55 });
56 }
57
58 step = 'input';
59 } catch (err: unknown) {
60 errorMessage = err instanceof Error ? err.message : String(err);
61 step = 'error';
62 }
63 }
64
65 function goToInstructions() {
66 if (!domain.trim()) return;
67 step = 'instructions';
68 }
69
70 async function verify() {
71 step = 'verifying';
72 try {
73 // Step 1: Verify DNS records
74 const dnsRes = await fetch('/api/verify-domain', {
75 method: 'POST',
76 headers: { 'Content-Type': 'application/json' },
77 body: JSON.stringify({ domain })
78 });
79
80 const dnsData = await dnsRes.json();
81
82 if (!dnsRes.ok || dnsData.error) {
83 errorMessage = dnsData.error;
84 errorHint = dnsData.hint || '';
85 step = 'error';
86 return;
87 }
88
89 // Step 2: Write URL to ATProto profile
90 const existing = await getRecord({
91 collection: 'site.standard.publication',
92 rkey: 'blento.self'
93 });
94
95 await putRecord({
96 collection: 'site.standard.publication',
97 rkey: 'blento.self',
98 record: {
99 ...(existing?.value || {}),
100 url: 'https://' + domain
101 }
102 });
103
104 // Step 3: Activate domain in KV (server verifies profile has the URL)
105 const activateRes = await fetch('/api/activate-domain', {
106 method: 'POST',
107 headers: { 'Content-Type': 'application/json' },
108 body: JSON.stringify({ did: user.did, domain })
109 });
110
111 const activateData = await activateRes.json();
112
113 if (!activateRes.ok || activateData.error) {
114 errorMessage = activateData.error;
115 errorHint = '';
116 step = 'error';
117 return;
118 }
119
120 // Refresh cached profile
121 if (user.profile) {
122 fetch(`/${getHandleOrDid(user.profile)}/api/refresh`).catch(() => {});
123 }
124
125 launchConfetti();
126 step = 'success';
127 } catch (err: unknown) {
128 errorMessage = err instanceof Error ? err.message : String(err);
129 step = 'error';
130 }
131 }
132
133 async function copyToClipboard(text: string) {
134 await navigator.clipboard.writeText(text);
135 }
136</script>
137
138<Modal bind:open={customDomainModalState.visible}>
139 {#if step === 'current'}
140 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
141 Custom Domain
142 </h3>
143
144 <div
145 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"
146 >
147 <span>{currentDomain}</span>
148 </div>
149
150 <div class="mt-4 flex gap-2">
151 <Button variant="ghost" onclick={removeDomain}>Remove</Button>
152 <Button variant="ghost" onclick={() => (step = 'input')}>Change</Button>
153 <Button onclick={() => customDomainModalState.hide()}>Close</Button>
154 </div>
155 {:else if step === 'input'}
156 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
157 Custom Domain
158 </h3>
159
160 <Input type="text" bind:value={domain} placeholder="mydomain.com" />
161
162 <div class="mt-4 flex gap-2">
163 <Button variant="ghost" onclick={() => customDomainModalState.hide()}>Cancel</Button>
164 <Button onclick={goToInstructions} disabled={!domain.trim()}>Next</Button>
165 </div>
166 {:else if step === 'instructions'}
167 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
168 Set up your domain
169 </h3>
170
171 <p class="text-base-800 dark:text-base-200 mt-2 text-sm">
172 Add a CNAME record for your domain pointing to:
173 </p>
174
175 <div
176 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"
177 >
178 <span>blento-proxy.fly.dev</span>
179 <button
180 class="text-base-600 hover:text-base-900 dark:text-base-400 dark:hover:text-base-100 ml-2 cursor-pointer"
181 onclick={() => copyToClipboard('blento-proxy.fly.dev')}
182 >
183 <svg
184 xmlns="http://www.w3.org/2000/svg"
185 fill="none"
186 viewBox="0 0 24 24"
187 stroke-width="1.5"
188 stroke="currentColor"
189 class="size-4"
190 >
191 <path
192 stroke-linecap="round"
193 stroke-linejoin="round"
194 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"
195 />
196 </svg>
197 <span class="sr-only">Copy to clipboard</span>
198 </button>
199 </div>
200
201 <div class="mt-4 flex gap-2">
202 <Button variant="ghost" onclick={() => (step = 'input')}>Back</Button>
203 <Button onclick={verify}>Verify</Button>
204 </div>
205 {:else if step === 'verifying'}
206 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
207 Verifying...
208 </h3>
209
210 <p class="text-base-800 dark:text-base-200 mt-2 text-sm">
211 Checking DNS records and verifying your domain.
212 </p>
213
214 <div class="mt-4 flex items-center gap-2">
215 <svg
216 class="text-base-500 size-5 animate-spin"
217 xmlns="http://www.w3.org/2000/svg"
218 fill="none"
219 viewBox="0 0 24 24"
220 >
221 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
222 ></circle>
223 <path
224 class="opacity-75"
225 fill="currentColor"
226 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"
227 ></path>
228 </svg>
229 <span class="text-base-600 dark:text-base-400 text-sm">Verifying...</span>
230 </div>
231 {:else if step === 'removing'}
232 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
233 Removing...
234 </h3>
235
236 <div class="mt-4 flex items-center gap-2">
237 <svg
238 class="text-base-500 size-5 animate-spin"
239 xmlns="http://www.w3.org/2000/svg"
240 fill="none"
241 viewBox="0 0 24 24"
242 >
243 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
244 ></circle>
245 <path
246 class="opacity-75"
247 fill="currentColor"
248 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"
249 ></path>
250 </svg>
251 <span class="text-base-600 dark:text-base-400 text-sm">Removing domain...</span>
252 </div>
253 {:else if step === 'success'}
254 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
255 Domain verified!
256 </h3>
257
258 <p class="text-base-800 dark:text-base-200 mt-2 text-sm">
259 Your custom domain {domain} has been set up successfully.
260 </p>
261
262 <div class="mt-4">
263 <Button onclick={() => customDomainModalState.hide()}>Close</Button>
264 </div>
265 {:else if step === 'error'}
266 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
267 Verification failed
268 </h3>
269
270 <p class="mt-2 text-sm text-red-500 dark:text-red-400">
271 {errorMessage}
272 </p>
273 {#if errorHint}
274 <p class="mt-1 text-sm font-bold text-red-500 dark:text-red-400">
275 {errorHint}
276 </p>
277 {/if}
278
279 <div class="mt-4 flex gap-2">
280 <Button variant="ghost" onclick={() => customDomainModalState.hide()}>Close</Button>
281 <Button onclick={verify}>Retry</Button>
282 </div>
283 {/if}
284</Modal>