this repo has no description
0
fork

Configure Feed

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

feat(profile): add iOS and Android app links

Made-with: Cursor

+331 -203
+11 -66
assets/styles.css
··· 2044 2044 padding: 1.5rem; 2045 2045 border-radius: 24px; 2046 2046 } 2047 - /** 2048 - * When the project has a `mainLink`, the hero is rendered as an 2049 - * anchor — visitors can tap anywhere on the panel to jump straight 2050 - * to the project. The diagonal arrow in the top-right corner sits 2051 - * in a soft glass disc so it reads as a real button affordance, and 2052 - * the whole hero lifts a touch on hover the same way the listing 2053 - * cards do. Padding-right reserves space so long names don't slide 2054 - * under the disc. 2055 - */ 2056 - a.profile-hero { 2057 - text-decoration: none; 2058 - color: inherit; 2059 - } 2060 - .profile-hero-button { 2061 - position: relative; 2062 - padding-right: 3.4rem; 2063 - cursor: pointer; 2064 - transition: transform 0.18s ease, box-shadow 0.18s ease; 2065 - } 2066 - .profile-hero-button:hover, 2067 - .profile-hero-button:focus-visible { 2068 - transform: translateY(-2px); 2069 - box-shadow: 0 14px 36px rgba(48, 70, 128, 0.14); 2070 - } 2071 - .profile-hero-arrow { 2072 - position: absolute; 2073 - top: 1rem; 2074 - right: 1rem; 2075 - display: inline-flex; 2076 - align-items: center; 2077 - justify-content: center; 2078 - width: 2.1rem; 2079 - height: 2.1rem; 2080 - border-radius: 999px; 2081 - background: rgba(255, 255, 255, 0.6); 2082 - border: 1px solid rgba(37, 74, 158, 0.2); 2083 - color: #254a9e; 2084 - box-shadow: 0 2px 6px rgba(48, 70, 128, 0.1); 2085 - transition: 2086 - transform 0.18s ease, 2087 - background 0.18s ease, 2088 - box-shadow 0.18s ease, 2089 - color 0.18s ease, 2090 - border-color 0.18s ease; 2091 - pointer-events: none; 2092 - } 2093 - .profile-hero-button:hover .profile-hero-arrow, 2094 - .profile-hero-button:focus-visible .profile-hero-arrow { 2095 - transform: translate(3px, -3px); 2096 - background: #254a9e; 2097 - border-color: #254a9e; 2098 - color: #ffffff; 2099 - box-shadow: 0 6px 16px rgba(37, 74, 158, 0.32); 2100 - } 2101 - .dark-phase .profile-hero-arrow { 2102 - background: rgba(255, 255, 255, 0.12); 2103 - border-color: rgba(200, 211, 255, 0.32); 2104 - color: #c8d3ff; 2105 - } 2106 - .dark-phase .profile-hero-button:hover .profile-hero-arrow, 2107 - .dark-phase .profile-hero-button:focus-visible .profile-hero-arrow { 2108 - background: #c8d3ff; 2109 - border-color: #c8d3ff; 2110 - color: #0e1428; 2111 - } 2112 2047 .profile-hero-avatar { 2113 2048 width: 120px; 2114 2049 height: 120px; ··· 2171 2106 color: rgba(255, 255, 255, 0.85); 2172 2107 } 2173 2108 2174 - /* ---- Profile detail call-to-action buttons (Bluesky + Website) ---- */ 2109 + /* ---- Profile detail call-to-action buttons (Web / apps / Atmosphere) ---- */ 2175 2110 .profile-actions { 2176 2111 margin-top: 1.5rem; 2177 2112 display: grid; ··· 2422 2357 flex-direction: column; 2423 2358 gap: 1.1rem; 2424 2359 margin-top: 1.5rem; 2360 + } 2361 + .profile-form-mobile-links { 2362 + display: grid; 2363 + grid-template-columns: repeat(2, minmax(0, 1fr)); 2364 + gap: 1rem; 2365 + } 2366 + @media (max-width: 720px) { 2367 + .profile-form-mobile-links { 2368 + grid-template-columns: 1fr; 2369 + } 2425 2370 } 2426 2371 2427 2372 /* Read-only "Signed in as @handle" row, with the sign-out button
+2 -2
components/explore/ProfileCard.tsx
··· 9 9 /** 10 10 * Listing-grid card. Clicking the card opens the project's profile 11 11 * detail page (/explore/<handle>) — visitors get the description, 12 - * Atmosphere services, landing page, etc. on the detail page, where 13 - * the hero card is what actually links out to mainLink. 12 + * Atmosphere services, Web / iOS / Android links, and any custom buttons 13 + * on the detail page. 14 14 */ 15 15 export default function ProfileCard({ profile }: Props) { 16 16 const t = useT();
+6 -41
components/explore/ProfileHero.tsx
··· 7 7 } 8 8 9 9 /** 10 - * Profile detail hero. When the project has a `mainLink` (the actual 11 - * app/service URL), the whole hero becomes a button that opens that 12 - * destination in a new tab — the diagonal arrow in the top-right 13 - * corner is the affordance. Records without a mainLink (legacy or 14 - * deliberately empty) render the same content as a static panel so 15 - * the page still reads correctly. 10 + * Profile detail hero. Destination links render as explicit action buttons 11 + * below this panel (Web / iOS / Android / Atmosphere / custom), so the hero 12 + * stays a static project summary. 16 13 */ 17 14 export default function ProfileHero({ profile }: Props) { 18 15 const t = useT(); ··· 21 18 const tBadges = t.badges; 22 19 const featured = profile.featured; 23 20 const cats = profile.categories; 24 - const hasMainLink = !!profile.mainLink; 25 21 26 - const inner = ( 27 - <> 28 - {hasMainLink && ( 29 - <span class="profile-hero-arrow" aria-hidden="true"> 30 - <svg 31 - width="18" 32 - height="18" 33 - viewBox="0 0 24 24" 34 - fill="none" 35 - stroke="currentColor" 36 - stroke-width="2.4" 37 - stroke-linecap="round" 38 - stroke-linejoin="round" 39 - > 40 - <line x1="7" y1="17" x2="17" y2="7"></line> 41 - <polyline points="9 7 17 7 17 15"></polyline> 42 - </svg> 43 - </span> 44 - )} 22 + return ( 23 + <div class="profile-hero glass"> 45 24 <div class="profile-hero-avatar"> 46 25 {profile.avatarCid 47 26 ? ( ··· 91 70 </div> 92 71 <p class="profile-hero-description">{profile.description}</p> 93 72 </div> 94 - </> 73 + </div> 95 74 ); 96 - 97 - if (hasMainLink) { 98 - return ( 99 - <a 100 - href={profile.mainLink!} 101 - target="_blank" 102 - rel="noopener noreferrer external" 103 - class="profile-hero glass profile-hero-button" 104 - > 105 - {inner} 106 - </a> 107 - ); 108 - } 109 - return <div class="profile-hero glass">{inner}</div>; 110 75 }
+99 -11
components/explore/ProfileLinks.tsx
··· 13 13 } 14 14 15 15 /** 16 - * Renders the public profile's action buttons. We iterate `profile.links` 17 - * in author-defined order and resolve each entry to a render-ready 18 - * bundle via `resolveLink`. The resolver tags each link with an 19 - * optional `iconKind` so we can render the on-brand inline SVG (which 20 - * inherits the site's blue via currentColor) for known services, while 21 - * still falling back to favicons / glyphs for everything else. 16 + * Renders the public profile's action buttons. Top-level app destinations 17 + * (`mainLink`, `iosLink`, `androidLink`) always render first as Web / iOS / 18 + * Android buttons. Then we iterate `profile.links` in author-defined order 19 + * for Atmosphere and custom links. 22 20 * 23 21 * URL subtitles are intentionally hidden for atmosphere services and 24 - * the website button — the title alone is enough; the URL is a 25 - * destination, not metadata. Custom links keep their subtitle so the 26 - * user knows where they're going. 22 + * platform buttons — the title alone is enough; the URL is a destination, 23 + * not metadata. 27 24 */ 28 25 export default function ProfileLinks({ profile }: Props) { 29 26 const t = useT(); 30 27 const tLink = t.linkKinds; 31 28 29 + const appLinks = [ 30 + profile.mainLink 31 + ? { 32 + title: tLink.website, 33 + subtitle: "", 34 + iconUrl: null, 35 + glyph: "↗", 36 + href: profile.mainLink, 37 + iconKind: "website" as const, 38 + } 39 + : null, 40 + profile.iosLink 41 + ? { 42 + title: "iOS", 43 + subtitle: "", 44 + iconUrl: null, 45 + glyph: "iOS", 46 + href: profile.iosLink, 47 + iconKind: "ios" as const, 48 + } 49 + : null, 50 + profile.androidLink 51 + ? { 52 + title: "Android", 53 + subtitle: "", 54 + iconUrl: null, 55 + glyph: "A", 56 + href: profile.androidLink, 57 + iconKind: "android" as const, 58 + } 59 + : null, 60 + ].filter((r): r is NonNullable<typeof r> => r !== null); 61 + 32 62 const resolved = profile.links 63 + // The form no longer emits `website`; old records may still carry it 64 + // as the former Landing Page button, which should no longer render. 65 + .filter((entry) => entry.kind !== "website") 33 66 .map((entry) => resolveLink(entry, profile.handle, tLink)) 34 67 .filter((r): r is NonNullable<typeof r> => r !== null); 35 68 36 - if (resolved.length === 0) return null; 69 + const actions = [...appLinks, ...resolved]; 70 + 71 + if (actions.length === 0) return null; 37 72 38 73 return ( 39 74 <div class="profile-actions"> 40 - {resolved.map((r, i) => ( 75 + {actions.map((r, i) => ( 41 76 <a 42 77 class={i === 0 43 78 ? "profile-action profile-action--primary" ··· 89 124 </span> 90 125 ); 91 126 } 127 + if (iconKind === "ios") { 128 + return ( 129 + <span class="profile-action-icon profile-action-icon--brand"> 130 + <AppleIcon class="profile-action-icon-svg" /> 131 + </span> 132 + ); 133 + } 134 + if (iconKind === "android") { 135 + return ( 136 + <span class="profile-action-icon profile-action-icon--brand"> 137 + <AndroidIcon class="profile-action-icon-svg" /> 138 + </span> 139 + ); 140 + } 92 141 if (iconUrl) { 93 142 return ( 94 143 <img ··· 104 153 <span class="profile-action-icon profile-action-icon--glyph">{glyph}</span> 105 154 ); 106 155 } 156 + 157 + function AppleIcon({ class: className }: { class?: string }) { 158 + return ( 159 + <svg 160 + viewBox="0 0 24 24" 161 + xmlns="http://www.w3.org/2000/svg" 162 + class={className} 163 + fill="currentColor" 164 + aria-hidden="true" 165 + > 166 + <path d="M16.53 12.52c-.02-2.1 1.72-3.12 1.8-3.17-1.01-1.48-2.55-1.68-3.08-1.7-1.29-.13-2.54.77-3.19.77-.67 0-1.68-.75-2.76-.73-1.4.02-2.7.83-3.42 2.1-1.48 2.56-.38 6.32 1.04 8.39.71 1.02 1.54 2.16 2.62 2.12 1.06-.04 1.46-.68 2.74-.68 1.27 0 1.64.68 2.76.66 1.14-.02 1.86-1.03 2.54-2.06.82-1.17 1.14-2.32 1.15-2.38-.02-.01-2.18-.84-2.2-3.32Z" /> 167 + <path d="M14.4 6.28c.57-.71.96-1.67.85-2.64-.82.04-1.84.57-2.43 1.26-.52.61-.99 1.61-.86 2.55.92.07 1.85-.47 2.44-1.17Z" /> 168 + </svg> 169 + ); 170 + } 171 + 172 + function AndroidIcon({ class: className }: { class?: string }) { 173 + return ( 174 + <svg 175 + viewBox="0 0 24 24" 176 + xmlns="http://www.w3.org/2000/svg" 177 + class={className} 178 + fill="none" 179 + stroke="currentColor" 180 + stroke-width="1.7" 181 + stroke-linecap="round" 182 + stroke-linejoin="round" 183 + aria-hidden="true" 184 + > 185 + <path d="M7.5 9.5h9a3 3 0 0 1 3 3v4.25a1.75 1.75 0 0 1-1.75 1.75H6.25a1.75 1.75 0 0 1-1.75-1.75V12.5a3 3 0 0 1 3-3Z" /> 186 + <path d="M8 9.5 6.5 6.75" /> 187 + <path d="m16 9.5 1.5-2.75" /> 188 + <path d="M8.25 14h.01" /> 189 + <path d="M15.75 14h.01" /> 190 + <path d="M8 18.5v1.75" /> 191 + <path d="M16 18.5v1.75" /> 192 + </svg> 193 + ); 194 + }
+11 -9
i18n/messages/en.tsx
··· 461 461 bsky: "Bluesky", 462 462 tangled: "Tangled", 463 463 supper: "Supper", 464 - /** The legacy `website` link kind is repurposed as the optional 465 - * "Landing Page" button on the public profile detail row. */ 466 - website: "Landing page", 464 + website: "Web", 467 465 custom: "Link", 468 466 }, 469 467 ··· 590 588 cancel: "Cancel", 591 589 }, 592 590 mainLink: { 593 - sectionLabel: "Main Link (URL)", 591 + sectionLabel: "Main Link / Web (URL)", 594 592 placeholder: "https://yourapp.com", 595 593 required: "Main Link is required.", 596 594 invalid: "Main Link must be a valid http(s) URL.", 597 595 }, 598 - landingPage: { 599 - sectionLabel: "Landing Page (URL)", 600 - placeholder: "https://yourproject.com", 601 - hint: 602 - "Optional. Use this if your app or service has a separate landing or marketing page.", 596 + appLinks: { 597 + iosLabel: "iOS link (optional)", 598 + iosPlaceholder: "https://apps.apple.com/app/…", 599 + iosHint: "Add this if your project has an iPhone or iPad app.", 600 + iosInvalid: "iOS link must be a valid http(s) URL.", 601 + androidLabel: "Android link (optional)", 602 + androidPlaceholder: "https://play.google.com/store/apps/details?id=…", 603 + androidHint: "Add this if your project has an Android app.", 604 + androidInvalid: "Android link must be a valid http(s) URL.", 603 605 }, 604 606 customLinks: { 605 607 sectionLabel: "Custom links",
+91 -53
islands/CreateProfileForm.tsx
··· 25 25 * (the chosen migration path was "treat existing website as Main 26 26 * Link"). */ 27 27 mainLink: string | null; 28 + /** Optional App Store / Android links rendered as platform buttons. */ 29 + iosLink: string | null; 30 + androidLink: string | null; 28 31 /** All categories that apply to the project (always non-empty). The 29 32 * first item is the primary, used for sort/grouping in lists. */ 30 33 categories: string[]; ··· 94 97 return btoa(binary); 95 98 } 96 99 100 + function isHttpUrl(value: string): boolean { 101 + try { 102 + const u = new URL(value); 103 + return u.protocol === "http:" || u.protocol === "https:"; 104 + } catch { 105 + return false; 106 + } 107 + } 108 + 97 109 interface CustomLinkRow { 98 110 label: string; 99 111 url: string; ··· 105 117 * `legacyWebsite` is the URL of any pre-mainLink `kind: website` entry. 106 118 * Callers use it to auto-promote that URL into the new top-level 107 119 * `mainLink` field when the existing record doesn't have one yet (the 108 - * "treat existing website as Main Link" migration); after promotion, 109 - * the landing-page input is left empty so the user doesn't end up with 110 - * duplicate buttons pointing at the same URL. 120 + * "treat existing website as Main Link" migration). Current records no 121 + * longer emit website links because mainLink renders as the Web button. 111 122 */ 112 123 function splitInitialLinks(links: LinkEntry[]): { 113 124 bskyClientIds: string[]; ··· 115 126 tangledOn: boolean; 116 127 supperOverride: string; 117 128 supperOn: boolean; 118 - landing: string; 129 + iosLink: string; 130 + androidLink: string; 119 131 legacyWebsite: string; 120 132 custom: CustomLinkRow[]; 121 133 } { ··· 124 136 let tangledOn = false; 125 137 let supperOverride = ""; 126 138 let supperOn = false; 127 - let landing = ""; 139 + let iosLink = ""; 140 + let androidLink = ""; 141 + let legacyWebsite = ""; 128 142 const custom: CustomLinkRow[] = []; 129 143 130 144 for (const e of links) { ··· 141 155 if (e.url) supperOverride = e.url; 142 156 break; 143 157 case "website": 144 - // The website kind now stores the optional Landing Page URL. 145 - if (e.url) landing = e.url; 158 + if (e.url && !legacyWebsite) legacyWebsite = e.url; 146 159 break; 147 160 case "other": 148 - if (e.url) custom.push({ label: e.label ?? "", url: e.url }); 161 + if (e.url) { 162 + const normalizedLabel = (e.label ?? "").trim().toLowerCase(); 163 + if ( 164 + !iosLink && 165 + (normalizedLabel === "ios" || normalizedLabel === "iphone") 166 + ) { 167 + iosLink = e.url; 168 + } else if ( 169 + !androidLink && 170 + (normalizedLabel === "android" || normalizedLabel === "google play") 171 + ) { 172 + androidLink = e.url; 173 + } else { 174 + custom.push({ label: e.label ?? "", url: e.url }); 175 + } 176 + } 149 177 break; 150 178 } 151 179 } ··· 155 183 tangledOn, 156 184 supperOverride, 157 185 supperOn, 158 - landing, 159 - legacyWebsite: landing, 186 + iosLink, 187 + androidLink, 188 + legacyWebsite, 160 189 custom, 161 190 }; 162 191 } ··· 176 205 const tAtmos = tForm.atmosphereLinks; 177 206 const tCustom = tForm.customLinks; 178 207 const tMainLink = tForm.mainLink; 179 - const tLanding = tForm.landingPage; 208 + const tAppLinks = tForm.appLinks; 180 209 const tManage = t.explore.manage; 181 210 /** Live registry status. Flips on save (-> true) and delete (-> false). */ 182 211 const published = useSignal<boolean>(initialPublished); ··· 187 216 const description = useSignal(initial?.description ?? ""); 188 217 /** 189 218 * Auto-promote the legacy `website` URL into the new mainLink slot 190 - * for records that pre-date mainLink. The landing-page input then 191 - * starts empty so the user doesn't unintentionally save the same 192 - * URL twice (once as Main Link, once as a Landing Page button). 219 + * for records that pre-date mainLink. Current saves no longer emit 220 + * `website` entries, so this is a one-way cleanup path. 193 221 */ 194 222 const promoteLegacyWebsite = !initial?.mainLink && 195 223 !!initialSplit.legacyWebsite; 196 224 const mainLink = useSignal<string>( 197 225 initial?.mainLink ?? 198 226 (promoteLegacyWebsite ? initialSplit.legacyWebsite : ""), 227 + ); 228 + const iosLink = useSignal<string>(initial?.iosLink ?? initialSplit.iosLink); 229 + const androidLink = useSignal<string>( 230 + initial?.androidLink ?? initialSplit.androidLink, 199 231 ); 200 232 const categories = useSignal<string[]>( 201 233 initial?.categories?.length ? initial.categories : ["app"], ··· 221 253 * open, if any. `null` = no modal open. */ 222 254 const urlOverrideOpen = useSignal<"tangled" | "supper" | null>(null); 223 255 224 - /** Optional secondary URL — rendered as a globe-icon button on the 225 - * public profile detail page. Stored as `kind: website` in the 226 - * links[] array for backward compatibility with existing records. */ 227 - const landingPage = useSignal<string>( 228 - promoteLegacyWebsite ? "" : initialSplit.landing, 229 - ); 230 256 const customLinks = useSignal<CustomLinkRow[]>(initialSplit.custom); 231 257 232 258 const tIcon = tForm.icon; ··· 443 469 * Reduce the form's working state into the lexicon-shaped LinkEntry[] 444 470 * we send to the API. Order matters for the public profile button row 445 471 * — atmosphere links first (in service order, with the user's chosen 446 - * primary bsky client at the head), then the optional Landing Page 447 - * (stored as `kind: website`), then custom links in display order. 472 + * primary bsky client at the head), then custom links in display order. 448 473 * 449 474 * The Main Link is NOT in this array — it lives at top level on the 450 475 * record (and on the API payload) and drives the listing card target. ··· 467 492 if (u) entry.url = u; 468 493 out.push(entry); 469 494 } 470 - const landing = landingPage.value.trim(); 471 - if (landing) out.push({ kind: "website", url: landing }); 472 495 for (const row of customLinks.value) { 473 496 const url = row.url.trim(); 474 497 const label = row.label.trim(); ··· 504 527 message.value = { kind: "error", text: tMainLink.invalid }; 505 528 return; 506 529 } 530 + const trimmedIosLink = iosLink.value.trim(); 531 + if (trimmedIosLink && !isHttpUrl(trimmedIosLink)) { 532 + message.value = { kind: "error", text: tAppLinks.iosInvalid }; 533 + return; 534 + } 535 + const trimmedAndroidLink = androidLink.value.trim(); 536 + if (trimmedAndroidLink && !isHttpUrl(trimmedAndroidLink)) { 537 + message.value = { kind: "error", text: tAppLinks.androidInvalid }; 538 + return; 539 + } 507 540 submitting.value = true; 508 541 message.value = null; 509 542 ··· 514 547 name: name.value.trim(), 515 548 description: description.value.trim(), 516 549 mainLink: trimmedMainLink, 550 + iosLink: trimmedIosLink || null, 551 + androidLink: trimmedAndroidLink || null, 517 552 categories: categories.value, 518 553 subcategories: showSubcategories ? subcategories.value : [], 519 554 links: cleanedLinks, ··· 766 801 {/* ---------------- Main Link ----------------------------- */} 767 802 { 768 803 /* 769 - Required. Drives the listing card's link target on /explore 770 - (whole card becomes a button). Also surfaced as a small 771 - arrow on hover. We keep it directly above Atmosphere links 772 - so the user sees the destination flow top-to-bottom: where 773 - the card goes (Main Link) → who runs the project (Atmosphere 774 - services) → optional secondary surfaces (Landing Page + 775 - custom). 804 + Required. Renders as the Web button on the public profile. 805 + We keep the mobile-app links directly underneath it so the 806 + user sees the primary destinations together before adding 807 + Atmosphere and custom buttons. 776 808 */ 777 809 } 778 810 <label class="profile-form-field"> ··· 791 823 /> 792 824 </label> 793 825 826 + {/* ---------------- Mobile app links (optional) ---------- */} 827 + <div class="profile-form-mobile-links"> 828 + <label class="profile-form-field"> 829 + <span class="profile-form-label">{tAppLinks.iosLabel}</span> 830 + <input 831 + type="url" 832 + class="profile-form-input" 833 + placeholder={tAppLinks.iosPlaceholder} 834 + value={iosLink.value} 835 + onInput={(e) => 836 + iosLink.value = (e.currentTarget as HTMLInputElement).value} 837 + /> 838 + <p class="profile-form-hint">{tAppLinks.iosHint}</p> 839 + </label> 840 + <label class="profile-form-field"> 841 + <span class="profile-form-label">{tAppLinks.androidLabel}</span> 842 + <input 843 + type="url" 844 + class="profile-form-input" 845 + placeholder={tAppLinks.androidPlaceholder} 846 + value={androidLink.value} 847 + onInput={(e) => 848 + androidLink.value = (e.currentTarget as HTMLInputElement).value} 849 + /> 850 + <p class="profile-form-hint">{tAppLinks.androidHint}</p> 851 + </label> 852 + </div> 853 + 794 854 {/* ---------------- Atmosphere links ----------------------- */} 795 855 <fieldset class="profile-form-field"> 796 856 <legend class="profile-form-label">{tAtmos.sectionLabel}</legend> ··· 812 872 )} 813 873 </div> 814 874 </fieldset> 815 - 816 - {/* ---------------- Landing Page (optional) --------------- */} 817 - { 818 - /* 819 - Optional secondary URL — a separate marketing/landing page 820 - distinct from the Main Link. Renders as the globe-icon 821 - button on /explore/<handle>. Stored as `kind: website` for 822 - backward compatibility with existing records. 823 - */ 824 - } 825 - <label class="profile-form-field"> 826 - <span class="profile-form-label">{tLanding.sectionLabel}</span> 827 - <input 828 - type="url" 829 - class="profile-form-input" 830 - placeholder={tLanding.placeholder} 831 - value={landingPage.value} 832 - onInput={(e) => 833 - landingPage.value = (e.currentTarget as HTMLInputElement).value} 834 - /> 835 - <p class="profile-form-hint">{tLanding.hint}</p> 836 - </label> 837 875 838 876 {/* ---------------- Custom links -------------------------- */} 839 877 <div class="profile-form-field">
+14 -2
lexicons/com/atmosphereaccount/registry/profile.json
··· 28 28 "type": "string", 29 29 "format": "uri", 30 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." 31 + "description": "Primary web destination for the project (the actual web app, service, or website). Rendered as the Web button on the public profile. Optional in the lexicon for backward compatibility with records created before mainLink existed; the registry UI requires it for new/updated records." 32 + }, 33 + "iosLink": { 34 + "type": "string", 35 + "format": "uri", 36 + "maxLength": 512, 37 + "description": "Optional App Store URL for projects with an iOS app. Rendered as an iOS button on the public profile when present." 38 + }, 39 + "androidLink": { 40 + "type": "string", 41 + "format": "uri", 42 + "maxLength": 512, 43 + "description": "Optional Google Play or Android distribution URL for projects with an Android app. Rendered as an Android button on the public profile when present." 32 44 }, 33 45 "avatar": { 34 46 "type": "blob", ··· 74 86 "type": "ref", 75 87 "ref": "#linkEntry" 76 88 }, 77 - "description": "Outbound buttons shown on the public profile. Atmosphere links (kind = bsky / tangled / supper) derive their URL from the project's current handle by default; a `url` override is allowed for tangled / supper when the canonical destination differs from the handle. Custom link kinds (kind = website / other) always carry their `url`." 89 + "description": "Outbound buttons shown on the public profile after the Web / iOS / Android app links. Atmosphere links (kind = bsky / tangled / supper) derive their URL from the project's current handle by default; a `url` override is allowed for tangled / supper when the canonical destination differs from the handle. Custom link kinds (kind = website / other) always carry their `url`; new records no longer emit website entries." 78 90 }, 79 91 "createdAt": { 80 92 "type": "string",
+8 -1
lib/atmosphere-links.ts
··· 141 141 * bluesky client; alt clients keep their favicon) 142 142 * - `tangled` — inline Tangled "dolly" mark 143 143 * - `website` — inline globe 144 + * - `ios` — inline Apple-style mark 145 + * - `android` — inline Android-style mark 144 146 */ 145 - export type ResolvedIconKind = "bsky" | "tangled" | "website"; 147 + export type ResolvedIconKind = 148 + | "bsky" 149 + | "tangled" 150 + | "website" 151 + | "ios" 152 + | "android"; 146 153 147 154 export interface ResolvedLink { 148 155 /** Display title for the button. */
+12
lib/db.ts
··· 93 93 name TEXT NOT NULL, 94 94 description TEXT NOT NULL, 95 95 main_link TEXT, 96 + ios_link TEXT, 97 + android_link TEXT, 96 98 categories TEXT NOT NULL DEFAULT '[]', 97 99 subcategories TEXT NOT NULL DEFAULT '[]', 98 100 links TEXT NOT NULL DEFAULT '[]', ··· 239 241 table: "profile", 240 242 column: "main_link", 241 243 ddl: "ALTER TABLE profile ADD COLUMN main_link TEXT", 244 + }, 245 + { 246 + table: "profile", 247 + column: "ios_link", 248 + ddl: "ALTER TABLE profile ADD COLUMN ios_link TEXT", 249 + }, 250 + { 251 + table: "profile", 252 + column: "android_link", 253 + ddl: "ALTER TABLE profile ADD COLUMN android_link TEXT", 242 254 }, 243 255 { 244 256 table: "profile",
+34 -11
lib/lexicons.ts
··· 115 115 name: string; 116 116 description: string; 117 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). 118 + * Primary web destination URL for the project. Rendered as the Web 119 + * button on the public profile. Optional in the lexicon for backward 120 + * compatibility with records created before mainLink existed; the 121 + * registry UI enforces it as required for new/updated records. 125 122 */ 126 123 mainLink?: string; 124 + /** Optional App Store URL for projects with an iOS app. */ 125 + iosLink?: string; 126 + /** Optional Google Play / Android distribution URL for projects with an Android app. */ 127 + androidLink?: string; 127 128 avatar?: BlobRef; 128 129 /** 129 130 * Optional vector icon (SVG) intended for developer use — sign-in ··· 136 137 categories: string[]; 137 138 subcategories?: string[]; 138 139 /** 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. 140 + * Outbound buttons shown on the public profile after the Web / iOS / 141 + * Android app links (Atmosphere link toggles and any custom links). 142 + * Legacy `website` entries are still valid for older records, but the 143 + * current form no longer emits them. 143 144 */ 144 145 links?: LinkEntry[]; 145 146 createdAt: string; ··· 328 329 } 329 330 normalizedMainLink = (v.mainLink as string).trim(); 330 331 } 332 + let normalizedIosLink: string | undefined; 333 + if (v.iosLink !== undefined && v.iosLink !== null && v.iosLink !== "") { 334 + if (!isStr(v.iosLink, 512) || !isUrl(v.iosLink)) { 335 + return { ok: false, error: "iosLink: must be an http(s) URL <=512" }; 336 + } 337 + normalizedIosLink = (v.iosLink as string).trim(); 338 + } 339 + let normalizedAndroidLink: string | undefined; 340 + if ( 341 + v.androidLink !== undefined && v.androidLink !== null && 342 + v.androidLink !== "" 343 + ) { 344 + if (!isStr(v.androidLink, 512) || !isUrl(v.androidLink)) { 345 + return { 346 + ok: false, 347 + error: "androidLink: must be an http(s) URL <=512", 348 + }; 349 + } 350 + normalizedAndroidLink = (v.androidLink as string).trim(); 351 + } 331 352 // categories[]: required, deduped, every entry must be a known CATEGORY. 332 353 // The first entry is treated as the primary category by the UI. 333 354 let normalizedCategories: string[]; ··· 391 412 name: v.name as string, 392 413 description: v.description as string, 393 414 mainLink: normalizedMainLink, 415 + iosLink: normalizedIosLink, 416 + androidLink: normalizedAndroidLink, 394 417 avatar: v.avatar as BlobRef | undefined, 395 418 icon: v.icon as BlobRef | undefined, 396 419 categories: normalizedCategories,
+7 -1
lib/public-profile.ts
··· 19 19 name: string; 20 20 description: string; 21 21 mainLink: string | null; 22 + iosLink: string | null; 23 + androidLink: string | null; 22 24 categories: string[]; 23 25 subcategories: string[]; 24 26 links: LinkEntry[]; ··· 65 67 name: profile.name, 66 68 description: profile.description, 67 69 mainLink: profile.mainLink, 70 + iosLink: profile.iosLink, 71 + androidLink: profile.androidLink, 68 72 categories: profile.categories, 69 73 subcategories: profile.subcategories, 70 - links: profile.links, 74 + // `website` was the former Landing Page button. The current public 75 + // API exposes the primary web destination via `mainLink` instead. 76 + links: profile.links.filter((entry) => entry.kind !== "website"), 71 77 avatarCid: profile.avatarCid, 72 78 avatarMime: profile.avatarMime, 73 79 avatarUrl,
+21 -6
lib/registry.ts
··· 49 49 handle: string; 50 50 name: string; 51 51 description: string; 52 - /** Primary destination for the profile card on /explore. May be null 53 - * for legacy records created before mainLink existed; the listing 54 - * card falls back to /explore/<handle> in that case. */ 52 + /** Primary web destination rendered as the Web button. May be null 53 + * for legacy records created before mainLink existed. */ 55 54 mainLink: string | null; 55 + /** Optional App Store URL rendered as the iOS button on the public profile. */ 56 + iosLink: string | null; 57 + /** Optional Android app URL rendered as the Android button on the public profile. */ 58 + androidLink: string | null; 56 59 /** All categories that apply (always non-empty). The first item is the 57 60 * primary category used for sort/grouping in lists. */ 58 61 categories: string[]; 59 62 subcategories: string[]; 60 - /** Outbound links (atmosphere services, landing page, custom) in author-defined order. */ 63 + /** Outbound links (atmosphere services and custom links) in author-defined order. */ 61 64 links: LinkEntry[]; 62 65 avatarCid: string | null; 63 66 avatarMime: string | null; ··· 101 104 name: string; 102 105 description: string; 103 106 main_link: string | null; 107 + ios_link: string | null; 108 + android_link: string | null; 104 109 categories: string; 105 110 subcategories: string; 106 111 links: string | null; ··· 186 191 name: r.name, 187 192 description: r.description, 188 193 mainLink: r.main_link && r.main_link.length > 0 ? r.main_link : null, 194 + iosLink: r.ios_link && r.ios_link.length > 0 ? r.ios_link : null, 195 + androidLink: r.android_link && r.android_link.length > 0 196 + ? r.android_link 197 + : null, 189 198 categories: safeJsonArray(r.categories), 190 199 subcategories: safeJsonArray(r.subcategories), 191 200 links: safeJsonLinks(r.links), ··· 237 246 * Stored as the textual URL; the registry UI/API enforce required-ness 238 247 * + URL shape on writes. */ 239 248 mainLink?: string | null; 249 + iosLink?: string | null; 250 + androidLink?: string | null; 240 251 /** Required: 1-4 known category strings. The first is the primary. */ 241 252 categories: string[]; 242 253 subcategories: string[]; ··· 283 294 await c.execute({ 284 295 sql: ` 285 296 INSERT INTO profile ( 286 - did, handle, name, description, main_link, 297 + did, handle, name, description, main_link, ios_link, android_link, 287 298 categories, subcategories, links, 288 299 avatar_cid, avatar_mime, icon_cid, icon_mime, icon_status, 289 300 icon_reviewed_by, icon_reviewed_at, icon_rejected_reason, ··· 293 304 takedown_status, takedown_reason, takedown_by, takedown_at, 294 305 pds_url, record_cid, record_rev, created_at, indexed_at 295 306 ) VALUES ( 296 - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 307 + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 297 308 NULL, NULL, NULL, 298 309 NULL, NULL, NULL, NULL, NULL, NULL, 299 310 NULL, NULL, NULL, NULL, ··· 304 315 name=excluded.name, 305 316 description=excluded.description, 306 317 main_link=excluded.main_link, 318 + ios_link=excluded.ios_link, 319 + android_link=excluded.android_link, 307 320 categories=excluded.categories, 308 321 subcategories=excluded.subcategories, 309 322 links=excluded.links, ··· 379 392 input.name, 380 393 input.description, 381 394 input.mainLink ?? null, 395 + input.iosLink ?? null, 396 + input.androidLink ?? null, 382 397 JSON.stringify(cats), 383 398 JSON.stringify(input.subcategories ?? []), 384 399 JSON.stringify(input.links ?? []),
+9
routes/api/registry/profile.ts
··· 43 43 /** Primary destination URL for the profile card. Required by the 44 44 * registry; the form enforces this, the API double-checks. */ 45 45 mainLink?: string; 46 + /** Optional app store links rendered as iOS / Android buttons. */ 47 + iosLink?: string; 48 + androidLink?: string; 46 49 /** Required multi-select. The first entry is the primary category. */ 47 50 categories?: string[]; 48 51 subcategories?: string[]; ··· 280 283 { status: 400 }, 281 284 ); 282 285 } 286 + const iosLink = trimOrNull(body.iosLink); 287 + const androidLink = trimOrNull(body.androidLink); 283 288 284 289 const draft: ProfileRecord = { 285 290 name: trimOrNull(body.name) ?? "", 286 291 description: trimOrNull(body.description) ?? "", 287 292 mainLink, 293 + iosLink, 294 + androidLink, 288 295 categories: normalizedCategories, 289 296 subcategories: asArray(body.subcategories), 290 297 links: links.length > 0 ? links : undefined, ··· 325 332 name: validation.value.name, 326 333 description: validation.value.description, 327 334 mainLink: validation.value.mainLink ?? null, 335 + iosLink: validation.value.iosLink ?? null, 336 + androidLink: validation.value.androidLink ?? null, 328 337 categories: validation.value.categories, 329 338 subcategories: validation.value.subcategories ?? [], 330 339 links: validation.value.links ?? [],
+4
routes/explore/manage.tsx
··· 54 54 name: existing.name, 55 55 description: existing.description, 56 56 mainLink: existing.mainLink, 57 + iosLink: existing.iosLink, 58 + androidLink: existing.androidLink, 57 59 categories: existing.categories, 58 60 subcategories: existing.subcategories, 59 61 links: existing.links, ··· 81 83 name: bsky.displayName ?? "", 82 84 description: bsky.description ?? "", 83 85 mainLink: null, 86 + iosLink: null, 87 + androidLink: null, 84 88 categories: ["app"], 85 89 subcategories: [], 86 90 links: [],
+2
worker/indexer.ts
··· 111 111 name: r.name, 112 112 description: r.description, 113 113 mainLink: r.mainLink ?? null, 114 + iosLink: r.iosLink ?? null, 115 + androidLink: r.androidLink ?? null, 114 116 categories: r.categories, 115 117 subcategories: r.subcategories ?? [], 116 118 links: r.links ?? [],