this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(manage): prefer initialAvatarUrl over registry proxy on first sign-in

The form's avatar-preview initialiser checked initial.avatar first
and pointed the <img> at /api/registry/avatar/<did>. On the
Bluesky-prefill path initial.avatar is set (so the BlobRef carries
through on Save) but the registry record doesn't exist yet, so the
proxy 404'd, onError fired, and the slot collapsed to the empty +
placeholder. Reorder the precedence to prefer an explicit
initialAvatarUrl when supplied.

feat(form): URL-override popup for Tangled / Supper

Replace the inline URL-override input on simple atmosphere rows with
a gear button that opens a centered modal (mirrors the Bluesky
picker pattern). The modal explains what the default URL would be,
lets users type an override, and offers a Reset button to clear it
and fall back to the handle-derived default. The row's subtitle
flips to "Using custom URL" when an override is active.

Made-with: Cursor

+208 -19
+14 -2
i18n/messages/en.tsx
··· 442 442 tangledDescription: "Social coding platform", 443 443 supperDescription: "AT Protocol native support page", 444 444 configureBskyLabel: "Configure Bluesky clients", 445 - urlOverrideLabel: "Custom URL (optional)", 446 - urlOverridePlaceholder: "https://…", 445 + configureUrlLabel: "Configure URL", 446 + usingDefault: "Using default URL", 447 + usingOverride: "Using custom URL", 448 + }, 449 + linkOverride: { 450 + title: (service: string): string => `${service} URL`, 451 + body: (service: string, defaultUrl: string): string => 452 + `By default, ${service} uses your handle (${defaultUrl}). ` + 453 + `Override below to point at a specific page or repository instead.`, 454 + inputLabel: "Custom URL", 455 + placeholder: "https://…", 456 + save: "Save", 457 + cancel: "Cancel", 458 + reset: "Reset to default", 447 459 }, 448 460 bskyPicker: { 449 461 title: "Bluesky clients",
+76 -17
islands/CreateProfileForm.tsx
··· 8 8 } from "../lib/lexicons.ts"; 9 9 import { 10 10 type AtmosphereService, 11 + getAtmosphereService, 11 12 visibleAtmosphereServices, 12 13 } from "../lib/atmosphere-links.ts"; 13 14 import { BSKY_CLIENTS, getBskyClient } from "../lib/bsky-clients.ts"; 14 15 import { useT } from "../i18n/mod.ts"; 15 16 import BskyClientPickerModal from "./BskyClientPickerModal.tsx"; 17 + import LinkUrlOverrideModal from "./LinkUrlOverrideModal.tsx"; 16 18 17 19 interface ExistingProfile { 18 20 name: string; ··· 149 151 const supperOn = useSignal<boolean>(initialSplit.supperOn); 150 152 const supperUrl = useSignal<string>(initialSplit.supperOverride); 151 153 154 + /** Which simple-atmosphere row currently has its URL-override modal 155 + * open, if any. `null` = no modal open. */ 156 + const urlOverrideOpen = useSignal<"tangled" | "supper" | null>(null); 157 + 152 158 const website = useSignal<string>(initialSplit.website); 153 159 const customLinks = useSignal<CustomLinkRow[]>(initialSplit.custom); 154 160 155 161 const avatarKeep = useSignal<BlobRefShape | null>(null); 156 - /** Preview URL precedence: locally-picked file > existing registry 157 - * record (cached proxy) > prefill source (Bluesky PDS) > none. */ 162 + /** 163 + * Preview URL precedence: 164 + * 1. Locally-picked file (set in `onAvatarChange`). 165 + * 2. An explicit `initialAvatarUrl` from the server — used by the 166 + * Bluesky-prefill path to point at the public bsky CDN; we 167 + * check this first because in the prefill case `initial.avatar` 168 + * is also set (so it can carry through the BlobRef on Save) but 169 + * the registry-side proxy doesn't have anything to serve yet. 170 + * 3. Existing registry record → cached server proxy. 171 + * 4. Empty placeholder. 172 + */ 158 173 const avatarPreview = useSignal<string | null>( 159 - initial?.avatar 160 - ? `/api/registry/avatar/${encodeURIComponent(did)}` 161 - : (initialAvatarUrl ?? null), 174 + initialAvatarUrl ?? 175 + (initial?.avatar 176 + ? `/api/registry/avatar/${encodeURIComponent(did)}` 177 + : null), 162 178 ); 163 179 const avatarFile = useSignal<File | null>(null); 164 180 const avatarRemoved = useSignal(false); ··· 518 534 tangledUrl, 519 535 supperOn, 520 536 supperUrl, 537 + urlOverrideOpen, 521 538 tAtmos, 539 + handle, 522 540 }) 523 541 )} 524 542 </div> ··· 625 643 onConfirm={onBskyConfirm} 626 644 onClose={() => (bskyPickerOpen.value = false)} 627 645 /> 646 + 647 + {/* URL-override modal, shared by Tangled and Supper. Only one is 648 + open at a time so we render a single instance and switch its 649 + props on `urlOverrideOpen`. */} 650 + {(() => { 651 + const which = urlOverrideOpen.value; 652 + const svc = which ? getAtmosphereService(which) : null; 653 + if (!which || !svc) return null; 654 + const sig = which === "tangled" ? tangledUrl : supperUrl; 655 + return ( 656 + <LinkUrlOverrideModal 657 + open 658 + serviceName={svc.name} 659 + defaultUrl={svc.defaultUrl(handle)} 660 + value={sig.value} 661 + onConfirm={(next) => { 662 + sig.value = next; 663 + urlOverrideOpen.value = null; 664 + }} 665 + onClose={() => (urlOverrideOpen.value = null)} 666 + labels={t.forms.profile.linkOverride} 667 + /> 668 + ); 669 + })()} 628 670 </form> 629 671 ); 630 672 } ··· 638 680 tangledUrl: { value: string }; 639 681 supperOn: { value: boolean }; 640 682 supperUrl: { value: string }; 683 + urlOverrideOpen: { value: "tangled" | "supper" | null }; 641 684 tAtmos: ReturnType<typeof useT>["forms"]["profile"]["atmosphereLinks"]; 685 + handle: string; 642 686 } 643 687 644 688 function renderAtmosphereRow(svc: AtmosphereService, ctx: AtmosphereRowCtx) { ··· 650 694 svc={svc} 651 695 on={ctx.tangledOn} 652 696 url={ctx.tangledUrl} 697 + modalKey="tangled" 653 698 /> 654 699 ); 655 700 } ··· 660 705 svc={svc} 661 706 on={ctx.supperOn} 662 707 url={ctx.supperUrl} 708 + modalKey="supper" 663 709 /> 664 710 ); 665 711 } ··· 761 807 svc: AtmosphereService; 762 808 on: { value: boolean }; 763 809 url: { value: string }; 810 + /** Identifier for the URL-override modal so the row can open it. */ 811 + modalKey: "tangled" | "supper"; 764 812 } 765 813 766 - function SimpleAtmosphereRow({ svc, on, url, ctx }: SimpleRowProps) { 814 + function SimpleAtmosphereRow( 815 + { svc, on, url, ctx, modalKey }: SimpleRowProps, 816 + ) { 817 + /** 818 + * The row is "using a custom URL" iff there's an override and it 819 + * differs from the handle-derived default. We compare against the 820 + * default to avoid showing the badge when the user typed in the 821 + * exact default URL by hand. 822 + */ 823 + const usingOverride = !!url.value && url.value !== svc.defaultUrl(ctx.handle); 824 + 767 825 return ( 768 826 <div class={`atmosphere-row ${on.value ? "is-on" : ""}`}> 769 827 <label class="atmosphere-row-toggle"> ··· 793 851 </div> 794 852 <div class="atmosphere-row-meta"> 795 853 <span class="atmosphere-row-name">{svc.name}</span> 796 - <span class="atmosphere-row-desc">{svc.description}</span> 854 + <span class="atmosphere-row-desc"> 855 + {usingOverride ? ctx.tAtmos.usingOverride : svc.description} 856 + </span> 797 857 </div> 798 858 </div> 799 - {svc.allowUrlOverride && on.value && ( 800 - <input 801 - type="url" 802 - class="profile-form-input atmosphere-row-url" 803 - placeholder={ctx.tAtmos.urlOverridePlaceholder} 804 - value={url.value} 805 - onInput={(e) => 806 - (url.value = (e.currentTarget as HTMLInputElement).value)} 807 - aria-label={ctx.tAtmos.urlOverrideLabel} 808 - /> 859 + {svc.allowUrlOverride && ( 860 + <button 861 + type="button" 862 + class="atmosphere-row-gear" 863 + onClick={() => (ctx.urlOverrideOpen.value = modalKey)} 864 + aria-label={ctx.tAtmos.configureUrlLabel} 865 + > 866 + 867 + </button> 809 868 )} 810 869 </div> 811 870 );
+118
islands/LinkUrlOverrideModal.tsx
··· 1 + import { useEffect } from "preact/hooks"; 2 + import { useSignal } from "@preact/signals"; 3 + 4 + interface Props { 5 + open: boolean; 6 + /** Display name of the service (e.g. "Tangled"). */ 7 + serviceName: string; 8 + /** 9 + * The URL the service would use by default if no override is set. 10 + * Shown as the placeholder + helper text so users understand what 11 + * they're overriding. 12 + */ 13 + defaultUrl: string; 14 + /** Current override value (empty string = "use the default"). */ 15 + value: string; 16 + /** Called with the new override value when the user clicks Save. */ 17 + onConfirm: (next: string) => void; 18 + onClose: () => void; 19 + labels: { 20 + title: (serviceName: string) => string; 21 + body: (serviceName: string, defaultUrl: string) => string; 22 + inputLabel: string; 23 + placeholder: string; 24 + save: string; 25 + cancel: string; 26 + reset: string; 27 + }; 28 + } 29 + 30 + /** 31 + * Centered modal for editing a per-service URL override (Tangled, 32 + * Supper). Local state is committed on Save; cancelling leaves the 33 + * parent untouched. The Reset button clears the override so the 34 + * service falls back to its handle-derived default URL. 35 + */ 36 + export default function LinkUrlOverrideModal( 37 + { open, serviceName, defaultUrl, value, onConfirm, onClose, labels }: Props, 38 + ) { 39 + const draft = useSignal<string>(value); 40 + 41 + useEffect(() => { 42 + if (open) draft.value = value; 43 + }, [open]); 44 + 45 + useEffect(() => { 46 + if (!open) return; 47 + const handler = (e: KeyboardEvent) => { 48 + if (e.key === "Escape") onClose(); 49 + }; 50 + globalThis.addEventListener("keydown", handler); 51 + return () => globalThis.removeEventListener("keydown", handler); 52 + }, [open]); 53 + 54 + if (!open) return null; 55 + 56 + return ( 57 + <div 58 + class="modal-backdrop" 59 + role="dialog" 60 + aria-modal="true" 61 + aria-labelledby="link-override-title" 62 + onClick={(e) => { 63 + if (e.target === e.currentTarget) onClose(); 64 + }} 65 + > 66 + <div class="modal-card"> 67 + <header class="modal-header"> 68 + <h2 id="link-override-title" class="modal-title"> 69 + {labels.title(serviceName)} 70 + </h2> 71 + <p class="modal-body-text"> 72 + {labels.body(serviceName, defaultUrl)} 73 + </p> 74 + </header> 75 + <label class="profile-form-field"> 76 + <span class="profile-form-label profile-form-label--small"> 77 + {labels.inputLabel} 78 + </span> 79 + <input 80 + type="url" 81 + class="profile-form-input" 82 + placeholder={labels.placeholder || defaultUrl} 83 + value={draft.value} 84 + onInput={(e) => 85 + (draft.value = (e.currentTarget as HTMLInputElement).value)} 86 + /> 87 + </label> 88 + <footer class="modal-footer"> 89 + <button 90 + type="button" 91 + class="profile-form-button-link" 92 + onClick={() => { 93 + draft.value = ""; 94 + onConfirm(""); 95 + }} 96 + > 97 + {labels.reset} 98 + </button> 99 + <span style={{ flex: 1 }} /> 100 + <button 101 + type="button" 102 + class="profile-form-button-secondary" 103 + onClick={onClose} 104 + > 105 + {labels.cancel} 106 + </button> 107 + <button 108 + type="button" 109 + class="profile-form-button-primary" 110 + onClick={() => onConfirm(draft.value.trim())} 111 + > 112 + {labels.save} 113 + </button> 114 + </footer> 115 + </div> 116 + </div> 117 + ); 118 + }