this repo has no description
10
fork

Configure Feed

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

feat: project pages — banner uploads, OG embeds, share button

Renames the public profile detail page to "project page" in user-facing
copy and wires up a project banner that doubles as the link-card image
when the page URL is shared.

- Lexicon: add optional `banner` blob to com.atmosphereaccount.registry.profile
(PNG/JPEG/WebP, 3MB, recommended 1200x630 — matches OG card ratio).
- DB: additive banner_cid / banner_mime columns on profile + matching
read/write paths in lib/registry.ts and lib/profile-sync.ts.
- Profile API: accept banner blob refs and base64 uploads via PUT
/api/registry/profile, mirroring the avatar contract.
- Form: new banner upload slot in CreateProfileForm (above the avatar +
fields row) with preview, replace, and remove controls.
- Banner endpoint: /api/registry/banner/<did> redirects to the Bluesky
CDN banner URL, mirroring the avatar compatibility route.
- Project page: render banner at the top of /explore/<handle>, set
per-page OG/Twitter meta via state.pageMeta so the banner becomes the
link-card image (with project name + description in the card text).
- Share: new ShareButton island with Web Share API on platforms that
support it and copy-to-clipboard fallback elsewhere.
- Copy: rename "View profile" / "View public profile" / "Edit this
profile" to project-page wording where the target is a project page.

Lexicon JSON is updated in-repo only — publish via `goat lex publish
--update` when ready. Not pushed.

Made-with: Cursor

+662 -26
+128
assets/styles.css
··· 2856 2856 grid-template-columns: 1fr; 2857 2857 } 2858 2858 } 2859 + .profile-form-banner { 2860 + display: flex; 2861 + flex-direction: column; 2862 + gap: 0.65rem; 2863 + margin-bottom: 1.25rem; 2864 + } 2865 + .profile-form-banner-preview { 2866 + position: relative; 2867 + width: 100%; 2868 + aspect-ratio: 1200 / 630; 2869 + border-radius: 18px; 2870 + overflow: hidden; 2871 + background: rgba(255, 255, 255, 0.4); 2872 + border: 1px solid rgba(255, 255, 255, 0.5); 2873 + display: flex; 2874 + align-items: center; 2875 + justify-content: center; 2876 + } 2877 + .profile-form-banner-preview--empty { 2878 + border-style: dashed; 2879 + background: rgba(255, 255, 255, 0.25); 2880 + } 2881 + .profile-form-banner-img { 2882 + width: 100%; 2883 + height: 100%; 2884 + object-fit: cover; 2885 + display: block; 2886 + } 2887 + .profile-form-banner-placeholder { 2888 + font-size: 0.95rem; 2889 + color: rgba(0, 0, 0, 0.5); 2890 + } 2891 + .profile-form-banner-controls { 2892 + display: flex; 2893 + flex-wrap: wrap; 2894 + gap: 0.6rem; 2895 + align-items: center; 2896 + } 2859 2897 .profile-form-avatar { 2860 2898 display: flex; 2861 2899 flex-direction: column; ··· 6106 6144 margin-top: 0.35rem; 6107 6145 font-size: 0.85rem; 6108 6146 padding: 0.45rem 0.9rem; 6147 + } 6148 + 6149 + /* ---- Project page (banner + share toolbar) ---- */ 6150 + .project-page-toolbar { 6151 + display: flex; 6152 + align-items: center; 6153 + justify-content: space-between; 6154 + gap: 1rem; 6155 + margin-bottom: 1rem; 6156 + flex-wrap: wrap; 6157 + } 6158 + .project-page-banner { 6159 + width: 100%; 6160 + aspect-ratio: 1200 / 630; 6161 + border-radius: 18px; 6162 + overflow: hidden; 6163 + margin-bottom: 1.25rem; 6164 + border: 1px solid rgba(255, 255, 255, 0.4); 6165 + background: rgba(255, 255, 255, 0.3); 6166 + } 6167 + .project-page-banner-img { 6168 + width: 100%; 6169 + height: 100%; 6170 + object-fit: cover; 6171 + display: block; 6172 + } 6173 + 6174 + /* ---- Share button + toast ---- */ 6175 + .share-button-wrap { 6176 + position: relative; 6177 + display: inline-flex; 6178 + align-items: center; 6179 + } 6180 + .share-button { 6181 + display: inline-flex; 6182 + align-items: center; 6183 + gap: 0.45rem; 6184 + padding: 0.45rem 0.9rem; 6185 + border-radius: 999px; 6186 + background: rgba(255, 255, 255, 0.78); 6187 + border: 1px solid rgba(255, 255, 255, 0.7); 6188 + color: #0e1428; 6189 + font-size: 0.9rem; 6190 + font-weight: 500; 6191 + cursor: pointer; 6192 + transition: transform 0.15s ease, background 0.15s ease; 6193 + } 6194 + .share-button:hover { 6195 + background: rgba(255, 255, 255, 0.95); 6196 + transform: translateY(-1px); 6197 + } 6198 + .share-button-icon { 6199 + display: inline-flex; 6200 + align-items: center; 6201 + justify-content: center; 6202 + } 6203 + .dark-phase .share-button { 6204 + background: rgba(255, 255, 255, 0.14); 6205 + border-color: rgba(255, 255, 255, 0.22); 6206 + color: #f0f4ff; 6207 + } 6208 + .dark-phase .share-button:hover { 6209 + background: rgba(255, 255, 255, 0.22); 6210 + } 6211 + .share-button-toast { 6212 + position: absolute; 6213 + top: calc(100% + 0.4rem); 6214 + right: 0; 6215 + padding: 0.4rem 0.7rem; 6216 + border-radius: 8px; 6217 + font-size: 0.85rem; 6218 + background: rgba(20, 30, 60, 0.92); 6219 + color: #fff; 6220 + white-space: nowrap; 6221 + pointer-events: none; 6222 + z-index: 10; 6223 + animation: share-toast-pop 0.2s ease-out; 6224 + } 6225 + .share-button-toast--error { 6226 + background: rgba(120, 30, 30, 0.92); 6227 + } 6228 + @keyframes share-toast-pop { 6229 + from { 6230 + opacity: 0; 6231 + transform: translateY(-4px); 6232 + } 6233 + to { 6234 + opacity: 1; 6235 + transform: translateY(0); 6236 + } 6109 6237 } 6110 6238 .account-reviews-empty { 6111 6239 display: flex;
+23 -8
i18n/messages/en.tsx
··· 508 508 browseBy: "Browse by", 509 509 nothingHere: "Nothing here yet.", 510 510 nothingHereSubtle: "Be the first to add a project in this category.", 511 - viewProfile: "View profile", 511 + viewProfile: "View project page", 512 512 by: "by", 513 513 poweredByYou: 514 514 "Powered by you — every entry is created and signed by the project's own Atmosphere account.", ··· 516 516 openOn: "Open on", 517 517 lastUpdated: "Last updated", 518 518 hostedOn: "Account Provider", 519 - editProfile: "Edit this profile", 520 - missingProfile: "We couldn't find a profile for that handle.", 519 + editProfile: "Edit this project page", 520 + missingProfile: "We couldn't find a project page for that handle.", 521 521 backToExplore: "Back to Explore", 522 522 categoryLabel: "Category", 523 523 whatsNew: { ··· 528 528 readFullUpdate: "Read full update", 529 529 }, 530 530 notFoundTitle: "404", 531 - notFoundBody: "We couldn't find a profile for that handle.", 531 + notFoundBody: "We couldn't find a project page for that handle.", 532 + share: { 533 + button: "Share", 534 + copyLink: "Copy link", 535 + copied: "Link copied", 536 + copyFailed: "Couldn't copy. Long-press the URL bar to copy it.", 537 + shareTitle: (name: string) => `${name} on Atmosphere Account`, 538 + bannerAlt: (name: string) => `${name} project banner`, 539 + }, 532 540 }, 533 541 create: { 534 542 eyebrow: "Add to Explore", ··· 562 570 "Publish to add this profile to Explore. Nothing is shared until you do.", 563 571 signOut: "Sign out", 564 572 signedInAs: "Signed in as", 565 - viewPublicProfile: "View public profile", 573 + viewPublicProfile: "View project page", 566 574 }, 567 575 }, 568 576 ··· 582 590 avatarHint: "PNG, JPEG, or WebP. 1MB max. Square works best.", 583 591 avatarReplace: "Replace icon", 584 592 avatarRemove: "Remove icon", 593 + bannerLabel: "Project banner", 594 + bannerHint: 595 + "Shown at the top of your project page and used as the share preview when your link is posted (Bluesky, Twitter, etc.). Recommended 1200×630, PNG/JPEG/WebP, 3MB max.", 596 + bannerReplace: "Replace banner", 597 + bannerRemove: "Remove banner", 598 + bannerInvalidType: "Banner must be PNG, JPEG, or WebP.", 599 + bannerTooLarge: "Banner must be 3MB or smaller.", 585 600 requiredHint: "Required", 586 601 avatarTooLarge: "Avatar must be 1MB or smaller.", 587 602 confirmDelete: "Remove your project from Explore?", ··· 667 682 icon: { 668 683 sectionLabel: "Developer icon (SVG, optional)", 669 684 hint: 670 - "Vector marks for developers — sign-in badges, app showcases, programmatic listings. Not shown on your public profile. SVG only, 200KB max per variant. Both variants are optional and can be uploaded independently.", 685 + "Vector marks for developers — sign-in badges, app showcases, programmatic listings. Not shown on your project page. SVG only, 200KB max per variant. Both variants are optional and can be uploaded independently.", 671 686 upload: "Upload SVG", 672 687 replace: "Replace SVG", 673 688 remove: "Remove SVG", ··· 726 741 eyebrow: "What's New", 727 742 title: "Project updates", 728 743 body: 729 - "Post release notes for your public profile. Each update is saved as its own record on your project account.", 744 + "Post release notes for your project page. Each update is saved as its own record on your project account.", 730 745 titleLabel: "Update title", 731 746 titlePlaceholder: "e.g. New beta release", 732 747 versionLabel: "Version (optional)", ··· 812 827 requestedAtLabel: "Requested", 813 828 grantedAtLabel: "Verified", 814 829 emailLabel: "Contact email", 815 - viewProfile: "View profile", 830 + viewProfile: "View project page", 816 831 }, 817 832 reports: { 818 833 headline: "Open reports",
+118
islands/CreateProfileForm.tsx
··· 36 36 links: LinkEntry[]; 37 37 screenshots: Array<{ ref: string; mime: string; size: number }>; 38 38 avatar: { ref: string; mime: string } | null; 39 + /** Optional project banner image. Rendered at the top of the project 40 + * page and used as the OG / share preview when the page is posted. */ 41 + banner: { ref: string; mime: string } | null; 39 42 /** Optional developer-facing SVG icon. */ 40 43 icon: 41 44 | { ··· 332 335 const avatarFile = useSignal<File | null>(null); 333 336 const avatarRemoved = useSignal(false); 334 337 338 + /** 339 + * Project banner. Mirrors the avatar contract — `bannerKeep` carries 340 + * the existing BlobRef so saves without a new file pass it through; 341 + * `bannerFile` holds a freshly-picked File before upload; `bannerPreview` 342 + * is the URL the form renders. Cleared via `removeBanner`. 343 + */ 344 + const bannerKeep = useSignal<BlobRefShape | null>(null); 345 + const bannerPreview = useSignal<string | null>( 346 + initial?.banner 347 + ? `/api/registry/banner/${encodeURIComponent(did)}?v=${ 348 + encodeURIComponent(initial.banner.ref) 349 + }` 350 + : null, 351 + ); 352 + const bannerFile = useSignal<File | null>(null); 353 + const bannerRemoved = useSignal(false); 354 + 335 355 const screenshots = useSignal<ScreenshotDraft[]>( 336 356 (initial?.screenshots ?? []).slice(0, SCREENSHOT_MAX_COUNT).map((s, i) => ({ 337 357 id: `existing-${s.ref}-${i}`, ··· 415 435 }, []); 416 436 417 437 useEffect(() => { 438 + if (!initial?.banner) return; 439 + bannerKeep.value = { 440 + $type: "blob", 441 + ref: { $link: initial.banner.ref }, 442 + mimeType: initial.banner.mime, 443 + size: 0, 444 + }; 445 + }, []); 446 + 447 + useEffect(() => { 418 448 if (!initial?.icon) return; 419 449 iconKeep.value = { 420 450 $type: "blob", ··· 498 528 avatarPreview.value = null; 499 529 }; 500 530 531 + const onBannerChange = (event: Event) => { 532 + const input = event.currentTarget as HTMLInputElement; 533 + const file = input.files?.[0]; 534 + if (!file) return; 535 + if ( 536 + file.type !== "image/png" && 537 + file.type !== "image/jpeg" && 538 + file.type !== "image/webp" 539 + ) { 540 + message.value = { kind: "error", text: tForm.bannerInvalidType }; 541 + input.value = ""; 542 + return; 543 + } 544 + if (file.size > 3_000_000) { 545 + message.value = { kind: "error", text: tForm.bannerTooLarge }; 546 + input.value = ""; 547 + return; 548 + } 549 + bannerFile.value = file; 550 + bannerRemoved.value = false; 551 + bannerPreview.value = URL.createObjectURL(file); 552 + }; 553 + 554 + const removeBanner = () => { 555 + bannerFile.value = null; 556 + bannerKeep.value = null; 557 + bannerRemoved.value = true; 558 + bannerPreview.value = null; 559 + }; 560 + 501 561 const onScreenshotsChange = (event: Event) => { 502 562 const input = event.currentTarget as HTMLInputElement; 503 563 const files = Array.from(input.files ?? []); ··· 742 802 payload.avatar = null; 743 803 } 744 804 805 + if (bannerFile.value) { 806 + payload.bannerUpload = { 807 + dataBase64: await readFileAsBase64(bannerFile.value), 808 + mimeType: bannerFile.value.type, 809 + }; 810 + } else if (!bannerRemoved.value && bannerKeep.value) { 811 + payload.banner = bannerKeep.value; 812 + } else { 813 + payload.banner = null; 814 + } 815 + 745 816 if (iconFile.value) { 746 817 payload.iconUpload = { 747 818 dataBase64: await readFileAsBase64(iconFile.value), ··· 854 925 : tManage.statusInactiveSub} 855 926 </span> 856 927 </span> 928 + </div> 929 + 930 + <div class="profile-form-banner"> 931 + <div 932 + class={`profile-form-banner-preview${ 933 + bannerPreview.value ? "" : " profile-form-banner-preview--empty" 934 + }`} 935 + aria-hidden={bannerPreview.value ? undefined : "true"} 936 + > 937 + {bannerPreview.value 938 + ? ( 939 + <img 940 + src={bannerPreview.value} 941 + alt="" 942 + class="profile-form-banner-img" 943 + onError={() => { 944 + bannerPreview.value = null; 945 + }} 946 + /> 947 + ) 948 + : ( 949 + <span class="profile-form-banner-placeholder"> 950 + {tForm.bannerLabel} 951 + </span> 952 + )} 953 + </div> 954 + <div class="profile-form-banner-controls"> 955 + <label class="profile-form-button-secondary"> 956 + {bannerPreview.value ? tForm.bannerReplace : tForm.bannerLabel} 957 + <input 958 + type="file" 959 + accept="image/png,image/jpeg,image/webp" 960 + hidden 961 + onChange={onBannerChange} 962 + /> 963 + </label> 964 + {bannerPreview.value && ( 965 + <button 966 + type="button" 967 + class="profile-form-button-link" 968 + onClick={removeBanner} 969 + > 970 + {tForm.bannerRemove} 971 + </button> 972 + )} 973 + </div> 974 + <p class="profile-form-hint">{tForm.bannerHint}</p> 857 975 </div> 858 976 859 977 <div class="profile-form-row">
+114
islands/ShareButton.tsx
··· 1 + import { useEffect } from "preact/hooks"; 2 + import { useSignal } from "@preact/signals"; 3 + 4 + export interface ShareButtonCopy { 5 + /** Default action label (e.g. "Share"). */ 6 + button: string; 7 + /** Fallback action label when only copy-to-clipboard is available. */ 8 + copyLink: string; 9 + /** Toast shown after a successful copy. */ 10 + copied: string; 11 + /** Toast shown when clipboard write fails. */ 12 + copyFailed: string; 13 + } 14 + 15 + interface Props { 16 + /** 17 + * URL to share. Made absolute by the caller — passing the full 18 + * `https://...` form means the Web Share API and the clipboard 19 + * fallback both produce the same string. 20 + */ 21 + url: string; 22 + /** Card title (used by the native share sheet). */ 23 + title: string; 24 + /** Card body (used by the native share sheet). */ 25 + text?: string; 26 + copy: ShareButtonCopy; 27 + } 28 + 29 + /** 30 + * Share entry point for project pages. On platforms that expose 31 + * `navigator.share` (mobile Safari, Android Chrome, etc.) we open the 32 + * native sheet; everywhere else we fall back to copying the URL to 33 + * the clipboard with a small confirmation toast. 34 + * 35 + * Renders a lightweight skeleton on the server so layout doesn't 36 + * shift; capability detection (`canShare`) only runs after hydration. 37 + */ 38 + export default function ShareButton({ url, title, text, copy }: Props) { 39 + const canShare = useSignal(false); 40 + const toast = useSignal<{ kind: "ok" | "error"; text: string } | null>(null); 41 + const busy = useSignal(false); 42 + 43 + useEffect(() => { 44 + canShare.value = typeof navigator !== "undefined" && 45 + typeof (navigator as Navigator & { share?: unknown }).share === 46 + "function"; 47 + }, []); 48 + 49 + useEffect(() => { 50 + if (!toast.value) return; 51 + const id = setTimeout(() => { 52 + toast.value = null; 53 + }, 2400); 54 + return () => clearTimeout(id); 55 + }, [toast.value]); 56 + 57 + const onClick = async () => { 58 + if (busy.value) return; 59 + busy.value = true; 60 + try { 61 + const nav = navigator as Navigator & { 62 + share?: (data: ShareData) => Promise<void>; 63 + }; 64 + if (canShare.value && typeof nav.share === "function") { 65 + try { 66 + await nav.share({ title, text, url }); 67 + } catch (_) { 68 + /** AbortError on dismiss is normal; nothing to do. */ 69 + } 70 + return; 71 + } 72 + try { 73 + await navigator.clipboard.writeText(url); 74 + toast.value = { kind: "ok", text: copy.copied }; 75 + } catch (_) { 76 + toast.value = { kind: "error", text: copy.copyFailed }; 77 + } 78 + } finally { 79 + busy.value = false; 80 + } 81 + }; 82 + 83 + return ( 84 + <span class="share-button-wrap"> 85 + <button 86 + type="button" 87 + class="share-button" 88 + onClick={onClick} 89 + aria-live="polite" 90 + > 91 + <span class="share-button-icon" aria-hidden="true"> 92 + <svg viewBox="0 0 24 24" width="16" height="16" fill="none"> 93 + <path 94 + d="M12 3v12m0-12-4 4m4-4 4 4M5 13v5a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-5" 95 + stroke="currentColor" 96 + stroke-width="1.6" 97 + stroke-linecap="round" 98 + stroke-linejoin="round" 99 + /> 100 + </svg> 101 + </span> 102 + <span>{canShare.value ? copy.button : copy.copyLink}</span> 103 + </button> 104 + {toast.value && ( 105 + <span 106 + class={`share-button-toast share-button-toast--${toast.value.kind}`} 107 + role="status" 108 + > 109 + {toast.value.text} 110 + </span> 111 + )} 112 + </span> 113 + ); 114 + }
+6
lexicons/com/atmosphereaccount/registry/profile.json
··· 53 53 "maxSize": 1000000, 54 54 "description": "Project icon. Recommended 512x512 square." 55 55 }, 56 + "banner": { 57 + "type": "blob", 58 + "accept": ["image/png", "image/jpeg", "image/webp"], 59 + "maxSize": 3000000, 60 + "description": "Optional project banner image. Rendered at the top of the project page and used as the OpenGraph / Twitter card preview when the page is shared. Recommended 1200x630 (1.91:1 OG ratio)." 61 + }, 56 62 "icon": { 57 63 "type": "blob", 58 64 "accept": ["image/svg+xml"],
+10
lib/avatar.ts
··· 6 6 export function bskyCdnAvatarUrl(did: string, cid: string): string { 7 7 return `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}`; 8 8 } 9 + 10 + /** 11 + * Same proxy, banner variant. Bluesky exposes a separate path with 12 + * banner-friendly resizing (16:9-ish, larger). Rendered at the top of 13 + * project pages and used as the OpenGraph / Twitter card image when 14 + * the page URL is shared. 15 + */ 16 + export function bskyCdnBannerUrl(did: string, cid: string): string { 17 + return `https://cdn.bsky.app/img/banner/plain/${did}/${cid}`; 18 + }
+12
lib/db.ts
··· 102 102 screenshots TEXT NOT NULL DEFAULT '[]', 103 103 avatar_cid TEXT, 104 104 avatar_mime TEXT, 105 + banner_cid TEXT, 106 + banner_mime TEXT, 105 107 icon_cid TEXT, 106 108 icon_mime TEXT, 107 109 icon_status TEXT, ··· 377 379 column: "screenshots", 378 380 ddl: 379 381 "ALTER TABLE profile ADD COLUMN screenshots TEXT NOT NULL DEFAULT '[]'", 382 + }, 383 + { 384 + table: "profile", 385 + column: "banner_cid", 386 + ddl: "ALTER TABLE profile ADD COLUMN banner_cid TEXT", 387 + }, 388 + { 389 + table: "profile", 390 + column: "banner_mime", 391 + ddl: "ALTER TABLE profile ADD COLUMN banner_mime TEXT", 380 392 }, 381 393 { 382 394 table: "profile",
+21
lib/lexicons.ts
··· 141 141 androidLink?: string; 142 142 avatar?: BlobRef; 143 143 /** 144 + * Optional banner image rendered at the top of the project page. 145 + * Doubles as the OpenGraph / Twitter card preview when the page is 146 + * shared. Recommended 1200x630 (the standard 1.91:1 OG ratio). 147 + */ 148 + banner?: BlobRef; 149 + /** 144 150 * Optional vector icon (SVG) intended for developer use — sign-in 145 151 * badges, app showcases, programmatic listings. Not displayed on the 146 152 * public profile. Must be `image/svg+xml`; we sanitise on upload. ··· 474 480 if (v.avatar !== undefined && !isBlob(v.avatar)) { 475 481 return { ok: false, error: "avatar: invalid blob ref" }; 476 482 } 483 + if (v.banner !== undefined) { 484 + if (!isBlob(v.banner)) { 485 + return { ok: false, error: "banner: invalid blob ref" }; 486 + } 487 + const mime = (v.banner as BlobRef).mimeType; 488 + if ( 489 + mime !== "image/png" && mime !== "image/jpeg" && mime !== "image/webp" 490 + ) { 491 + return { ok: false, error: "banner: must be png, jpeg, or webp" }; 492 + } 493 + if ((v.banner as BlobRef).size > 3_000_000) { 494 + return { ok: false, error: "banner: max 3MB" }; 495 + } 496 + } 477 497 if (v.icon !== undefined) { 478 498 if (!isBlob(v.icon)) { 479 499 return { ok: false, error: "icon: invalid blob ref" }; ··· 519 539 iosLink: normalizedIosLink, 520 540 androidLink: normalizedAndroidLink, 521 541 avatar: v.avatar as BlobRef | undefined, 542 + banner: v.banner as BlobRef | undefined, 522 543 icon: v.icon as BlobRef | undefined, 523 544 iconBw: v.iconBw as BlobRef | undefined, 524 545 screenshots: screenshotsRes.value.length > 0
+2
lib/profile-sync.ts
··· 46 46 screenshots: r.screenshots ?? [], 47 47 avatarCid: r.avatar?.ref.$link ?? null, 48 48 avatarMime: r.avatar?.mimeType ?? null, 49 + bannerCid: r.banner?.ref.$link ?? null, 50 + bannerMime: r.banner?.mimeType ?? null, 49 51 iconCid: r.icon?.ref.$link ?? null, 50 52 iconMime: r.icon?.mimeType ?? null, 51 53 iconBwCid: r.iconBw?.ref.$link ?? null,
+18 -2
lib/registry.ts
··· 71 71 screenshots: ScreenshotEntry[]; 72 72 avatarCid: string | null; 73 73 avatarMime: string | null; 74 + /** Optional banner image rendered at the top of the project page and 75 + * used as the OG/Twitter card preview when the page is shared. 76 + * Recommended 1200x630 (1.91:1 OG ratio). */ 77 + bannerCid: string | null; 78 + bannerMime: string | null; 74 79 /** Optional developer-facing SVG icon. Not rendered on public profile. 75 80 * Approval state lives in `iconStatus`. */ 76 81 iconCid: string | null; ··· 129 134 screenshots: string | null; 130 135 avatar_cid: string | null; 131 136 avatar_mime: string | null; 137 + banner_cid: string | null; 138 + banner_mime: string | null; 132 139 icon_cid: string | null; 133 140 icon_mime: string | null; 134 141 icon_status: string | null; ··· 257 264 screenshots: safeJsonScreenshots(r.screenshots), 258 265 avatarCid: r.avatar_cid, 259 266 avatarMime: r.avatar_mime, 267 + bannerCid: r.banner_cid, 268 + bannerMime: r.banner_mime, 260 269 iconCid: r.icon_cid, 261 270 iconMime: r.icon_mime, 262 271 iconStatus: normalizeIconStatus(r.icon_status), ··· 321 330 screenshots?: ScreenshotEntry[] | null; 322 331 avatarCid?: string | null; 323 332 avatarMime?: string | null; 333 + bannerCid?: string | null; 334 + bannerMime?: string | null; 324 335 iconCid?: string | null; 325 336 iconMime?: string | null; 326 337 iconBwCid?: string | null; ··· 366 377 INSERT INTO profile ( 367 378 did, handle, profile_type, name, description, main_link, ios_link, android_link, 368 379 categories, subcategories, links, screenshots, 369 - avatar_cid, avatar_mime, icon_cid, icon_mime, icon_status, 380 + avatar_cid, avatar_mime, banner_cid, banner_mime, 381 + icon_cid, icon_mime, icon_status, 370 382 icon_reviewed_by, icon_reviewed_at, icon_rejected_reason, 371 383 icon_bw_cid, icon_bw_mime, icon_bw_status, 372 384 icon_bw_reviewed_by, icon_bw_reviewed_at, icon_bw_rejected_reason, ··· 376 388 takedown_status, takedown_reason, takedown_by, takedown_at, 377 389 pds_url, record_cid, record_rev, created_at, indexed_at 378 390 ) VALUES ( 379 - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 391 + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 380 392 NULL, NULL, NULL, 381 393 ?, ?, ?, 382 394 NULL, NULL, NULL, ··· 398 410 screenshots=excluded.screenshots, 399 411 avatar_cid=excluded.avatar_cid, 400 412 avatar_mime=excluded.avatar_mime, 413 + banner_cid=excluded.banner_cid, 414 + banner_mime=excluded.banner_mime, 401 415 icon_cid=excluded.icon_cid, 402 416 icon_mime=excluded.icon_mime, 403 417 /** ··· 505 519 JSON.stringify(input.screenshots ?? []), 506 520 input.avatarCid ?? null, 507 521 input.avatarMime ?? null, 522 + input.bannerCid ?? null, 523 + input.bannerMime ?? null, 508 524 input.iconCid ?? null, 509 525 input.iconMime ?? null, 510 526 initialIconStatus,
+31 -12
routes/_app.tsx
··· 259 259 : url.pathname.startsWith("/explore") 260 260 ? "/og-explore.png" 261 261 : "/og-hero.png"; 262 - const socialImageAlt = url.pathname.startsWith("/developer-resources") 262 + const defaultSocialImageAlt = url.pathname.startsWith("/developer-resources") 263 263 ? "Atmosphere developer resources: tools to make the Atmosphere easier to understand." 264 264 : url.pathname.startsWith("/explore") 265 265 ? "Explore the Atmosphere registry: apps, profiles, reviews, and updates." 266 266 : t.meta.ogImageAlt; 267 + /** 268 + * Per-page OG overrides set by route handlers via `ctx.state.pageMeta`. 269 + * Used by project pages so the project's banner becomes the share-card 270 + * image (instead of the generic `/og-explore.png`) and the title / 271 + * description match the project. We still fall back to the site-wide 272 + * defaults for any field a page doesn't override. 273 + */ 274 + const pageMeta = state.pageMeta ?? {}; 275 + const pageTitle = pageMeta.title ?? t.meta.title; 276 + const pageDescription = pageMeta.description ?? t.meta.description; 277 + const pageOgType = pageMeta.ogType ?? "website"; 278 + const pageOgImage = pageMeta.imageUrl 279 + ? socialImageUrl(pageMeta.imageUrl) 280 + : socialImageUrl(socialImagePath); 281 + const pageOgImageAlt = pageMeta.imageAlt ?? defaultSocialImageAlt; 282 + const pageOgImageWidth = pageMeta.imageWidth ?? 1200; 283 + const pageOgImageHeight = pageMeta.imageHeight ?? 630; 267 284 return ( 268 285 <html lang={locale} class={htmlClass}> 269 286 <head> 270 287 <meta charset="utf-8" /> 271 288 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 272 - <title>{t.meta.title}</title> 273 - <meta name="description" content={t.meta.description} /> 274 - <meta property="og:title" content={t.meta.ogTitle} /> 275 - <meta property="og:description" content={t.meta.ogDescription} /> 289 + <title>{pageTitle}</title> 290 + <meta name="description" content={pageDescription} /> 291 + <meta property="og:title" content={pageMeta.title ?? t.meta.ogTitle} /> 292 + <meta 293 + property="og:description" 294 + content={pageMeta.description ?? t.meta.ogDescription} 295 + /> 276 296 <meta property="og:locale" content={locale} /> 277 - <meta property="og:type" content="website" /> 278 - <meta property="og:image" content={socialImageUrl(socialImagePath)} /> 279 - <meta property="og:image:type" content="image/png" /> 280 - <meta property="og:image:width" content="1200" /> 281 - <meta property="og:image:height" content="630" /> 282 - <meta property="og:image:alt" content={socialImageAlt} /> 297 + <meta property="og:type" content={pageOgType} /> 298 + <meta property="og:image" content={pageOgImage} /> 299 + <meta property="og:image:width" content={String(pageOgImageWidth)} /> 300 + <meta property="og:image:height" content={String(pageOgImageHeight)} /> 301 + <meta property="og:image:alt" content={pageOgImageAlt} /> 283 302 <meta name="twitter:card" content="summary_large_image" /> 284 - <meta name="twitter:image" content={socialImageUrl(socialImagePath)} /> 303 + <meta name="twitter:image" content={pageOgImage} /> 285 304 <link rel="icon" href="/favicon.ico" sizes="any" /> 286 305 <link rel="icon" type="image/svg+xml" href="/union.svg" /> 287 306 <link rel="apple-touch-icon" href="/union.svg" />
+30
routes/api/registry/banner/[did].ts
··· 1 + /** 2 + * Compatibility endpoint for registry profile banners. Mirrors the 3 + * avatar route — primary UI paths can hit Bluesky's CDN directly via 4 + * `bskyCdnBannerUrl`; this route exists so external link unfurlers, 5 + * old embeds, or any consumer that just knows the DID can resolve the 6 + * banner without first looking up the cid. 7 + */ 8 + import { define } from "../../../../utils.ts"; 9 + import { bskyCdnBannerUrl } from "../../../../lib/avatar.ts"; 10 + import { getProfileByDid } from "../../../../lib/registry.ts"; 11 + import { withRateLimit } from "../../../../lib/rate-limit.ts"; 12 + 13 + export const handler = define.handlers({ 14 + GET: withRateLimit(async (ctx) => { 15 + const did = decodeURIComponent(ctx.params.did); 16 + const profile = await getProfileByDid(did).catch(() => null); 17 + const bannerCid = profile?.bannerCid; 18 + if (!bannerCid) { 19 + return new Response("not found", { status: 404 }); 20 + } 21 + return new Response(null, { 22 + status: 302, 23 + headers: { 24 + location: bskyCdnBannerUrl(did, bannerCid), 25 + "cache-control": 26 + "public, max-age=300, s-maxage=3600, stale-while-revalidate=3600", 27 + }, 28 + }); 29 + }), 30 + });
+48
routes/api/registry/profile.ts
··· 32 32 import { getEffectiveAccountType } from "../../../lib/account-types.ts"; 33 33 34 34 const ICON_MAX_BYTES = 200_000; 35 + const BANNER_MAX_BYTES = 3_000_000; 35 36 const SCREENSHOT_MAX_BYTES = 5_000_000; 36 37 const SCREENSHOT_MAX_COUNT = 4; 37 38 const SCREENSHOT_MIME_TYPES = new Set([ ··· 39 40 "image/jpeg", 40 41 "image/webp", 41 42 ]); 43 + const BANNER_MIME_TYPES = SCREENSHOT_MIME_TYPES; 42 44 43 45 interface LinkPayload { 44 46 kind?: string; ··· 68 70 size: number; 69 71 } | null; 70 72 avatarUpload?: { dataBase64: string; mimeType: string }; 73 + /** 74 + * Project banner image. Same shape as `avatar`: pass the existing 75 + * BlobRef back to keep, `null` to clear, or `bannerUpload` to replace. 76 + * Rendered at the top of the project page and used as the OG/Twitter 77 + * card preview when the URL is shared. Recommended 1200x630. 78 + */ 79 + banner?: { 80 + $type: "blob"; 81 + ref: { $link: string }; 82 + mimeType: string; 83 + size: number; 84 + } | null; 85 + bannerUpload?: { dataBase64: string; mimeType: string }; 71 86 /** 72 87 * Developer-facing SVG icon. Same shape as `avatar`: pass the 73 88 * existing BlobRef to keep, `null` to clear, or `iconUpload` to ··· 232 247 } 233 248 234 249 /** 250 + * Banner upload mirrors the avatar contract — pass the existing 251 + * BlobRef back to keep it, `null` to clear, or `bannerUpload` to 252 + * replace. The PDS blob doubles as the OG/Twitter card image 253 + * referenced from the project page meta tags. 254 + */ 255 + let banner = body.banner ?? undefined; 256 + if (body.bannerUpload?.dataBase64) { 257 + if (!BANNER_MIME_TYPES.has(body.bannerUpload.mimeType)) { 258 + return new Response("banner must be png, jpeg, or webp", { 259 + status: 400, 260 + }); 261 + } 262 + const bytes = decodeBase64(body.bannerUpload.dataBase64); 263 + if (bytes.byteLength > BANNER_MAX_BYTES) { 264 + return new Response("banner exceeds 3MB", { status: 400 }); 265 + } 266 + try { 267 + banner = await uploadBlob( 268 + user.did, 269 + session.pdsUrl, 270 + bytes, 271 + body.bannerUpload.mimeType, 272 + ); 273 + } catch (err) { 274 + const m = err instanceof Error ? err.message : String(err); 275 + return new Response(`banner upload failed: ${m}`, { status: 502 }); 276 + } 277 + } 278 + 279 + /** 235 280 * Developer-facing SVG icons (color + optional B/W companion). 236 281 * Two gates apply identically to both variants: 237 282 * ··· 408 453 subcategories: asArray(body.subcategories), 409 454 links: links.length > 0 ? links : undefined, 410 455 avatar: avatar ?? undefined, 456 + banner: banner ?? undefined, 411 457 icon: icon ?? undefined, 412 458 iconBw: iconBw ?? undefined, 413 459 screenshots: screenshots.length > 0 ? screenshots : undefined, ··· 455 501 screenshots: validation.value.screenshots ?? [], 456 502 avatarCid: validation.value.avatar?.ref.$link ?? null, 457 503 avatarMime: validation.value.avatar?.mimeType ?? null, 504 + bannerCid: validation.value.banner?.ref.$link ?? null, 505 + bannerMime: validation.value.banner?.mimeType ?? null, 458 506 iconCid: validation.value.icon?.ref.$link ?? null, 459 507 iconMime: validation.value.icon?.mimeType ?? null, 460 508 iconBwCid: validation.value.iconBw?.ref.$link ?? null,
+73 -4
routes/explore/[handle].tsx
··· 10 10 } from "../../components/explore/ProfileReviewList.tsx"; 11 11 import ProfileReviewComposer from "../../islands/ProfileReviewComposer.tsx"; 12 12 import ReportProfileButton from "../../islands/ReportProfileButton.tsx"; 13 + import ShareButton from "../../islands/ShareButton.tsx"; 13 14 import { getMessages } from "../../i18n/mod.ts"; 14 15 import type { Locale } from "../../i18n/mod.ts"; 15 16 import { ··· 27 28 import { accountProviderName } from "../../lib/account-providers.ts"; 28 29 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 29 30 import { getAppUser } from "../../lib/account-types.ts"; 30 - import { bskyCdnAvatarUrl } from "../../lib/avatar.ts"; 31 + import { bskyCdnAvatarUrl, bskyCdnBannerUrl } from "../../lib/avatar.ts"; 31 32 import { 32 33 listProfileUpdates, 33 34 type ProfileUpdateRow, ··· 71 72 [] as ProfileUpdateRow[], 72 73 ]; 73 74 const displayReviews = profile ? await enrichReviews(reviews) : []; 75 + /** 76 + * Per-page social meta. When the project has a banner, the 77 + * Bluesky CDN URL is used as the OG/Twitter image so the project 78 + * banner becomes the link card preview anywhere the URL is 79 + * shared. Project name + description fill the title and 80 + * description so the card carries enough context on its own. 81 + */ 82 + if (profile) { 83 + const messages = getMessages(ctx.state.locale).explore; 84 + const pageTitle = `${profile.name} on Atmosphere Account`; 85 + const pageDescription = profile.description || 86 + messages.detail.missingProfile; 87 + const ogImageUrl = profile.bannerCid 88 + ? bskyCdnBannerUrl(profile.did, profile.bannerCid) 89 + : undefined; 90 + ctx.state.pageMeta = { 91 + title: pageTitle, 92 + description: pageDescription, 93 + ogType: "profile", 94 + imageUrl: ogImageUrl, 95 + imageAlt: profile.bannerCid 96 + ? messages.detail.share.bannerAlt(profile.name) 97 + : undefined, 98 + }; 99 + } 100 + /** 101 + * Build the absolute canonical URL once on the server. The Web 102 + * Share API + clipboard fallback both want a fully-qualified URL, 103 + * and computing it from the request keeps it correct across 104 + * preview deployments / custom domains. 105 + */ 106 + const shareUrl = profile 107 + ? new URL( 108 + `/explore/${encodeURIComponent(profile.handle)}`, 109 + ctx.url.origin, 110 + ).toString() 111 + : ctx.url.toString(); 74 112 return ctx.render( 75 113 <ProfileDetailPage 76 114 profile={profile} ··· 82 120 account={buildAccountMenuProps(ctx.state, ownerProfile?.handle ?? null)} 83 121 ownerHandle={ownerProfile?.handle ?? null} 84 122 locale={ctx.state.locale} 123 + shareUrl={shareUrl} 85 124 />, 86 125 { status: profile ? 200 : 404 }, 87 126 ); ··· 98 137 account: ReturnType<typeof buildAccountMenuProps>; 99 138 ownerHandle: string | null; 100 139 locale: Locale; 140 + /** Absolute URL of this project page; passed to the Share button so 141 + * copy-to-clipboard / Web Share API both get the canonical link. */ 142 + shareUrl: string; 101 143 } 102 144 103 145 function ProfileDetailPage( ··· 111 153 account, 112 154 ownerHandle: _ownerHandle, 113 155 locale, 156 + shareUrl, 114 157 }: DetailProps, 115 158 ) { 116 159 const messages = getMessages(locale); ··· 130 173 * which isn't useful in UI. Collapse known umbrella PDSes to their 131 174 * brand name (Bluesky, etc.) and fall back to the bare host. */ 132 175 const providerName = accountProviderName(profile.pdsUrl); 176 + const bannerUrl = profile.bannerCid 177 + ? bskyCdnBannerUrl(profile.did, profile.bannerCid) 178 + : null; 179 + const shareCopy = t.detail.share; 133 180 return ( 134 181 <div id="page-top"> 135 182 <div class="content-layer"> 136 183 <Nav account={account} /> 137 184 <section class="explore-profile-detail"> 138 185 <div class="container" style={{ maxWidth: "880px" }}> 139 - <p> 186 + <div class="project-page-toolbar"> 140 187 <a href="/explore" class="text-link-button"> 141 188 ← {t.detail.backToExplore} 142 189 </a> 143 - </p> 144 - <div style={{ marginTop: "1rem" }}> 190 + <ShareButton 191 + url={shareUrl} 192 + title={shareCopy.shareTitle(profile.name)} 193 + text={profile.description} 194 + copy={{ 195 + button: shareCopy.button, 196 + copyLink: shareCopy.copyLink, 197 + copied: shareCopy.copied, 198 + copyFailed: shareCopy.copyFailed, 199 + }} 200 + /> 201 + </div> 202 + {bannerUrl && ( 203 + <div class="project-page-banner" aria-hidden={false}> 204 + <img 205 + src={bannerUrl} 206 + alt={shareCopy.bannerAlt(profile.name)} 207 + class="project-page-banner-img" 208 + loading="lazy" 209 + decoding="async" 210 + /> 211 + </div> 212 + )} 213 + <div style={{ marginTop: bannerUrl ? "0" : "1rem" }}> 145 214 <ProfileHero profile={profile} /> 146 215 </div> 147 216 <ProfileScreenshots profile={profile} />
+4
routes/explore/manage.tsx
··· 68 68 avatar: existing.avatarCid && existing.avatarMime 69 69 ? { ref: existing.avatarCid, mime: existing.avatarMime } 70 70 : null, 71 + banner: existing.bannerCid && existing.bannerMime 72 + ? { ref: existing.bannerCid, mime: existing.bannerMime } 73 + : null, 71 74 icon: existing.iconCid && existing.iconMime 72 75 ? { 73 76 ref: existing.iconCid, ··· 107 110 mime: bsky.avatar.mimeType, 108 111 } 109 112 : null, 113 + banner: null, 110 114 icon: null, 111 115 iconBw: null, 112 116 iconAccessStatus: null,
+24
utils.ts
··· 8 8 handle: string; 9 9 } 10 10 11 + /** 12 + * Per-page social/Open Graph overrides. Routes can set `state.pageMeta` 13 + * inside their handler to make `_app.tsx` emit page-specific OG tags 14 + * (page title, description, share image). Used by project pages so the 15 + * project's banner becomes the link-card preview when the URL is shared. 16 + */ 17 + export interface PageMeta { 18 + /** Replaces the document <title>. */ 19 + title?: string; 20 + /** Replaces meta[name=description] and og:description. */ 21 + description?: string; 22 + /** Absolute (or root-relative) URL of the share image. */ 23 + imageUrl?: string; 24 + /** Alt text for the share image. */ 25 + imageAlt?: string; 26 + /** OG image dimensions, when known. Defaults match the site-wide OG image. */ 27 + imageWidth?: number; 28 + imageHeight?: number; 29 + /** Override og:type (defaults to "website"; project pages use "profile"). */ 30 + ogType?: string; 31 + } 32 + 11 33 export interface State { 12 34 /** Active locale for this request. Set by the locale middleware. */ 13 35 locale: Locale; ··· 19 41 * most-recently-used order. Populated by sessionMiddleware so 20 42 * routes can hand the list to AccountMenu for the switcher. */ 21 43 rememberedAccounts: RememberedAccount[]; 44 + /** Optional per-page social/OG meta overrides; see {@link PageMeta}. */ 45 + pageMeta?: PageMeta; 22 46 // deno-lint-ignore no-explicit-any 23 47 [key: string]: any; 24 48 }