this repo has no description
10
fork

Configure Feed

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

Add optional B/W project SVG icon alongside colour icon

Projects can upload a second black-and-white SVG (same gate, sanitizer,
and size limits as the colour mark). Persist iconBw in the lexicon,
registry DB, profile sync, and public JSON (iconBwUrl). Serve it at
/api/registry/icon-bw/:did; include both variants in the dev ZIP and
list API. Manage form shows parallel upload slots; developer resources
grid has a Colour/B/W toggle for preview and per-variant downloads.

Fix TypeScript narrowing in processIconVariant by capturing did/pdsUrl
after auth checks.

Made-with: Cursor

+750 -119
+109
assets/styles.css
··· 3592 3592 color: rgba(255, 255, 255, 0.5); 3593 3593 } 3594 3594 3595 + /* Two parallel slots — colour and B/W — share the same access gate 3596 + and sanitiser, so we render them side-by-side. They wrap to a single 3597 + column on narrow viewports so the upload buttons stay reachable. */ 3598 + .profile-form-icon-grid { 3599 + display: grid; 3600 + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); 3601 + gap: 1rem; 3602 + margin-top: 0.6rem; 3603 + } 3604 + .profile-form-icon-slot { 3605 + display: flex; 3606 + flex-direction: column; 3607 + gap: 0.4rem; 3608 + padding: 0.85rem; 3609 + border-radius: 14px; 3610 + background: rgba(255, 255, 255, 0.35); 3611 + border: 1px solid rgba(18, 26, 47, 0.06); 3612 + } 3613 + .profile-form-icon-slot-heading { 3614 + display: flex; 3615 + flex-direction: column; 3616 + gap: 0.15rem; 3617 + } 3618 + .profile-form-icon-slot-hint { 3619 + margin: 0; 3620 + } 3621 + /* Render B/W previews with a neutral checker so a black-on-transparent 3622 + mark is still visible without us having to inject CSS into the SVG. */ 3623 + .profile-form-icon-preview--bw { 3624 + background: 3625 + linear-gradient(45deg, rgba(0, 0, 0, 0.04) 25%, transparent 25%), 3626 + linear-gradient(-45deg, rgba(0, 0, 0, 0.04) 25%, transparent 25%), 3627 + linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.04) 75%), 3628 + linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.04) 75%); 3629 + background-size: 12px 12px; 3630 + background-position: 0 0, 0 6px, 6px -6px, -6px 0; 3631 + } 3632 + .dark-phase .profile-form-icon-slot { 3633 + background: rgba(255, 255, 255, 0.04); 3634 + border-color: rgba(255, 255, 255, 0.08); 3635 + } 3636 + .dark-phase .profile-form-icon-preview--bw { 3637 + background-color: rgba(255, 255, 255, 0.06); 3638 + background-image: 3639 + linear-gradient(45deg, rgba(255, 255, 255, 0.06) 25%, transparent 25%), 3640 + linear-gradient(-45deg, rgba(255, 255, 255, 0.06) 25%, transparent 25%), 3641 + linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.06) 75%), 3642 + linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.06) 75%); 3643 + } 3644 + 3595 3645 /* ------------------------------------------------------------------ * 3596 3646 * Modal popup (used by BskyClientPickerModal) 3597 3647 * ------------------------------------------------------------------ */ ··· 4882 4932 min-height: 2.4rem; 4883 4933 } 4884 4934 4935 + /* Colour / B/W toggle. Sits between the search input and the ZIP 4936 + button so the toolbar reads search → variant → bulk-download. */ 4937 + .svg-download-tabs { 4938 + display: inline-flex; 4939 + align-items: stretch; 4940 + padding: 3px; 4941 + border-radius: 999px; 4942 + background: rgba(14, 20, 40, 0.06); 4943 + border: 1px solid rgba(14, 20, 40, 0.08); 4944 + } 4945 + .svg-download-tab { 4946 + appearance: none; 4947 + border: 0; 4948 + background: transparent; 4949 + color: rgba(14, 20, 40, 0.7); 4950 + font-family: "IBM Plex Mono", monospace; 4951 + font-size: 0.78rem; 4952 + letter-spacing: 0.03em; 4953 + padding: 0.35rem 0.85rem; 4954 + border-radius: 999px; 4955 + cursor: pointer; 4956 + transition: background 120ms ease, color 120ms ease; 4957 + } 4958 + .svg-download-tab:hover { 4959 + color: #0e1428; 4960 + } 4961 + .svg-download-tab.is-active { 4962 + background: rgba(255, 255, 255, 0.95); 4963 + color: #0e1428; 4964 + box-shadow: 0 1px 2px rgba(14, 20, 40, 0.08); 4965 + } 4966 + .dark-phase .svg-download-tabs { 4967 + background: rgba(255, 255, 255, 0.06); 4968 + border-color: rgba(255, 255, 255, 0.1); 4969 + } 4970 + .dark-phase .svg-download-tab { 4971 + color: rgba(255, 255, 255, 0.7); 4972 + } 4973 + .dark-phase .svg-download-tab:hover { 4974 + color: #fff; 4975 + } 4976 + .dark-phase .svg-download-tab.is-active { 4977 + background: rgba(255, 255, 255, 0.16); 4978 + color: #fff; 4979 + box-shadow: none; 4980 + } 4981 + 4885 4982 .svg-download-meta { 4886 4983 margin-top: 0.9rem; 4887 4984 font-size: 0.82rem; ··· 4927 5024 max-width: 72px; 4928 5025 max-height: 72px; 4929 5026 object-fit: contain; 5027 + } 5028 + 5029 + /* When showing the B/W variant we paint the preview tile white so a 5030 + pure-black mark remains legible regardless of the page theme. The 5031 + downloaded SVG itself is unmodified — this is preview-only chrome. */ 5032 + .svg-download-preview--bw { 5033 + background: #ffffff; 5034 + border: 1px solid rgba(14, 20, 40, 0.08); 5035 + } 5036 + .dark-phase .svg-download-preview--bw { 5037 + background: #ffffff; 5038 + border-color: rgba(255, 255, 255, 0.18); 4930 5039 } 4931 5040 4932 5041 .svg-download-details {
+14 -1
i18n/messages/en.tsx
··· 301 301 searchPlaceholder: "Search by project or handle", 302 302 downloadZip: "Download all SVGs (ZIP)", 303 303 downloadSvg: "Download SVG", 304 + downloadSvgBw: "Download B/W SVG", 304 305 loading: "Loading current project icons...", 305 306 count: "{count} SVG icons available", 306 307 empty: "No verified project SVG icons are available yet.", 308 + emptyBw: "No verified projects have published a B/W icon yet.", 307 309 noResults: "No icons match that search.", 308 310 error: "Could not load icons: {error}", 309 311 iconAlt: "{name} SVG icon", 312 + variantToggleLabel: "Icon variant", 313 + variantColor: "Colour", 314 + variantBw: "B/W", 310 315 }, 311 316 schemaHeading: "Profile schema", 312 317 schemaBody: ··· 661 666 icon: { 662 667 sectionLabel: "Developer icon (SVG, optional)", 663 668 hint: 664 - "A vector mark for developers — sign-in badges, app showcases, programmatic listings. Not shown on your public profile. SVG only, 200KB max.", 669 + "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.", 665 670 upload: "Upload SVG", 666 671 replace: "Replace SVG", 667 672 remove: "Remove SVG", 673 + colorLabel: "Colour", 674 + colorHint: "Your primary mark, used by default in badges and listings.", 675 + bwLabel: "Black & white", 676 + bwHint: 677 + "Optional monochrome companion for light/dark badges, sign-in chrome, and print.", 678 + bwUpload: "Upload B/W SVG", 679 + bwReplace: "Replace B/W SVG", 680 + bwRemove: "Remove B/W SVG", 668 681 invalidType: "Icon must be an SVG (image/svg+xml).", 669 682 tooLarge: "Icon must be 200KB or smaller.", 670 683 gate: {
+174 -46
islands/CreateProfileForm.tsx
··· 43 43 mime: string; 44 44 } 45 45 | null; 46 + /** Optional black-and-white companion to `icon`. Same access gate. */ 47 + iconBw: 48 + | { 49 + ref: string; 50 + mime: string; 51 + } 52 + | null; 46 53 /** 47 54 * Per-project verification gate for the SVG icon uploader. Drives the 48 55 * locked / pending / denied / granted UX in the icon section. ··· 337 344 const iconFile = useSignal<File | null>(null); 338 345 const iconRemoved = useSignal(false); 339 346 347 + const iconBwKeep = useSignal<BlobRefShape | null>(null); 348 + const iconBwPreviewUrl = useSignal<string | null>( 349 + initial?.iconBw ? `/api/registry/icon-bw/${encodeURIComponent(did)}` : null, 350 + ); 351 + const iconBwFile = useSignal<File | null>(null); 352 + const iconBwRemoved = useSignal(false); 353 + 340 354 /** 341 355 * Live access status. Starts from the value the server rendered, then 342 356 * flips to `requested` when the user submits the request modal so the ··· 384 398 $type: "blob", 385 399 ref: { $link: initial.icon.ref }, 386 400 mimeType: initial.icon.mime, 401 + size: 0, 402 + }; 403 + }, []); 404 + 405 + useEffect(() => { 406 + if (!initial?.iconBw) return; 407 + iconBwKeep.value = { 408 + $type: "blob", 409 + ref: { $link: initial.iconBw.ref }, 410 + mimeType: initial.iconBw.mime, 387 411 size: 0, 388 412 }; 389 413 }, []); ··· 534 558 iconPreviewUrl.value = null; 535 559 }; 536 560 561 + const onIconBwChange = (event: Event) => { 562 + const input = event.currentTarget as HTMLInputElement; 563 + const file = input.files?.[0]; 564 + if (!file) return; 565 + if (file.type !== "image/svg+xml") { 566 + message.value = { kind: "error", text: tIcon.invalidType }; 567 + input.value = ""; 568 + return; 569 + } 570 + if (file.size > 200_000) { 571 + message.value = { kind: "error", text: tIcon.tooLarge }; 572 + input.value = ""; 573 + return; 574 + } 575 + iconBwFile.value = file; 576 + iconBwRemoved.value = false; 577 + iconBwPreviewUrl.value = URL.createObjectURL(file); 578 + }; 579 + 580 + const removeIconBw = () => { 581 + iconBwFile.value = null; 582 + iconBwKeep.value = null; 583 + iconBwRemoved.value = true; 584 + iconBwPreviewUrl.value = null; 585 + }; 586 + 537 587 /** 538 588 * Submit the verification request to the server. We optimistically 539 589 * update `iconAccessStatus` to `requested` so the gate UI flips ··· 681 731 payload.icon = null; 682 732 } 683 733 734 + if (iconBwFile.value) { 735 + payload.iconBwUpload = { 736 + dataBase64: await readFileAsBase64(iconBwFile.value), 737 + mimeType: iconBwFile.value.type, 738 + }; 739 + } else if (!iconBwRemoved.value && iconBwKeep.value) { 740 + payload.iconBw = iconBwKeep.value; 741 + } else { 742 + payload.iconBw = null; 743 + } 744 + 684 745 payload.screenshots = screenshots.value 685 746 .filter((s) => s.blob) 686 747 .map((s) => ({ image: s.blob })); ··· 1168 1229 </p> 1169 1230 )} 1170 1231 1171 - {/* ---- Uploader (greyed-out unless granted) ---- */} 1172 - <div 1173 - class={`profile-form-icon-row ${ 1174 - iconUploadUnlocked ? "" : "is-locked" 1175 - }`} 1176 - > 1177 - <div class="profile-form-icon-preview" aria-hidden="true"> 1178 - {iconPreviewUrl.value 1179 - ? ( 1180 - <img 1181 - src={iconPreviewUrl.value} 1182 - alt="" 1183 - class="profile-form-icon-preview-img" 1184 - onError={() => { 1185 - iconPreviewUrl.value = null; 1186 - }} 1187 - /> 1188 - ) 1189 - : <span class="profile-form-icon-placeholder">SVG</span>} 1190 - </div> 1191 - <div class="profile-form-icon-actions"> 1192 - <label 1193 - class={`profile-form-button-secondary ${ 1194 - iconUploadUnlocked ? "" : "is-disabled" 1195 - }`} 1196 - aria-disabled={!iconUploadUnlocked} 1197 - > 1198 - {iconPreviewUrl.value ? tIcon.replace : tIcon.upload} 1199 - <input 1200 - type="file" 1201 - accept="image/svg+xml" 1202 - hidden 1203 - disabled={!iconUploadUnlocked} 1204 - onChange={onIconChange} 1205 - /> 1206 - </label> 1207 - {iconPreviewUrl.value && iconUploadUnlocked && ( 1208 - <button 1209 - type="button" 1210 - class="profile-form-button-link" 1211 - onClick={removeIcon} 1212 - > 1213 - {tIcon.remove} 1214 - </button> 1215 - )} 1216 - </div> 1232 + {/* ---- Two slots: colour + optional B/W companion ---- */} 1233 + { 1234 + /* 1235 + Colour and B/W share the same access gate, sanitiser, and 1236 + 200KB cap — we just persist them to parallel `icon_*` / 1237 + `icon_bw_*` columns and surface both on the developer 1238 + downloads UI. 1239 + */ 1240 + } 1241 + <div class="profile-form-icon-grid"> 1242 + <IconUploadSlot 1243 + label={tIcon.colorLabel} 1244 + hint={tIcon.colorHint} 1245 + previewClass="profile-form-icon-preview" 1246 + placeholderText="SVG" 1247 + previewUrl={iconPreviewUrl.value} 1248 + onClearPreview={() => (iconPreviewUrl.value = null)} 1249 + uploadLabel={iconPreviewUrl.value ? tIcon.replace : tIcon.upload} 1250 + removeLabel={tIcon.remove} 1251 + unlocked={iconUploadUnlocked} 1252 + onChange={onIconChange} 1253 + onRemove={removeIcon} 1254 + /> 1255 + <IconUploadSlot 1256 + label={tIcon.bwLabel} 1257 + hint={tIcon.bwHint} 1258 + previewClass="profile-form-icon-preview profile-form-icon-preview--bw" 1259 + placeholderText="B/W" 1260 + previewUrl={iconBwPreviewUrl.value} 1261 + onClearPreview={() => (iconBwPreviewUrl.value = null)} 1262 + uploadLabel={iconBwPreviewUrl.value 1263 + ? tIcon.bwReplace 1264 + : tIcon.bwUpload} 1265 + removeLabel={tIcon.bwRemove} 1266 + unlocked={iconUploadUnlocked} 1267 + onChange={onIconBwChange} 1268 + onRemove={removeIconBw} 1269 + /> 1217 1270 </div> 1218 1271 <p class="profile-form-hint">{tIcon.hint}</p> 1219 1272 </div> ··· 1374 1427 ); 1375 1428 })()} 1376 1429 </form> 1430 + ); 1431 + } 1432 + 1433 + /* ----------------------- Developer icon slot ---------------------------- */ 1434 + 1435 + interface IconUploadSlotProps { 1436 + label: string; 1437 + hint: string; 1438 + previewClass: string; 1439 + placeholderText: string; 1440 + previewUrl: string | null; 1441 + onClearPreview: () => void; 1442 + uploadLabel: string; 1443 + removeLabel: string; 1444 + unlocked: boolean; 1445 + onChange: (e: Event) => void; 1446 + onRemove: () => void; 1447 + } 1448 + 1449 + function IconUploadSlot(props: IconUploadSlotProps) { 1450 + return ( 1451 + <div class="profile-form-icon-slot"> 1452 + <div class="profile-form-icon-slot-heading"> 1453 + <span class="profile-form-label">{props.label}</span> 1454 + <span class="profile-form-hint profile-form-icon-slot-hint"> 1455 + {props.hint} 1456 + </span> 1457 + </div> 1458 + <div 1459 + class={`profile-form-icon-row ${props.unlocked ? "" : "is-locked"}`} 1460 + > 1461 + <div class={props.previewClass} aria-hidden="true"> 1462 + {props.previewUrl 1463 + ? ( 1464 + <img 1465 + src={props.previewUrl} 1466 + alt="" 1467 + class="profile-form-icon-preview-img" 1468 + onError={props.onClearPreview} 1469 + /> 1470 + ) 1471 + : ( 1472 + <span class="profile-form-icon-placeholder"> 1473 + {props.placeholderText} 1474 + </span> 1475 + )} 1476 + </div> 1477 + <div class="profile-form-icon-actions"> 1478 + <label 1479 + class={`profile-form-button-secondary ${ 1480 + props.unlocked ? "" : "is-disabled" 1481 + }`} 1482 + aria-disabled={!props.unlocked} 1483 + > 1484 + {props.uploadLabel} 1485 + <input 1486 + type="file" 1487 + accept="image/svg+xml" 1488 + hidden 1489 + disabled={!props.unlocked} 1490 + onChange={props.onChange} 1491 + /> 1492 + </label> 1493 + {props.previewUrl && props.unlocked && ( 1494 + <button 1495 + type="button" 1496 + class="profile-form-button-link" 1497 + onClick={props.onRemove} 1498 + > 1499 + {props.removeLabel} 1500 + </button> 1501 + )} 1502 + </div> 1503 + </div> 1504 + </div> 1377 1505 ); 1378 1506 } 1379 1507
+86 -29
islands/SvgIconDownloads.tsx
··· 2 2 import { useEffect } from "preact/hooks"; 3 3 import { useT } from "../i18n/mod.ts"; 4 4 5 + interface IconVariant { 6 + iconUrl: string; 7 + downloadFilename: string; 8 + } 9 + 5 10 interface IconDownload { 6 11 did: string; 7 12 handle: string; 8 13 name: string; 9 - iconUrl: string; 10 - downloadFilename: string; 14 + color: IconVariant | null; 15 + bw: IconVariant | null; 11 16 indexedAt: number; 12 17 } 13 18 19 + type Tab = "color" | "bw"; 20 + 14 21 export default function SvgIconDownloads() { 15 22 const t = useT().developerResources.icons; 16 23 const icons = useSignal<IconDownload[]>([]); 17 24 const query = useSignal(""); 25 + const tab = useSignal<Tab>("color"); 18 26 const loading = useSignal(true); 19 27 const error = useSignal<string | null>(null); 20 28 ··· 47 55 }, []); 48 56 49 57 const needle = query.value.trim().toLowerCase(); 50 - const filtered = needle 51 - ? icons.value.filter((icon) => 52 - `${icon.name} ${icon.handle}`.toLowerCase().includes(needle) 53 - ) 54 - : icons.value; 58 + const matchesQuery = (icon: IconDownload) => 59 + needle 60 + ? `${icon.name} ${icon.handle}`.toLowerCase().includes(needle) 61 + : true; 62 + 63 + const activeVariant = (icon: IconDownload): IconVariant | null => 64 + tab.value === "bw" ? icon.bw : icon.color; 65 + 66 + // Only show projects that have published the currently-selected 67 + // variant. Switching tabs swaps the grid; the totals reflect the 68 + // chosen variant so the count never lies. 69 + const filtered = icons.value 70 + .filter(matchesQuery) 71 + .filter((icon) => activeVariant(icon) !== null); 55 72 56 73 return ( 57 74 <div class="svg-download-tool"> ··· 68 85 ) => (query.value = (e.currentTarget as HTMLInputElement).value)} 69 86 /> 70 87 </label> 88 + <div 89 + class="svg-download-tabs" 90 + role="tablist" 91 + aria-label={t.variantToggleLabel} 92 + > 93 + <button 94 + type="button" 95 + role="tab" 96 + aria-selected={tab.value === "color"} 97 + class={`svg-download-tab ${ 98 + tab.value === "color" ? "is-active" : "" 99 + }`} 100 + onClick={() => (tab.value = "color")} 101 + > 102 + {t.variantColor} 103 + </button> 104 + <button 105 + type="button" 106 + role="tab" 107 + aria-selected={tab.value === "bw"} 108 + class={`svg-download-tab ${tab.value === "bw" ? "is-active" : ""}`} 109 + onClick={() => (tab.value = "bw")} 110 + > 111 + {t.variantBw} 112 + </button> 113 + </div> 71 114 <a 72 115 href="/api/registry/icons.zip" 73 116 download="atmosphere-project-icons.zip" ··· 91 134 92 135 {!loading.value && !error.value && filtered.length === 0 && ( 93 136 <p class="text-body-sm svg-download-empty"> 94 - {icons.value.length === 0 ? t.empty : t.noResults} 137 + {icons.value.length === 0 138 + ? t.empty 139 + : tab.value === "bw" 140 + ? t.emptyBw 141 + : t.noResults} 95 142 </p> 96 143 )} 97 144 98 145 {filtered.length > 0 && ( 99 146 <div class="svg-download-grid"> 100 - {filtered.map((icon) => ( 101 - <article class="svg-download-card" key={icon.did}> 102 - <div class="svg-download-preview"> 103 - <img 104 - src={icon.iconUrl} 105 - alt={t.iconAlt.replace("{name}", icon.name)} 106 - loading="lazy" 107 - /> 108 - </div> 109 - <div class="svg-download-details"> 110 - <h3 class="svg-download-name">{icon.name}</h3> 111 - <p class="svg-download-handle">@{icon.handle}</p> 112 - </div> 113 - <a 114 - href={icon.iconUrl} 115 - download={icon.downloadFilename} 116 - class="badge-download-btn font-mono svg-download-button" 147 + {filtered.map((icon) => { 148 + const variant = activeVariant(icon)!; 149 + return ( 150 + <article 151 + class="svg-download-card" 152 + key={`${icon.did}-${tab.value}`} 117 153 > 118 - {t.downloadSvg} 119 - </a> 120 - </article> 121 - ))} 154 + <div 155 + class={`svg-download-preview ${ 156 + tab.value === "bw" ? "svg-download-preview--bw" : "" 157 + }`} 158 + > 159 + <img 160 + src={variant.iconUrl} 161 + alt={t.iconAlt.replace("{name}", icon.name)} 162 + loading="lazy" 163 + /> 164 + </div> 165 + <div class="svg-download-details"> 166 + <h3 class="svg-download-name">{icon.name}</h3> 167 + <p class="svg-download-handle">@{icon.handle}</p> 168 + </div> 169 + <a 170 + href={variant.iconUrl} 171 + download={variant.downloadFilename} 172 + class="badge-download-btn font-mono svg-download-button" 173 + > 174 + {tab.value === "bw" ? t.downloadSvgBw : t.downloadSvg} 175 + </a> 176 + </article> 177 + ); 178 + })} 122 179 </div> 123 180 )} 124 181 </div>
+6
lexicons/com/atmosphereaccount/registry/profile.json
··· 59 59 "maxSize": 200000, 60 60 "description": "Optional vector icon (SVG) intended for developers building badges, app showcases, sign-in flows, etc. Not displayed on the public Explore profile. Sanitised on upload (script tags, event handlers, foreignObject and javascript:/data: hrefs are stripped)." 61 61 }, 62 + "iconBw": { 63 + "type": "blob", 64 + "accept": ["image/svg+xml"], 65 + "maxSize": 200000, 66 + "description": "Optional black & white companion to `icon`. Same audience and sanitisation rules — published alongside the colour mark for monochrome contexts (light/dark badges, sign-in chrome, print)." 67 + }, 62 68 "screenshots": { 63 69 "type": "array", 64 70 "maxLength": 4,
+36
lib/db.ts
··· 108 108 icon_reviewed_by TEXT, 109 109 icon_reviewed_at INTEGER, 110 110 icon_rejected_reason TEXT, 111 + icon_bw_cid TEXT, 112 + icon_bw_mime TEXT, 113 + icon_bw_status TEXT, 114 + icon_bw_reviewed_by TEXT, 115 + icon_bw_reviewed_at INTEGER, 116 + icon_bw_rejected_reason TEXT, 111 117 icon_access_status TEXT, 112 118 icon_access_email TEXT, 113 119 icon_access_requested_at INTEGER, ··· 401 407 table: "profile", 402 408 column: "icon_rejected_reason", 403 409 ddl: "ALTER TABLE profile ADD COLUMN icon_rejected_reason TEXT", 410 + }, 411 + { 412 + table: "profile", 413 + column: "icon_bw_cid", 414 + ddl: "ALTER TABLE profile ADD COLUMN icon_bw_cid TEXT", 415 + }, 416 + { 417 + table: "profile", 418 + column: "icon_bw_mime", 419 + ddl: "ALTER TABLE profile ADD COLUMN icon_bw_mime TEXT", 420 + }, 421 + { 422 + table: "profile", 423 + column: "icon_bw_status", 424 + ddl: "ALTER TABLE profile ADD COLUMN icon_bw_status TEXT", 425 + }, 426 + { 427 + table: "profile", 428 + column: "icon_bw_reviewed_by", 429 + ddl: "ALTER TABLE profile ADD COLUMN icon_bw_reviewed_by TEXT", 430 + }, 431 + { 432 + table: "profile", 433 + column: "icon_bw_reviewed_at", 434 + ddl: "ALTER TABLE profile ADD COLUMN icon_bw_reviewed_at INTEGER", 435 + }, 436 + { 437 + table: "profile", 438 + column: "icon_bw_rejected_reason", 439 + ddl: "ALTER TABLE profile ADD COLUMN icon_bw_rejected_reason TEXT", 404 440 }, 405 441 { 406 442 table: "profile",
+15
lib/lexicons.ts
··· 146 146 * public profile. Must be `image/svg+xml`; we sanitise on upload. 147 147 */ 148 148 icon?: BlobRef; 149 + /** 150 + * Optional black & white companion to `icon`. Same audience and 151 + * sanitisation rules — surfaced for monochrome badge / dark-mode 152 + * contexts via the developer downloads UI and `iconBwUrl` JSON. 153 + */ 154 + iconBw?: BlobRef; 149 155 /** Optional detail-page screenshots. Stored as PDS blobs and lazy-loaded 150 156 * only on the profile detail page. */ 151 157 screenshots?: ScreenshotEntry[]; ··· 476 482 return { ok: false, error: "icon: must be image/svg+xml" }; 477 483 } 478 484 } 485 + if (v.iconBw !== undefined) { 486 + if (!isBlob(v.iconBw)) { 487 + return { ok: false, error: "iconBw: invalid blob ref" }; 488 + } 489 + if ((v.iconBw as BlobRef).mimeType !== "image/svg+xml") { 490 + return { ok: false, error: "iconBw: must be image/svg+xml" }; 491 + } 492 + } 479 493 const screenshotsRes = validateScreenshots(v.screenshots); 480 494 if (!screenshotsRes.ok) return { ok: false, error: screenshotsRes.error }; 481 495 const linksRes = normalizeLinks(v.links); ··· 506 520 androidLink: normalizedAndroidLink, 507 521 avatar: v.avatar as BlobRef | undefined, 508 522 icon: v.icon as BlobRef | undefined, 523 + iconBw: v.iconBw as BlobRef | undefined, 509 524 screenshots: screenshotsRes.value.length > 0 510 525 ? screenshotsRes.value 511 526 : undefined,
+2
lib/profile-sync.ts
··· 48 48 avatarMime: r.avatar?.mimeType ?? null, 49 49 iconCid: r.icon?.ref.$link ?? null, 50 50 iconMime: r.icon?.mimeType ?? null, 51 + iconBwCid: r.iconBw?.ref.$link ?? null, 52 + iconBwMime: r.iconBw?.mimeType ?? null, 51 53 pdsUrl: input.pdsUrl, 52 54 recordCid: input.record.cid, 53 55 recordRev: input.recordRev,
+11
lib/public-profile.ts
··· 42 42 * is verified; otherwise null. Raw `iconCid` / review state are not exposed. 43 43 */ 44 44 iconUrl: string | null; 45 + /** 46 + * Optional black-and-white companion to `iconUrl`. Same gating 47 + * (verified project + approved variant); null when not present. 48 + */ 49 + iconBwUrl: string | null; 45 50 pdsUrl: string; 46 51 recordCid: string; 47 52 recordRev: string; ··· 64 69 profile.iconAccessStatus === "granted" 65 70 ? `${origin}/api/registry/icon/${encodeURIComponent(profile.did)}` 66 71 : null; 72 + const iconBwUrl = profile.iconBwCid && 73 + profile.iconBwStatus === "approved" && 74 + profile.iconAccessStatus === "granted" 75 + ? `${origin}/api/registry/icon-bw/${encodeURIComponent(profile.did)}` 76 + : null; 67 77 const screenshotUrls = profile.screenshots.map((_, i) => 68 78 `${origin}/api/registry/screenshot/${encodeURIComponent(profile.did)}/${i}` 69 79 ); ··· 88 98 avatarUrl, 89 99 verified, 90 100 iconUrl, 101 + iconBwUrl, 91 102 pdsUrl: profile.pdsUrl, 92 103 recordCid: profile.recordCid, 93 104 recordRev: profile.recordRev,
+83 -6
lib/registry.ts
··· 79 79 iconReviewedBy: string | null; 80 80 iconReviewedAt: number | null; 81 81 iconRejectedReason: string | null; 82 + /** Optional black-and-white companion to `iconCid`. Same access gate 83 + * and per-icon approval lifecycle as the colour icon — surfaced on 84 + * the developer downloads UI alongside (or instead of) `iconCid`. */ 85 + iconBwCid: string | null; 86 + iconBwMime: string | null; 87 + iconBwStatus: IconStatus | null; 88 + iconBwReviewedBy: string | null; 89 + iconBwReviewedAt: number | null; 90 + iconBwRejectedReason: string | null; 82 91 /** Per-project SVG-upload verification state. */ 83 92 iconAccessStatus: IconAccessStatus | null; 84 93 /** Contact email captured at request time (admin-only sees this). */ ··· 126 135 icon_reviewed_by: string | null; 127 136 icon_reviewed_at: number | null; 128 137 icon_rejected_reason: string | null; 138 + icon_bw_cid: string | null; 139 + icon_bw_mime: string | null; 140 + icon_bw_status: string | null; 141 + icon_bw_reviewed_by: string | null; 142 + icon_bw_reviewed_at: number | null; 143 + icon_bw_rejected_reason: string | null; 129 144 icon_access_status: string | null; 130 145 icon_access_email: string | null; 131 146 icon_access_requested_at: number | null; ··· 250 265 ? Number(r.icon_reviewed_at) 251 266 : null, 252 267 iconRejectedReason: r.icon_rejected_reason, 268 + iconBwCid: r.icon_bw_cid, 269 + iconBwMime: r.icon_bw_mime, 270 + iconBwStatus: normalizeIconStatus(r.icon_bw_status), 271 + iconBwReviewedBy: r.icon_bw_reviewed_by, 272 + iconBwReviewedAt: r.icon_bw_reviewed_at != null 273 + ? Number(r.icon_bw_reviewed_at) 274 + : null, 275 + iconBwRejectedReason: r.icon_bw_rejected_reason, 253 276 iconAccessStatus: normalizeIconAccessStatus(r.icon_access_status), 254 277 iconAccessEmail: r.icon_access_email, 255 278 iconAccessRequestedAt: r.icon_access_requested_at != null ··· 300 323 avatarMime?: string | null; 301 324 iconCid?: string | null; 302 325 iconMime?: string | null; 326 + iconBwCid?: string | null; 327 + iconBwMime?: string | null; 303 328 pdsUrl: string; 304 329 recordCid: string; 305 330 recordRev: string; ··· 343 368 categories, subcategories, links, screenshots, 344 369 avatar_cid, avatar_mime, icon_cid, icon_mime, icon_status, 345 370 icon_reviewed_by, icon_reviewed_at, icon_rejected_reason, 371 + icon_bw_cid, icon_bw_mime, icon_bw_status, 372 + icon_bw_reviewed_by, icon_bw_reviewed_at, icon_bw_rejected_reason, 346 373 icon_access_status, icon_access_email, icon_access_requested_at, 347 374 icon_access_reviewed_at, icon_access_reviewed_by, 348 375 icon_access_denied_reason, ··· 350 377 pds_url, record_cid, record_rev, created_at, indexed_at 351 378 ) VALUES ( 352 379 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 380 + NULL, NULL, NULL, 381 + ?, ?, ?, 353 382 NULL, NULL, NULL, 354 383 NULL, NULL, NULL, NULL, NULL, NULL, 355 384 NULL, NULL, NULL, NULL, ··· 405 434 ELSE NULL 406 435 END, 407 436 /** 437 + * Black-and-white companion icon — same per-project gate 438 + * and lifecycle as the colour icon. Tracked independently so 439 + * a project can swap one variant without revoking the other. 440 + */ 441 + icon_bw_cid=excluded.icon_bw_cid, 442 + icon_bw_mime=excluded.icon_bw_mime, 443 + icon_bw_status = CASE 444 + WHEN excluded.icon_bw_cid IS NULL THEN NULL 445 + WHEN profile.icon_bw_cid IS NOT NULL AND profile.icon_bw_cid = excluded.icon_bw_cid THEN profile.icon_bw_status 446 + WHEN profile.icon_access_status = 'granted' THEN 'approved' 447 + ELSE 'pending' 448 + END, 449 + icon_bw_reviewed_by = CASE 450 + WHEN excluded.icon_bw_cid IS NULL THEN NULL 451 + WHEN profile.icon_bw_cid IS NOT NULL AND profile.icon_bw_cid = excluded.icon_bw_cid THEN profile.icon_bw_reviewed_by 452 + ELSE NULL 453 + END, 454 + icon_bw_reviewed_at = CASE 455 + WHEN excluded.icon_bw_cid IS NULL THEN NULL 456 + WHEN profile.icon_bw_cid IS NOT NULL AND profile.icon_bw_cid = excluded.icon_bw_cid THEN profile.icon_bw_reviewed_at 457 + ELSE NULL 458 + END, 459 + icon_bw_rejected_reason = CASE 460 + WHEN excluded.icon_bw_cid IS NULL THEN NULL 461 + WHEN profile.icon_bw_cid IS NOT NULL AND profile.icon_bw_cid = excluded.icon_bw_cid THEN profile.icon_bw_rejected_reason 462 + ELSE NULL 463 + END, 464 + /** 408 465 * Per-project verification state is admin-managed and must 409 466 * survive any firehose-driven re-upsert. Same shape as the 410 467 * takedown columns: only mutated by the dedicated grant / ··· 451 508 input.iconCid ?? null, 452 509 input.iconMime ?? null, 453 510 initialIconStatus, 511 + input.iconBwCid ?? null, 512 + input.iconBwMime ?? null, 513 + initialIconStatus, 454 514 input.pdsUrl, 455 515 input.recordCid, 456 516 input.recordRev, ··· 541 601 * project was previously granted, then revoked, then granted 542 602 * again), promote it straight to approved so the developer 543 603 * API picks up serving without a republish from the user. 604 + * Same logic for the black-and-white companion. 544 605 */ 545 606 icon_status = CASE 546 607 WHEN icon_cid IS NOT NULL THEN 'approved' 547 608 ELSE icon_status 609 + END, 610 + icon_bw_status = CASE 611 + WHEN icon_bw_cid IS NOT NULL THEN 'approved' 612 + ELSE icon_bw_status 548 613 END 549 614 WHERE did = ? 550 615 `, ··· 619 684 * denied/revoked. The blob stays on the user's PDS untouched 620 685 * (we don't have authority to delete it), but our serve route 621 686 * checks icon_status alongside the access gate so flipping 622 - * this is enough to take it offline immediately. 687 + * this is enough to take it offline immediately. Mirrors for 688 + * the black-and-white companion. 623 689 */ 624 690 icon_status = CASE 625 691 WHEN icon_cid IS NOT NULL THEN 'rejected' 626 692 ELSE icon_status 693 + END, 694 + icon_bw_status = CASE 695 + WHEN icon_bw_cid IS NOT NULL THEN 'rejected' 696 + ELSE icon_bw_status 627 697 END 628 698 WHERE did = ? 629 699 `, ··· 1010 1080 } 1011 1081 1012 1082 /** 1013 - * Public projection of every approved developer SVG icon. Used by the 1014 - * developer resources download tool and ZIP endpoint. 1083 + * Public projection of every approved developer SVG icon. A project 1084 + * appears once it has at least one approved variant (colour or B/W); 1085 + * the downloads UI decides which buttons to render per row based on 1086 + * `iconCid` / `iconBwCid` presence. 1015 1087 */ 1016 1088 export async function listApprovedSvgIconProfiles(): Promise<ProfileRow[]> { 1017 1089 return await withDb(async (c) => { ··· 1021 1093 WHERE p.takedown_status IS NULL 1022 1094 AND p.profile_type = 'project' 1023 1095 AND p.icon_access_status = 'granted' 1024 - AND p.icon_status = 'approved' 1025 - AND p.icon_cid IS NOT NULL 1026 - AND p.icon_mime = 'image/svg+xml' 1096 + AND ( 1097 + (p.icon_status = 'approved' 1098 + AND p.icon_cid IS NOT NULL 1099 + AND p.icon_mime = 'image/svg+xml') 1100 + OR (p.icon_bw_status = 'approved' 1101 + AND p.icon_bw_cid IS NOT NULL 1102 + AND p.icon_bw_mime = 'image/svg+xml') 1103 + ) 1027 1104 ORDER BY LOWER(p.name) ASC, LOWER(p.handle) ASC 1028 1105 `, 1029 1106 args: [],
+52 -7
lib/svg-icon-downloads.ts
··· 1 1 import type { ProfileRow } from "./registry.ts"; 2 2 3 + export type IconVariant = "color" | "bw"; 4 + 5 + export interface PublicSvgIconVariant { 6 + iconUrl: string; 7 + downloadFilename: string; 8 + } 9 + 3 10 export interface PublicSvgIconDownload { 4 11 did: string; 5 12 handle: string; 6 13 name: string; 7 - iconUrl: string; 8 - downloadFilename: string; 14 + /** Colour variant. `null` when the project has only published a B/W icon. */ 15 + color: PublicSvgIconVariant | null; 16 + /** Optional black-and-white companion. `null` when not uploaded / approved. */ 17 + bw: PublicSvgIconVariant | null; 9 18 indexedAt: number; 10 19 } 11 20 ··· 20 29 return slug || "icon"; 21 30 } 22 31 23 - export function svgIconDownloadFilename(profile: ProfileRow): string { 32 + function variantSuffix(variant: IconVariant): string { 33 + return variant === "bw" ? "-bw" : ""; 34 + } 35 + 36 + export function svgIconDownloadFilename( 37 + profile: ProfileRow, 38 + variant: IconVariant, 39 + ): string { 24 40 const label = profile.name.trim() || profile.handle; 25 - return `${slugifyFilenamePart(label)}.svg`; 41 + return `${slugifyFilenamePart(label)}${variantSuffix(variant)}.svg`; 42 + } 43 + 44 + function variantUrlPath(variant: IconVariant): string { 45 + return variant === "bw" ? "icon-bw" : "icon"; 46 + } 47 + 48 + function buildVariant( 49 + profile: ProfileRow, 50 + variant: IconVariant, 51 + origin: string, 52 + ): PublicSvgIconVariant { 53 + return { 54 + iconUrl: `${origin}/api/registry/${variantUrlPath(variant)}/${ 55 + encodeURIComponent(profile.did) 56 + }`, 57 + downloadFilename: svgIconDownloadFilename(profile, variant), 58 + }; 26 59 } 27 60 61 + /** 62 + * Public projection of the per-project icon downloads. A project may 63 + * publish either or both variants; the UI hides slots that are `null`. 64 + */ 28 65 export function publicSvgIconDownload( 29 66 profile: ProfileRow, 30 67 origin: string, 31 68 ): PublicSvgIconDownload { 69 + const hasColor = !!profile.iconCid && profile.iconStatus === "approved"; 70 + const hasBw = !!profile.iconBwCid && profile.iconBwStatus === "approved"; 32 71 return { 33 72 did: profile.did, 34 73 handle: profile.handle, 35 74 name: profile.name, 36 - iconUrl: `${origin}/api/registry/icon/${encodeURIComponent(profile.did)}`, 37 - downloadFilename: svgIconDownloadFilename(profile), 75 + color: hasColor ? buildVariant(profile, "color", origin) : null, 76 + bw: hasBw ? buildVariant(profile, "bw", origin) : null, 38 77 indexedAt: profile.indexedAt, 39 78 }; 40 79 } 41 80 81 + /** 82 + * Disambiguate the ZIP entry filename so two projects with the same 83 + * slug don't collide. Tries the slug, then -<handle>, then -<did 84 + * fragment>, then a numeric suffix. 85 + */ 42 86 export function uniqueZipFilename( 43 87 profile: ProfileRow, 88 + variant: IconVariant, 44 89 used: Set<string>, 45 90 ): string { 46 - const base = svgIconDownloadFilename(profile).replace(/\.svg$/i, ""); 91 + const base = svgIconDownloadFilename(profile, variant).replace(/\.svg$/i, ""); 47 92 const didPart = profile.did.split(":").pop() ?? "icon"; 48 93 const candidates = [ 49 94 `${base}.svg`,
+2
routes/api/account/profile.ts
··· 117 117 avatarMime: validation.value.avatar?.mimeType ?? null, 118 118 iconCid: null, 119 119 iconMime: null, 120 + iconBwCid: null, 121 + iconBwMime: null, 120 122 pdsUrl: session.pdsUrl, 121 123 recordCid: put.cid, 122 124 recordRev: put.commit?.rev ?? put.cid,
+63
routes/api/registry/icon-bw/[did].ts
··· 1 + /** 2 + * Proxy + cache the developer-facing black-and-white SVG icon for a 3 + * registry profile. Mirrors `/api/registry/icon/:did` exactly — same 4 + * gating (project verification + per-icon approval), same security 5 + * headers, same caching policy. 6 + * 7 + * GET /api/registry/icon-bw/did:plc:abc123… 8 + */ 9 + import { define } from "../../../../utils.ts"; 10 + import { getProfileByDid } from "../../../../lib/registry.ts"; 11 + import { fetchBlobPublic } from "../../../../lib/pds.ts"; 12 + import { withRateLimit } from "../../../../lib/rate-limit.ts"; 13 + 14 + export const handler = define.handlers({ 15 + GET: withRateLimit(async (ctx) => { 16 + const did = decodeURIComponent(ctx.params.did); 17 + const profile = await getProfileByDid(did).catch(() => null); 18 + if (!profile || !profile.iconBwCid) { 19 + return new Response("not found", { status: 404 }); 20 + } 21 + const owner = ctx.state.user?.did === did; 22 + if (!owner) { 23 + if (profile.iconAccessStatus !== "granted") { 24 + return new Response("not found", { status: 404 }); 25 + } 26 + if (profile.iconBwStatus !== "approved") { 27 + return new Response("not found", { status: 404 }); 28 + } 29 + } 30 + try { 31 + const upstream = await fetchBlobPublic( 32 + profile.pdsUrl, 33 + did, 34 + profile.iconBwCid, 35 + ); 36 + if (!upstream.ok) { 37 + return new Response("not found", { status: 404 }); 38 + } 39 + const headers = new Headers(); 40 + headers.set("content-type", "image/svg+xml; charset=utf-8"); 41 + headers.set("x-content-type-options", "nosniff"); 42 + headers.set( 43 + "content-security-policy", 44 + "default-src 'none'; style-src 'unsafe-inline'; img-src data:", 45 + ); 46 + headers.set( 47 + "content-disposition", 48 + 'inline; filename="atmosphere-icon-bw.svg"', 49 + ); 50 + headers.set( 51 + "cache-control", 52 + profile.iconBwStatus === "approved" 53 + ? "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" 54 + : "private, max-age=60", 55 + ); 56 + headers.set("etag", profile.iconBwCid); 57 + return new Response(upstream.body, { status: 200, headers }); 58 + } catch (err) { 59 + console.warn("icon-bw proxy error:", err); 60 + return new Response("upstream error", { status: 502 }); 61 + } 62 + }), 63 + });
+27 -14
routes/api/registry/icons.zip.ts
··· 1 1 import { define } from "../../../utils.ts"; 2 2 import { fetchBlobPublic } from "../../../lib/pds.ts"; 3 3 import { listApprovedSvgIconProfiles } from "../../../lib/registry.ts"; 4 - import { uniqueZipFilename } from "../../../lib/svg-icon-downloads.ts"; 4 + import { 5 + type IconVariant, 6 + uniqueZipFilename, 7 + } from "../../../lib/svg-icon-downloads.ts"; 5 8 import { withRateLimit } from "../../../lib/rate-limit.ts"; 6 9 import { createZip, type ZipEntry } from "../../../lib/zip.ts"; 7 10 ··· 12 15 const entries: ZipEntry[] = []; 13 16 14 17 for (const profile of profiles) { 15 - if (!profile.iconCid) continue; 16 - const upstream = await fetchBlobPublic( 17 - profile.pdsUrl, 18 - profile.did, 19 - profile.iconCid, 20 - ); 21 - if (!upstream.ok) continue; 22 - const bytes = new Uint8Array(await upstream.arrayBuffer()); 23 - entries.push({ 24 - name: uniqueZipFilename(profile, used), 25 - data: bytes, 26 - modifiedAt: new Date(profile.indexedAt), 27 - }); 18 + // Both variants are optional and approved-only — listing already 19 + // filtered for at least one approved variant per project. 20 + const variants: Array<{ cid: string; variant: IconVariant }> = []; 21 + if (profile.iconCid && profile.iconStatus === "approved") { 22 + variants.push({ cid: profile.iconCid, variant: "color" }); 23 + } 24 + if (profile.iconBwCid && profile.iconBwStatus === "approved") { 25 + variants.push({ cid: profile.iconBwCid, variant: "bw" }); 26 + } 27 + for (const { cid, variant } of variants) { 28 + const upstream = await fetchBlobPublic( 29 + profile.pdsUrl, 30 + profile.did, 31 + cid, 32 + ); 33 + if (!upstream.ok) continue; 34 + const bytes = new Uint8Array(await upstream.arrayBuffer()); 35 + entries.push({ 36 + name: uniqueZipFilename(profile, variant, used), 37 + data: bytes, 38 + modifiedAt: new Date(profile.indexedAt), 39 + }); 40 + } 28 41 } 29 42 30 43 const zip = createZip(entries);
+63 -16
routes/api/registry/profile.ts
··· 80 80 size: number; 81 81 } | null; 82 82 iconUpload?: { dataBase64: string; mimeType: string }; 83 + /** 84 + * Black-and-white companion icon. Same shape and contract as `icon` 85 + * — gated behind the same per-project verification, sanitised before 86 + * upload, and persisted via parallel `icon_bw_*` columns. 87 + */ 88 + iconBw?: { 89 + $type: "blob"; 90 + ref: { $link: string }; 91 + mimeType: string; 92 + size: number; 93 + } | null; 94 + iconBwUpload?: { dataBase64: string; mimeType: string }; 83 95 /** Existing screenshots to keep plus new uploads to append. */ 84 96 screenshots?: ScreenshotEntry[]; 85 97 screenshotUploads?: { dataBase64: string; mimeType: string }[]; ··· 220 232 } 221 233 222 234 /** 223 - * Developer-facing SVG icon. Two gates: 235 + * Developer-facing SVG icons (colour + optional B/W companion). 236 + * Two gates apply identically to both variants: 224 237 * 225 238 * 1. Per-project verification (`icon_access_status === 'granted'`). 226 239 * Uploads from unverified projects are refused outright. The ··· 234 247 * also requires verification — that handles the revoke→re-save case 235 248 * where we want the icon to be dropped automatically. 236 249 */ 237 - const wantsIcon = !!(body.iconUpload?.dataBase64 || body.icon); 250 + const wantsIcon = !!( 251 + body.iconUpload?.dataBase64 || 252 + body.icon || 253 + body.iconBwUpload?.dataBase64 || 254 + body.iconBw 255 + ); 238 256 if (wantsIcon && existing?.iconAccessStatus !== "granted") { 239 257 return new Response( 240 258 JSON.stringify({ ··· 249 267 ); 250 268 } 251 269 252 - let icon = body.icon ?? undefined; 253 - if (body.iconUpload?.dataBase64) { 254 - const mime = body.iconUpload.mimeType; 270 + /** 271 + * Process one icon-variant upload. Centralised so the colour and 272 + * B/W slots stay 1:1 — same MIME check, size cap, sanitiser, and 273 + * PDS upload path. 274 + * 275 + * `userDid` and `pdsUrl` are captured up front because TS doesn't 276 + * carry the `if (!user) ... if (!session)` narrowings into this 277 + * inner closure. 278 + */ 279 + const userDid = user.did; 280 + const pdsUrl = session.pdsUrl; 281 + async function processIconVariant( 282 + label: "icon" | "iconBw", 283 + keepRef: BlobRef | null | undefined, 284 + upload: { dataBase64: string; mimeType: string } | undefined, 285 + ): Promise<BlobRef | undefined | Response> { 286 + if (!upload?.dataBase64) return keepRef ?? undefined; 287 + const mime = upload.mimeType; 255 288 if (mime !== "image/svg+xml") { 256 - return new Response("icon must be image/svg+xml", { status: 400 }); 289 + return new Response(`${label} must be image/svg+xml`, { status: 400 }); 257 290 } 258 - const raw = decodeBase64(body.iconUpload.dataBase64); 291 + const raw = decodeBase64(upload.dataBase64); 259 292 if (raw.byteLength > ICON_MAX_BYTES) { 260 - return new Response(`icon exceeds ${ICON_MAX_BYTES} bytes`, { 293 + return new Response(`${label} exceeds ${ICON_MAX_BYTES} bytes`, { 261 294 status: 400, 262 295 }); 263 296 } ··· 266 299 cleaned = sanitizeSvgBytes(raw); 267 300 } catch (err) { 268 301 const m = err instanceof Error ? err.message : String(err); 269 - return new Response(`invalid svg: ${m}`, { status: 400 }); 302 + return new Response(`invalid svg (${label}): ${m}`, { status: 400 }); 270 303 } 271 304 try { 272 - icon = await uploadBlob( 273 - user.did, 274 - session.pdsUrl, 275 - cleaned, 276 - "image/svg+xml", 277 - ); 305 + return await uploadBlob(userDid, pdsUrl, cleaned, "image/svg+xml"); 278 306 } catch (err) { 279 307 const m = err instanceof Error ? err.message : String(err); 280 - return new Response(`icon upload failed: ${m}`, { status: 502 }); 308 + return new Response(`${label} upload failed: ${m}`, { status: 502 }); 281 309 } 282 310 } 283 311 312 + const iconResult = await processIconVariant( 313 + "icon", 314 + body.icon ?? undefined, 315 + body.iconUpload, 316 + ); 317 + if (iconResult instanceof Response) return iconResult; 318 + const icon = iconResult; 319 + 320 + const iconBwResult = await processIconVariant( 321 + "iconBw", 322 + body.iconBw ?? undefined, 323 + body.iconBwUpload, 324 + ); 325 + if (iconBwResult instanceof Response) return iconBwResult; 326 + const iconBw = iconBwResult; 327 + 284 328 const screenshots: ScreenshotEntry[] = []; 285 329 if (Array.isArray(body.screenshots)) { 286 330 for (const entry of body.screenshots) { ··· 365 409 links: links.length > 0 ? links : undefined, 366 410 avatar: avatar ?? undefined, 367 411 icon: icon ?? undefined, 412 + iconBw: iconBw ?? undefined, 368 413 screenshots: screenshots.length > 0 ? screenshots : undefined, 369 414 createdAt: new Date().toISOString(), 370 415 }; ··· 412 457 avatarMime: validation.value.avatar?.mimeType ?? null, 413 458 iconCid: validation.value.icon?.ref.$link ?? null, 414 459 iconMime: validation.value.icon?.mimeType ?? null, 460 + iconBwCid: validation.value.iconBw?.ref.$link ?? null, 461 + iconBwMime: validation.value.iconBw?.mimeType ?? null, 415 462 pdsUrl: session.pdsUrl, 416 463 recordCid: result.cid, 417 464 recordRev: result.commit?.rev ?? result.cid,
+7
routes/explore/manage.tsx
··· 71 71 mime: existing.iconMime, 72 72 } 73 73 : null, 74 + iconBw: existing.iconBwCid && existing.iconBwMime 75 + ? { 76 + ref: existing.iconBwCid, 77 + mime: existing.iconBwMime, 78 + } 79 + : null, 74 80 iconAccessStatus: existing.iconAccessStatus, 75 81 iconAccessEmail: existing.iconAccessEmail, 76 82 iconAccessDeniedReason: existing.iconAccessDeniedReason, ··· 99 105 } 100 106 : null, 101 107 icon: null, 108 + iconBw: null, 102 109 iconAccessStatus: null, 103 110 iconAccessEmail: null, 104 111 iconAccessDeniedReason: null,