File
Blob: ui/client/CustomDomains.svelte
| 1 | <script lang="ts"> |
| 2 | import { onDestroy } from "svelte"; |
| 3 | import Button from "../shared/Button.svelte"; |
| 4 | import { INPUT_CLASSES } from "../shared/styles"; |
| 5 | import { createAPI, errorMessage, type DomainRecord } from "./api"; |
| 6 | |
| 7 | type DomainAction = "acme" | "validate"; |
| 8 | type DomainResult = DomainRecord & { domain: string; verified: boolean }; |
| 9 | |
| 10 | let domain = $state(""); |
| 11 | let pending = $state<DomainAction | "">(""); |
| 12 | let requestedDomain = $state(""); |
| 13 | let output = $state<DomainResult | null>(null); |
| 14 | let error = $state(""); |
| 15 | let domainRevision = 0; |
| 16 | const api = createAPI(); |
| 17 | |
| 18 | function editDomain() { |
| 19 | domainRevision += 1; |
| 20 | output = null; |
| 21 | error = ""; |
| 22 | } |
| 23 | |
| 24 | async function domainAction(action: DomainAction) { |
| 25 | if (pending) return; |
| 26 | const submittedDomain = domain.trim(); |
| 27 | if (!submittedDomain || /[\s/:]/.test(submittedDomain)) { |
| 28 | error = "Enter a domain without a protocol or path."; |
| 29 | return; |
| 30 | } |
| 31 | const revision = domainRevision; |
| 32 | pending = action; |
| 33 | requestedDomain = submittedDomain; |
| 34 | error = ""; |
| 35 | output = null; |
| 36 | try { |
| 37 | const record = await api.request<DomainRecord>(`/api/${action}/${encodeURIComponent(submittedDomain)}`); |
| 38 | if (revision === domainRevision) |
| 39 | output = { ...record, domain: submittedDomain, verified: action === "validate" }; |
| 40 | } catch (cause) { |
| 41 | if (revision === domainRevision) error = `${submittedDomain}: ${errorMessage(cause)}`; |
| 42 | } finally { |
| 43 | pending = ""; |
| 44 | requestedDomain = ""; |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | onDestroy(api.abort); |
| 49 | </script> |
| 50 | |
| 51 | <section id="domains" class="min-w-0 scroll-mt-6" aria-labelledby="domains-heading"> |
| 52 | <h2 id="domains-heading" class="mb-3.5 text-lg leading-snug font-semibold tracking-tight">Custom domains</h2> |
| 53 | <form onsubmit={(event) => { event.preventDefault(); void domainAction("acme"); }}> |
| 54 | <label for="domain" class="mb-1.5 block font-medium">Domain</label> |
| 55 | <input |
| 56 | id="domain" |
| 57 | name="domain" |
| 58 | type="text" |
| 59 | class={INPUT_CLASSES} |
| 60 | bind:value={domain} |
| 61 | oninput={editDomain} |
| 62 | placeholder="app.example.com" |
| 63 | autocomplete="off" |
| 64 | autocapitalize="none" |
| 65 | spellcheck="false" |
| 66 | maxlength="253" |
| 67 | required |
| 68 | aria-describedby="domain-hint domain-feedback" |
| 69 | aria-invalid={Boolean(error)} |
| 70 | /> |
| 71 | <p id="domain-hint" class="mt-1.5 mb-2.5 text-[0.8125rem] text-muted">Add the record in DNS, then verify.</p> |
| 72 | <div class="flex flex-wrap items-center gap-2"> |
| 73 | <Button type="submit" disabled={Boolean(pending) || !domain.trim()}>{pending === "acme" ? "Getting record…" : "Get DNS record"}</Button> |
| 74 | <Button disabled={Boolean(pending) || !domain.trim()} onclick={() => domainAction("validate")}>{pending === "validate" ? "Verifying…" : "Verify ownership"}</Button> |
| 75 | </div> |
| 76 | </form> |
| 77 | |
| 78 | <div id="domain-feedback" aria-live="polite" aria-busy={Boolean(pending)}> |
| 79 | {#if pending} |
| 80 | <p class="my-2.5 rounded-md border border-line bg-chrome px-3 py-2 wrap-anywhere"> |
| 81 | {pending === "validate" ? "Verifying" : "Fetching"} <span class="font-mono text-[0.8125rem]">{requestedDomain}</span>… |
| 82 | </p> |
| 83 | {/if} |
| 84 | {#if error} |
| 85 | <div class="my-2.5 rounded-md border border-danger/40 bg-chrome px-3 py-2 text-danger wrap-anywhere"> |
| 86 | {#if error.length > 120} |
| 87 | <details><summary class="cursor-pointer py-1 font-medium text-zinc-200">Domain request failed</summary><p class="mt-2">{error}</p></details> |
| 88 | {:else} |
| 89 | {error} |
| 90 | {/if} |
| 91 | </div> |
| 92 | {/if} |
| 93 | {#if output} |
| 94 | <div class="mt-3.5 border-t border-line pt-3"> |
| 95 | <h3 class={['text-[0.9375rem] font-semibold wrap-anywhere', output.verified && 'text-success']}> |
| 96 | {output.verified ? "Verified" : "DNS record"} <span class="font-mono text-[0.8125rem]">{output.domain}</span> |
| 97 | </h3> |
| 98 | <dl class="mt-3 [&>div]:grid [&>div]:grid-cols-[4rem_minmax(0,1fr)] [&>div]:gap-4 [&>div]:py-1.5 [&_dd]:font-mono [&_dd]:text-[0.8125rem] [&_dd]:wrap-anywhere [&_dt]:text-muted"> |
| 99 | <div><dt>Name</dt><dd>{output.record}</dd></div> |
| 100 | <div><dt>Type</dt><dd>{output.type}</dd></div> |
| 101 | <div><dt>Value</dt><dd>{output.content}</dd></div> |
| 102 | </dl> |
| 103 | {#if output.verified}<a href="#configuration" class="text-accent-300 underline underline-offset-4 hover:text-accent-200">Add to configuration</a>{/if} |
| 104 | </div> |
| 105 | {/if} |
| 106 | </div> |
| 107 | </section> |