this repo has no description
10
fork

Configure Feed

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

feat(profile): primary Main Link drives card target; Website becomes optional Landing Page

The whole /explore profile card is now a button: clicking it sends
visitors to the project's mainLink (the actual app, service, or
landing page) with an external-link arrow in the top-right corner.
The previous "Website" concept becomes an optional secondary
"Landing Page" button on the profile detail page.

Lexicon
* com.atmosphereaccount.registry.profile: add optional mainLink
string (uri, <=512). Optional in the lexicon for backward-compat
reads of pre-mainLink records; the registry UI/API enforce it as
required for new/updated records.
* The legacy `website` link kind is repurposed as the storage shape
for the optional Landing Page button (no schema rename — keeps
existing records valid).

DB
* profile.main_link TEXT column (additive migration; safe on
existing Turso DBs).
* upsertProfile reads/writes main_link; ON CONFLICT preserves the
same admin/icon semantics as before.

Form (/explore/manage)
* New required "Main Link (URL)" field directly above Atmosphere
links.
* Legacy `kind: website` URL is auto-promoted into the Main Link
slot on first load when the record has no mainLink — the user
agreed to "treat existing website as Main Link" semantics so this
is one save away from clean.
* Renamed Website field to "Landing Page (URL)" with a hint
clarifying it's a separate marketing/landing surface.
* Client-side guard: empty / non-http(s) Main Link surfaces a clean
inline error before round-tripping.

API (PUT /api/registry/profile)
* mainLink is required server-side; rejects with 400 + a clear
error otherwise. Validation library still treats mainLink as
optional so reads of pre-mainLink records keep parsing.

Public surfaces
* ProfileCard: whole card is an anchor to mainLink (target=_blank,
rel="noopener noreferrer external"), with a small inline SVG
arrow that lifts on hover. Falls back to /explore/<handle> for
legacy records that lack mainLink so the card never 404s.
* ProfileLinks already filters out empty entries, so missing
Atmosphere links / missing Landing Page just don't render — no
empty placeholder buttons.
* Globe icon + "Landing page" label propagate via the existing
resolveLink path; no detail-page changes needed.

i18n
* Added forms.profile.mainLink + forms.profile.landingPage; kept
the legacy bskyDescription strings around for now (used elsewhere).
* linkKinds.website re-labelled "Landing page" so the public button
reads correctly without renaming the underlying kind.

Migration story
* Existing records keep working unchanged. On first re-save through
the form, mainLink lands on the record and the legacy website
entry drops off (the form leaves Landing Page empty post-promotion
so users don't end up with two buttons pointing at the same URL).

Made-with: Cursor

+291 -45
+39
assets/styles.css
··· 1906 1906 transform: translateY(-2px); 1907 1907 box-shadow: 0 12px 32px rgba(48, 70, 128, 0.12); 1908 1908 } 1909 + /** 1910 + * Card-as-button affordance: anchor the arrow in the top-right corner 1911 + * (relative to the card) so it sits clear of the avatar/title row but 1912 + * doesn't obstruct content. The arrow inherits text colour and fades 1913 + * up to full opacity on hover/focus. Padding-right reserves space so 1914 + * long names don't slide under the arrow. 1915 + */ 1916 + .profile-card-button { 1917 + position: relative; 1918 + padding-right: 2.4rem; 1919 + cursor: pointer; 1920 + } 1921 + .profile-card-arrow { 1922 + position: absolute; 1923 + top: 0.85rem; 1924 + right: 0.85rem; 1925 + display: inline-flex; 1926 + align-items: center; 1927 + justify-content: center; 1928 + width: 1.4rem; 1929 + height: 1.4rem; 1930 + color: rgba(18, 26, 47, 0.45); 1931 + opacity: 0.85; 1932 + transition: transform 0.15s ease, opacity 0.15s ease, color 0.15s ease; 1933 + pointer-events: none; 1934 + } 1935 + .profile-card-button:hover .profile-card-arrow, 1936 + .profile-card-button:focus-visible .profile-card-arrow { 1937 + transform: translate(2px, -2px); 1938 + opacity: 1; 1939 + color: #254a9e; 1940 + } 1941 + .dark-phase .profile-card-arrow { 1942 + color: rgba(255, 255, 255, 0.55); 1943 + } 1944 + .dark-phase .profile-card-button:hover .profile-card-arrow, 1945 + .dark-phase .profile-card-button:focus-visible .profile-card-arrow { 1946 + color: #c8d3ff; 1947 + } 1909 1948 .profile-card-avatar { 1910 1949 flex: 0 0 56px; 1911 1950 width: 56px;
+34 -4
components/explore/ProfileCard.tsx
··· 5 5 profile: ProfileRow; 6 6 } 7 7 8 + /** 9 + * The profile card is the primary surface of /explore. The whole card 10 + * is clickable and lands the visitor on the project's `mainLink` (the 11 + * actual app/service/page) — a small top-right arrow signals the 12 + * external destination. Legacy records that pre-date `mainLink` fall 13 + * back to the local /explore/<handle> detail page so the card never 14 + * 404s, just degrades gracefully. 15 + */ 8 16 export default function ProfileCard({ profile }: Props) { 9 17 const t = useT(); 10 18 const tCat = t.categories as Record<string, string>; 11 - /** Show every category the project applies to (e.g. "App + Account 12 - * provider"), capped to keep the card from getting busy. */ 13 19 const cats = profile.categories.slice(0, 3); 14 20 const featured = profile.featured; 21 + 22 + const isExternal = !!profile.mainLink; 23 + const href = profile.mainLink ?? 24 + `/explore/${encodeURIComponent(profile.handle)}`; 25 + 15 26 return ( 16 27 <a 17 - href={`/explore/${encodeURIComponent(profile.handle)}`} 18 - class="glass profile-card" 28 + href={href} 29 + class="glass profile-card profile-card-button" 30 + {...(isExternal 31 + ? { target: "_blank", rel: "noopener noreferrer external" } 32 + : {})} 19 33 > 34 + <span class="profile-card-arrow" aria-hidden="true"> 35 + <svg 36 + width="18" 37 + height="18" 38 + viewBox="0 0 24 24" 39 + fill="none" 40 + stroke="currentColor" 41 + stroke-width="2" 42 + stroke-linecap="round" 43 + stroke-linejoin="round" 44 + > 45 + <line x1="7" y1="17" x2="17" y2="7"></line> 46 + <polyline points="9 7 17 7 17 15"></polyline> 47 + </svg> 48 + </span> 49 + 20 50 <div class="profile-card-avatar"> 21 51 {profile.avatarCid 22 52 ? (
+14 -4
i18n/messages/en.tsx
··· 453 453 bsky: "Bluesky", 454 454 tangled: "Tangled", 455 455 supper: "Supper", 456 - website: "Website", 456 + /** The legacy `website` link kind is repurposed as the optional 457 + * "Landing Page" button on the public profile detail row. */ 458 + website: "Landing page", 457 459 custom: "Link", 458 460 }, 459 461 ··· 579 581 done: "Done", 580 582 cancel: "Cancel", 581 583 }, 582 - website: { 583 - sectionLabel: "Website", 584 - placeholder: "https://yoursite.com", 584 + mainLink: { 585 + sectionLabel: "Main Link (URL)", 586 + placeholder: "https://yourapp.com", 587 + required: "Main Link is required.", 588 + invalid: "Main Link must be a valid http(s) URL.", 589 + }, 590 + landingPage: { 591 + sectionLabel: "Landing Page (URL)", 592 + placeholder: "https://yourproject.com", 593 + hint: 594 + "Optional. Use this if your app or service has a separate landing or marketing page.", 585 595 }, 586 596 customLinks: { 587 597 sectionLabel: "Custom links",
+110 -17
islands/CreateProfileForm.tsx
··· 19 19 interface ExistingProfile { 20 20 name: string; 21 21 description: string; 22 + /** Primary destination URL for the profile. May be null on legacy 23 + * records that pre-date the field; in that case the form auto-promotes 24 + * any existing `kind: website` link into this slot on first load 25 + * (the chosen migration path was "treat existing website as Main 26 + * Link"). */ 27 + mainLink: string | null; 22 28 /** All categories that apply to the project (always non-empty). The 23 29 * first item is the primary, used for sort/grouping in lists. */ 24 30 categories: string[]; ··· 80 86 url: string; 81 87 } 82 88 83 - /** Collapse the saved `LinkEntry[]` into the form's working state. */ 89 + /** 90 + * Collapse the saved `LinkEntry[]` into the form's working state. 91 + * 92 + * `legacyWebsite` is the URL of any pre-mainLink `kind: website` entry. 93 + * Callers use it to auto-promote that URL into the new top-level 94 + * `mainLink` field when the existing record doesn't have one yet (the 95 + * "treat existing website as Main Link" migration); after promotion, 96 + * the landing-page input is left empty so the user doesn't end up with 97 + * duplicate buttons pointing at the same URL. 98 + */ 84 99 function splitInitialLinks(links: LinkEntry[]): { 85 100 bskyClientIds: string[]; 86 101 tangledOverride: string; 87 102 tangledOn: boolean; 88 103 supperOverride: string; 89 104 supperOn: boolean; 90 - website: string; 105 + landing: string; 106 + legacyWebsite: string; 91 107 custom: CustomLinkRow[]; 92 108 } { 93 109 const bskyClientIds: string[] = []; ··· 95 111 let tangledOn = false; 96 112 let supperOverride = ""; 97 113 let supperOn = false; 98 - let website = ""; 114 + let landing = ""; 99 115 const custom: CustomLinkRow[] = []; 100 116 101 117 for (const e of links) { ··· 112 128 if (e.url) supperOverride = e.url; 113 129 break; 114 130 case "website": 115 - if (e.url) website = e.url; 131 + // The website kind now stores the optional Landing Page URL. 132 + if (e.url) landing = e.url; 116 133 break; 117 134 case "other": 118 135 if (e.url) custom.push({ label: e.label ?? "", url: e.url }); ··· 125 142 tangledOn, 126 143 supperOverride, 127 144 supperOn, 128 - website, 145 + landing, 146 + legacyWebsite: landing, 129 147 custom, 130 148 }; 131 149 } ··· 144 162 const tForm = t.forms.profile; 145 163 const tAtmos = tForm.atmosphereLinks; 146 164 const tCustom = tForm.customLinks; 147 - const tWebsite = tForm.website; 165 + const tMainLink = tForm.mainLink; 166 + const tLanding = tForm.landingPage; 148 167 const tManage = t.explore.manage; 149 168 /** Live registry status. Flips on save (-> true) and delete (-> false). */ 150 169 const published = useSignal<boolean>(initialPublished); ··· 153 172 154 173 const name = useSignal(initial?.name ?? ""); 155 174 const description = useSignal(initial?.description ?? ""); 175 + /** 176 + * Auto-promote the legacy `website` URL into the new mainLink slot 177 + * for records that pre-date mainLink. The landing-page input then 178 + * starts empty so the user doesn't unintentionally save the same 179 + * URL twice (once as Main Link, once as a Landing Page button). 180 + */ 181 + const promoteLegacyWebsite = !initial?.mainLink && 182 + !!initialSplit.legacyWebsite; 183 + const mainLink = useSignal<string>( 184 + initial?.mainLink ?? (promoteLegacyWebsite ? initialSplit.legacyWebsite : ""), 185 + ); 156 186 const categories = useSignal<string[]>( 157 187 initial?.categories?.length ? initial.categories : ["app"], 158 188 ); ··· 177 207 * open, if any. `null` = no modal open. */ 178 208 const urlOverrideOpen = useSignal<"tangled" | "supper" | null>(null); 179 209 180 - const website = useSignal<string>(initialSplit.website); 210 + /** Optional secondary URL — rendered as a globe-icon button on the 211 + * public profile detail page. Stored as `kind: website` in the 212 + * links[] array for backward compatibility with existing records. */ 213 + const landingPage = useSignal<string>( 214 + promoteLegacyWebsite ? "" : initialSplit.landing, 215 + ); 181 216 const customLinks = useSignal<CustomLinkRow[]>(initialSplit.custom); 182 217 183 218 const tIcon = tForm.icon; ··· 335 370 336 371 /** 337 372 * Reduce the form's working state into the lexicon-shaped LinkEntry[] 338 - * we send to the API. Order matters — we put atmosphere links first 339 - * (in service order, with the user's chosen primary bsky client at the 340 - * head), then website, then custom links in display order. 373 + * we send to the API. Order matters for the public profile button row 374 + * — atmosphere links first (in service order, with the user's chosen 375 + * primary bsky client at the head), then the optional Landing Page 376 + * (stored as `kind: website`), then custom links in display order. 377 + * 378 + * The Main Link is NOT in this array — it lives at top level on the 379 + * record (and on the API payload) and drives the listing card target. 341 380 */ 342 381 const buildLinksPayload = (): LinkEntry[] => { 343 382 const out: LinkEntry[] = []; ··· 357 396 if (u) entry.url = u; 358 397 out.push(entry); 359 398 } 360 - const w = website.value.trim(); 361 - if (w) out.push({ kind: "website", url: w }); 399 + const landing = landingPage.value.trim(); 400 + if (landing) out.push({ kind: "website", url: landing }); 362 401 for (const row of customLinks.value) { 363 402 const url = row.url.trim(); 364 403 const label = row.label.trim(); ··· 375 414 message.value = { kind: "error", text: tForm.categoryRequired }; 376 415 return; 377 416 } 417 + const trimmedMainLink = mainLink.value.trim(); 418 + if (!trimmedMainLink) { 419 + message.value = { kind: "error", text: tMainLink.required }; 420 + return; 421 + } 422 + /** 423 + * Cheap http(s) URL guard. The server validates again with proper 424 + * URL parsing — this is just so the user doesn't have to round-trip 425 + * to find out they typed "yourapp.com" without a protocol. 426 + */ 427 + try { 428 + const u = new URL(trimmedMainLink); 429 + if (u.protocol !== "http:" && u.protocol !== "https:") { 430 + throw new Error("non-http"); 431 + } 432 + } catch { 433 + message.value = { kind: "error", text: tMainLink.invalid }; 434 + return; 435 + } 378 436 submitting.value = true; 379 437 message.value = null; 380 438 ··· 384 442 const payload: Record<string, unknown> = { 385 443 name: name.value.trim(), 386 444 description: description.value.trim(), 445 + mainLink: trimmedMainLink, 387 446 categories: categories.value, 388 447 subcategories: showSubcategories ? subcategories.value : [], 389 448 links: cleanedLinks, ··· 603 662 </fieldset> 604 663 )} 605 664 665 + {/* ---------------- Main Link ----------------------------- */} 666 + {/* 667 + Required. Drives the listing card's link target on /explore 668 + (whole card becomes a button). Also surfaced as a small 669 + arrow on hover. We keep it directly above Atmosphere links 670 + so the user sees the destination flow top-to-bottom: where 671 + the card goes (Main Link) → who runs the project (Atmosphere 672 + services) → optional secondary surfaces (Landing Page + 673 + custom). 674 + */} 675 + <label class="profile-form-field"> 676 + <span class="profile-form-label"> 677 + {tMainLink.sectionLabel}{" "} 678 + <span class="profile-form-required">*</span> 679 + </span> 680 + <input 681 + type="url" 682 + class="profile-form-input" 683 + placeholder={tMainLink.placeholder} 684 + value={mainLink.value} 685 + required 686 + onInput={(e) => 687 + mainLink.value = (e.currentTarget as HTMLInputElement).value} 688 + /> 689 + </label> 690 + 606 691 {/* ---------------- Atmosphere links ----------------------- */} 607 692 <fieldset class="profile-form-field"> 608 693 <legend class="profile-form-label">{tAtmos.sectionLabel}</legend> ··· 625 710 </div> 626 711 </fieldset> 627 712 628 - {/* ---------------- Website ------------------------------- */} 713 + {/* ---------------- Landing Page (optional) --------------- */} 714 + {/* 715 + Optional secondary URL — a separate marketing/landing page 716 + distinct from the Main Link. Renders as the globe-icon 717 + button on /explore/<handle>. Stored as `kind: website` for 718 + backward compatibility with existing records. 719 + */} 629 720 <label class="profile-form-field"> 630 - <span class="profile-form-label">{tWebsite.sectionLabel}</span> 721 + <span class="profile-form-label">{tLanding.sectionLabel}</span> 631 722 <input 632 723 type="url" 633 724 class="profile-form-input" 634 - placeholder={tWebsite.placeholder} 635 - value={website.value} 725 + placeholder={tLanding.placeholder} 726 + value={landingPage.value} 636 727 onInput={(e) => 637 - website.value = (e.currentTarget as HTMLInputElement).value} 728 + landingPage.value = 729 + (e.currentTarget as HTMLInputElement).value} 638 730 /> 731 + <p class="profile-form-hint">{tLanding.hint}</p> 639 732 </label> 640 733 641 734 {/* ---------------- Custom links -------------------------- */}
+22 -16
lexicons/com/atmosphereaccount/registry/profile.json
··· 8 8 "key": "literal:self", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["name", "description", "categories", "createdAt"], 12 - "properties": { 13 - "name": { 14 - "type": "string", 15 - "minLength": 1, 16 - "maxLength": 60, 17 - "maxGraphemes": 60, 18 - "description": "Display name for the project." 19 - }, 20 - "description": { 21 - "type": "string", 22 - "minLength": 1, 23 - "maxLength": 500, 24 - "maxGraphemes": 500, 25 - "description": "Short description of the project." 26 - }, 11 + "required": ["name", "description", "categories", "createdAt"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "minLength": 1, 16 + "maxLength": 60, 17 + "maxGraphemes": 60, 18 + "description": "Display name for the project." 19 + }, 20 + "description": { 21 + "type": "string", 22 + "minLength": 1, 23 + "maxLength": 500, 24 + "maxGraphemes": 500, 25 + "description": "Short description of the project." 26 + }, 27 + "mainLink": { 28 + "type": "string", 29 + "format": "uri", 30 + "maxLength": 512, 31 + "description": "Primary destination for the project (the actual app, service, or landing page). The whole profile card on /explore is rendered as a button to this URL. Optional in the lexicon for backward compatibility with records created before mainLink existed; the registry UI requires it for new/updated records." 32 + }, 27 33 "avatar": { 28 34 "type": "blob", 29 35 "accept": ["image/png", "image/jpeg", "image/webp"],
+6
lib/db.ts
··· 70 70 handle TEXT NOT NULL, 71 71 name TEXT NOT NULL, 72 72 description TEXT NOT NULL, 73 + main_link TEXT, 73 74 categories TEXT NOT NULL DEFAULT '[]', 74 75 subcategories TEXT NOT NULL DEFAULT '[]', 75 76 links TEXT NOT NULL DEFAULT '[]', ··· 200 201 column: "categories", 201 202 ddl: 202 203 "ALTER TABLE profile ADD COLUMN categories TEXT NOT NULL DEFAULT '[]'", 204 + }, 205 + { 206 + table: "profile", 207 + column: "main_link", 208 + ddl: "ALTER TABLE profile ADD COLUMN main_link TEXT", 203 209 }, 204 210 { 205 211 table: "profile",
+28 -1
lib/lexicons.ts
··· 114 114 $type?: typeof PROFILE_NSID; 115 115 name: string; 116 116 description: string; 117 + /** 118 + * Primary destination URL for the project — the actual app, service, 119 + * or marketing page. The whole profile card on /explore is rendered 120 + * as a button that opens this URL. Optional in the lexicon for 121 + * backward compatibility with records created before mainLink 122 + * existed; the registry UI enforces it as required for new/updated 123 + * records (the public listing falls back to /explore/<handle> when 124 + * absent on a legacy record). 125 + */ 126 + mainLink?: string; 117 127 avatar?: BlobRef; 118 128 /** 119 129 * Optional vector icon (SVG) intended for developer use — sign-in ··· 125 135 * primary category used for sort/grouping in lists. */ 126 136 categories: string[]; 127 137 subcategories?: string[]; 128 - /** Outbound buttons shown on the public profile. */ 138 + /** 139 + * Outbound buttons shown on the public profile (Atmosphere link 140 + * toggles, the optional Landing Page button, and any custom links). 141 + * The legacy `website` kind is rendered as a Landing Page button 142 + * post-migration; new records emit `website` for the same purpose. 143 + */ 129 144 links?: LinkEntry[]; 130 145 createdAt: string; 131 146 } ··· 302 317 ) { 303 318 return { ok: false, error: "description: 1..500 chars required" }; 304 319 } 320 + // mainLink: optional in the lexicon for backward compat, but if present 321 + // must parse as an http(s) URL <=512 chars. The registry UI / API both 322 + // enforce required-ness for new writes; we don't reject reads here so 323 + // pre-mainLink records keep validating. 324 + let normalizedMainLink: string | undefined; 325 + if (v.mainLink !== undefined && v.mainLink !== null && v.mainLink !== "") { 326 + if (!isStr(v.mainLink, 512) || !isUrl(v.mainLink)) { 327 + return { ok: false, error: "mainLink: must be an http(s) URL <=512" }; 328 + } 329 + normalizedMainLink = (v.mainLink as string).trim(); 330 + } 305 331 // categories[]: required, deduped, every entry must be a known CATEGORY. 306 332 // The first entry is treated as the primary category by the UI. 307 333 let normalizedCategories: string[]; ··· 364 390 $type: PROFILE_NSID, 365 391 name: v.name as string, 366 392 description: v.description as string, 393 + mainLink: normalizedMainLink, 367 394 avatar: v.avatar as BlobRef | undefined, 368 395 icon: v.icon as BlobRef | undefined, 369 396 categories: normalizedCategories,
+16 -3
lib/registry.ts
··· 33 33 handle: string; 34 34 name: string; 35 35 description: string; 36 + /** Primary destination for the profile card on /explore. May be null 37 + * for legacy records created before mainLink existed; the listing 38 + * card falls back to /explore/<handle> in that case. */ 39 + mainLink: string | null; 36 40 /** All categories that apply (always non-empty). The first item is the 37 41 * primary category used for sort/grouping in lists. */ 38 42 categories: string[]; 39 43 subcategories: string[]; 40 - /** Outbound links (atmosphere services, website, custom) in author-defined order. */ 44 + /** Outbound links (atmosphere services, landing page, custom) in author-defined order. */ 41 45 links: LinkEntry[]; 42 46 avatarCid: string | null; 43 47 avatarMime: string | null; ··· 71 75 handle: string; 72 76 name: string; 73 77 description: string; 78 + main_link: string | null; 74 79 categories: string; 75 80 subcategories: string; 76 81 links: string | null; ··· 142 147 handle: r.handle, 143 148 name: r.name, 144 149 description: r.description, 150 + mainLink: r.main_link && r.main_link.length > 0 ? r.main_link : null, 145 151 categories: safeJsonArray(r.categories), 146 152 subcategories: safeJsonArray(r.subcategories), 147 153 links: safeJsonLinks(r.links), ··· 179 185 handle: string; 180 186 name: string; 181 187 description: string; 188 + /** Optional: nullable for legacy records that pre-date the field. 189 + * Stored as the textual URL; the registry UI/API enforce required-ness 190 + * + URL shape on writes. */ 191 + mainLink?: string | null; 182 192 /** Required: 1-4 known category strings. The first is the primary. */ 183 193 categories: string[]; 184 194 subcategories: string[]; ··· 224 234 await c.execute({ 225 235 sql: ` 226 236 INSERT INTO profile ( 227 - did, handle, name, description, categories, subcategories, links, 237 + did, handle, name, description, main_link, 238 + categories, subcategories, links, 228 239 avatar_cid, avatar_mime, icon_cid, icon_mime, icon_status, 229 240 icon_reviewed_by, icon_reviewed_at, icon_rejected_reason, 230 241 takedown_status, takedown_reason, takedown_by, takedown_at, 231 242 pds_url, record_cid, record_rev, created_at, indexed_at 232 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?) 243 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?) 233 244 ON CONFLICT(did) DO UPDATE SET 234 245 handle=excluded.handle, 235 246 name=excluded.name, 236 247 description=excluded.description, 248 + main_link=excluded.main_link, 237 249 categories=excluded.categories, 238 250 subcategories=excluded.subcategories, 239 251 links=excluded.links, ··· 283 295 input.handle, 284 296 input.name, 285 297 input.description, 298 + input.mainLink ?? null, 286 299 JSON.stringify(cats), 287 300 JSON.stringify(input.subcategories ?? []), 288 301 JSON.stringify(input.links ?? []),
+19
routes/api/registry/profile.ts
··· 40 40 interface ProfileFormPayload { 41 41 name?: string; 42 42 description?: string; 43 + /** Primary destination URL for the profile card. Required by the 44 + * registry; the form enforces this, the API double-checks. */ 45 + mainLink?: string; 43 46 /** Required multi-select. The first entry is the primary category. */ 44 47 categories?: string[]; 45 48 subcategories?: string[]; ··· 240 243 241 244 const links = normalizeLinksPayload(body.links); 242 245 246 + /** 247 + * mainLink is required at the API layer too. The lexicon keeps it 248 + * optional for backward-compat reads of pre-mainLink records, but 249 + * any new write must carry one — that's how the listing card knows 250 + * where to send visitors. 251 + */ 252 + const mainLink = trimOrNull(body.mainLink); 253 + if (!mainLink) { 254 + return new Response( 255 + "main link is required (the URL people land on when they tap your card)", 256 + { status: 400 }, 257 + ); 258 + } 259 + 243 260 const draft: ProfileRecord = { 244 261 name: trimOrNull(body.name) ?? "", 245 262 description: trimOrNull(body.description) ?? "", 263 + mainLink, 246 264 categories: normalizedCategories, 247 265 subcategories: asArray(body.subcategories), 248 266 links: links.length > 0 ? links : undefined, ··· 282 300 handle: user.handle, 283 301 name: validation.value.name, 284 302 description: validation.value.description, 303 + mainLink: validation.value.mainLink ?? null, 285 304 categories: validation.value.categories, 286 305 subcategories: validation.value.subcategories ?? [], 287 306 links: validation.value.links ?? [],
+2
routes/explore/manage.tsx
··· 52 52 initial = { 53 53 name: existing.name, 54 54 description: existing.description, 55 + mainLink: existing.mainLink, 55 56 categories: existing.categories, 56 57 subcategories: existing.subcategories, 57 58 links: existing.links, ··· 77 78 initial = { 78 79 name: bsky.displayName ?? "", 79 80 description: bsky.description ?? "", 81 + mainLink: null, 80 82 categories: ["app"], 81 83 subcategories: [], 82 84 links: [],
+1
worker/indexer.ts
··· 110 110 handle, 111 111 name: r.name, 112 112 description: r.description, 113 + mainLink: r.mainLink ?? null, 113 114 categories: r.categories, 114 115 subcategories: r.subcategories ?? [], 115 116 links: r.links ?? [],