this repo has no description
10
fork

Configure Feed

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

feat(profile): refine app and secondary link buttons

Move primary app destinations into the profile hero card and keep Atmosphere links as compact secondary actions.

Made-with: Cursor

+248 -191
+110 -30
assets/styles.css
··· 2040 2040 display: flex; 2041 2041 gap: 1.5rem; 2042 2042 align-items: flex-start; 2043 - flex-wrap: wrap; 2044 2043 padding: 1.5rem; 2045 2044 border-radius: 24px; 2046 2045 } ··· 2069 2068 flex: 1; 2070 2069 min-width: 240px; 2071 2070 } 2071 + .profile-hero-actions { 2072 + align-self: stretch; 2073 + border-left: 1px solid rgba(18, 26, 47, 0.12); 2074 + padding-left: 1.25rem; 2075 + min-width: 156px; 2076 + display: flex; 2077 + flex-direction: column; 2078 + justify-content: center; 2079 + gap: 0.65rem; 2080 + } 2081 + .profile-hero-action { 2082 + display: flex; 2083 + align-items: center; 2084 + gap: 0.65rem; 2085 + padding: 0.75rem 0.85rem; 2086 + border-radius: 15px; 2087 + background: rgba(255, 255, 255, 0.55); 2088 + border: 1px solid rgba(255, 255, 255, 0.55); 2089 + color: #0e1428; 2090 + text-decoration: none; 2091 + font-weight: 650; 2092 + font-size: 0.92rem; 2093 + transition: 2094 + background 0.15s ease, 2095 + border-color 0.15s ease, 2096 + transform 0.15s ease; 2097 + } 2098 + .profile-hero-action:hover { 2099 + background: rgba(255, 255, 255, 0.86); 2100 + border-color: rgba(42, 90, 168, 0.2); 2101 + transform: translateY(-1px); 2102 + } 2103 + .profile-hero-action-icon { 2104 + width: 30px; 2105 + height: 30px; 2106 + border-radius: 9px; 2107 + background: #ffffff; 2108 + color: #254a9e; 2109 + display: inline-flex; 2110 + align-items: center; 2111 + justify-content: center; 2112 + flex-shrink: 0; 2113 + padding: 5px; 2114 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); 2115 + } 2116 + .profile-hero-action-icon-svg { 2117 + width: 100%; 2118 + height: 100%; 2119 + } 2120 + .profile-hero-action-arrow { 2121 + margin-left: auto; 2122 + color: rgba(42, 90, 168, 0.75); 2123 + font-size: 0.92rem; 2124 + } 2125 + .dark-phase .profile-hero-actions { 2126 + border-left-color: rgba(255, 255, 255, 0.14); 2127 + } 2128 + .dark-phase .profile-hero-action { 2129 + background: rgba(255, 255, 255, 0.1); 2130 + border-color: rgba(255, 255, 255, 0.16); 2131 + color: #f0f4ff; 2132 + } 2133 + .dark-phase .profile-hero-action:hover { 2134 + background: rgba(255, 255, 255, 0.16); 2135 + border-color: rgba(255, 255, 255, 0.24); 2136 + } 2137 + .dark-phase .profile-hero-action-icon { 2138 + background: rgba(255, 255, 255, 0.92); 2139 + } 2140 + .dark-phase .profile-hero-action-arrow { 2141 + color: rgba(255, 255, 255, 0.72); 2142 + } 2143 + @media (max-width: 720px) { 2144 + .profile-hero { 2145 + flex-wrap: wrap; 2146 + } 2147 + .profile-hero-actions { 2148 + width: 100%; 2149 + border-left: 0; 2150 + border-top: 1px solid rgba(18, 26, 47, 0.12); 2151 + padding-left: 0; 2152 + padding-top: 1rem; 2153 + flex-direction: row; 2154 + flex-wrap: wrap; 2155 + } 2156 + .profile-hero-action { 2157 + flex: 1 1 150px; 2158 + } 2159 + .dark-phase .profile-hero-actions { 2160 + border-top-color: rgba(255, 255, 255, 0.14); 2161 + } 2162 + } 2072 2163 .profile-hero-name-row { 2073 2164 display: flex; 2074 2165 align-items: center; ··· 2106 2197 color: rgba(255, 255, 255, 0.85); 2107 2198 } 2108 2199 2109 - /* ---- Profile detail call-to-action buttons (Web / apps / Atmosphere) ---- */ 2200 + /* ---- Profile detail secondary buttons (Atmosphere / custom) ---- */ 2110 2201 .profile-actions { 2111 2202 margin-top: 1.5rem; 2112 - display: grid; 2203 + display: flex; 2113 2204 gap: 0.7rem; 2114 - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); 2205 + flex-wrap: wrap; 2115 2206 } 2116 2207 .profile-action { 2117 2208 display: flex; ··· 2128 2219 border-color 0.15s ease, 2129 2220 transform 0.15s ease; 2130 2221 } 2222 + .profile-action--compact { 2223 + width: 46px; 2224 + height: 46px; 2225 + padding: 0; 2226 + justify-content: center; 2227 + border-radius: 999px; 2228 + } 2131 2229 .profile-action:hover { 2132 2230 background: rgba(255, 255, 255, 0.85); 2133 2231 transform: translateY(-1px); 2134 - } 2135 - .profile-action--primary { 2136 - background: linear-gradient( 2137 - 135deg, 2138 - rgba(42, 90, 168, 0.95), 2139 - rgba(60, 124, 220, 0.95) 2140 - ); 2141 - color: #ffffff; 2142 - border-color: rgba(255, 255, 255, 0.18); 2143 - } 2144 - .profile-action--primary:hover { 2145 - background: linear-gradient( 2146 - 135deg, 2147 - rgba(42, 90, 168, 1), 2148 - rgba(72, 138, 232, 1) 2149 - ); 2150 2232 } 2151 2233 .profile-action-icon { 2152 2234 width: 32px; ··· 2167 2249 font-size: 1rem; 2168 2250 padding: 0; 2169 2251 } 2170 - /* Wrapper for inline-SVG branded icons (Bluesky / Tangled / Website 2171 - * globe). Tints the SVG to the site's primary blue via currentColor. 2172 - * On the primary CTA we invert to white so the icon stays legible 2173 - * against the blue gradient background. */ 2252 + /* Wrapper for inline-SVG branded icons (Bluesky / Tangled). Tints the SVG 2253 + * to the site's primary blue via currentColor. */ 2174 2254 .profile-action-icon--brand { 2175 2255 display: inline-flex; 2176 2256 align-items: center; ··· 2182 2262 width: 100%; 2183 2263 height: 100%; 2184 2264 } 2185 - .profile-action--primary .profile-action-icon--brand { 2186 - color: #1d3a78; 2187 - background: #ffffff; 2265 + .profile-action--compact .profile-action-icon { 2266 + width: 28px; 2267 + height: 28px; 2268 + border-radius: 999px; 2269 + padding: 5px; 2270 + box-shadow: none; 2188 2271 } 2189 2272 .profile-action-label { 2190 2273 display: flex; ··· 2202 2285 } 2203 2286 .dark-phase .profile-action:hover { 2204 2287 background: rgba(255, 255, 255, 0.16); 2205 - } 2206 - .dark-phase .profile-action--primary { 2207 - border-color: rgba(255, 255, 255, 0.24); 2208 2288 } 2209 2289 .dark-phase .profile-action-icon--glyph { 2210 2290 background: rgba(255, 255, 255, 0.12);
+47 -3
components/explore/ProfileHero.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 2 import { useT } from "../../i18n/mod.ts"; 3 3 import VerifiedBadge from "../VerifiedBadge.tsx"; 4 + import WebsiteIcon from "../icons/WebsiteIcon.tsx"; 5 + import { AndroidIcon, AppleIcon } from "../icons/PlatformIcons.tsx"; 4 6 5 7 interface Props { 6 8 profile: ProfileRow; 7 9 } 8 10 9 11 /** 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. 12 + * Profile detail hero. Primary app destinations live in a right-side rail; 13 + * secondary Atmosphere/custom links render below the card. 13 14 */ 14 15 export default function ProfileHero({ profile }: Props) { 15 16 const t = useT(); 16 17 const tCat = t.categories as Record<string, string>; 17 18 const tSub = t.subcategories as Record<string, string>; 18 19 const tBadges = t.badges; 20 + const tLink = t.linkKinds; 19 21 const featured = profile.featured; 20 22 const cats = profile.categories; 23 + const primaryLinks = [ 24 + profile.mainLink 25 + ? { 26 + title: tLink.website, 27 + href: profile.mainLink, 28 + icon: <WebsiteIcon class="profile-hero-action-icon-svg" />, 29 + } 30 + : null, 31 + profile.iosLink 32 + ? { 33 + title: "iOS", 34 + href: profile.iosLink, 35 + icon: <AppleIcon class="profile-hero-action-icon-svg" />, 36 + } 37 + : null, 38 + profile.androidLink 39 + ? { 40 + title: "Android", 41 + href: profile.androidLink, 42 + icon: <AndroidIcon class="profile-hero-action-icon-svg" />, 43 + } 44 + : null, 45 + ].filter((link): link is NonNullable<typeof link> => link !== null); 21 46 22 47 return ( 23 48 <div class="profile-hero glass"> ··· 72 97 <p class="profile-hero-description">{profile.description}</p> 73 98 )} 74 99 </div> 100 + {primaryLinks.length > 0 && ( 101 + <div class="profile-hero-actions" aria-label="Primary links"> 102 + {primaryLinks.map((link) => ( 103 + <a 104 + class="profile-hero-action" 105 + href={link.href} 106 + target="_blank" 107 + rel="noopener noreferrer" 108 + key={link.href} 109 + > 110 + <span class="profile-hero-action-icon">{link.icon}</span> 111 + <span>{link.title}</span> 112 + <span class="profile-hero-action-arrow" aria-hidden="true"> 113 + 114 + </span> 115 + </a> 116 + ))} 117 + </div> 118 + )} 75 119 </div> 76 120 ); 77 121 }
+28 -118
components/explore/ProfileLinks.tsx
··· 6 6 import { useT } from "../../i18n/mod.ts"; 7 7 import BskyIcon from "../icons/BskyIcon.tsx"; 8 8 import TangledIcon from "../icons/TangledIcon.tsx"; 9 - import WebsiteIcon from "../icons/WebsiteIcon.tsx"; 10 9 11 10 interface Props { 12 11 profile: ProfileRow; 13 12 } 14 13 15 14 /** 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. 15 + * Renders secondary public profile links. Primary destinations (`mainLink`, 16 + * `iosLink`, `androidLink`) live inside the hero card. 20 17 * 21 - * URL subtitles are intentionally hidden for atmosphere services and 22 - * platform buttons — the title alone is enough; the URL is a destination, 23 - * not metadata. 18 + * URL subtitles are intentionally hidden for these buttons — the title alone 19 + * is enough; the URL is a destination, not metadata. 24 20 */ 25 21 export default function ProfileLinks({ profile }: Props) { 26 22 const t = useT(); 27 23 const tLink = t.linkKinds; 28 24 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 - 62 25 const resolved = profile.links 63 26 // The form no longer emits `website`; old records may still carry it 64 27 // as the former Landing Page button, which should no longer render. ··· 66 29 .map((entry) => resolveLink(entry, profile.handle, tLink)) 67 30 .filter((r): r is NonNullable<typeof r> => r !== null); 68 31 69 - const actions = [...appLinks, ...resolved]; 32 + const actions = resolved; 70 33 71 34 if (actions.length === 0) return null; 72 35 73 36 return ( 74 37 <div class="profile-actions"> 75 - {actions.map((r, i) => ( 76 - <a 77 - class={i === 0 78 - ? "profile-action profile-action--primary" 79 - : "profile-action"} 80 - href={r.href} 81 - target="_blank" 82 - rel="noopener noreferrer" 83 - key={`${r.href}-${i}`} 84 - > 85 - {renderIcon(r.iconKind, r.iconUrl, r.glyph)} 86 - <span class="profile-action-label"> 87 - <span class="profile-action-title">{r.title}</span> 88 - </span> 89 - </a> 90 - ))} 38 + {actions.map((r, i) => { 39 + const compact = r.iconKind === "bsky" || r.iconKind === "tangled"; 40 + return ( 41 + <a 42 + class={compact 43 + ? "profile-action profile-action--compact" 44 + : "profile-action"} 45 + href={r.href} 46 + target="_blank" 47 + rel="noopener noreferrer" 48 + aria-label={compact ? r.title : undefined} 49 + title={compact ? r.title : undefined} 50 + key={`${r.href}-${i}`} 51 + > 52 + {renderIcon(r.iconKind, r.iconUrl, r.glyph)} 53 + {!compact && ( 54 + <span class="profile-action-label"> 55 + <span class="profile-action-title">{r.title}</span> 56 + </span> 57 + )} 58 + </a> 59 + ); 60 + })} 91 61 </div> 92 62 ); 93 63 } ··· 117 87 </span> 118 88 ); 119 89 } 120 - if (iconKind === "website") { 121 - return ( 122 - <span class="profile-action-icon profile-action-icon--brand"> 123 - <WebsiteIcon class="profile-action-icon-svg" /> 124 - </span> 125 - ); 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 - } 141 90 if (iconUrl) { 142 91 return ( 143 92 <img ··· 153 102 <span class="profile-action-icon profile-action-icon--glyph">{glyph}</span> 154 103 ); 155 104 } 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 - }
+33
components/icons/PlatformIcons.tsx
··· 1 + interface Props { 2 + class?: string; 3 + } 4 + 5 + export function AppleIcon({ class: className }: Props) { 6 + return ( 7 + <svg 8 + viewBox="0 0 24 24" 9 + xmlns="http://www.w3.org/2000/svg" 10 + class={className} 11 + fill="currentColor" 12 + aria-hidden="true" 13 + > 14 + <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" /> 15 + <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" /> 16 + </svg> 17 + ); 18 + } 19 + 20 + export function AndroidIcon({ class: className }: Props) { 21 + return ( 22 + <svg 23 + viewBox="0 0 24 24" 24 + xmlns="http://www.w3.org/2000/svg" 25 + class={className} 26 + fill="currentColor" 27 + aria-hidden="true" 28 + > 29 + <path d="M17.53 7.35 19.1 4.63a.67.67 0 0 0-.24-.91.66.66 0 0 0-.91.24l-1.6 2.77a9.66 9.66 0 0 0-8.7 0l-1.6-2.77a.66.66 0 0 0-.91-.24.67.67 0 0 0-.24.91l1.57 2.72A8.37 8.37 0 0 0 2.2 14.7h19.6a8.37 8.37 0 0 0-4.27-7.35ZM7.6 11.35a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm8.8 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" /> 30 + <path d="M3.1 15.9v.92c0 1.07.86 1.93 1.93 1.93h.7v2.16c0 .75.61 1.36 1.36 1.36s1.36-.61 1.36-1.36v-2.16h7.1v2.16c0 .75.61 1.36 1.36 1.36s1.36-.61 1.36-1.36v-2.16h.7c1.07 0 1.93-.86 1.93-1.93v-.92H3.1Z" /> 31 + </svg> 32 + ); 33 + }
+3 -3
i18n/messages/en.tsx
··· 592 592 cancel: "Cancel", 593 593 }, 594 594 mainLink: { 595 - sectionLabel: "Main Link / Web (URL)", 595 + sectionLabel: "Web link", 596 596 placeholder: "https://yourapp.com", 597 - required: "Main Link is required.", 598 - invalid: "Main Link must be a valid http(s) URL.", 597 + required: "Add at least one Web, iOS, or Android link.", 598 + invalid: "Web link must be a valid http(s) URL.", 599 599 }, 600 600 appLinks: { 601 601 iosLabel: "iOS link (optional)",
+16 -17
islands/CreateProfileForm.tsx
··· 509 509 return; 510 510 } 511 511 const trimmedMainLink = mainLink.value.trim(); 512 - if (!trimmedMainLink) { 512 + const trimmedIosLink = iosLink.value.trim(); 513 + const trimmedAndroidLink = androidLink.value.trim(); 514 + if (!trimmedMainLink && !trimmedIosLink && !trimmedAndroidLink) { 513 515 message.value = { kind: "error", text: tMainLink.required }; 514 516 return; 515 517 } ··· 518 520 * URL parsing — this is just so the user doesn't have to round-trip 519 521 * to find out they typed "yourapp.com" without a protocol. 520 522 */ 521 - try { 522 - const u = new URL(trimmedMainLink); 523 - if (u.protocol !== "http:" && u.protocol !== "https:") { 524 - throw new Error("non-http"); 523 + if (trimmedMainLink) { 524 + try { 525 + const u = new URL(trimmedMainLink); 526 + if (u.protocol !== "http:" && u.protocol !== "https:") { 527 + throw new Error("non-http"); 528 + } 529 + } catch { 530 + message.value = { kind: "error", text: tMainLink.invalid }; 531 + return; 525 532 } 526 - } catch { 527 - message.value = { kind: "error", text: tMainLink.invalid }; 528 - return; 529 533 } 530 - const trimmedIosLink = iosLink.value.trim(); 531 534 if (trimmedIosLink && !isHttpUrl(trimmedIosLink)) { 532 535 message.value = { kind: "error", text: tAppLinks.iosInvalid }; 533 536 return; 534 537 } 535 - const trimmedAndroidLink = androidLink.value.trim(); 536 538 if (trimmedAndroidLink && !isHttpUrl(trimmedAndroidLink)) { 537 539 message.value = { kind: "error", text: tAppLinks.androidInvalid }; 538 540 return; ··· 800 802 {/* ---------------- Main Link ----------------------------- */} 801 803 { 802 804 /* 803 - Required. Renders as the Web button on the public profile. 804 - We keep the mobile-app links directly underneath it so the 805 - user sees the primary destinations together before adding 806 - Atmosphere and custom buttons. 805 + Primary destinations render as buttons inside the public 806 + profile card. A project needs at least one Web / iOS / 807 + Android destination, but each individual field is optional. 807 808 */ 808 809 } 809 810 <label class="profile-form-field"> 810 811 <span class="profile-form-label"> 811 - {tMainLink.sectionLabel}{" "} 812 - <span class="profile-form-required">*</span> 812 + {tMainLink.sectionLabel} 813 813 </span> 814 814 <input 815 815 type="url" 816 816 class="profile-form-input" 817 817 placeholder={tMainLink.placeholder} 818 818 value={mainLink.value} 819 - required 820 819 onInput={(e) => 821 820 mainLink.value = (e.currentTarget as HTMLInputElement).value} 822 821 />
+1 -1
lexicons/com/atmosphereaccount/registry/profile.json
··· 27 27 "type": "string", 28 28 "format": "uri", 29 29 "maxLength": 512, 30 - "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." 30 + "description": "Primary web destination for the project (the actual web app, service, or website). Rendered as the Web button inside the public profile card when present. Optional when an iOS or Android destination is provided." 31 31 }, 32 32 "iosLink": { 33 33 "type": "string",
+4 -7
lib/lexicons.ts
··· 116 116 description: string; 117 117 /** 118 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. 119 + * button inside the public profile card when present. 122 120 */ 123 121 mainLink?: string; 124 122 /** Optional App Store URL for projects with an iOS app. */ ··· 318 316 if (normalizedDescription.length > 500) { 319 317 return { ok: false, error: "description: must be <=500 chars" }; 320 318 } 321 - // mainLink: optional in the lexicon for backward compat, but if present 322 - // must parse as an http(s) URL <=512 chars. The registry UI / API both 323 - // enforce required-ness for new writes; we don't reject reads here so 324 - // pre-mainLink records keep validating. 319 + // mainLink: optional, but if present must parse as an http(s) URL <=512 320 + // chars. The registry UI / API require at least one primary destination 321 + // among mainLink, iosLink, and androidLink on new writes. 325 322 let normalizedMainLink: string | undefined; 326 323 if (v.mainLink !== undefined && v.mainLink !== null && v.mainLink !== "") { 327 324 if (!isStr(v.mainLink, 512) || !isUrl(v.mainLink)) {
+6 -12
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. */ 43 + /** Optional Web destination. At least one of mainLink, iosLink, or 44 + * androidLink is required for new writes. */ 45 45 mainLink?: string; 46 46 /** Optional app store links rendered as iOS / Android buttons. */ 47 47 iosLink?: string; ··· 270 270 271 271 const links = normalizeLinksPayload(body.links); 272 272 273 - /** 274 - * mainLink is required at the API layer too. The lexicon keeps it 275 - * optional for backward-compat reads of pre-mainLink records, but 276 - * any new write must carry one — that's how the listing card knows 277 - * where to send visitors. 278 - */ 279 273 const mainLink = trimOrNull(body.mainLink); 280 - if (!mainLink) { 274 + const iosLink = trimOrNull(body.iosLink); 275 + const androidLink = trimOrNull(body.androidLink); 276 + if (!mainLink && !iosLink && !androidLink) { 281 277 return new Response( 282 - "main link is required (the URL people land on when they tap your card)", 278 + "at least one Web, iOS, or Android link is required", 283 279 { status: 400 }, 284 280 ); 285 281 } 286 - const iosLink = trimOrNull(body.iosLink); 287 - const androidLink = trimOrNull(body.androidLink); 288 282 289 283 const draft: ProfileRecord = { 290 284 name: trimOrNull(body.name) ?? "",