this repo has no description
0
fork

Configure Feed

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

feat(developer): add SVG icon downloads and route skeletons

Made-with: Cursor

+872 -33
+269 -2
assets/styles.css
··· 586 586 587 587 .page-skeleton-logo, 588 588 .page-skeleton-pill, 589 + .page-skeleton-avatar, 589 590 .page-skeleton-card, 590 591 .page-skeleton-block { 591 592 display: block; ··· 623 624 margin-bottom: 1rem; 624 625 } 625 626 627 + .page-skeleton-hero-split { 628 + min-height: 460px; 629 + display: grid; 630 + grid-template-columns: minmax(0, 1.1fr) minmax(220px, 0.9fr); 631 + align-items: center; 632 + gap: 2rem; 633 + padding: 2rem 0; 634 + } 635 + 636 + .page-skeleton-main--home { 637 + width: min(100%, 1120px); 638 + padding-top: calc(var(--nav-bar-height) + 2rem); 639 + } 640 + 641 + .page-skeleton-main--profile, 642 + .page-skeleton-main--developer { 643 + width: min(100%, 880px); 644 + } 645 + 646 + .page-skeleton-row { 647 + display: flex; 648 + flex-wrap: wrap; 649 + gap: 0.75rem; 650 + margin-top: 1.2rem; 651 + } 652 + 626 653 .page-skeleton-grid { 627 654 display: grid; 628 655 grid-template-columns: repeat(3, minmax(0, 1fr)); 629 656 gap: 1rem; 630 657 } 631 658 659 + .page-skeleton-grid--feature { 660 + grid-template-columns: repeat(3, minmax(0, 1fr)); 661 + } 662 + 663 + .page-skeleton-grid--profiles { 664 + grid-template-columns: repeat(3, minmax(0, 1fr)); 665 + } 666 + 667 + .page-skeleton-grid--screenshots { 668 + grid-template-columns: repeat(2, minmax(0, 1fr)); 669 + margin: 1rem 0; 670 + } 671 + 672 + .page-skeleton-grid--resources { 673 + grid-template-columns: 1fr; 674 + margin-top: 1rem; 675 + } 676 + 632 677 .page-skeleton-block { 633 678 height: 1rem; 634 679 margin-bottom: 0.9rem; 680 + } 681 + 682 + .page-skeleton-block--eyebrow { 683 + width: 9rem; 684 + height: 0.85rem; 635 685 } 636 686 637 687 .page-skeleton-block--title { ··· 639 689 height: 2.4rem; 640 690 } 641 691 692 + .page-skeleton-block--wide { 693 + width: min(86%, 620px); 694 + height: 4.2rem; 695 + } 696 + 642 697 .page-skeleton-block--body { 643 698 width: min(86%, 620px); 644 699 } ··· 647 702 width: min(48%, 340px); 648 703 } 649 704 705 + .page-skeleton-block--button { 706 + width: 9.5rem; 707 + height: 2.6rem; 708 + margin-bottom: 0; 709 + } 710 + 711 + .page-skeleton-block--button-secondary { 712 + width: 8rem; 713 + } 714 + 715 + .page-skeleton-block--tab { 716 + width: 8rem; 717 + height: 2.1rem; 718 + margin-bottom: 0; 719 + } 720 + 721 + .page-skeleton-block--badge { 722 + width: min(70%, 360px); 723 + height: 7rem; 724 + margin: 1.5rem auto 1rem; 725 + border-radius: 18px; 726 + } 727 + 728 + .page-skeleton-tabs { 729 + display: flex; 730 + flex-wrap: wrap; 731 + gap: 0.6rem; 732 + margin: 0 0 1rem; 733 + } 734 + 735 + .page-skeleton-card--cloud { 736 + min-height: 300px; 737 + border-radius: 48% 52% 46% 54%; 738 + } 739 + 740 + .page-skeleton-card--profile { 741 + min-height: 210px; 742 + position: relative; 743 + } 744 + 745 + .page-skeleton-card--profile::before { 746 + content: ""; 747 + position: absolute; 748 + top: 1.2rem; 749 + left: 1.2rem; 750 + width: 3.25rem; 751 + height: 3.25rem; 752 + border-radius: 18px; 753 + background: rgba(255, 255, 255, 0.5); 754 + } 755 + 756 + .page-skeleton-card--screenshot { 757 + min-height: 180px; 758 + } 759 + 760 + .page-skeleton-card--reviews { 761 + min-height: 220px; 762 + } 763 + 764 + .page-skeleton-profile-card { 765 + display: flex; 766 + gap: 1.25rem; 767 + align-items: center; 768 + min-height: 220px; 769 + padding: 1.5rem; 770 + } 771 + 772 + .page-skeleton-avatar { 773 + width: 6rem; 774 + height: 6rem; 775 + border-radius: 28px; 776 + flex: 0 0 auto; 777 + } 778 + 779 + .page-skeleton-profile-lines { 780 + flex: 1; 781 + } 782 + 783 + .page-skeleton-card--resource { 784 + min-height: 340px; 785 + padding: 1.75rem; 786 + text-align: center; 787 + } 788 + 789 + .page-skeleton-card--resource-small { 790 + min-height: 140px; 791 + } 792 + 650 793 @keyframes page-skeleton-shimmer { 651 794 to { 652 795 background-position-x: -220%; ··· 657 800 .page-skeleton-main { 658 801 padding-left: 1rem; 659 802 padding-right: 1rem; 803 + } 804 + 805 + .page-skeleton-hero-split { 806 + min-height: 420px; 807 + grid-template-columns: 1fr; 660 808 } 661 809 662 810 .page-skeleton-grid { 663 811 grid-template-columns: 1fr; 812 + } 813 + 814 + .page-skeleton-profile-card { 815 + align-items: flex-start; 816 + } 817 + 818 + .page-skeleton-avatar { 819 + width: 4.5rem; 820 + height: 4.5rem; 664 821 } 665 822 666 823 .page-skeleton-card:nth-child(n+2) { ··· 4697 4854 margin-top: 0.6rem; 4698 4855 } 4699 4856 4857 + /* ---- Project SVG icon download tool ---- */ 4858 + 4859 + .svg-download-tool { 4860 + margin-top: 1rem; 4861 + padding: 1.5rem; 4862 + border-radius: 20px; 4863 + background: rgba(255, 255, 255, 0.55); 4864 + border: 1px solid rgba(255, 255, 255, 0.6); 4865 + box-shadow: 0 8px 24px rgba(14, 20, 40, 0.06); 4866 + text-align: left; 4867 + } 4868 + 4869 + .svg-download-toolbar { 4870 + display: flex; 4871 + gap: 0.85rem; 4872 + align-items: end; 4873 + justify-content: space-between; 4874 + flex-wrap: wrap; 4875 + } 4876 + 4877 + .svg-download-search { 4878 + flex: 1 1 240px; 4879 + } 4880 + 4881 + .svg-download-zip { 4882 + min-height: 2.4rem; 4883 + } 4884 + 4885 + .svg-download-meta { 4886 + margin-top: 0.9rem; 4887 + font-size: 0.82rem; 4888 + color: rgba(14, 20, 40, 0.62); 4889 + } 4890 + 4891 + .svg-download-empty { 4892 + margin: 1rem 0 0; 4893 + } 4894 + 4895 + .svg-download-grid { 4896 + margin-top: 1rem; 4897 + display: grid; 4898 + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); 4899 + gap: 0.85rem; 4900 + } 4901 + 4902 + .svg-download-card { 4903 + display: flex; 4904 + flex-direction: column; 4905 + gap: 0.7rem; 4906 + padding: 1rem; 4907 + border-radius: 16px; 4908 + border: 1px solid rgba(14, 20, 40, 0.08); 4909 + background: rgba(255, 255, 255, 0.58); 4910 + } 4911 + 4912 + .svg-download-preview { 4913 + display: grid; 4914 + place-items: center; 4915 + min-height: 96px; 4916 + border-radius: 12px; 4917 + background: 4918 + linear-gradient(45deg, rgba(14, 20, 40, 0.04) 25%, transparent 25%), 4919 + linear-gradient(-45deg, rgba(14, 20, 40, 0.04) 25%, transparent 25%), 4920 + linear-gradient(45deg, transparent 75%, rgba(14, 20, 40, 0.04) 75%), 4921 + linear-gradient(-45deg, transparent 75%, rgba(14, 20, 40, 0.04) 75%); 4922 + background-position: 0 0, 0 8px, 8px -8px, -8px 0; 4923 + background-size: 16px 16px; 4924 + } 4925 + 4926 + .svg-download-preview img { 4927 + max-width: 72px; 4928 + max-height: 72px; 4929 + object-fit: contain; 4930 + } 4931 + 4932 + .svg-download-details { 4933 + min-width: 0; 4934 + } 4935 + 4936 + .svg-download-name { 4937 + margin: 0; 4938 + font-family: "IBM Plex Mono", monospace; 4939 + font-size: 0.92rem; 4940 + color: #0e1428; 4941 + overflow: hidden; 4942 + text-overflow: ellipsis; 4943 + white-space: nowrap; 4944 + } 4945 + 4946 + .svg-download-handle { 4947 + margin: 0.15rem 0 0; 4948 + font-size: 0.78rem; 4949 + color: rgba(14, 20, 40, 0.6); 4950 + overflow: hidden; 4951 + text-overflow: ellipsis; 4952 + white-space: nowrap; 4953 + } 4954 + 4955 + .svg-download-button { 4956 + width: 100%; 4957 + } 4958 + 4700 4959 /* Dark phase — keep the playground readable when the sky is in its 4701 4960 * darker pass. The /developer-resources page forces sky-static today 4702 4961 * (effectsOff allowlist), but mobile and reduced-motion users may 4703 4962 * still hit the dark phase, so we cover the cases. */ 4704 4963 .dark-phase .api-playground { 4964 + background: rgba(20, 26, 48, 0.55); 4965 + border-color: rgba(255, 255, 255, 0.12); 4966 + } 4967 + .dark-phase .svg-download-tool, 4968 + .dark-phase .svg-download-card { 4705 4969 background: rgba(20, 26, 48, 0.55); 4706 4970 border-color: rgba(255, 255, 255, 0.12); 4707 4971 } ··· 4728 4992 } 4729 4993 .dark-phase .api-endpoint-summary, 4730 4994 .dark-phase .api-endpoint-cache, 4731 - .dark-phase .api-endpoint-params-list { 4995 + .dark-phase .api-endpoint-params-list, 4996 + .dark-phase .svg-download-meta, 4997 + .dark-phase .svg-download-handle { 4732 4998 color: rgba(245, 247, 250, 0.78); 4733 4999 } 4734 - .dark-phase .api-endpoint-path { 5000 + .dark-phase .api-endpoint-path, 5001 + .dark-phase .svg-download-name { 4735 5002 color: rgba(245, 247, 250, 0.95); 4736 5003 } 4737 5004 .dark-phase .api-endpoint-params-label,
+15 -3
components/DeveloperResources.tsx
··· 1 1 import { useT } from "../i18n/mod.ts"; 2 + import SvgIconDownloads from "../islands/SvgIconDownloads.tsx"; 2 3 3 4 const PROFILE_SCHEMA_URL = 4 5 "https://tangled.org/joebasser.com/atmosphere-account/blob/main/lexicons/com/atmosphereaccount/registry/profile.json"; ··· 7 8 const t = useT(); 8 9 return ( 9 10 <> 10 - <section class="section-sm reveal"> 11 + <section class="section-sm"> 11 12 <div class="container-narrow text-center"> 12 13 <h1 class="text-section">{t.developerResources.heading}</h1> 13 14 <div class="divider" /> ··· 38 39 </div> 39 40 </section> 40 41 41 - <section class="section-sm reveal"> 42 + <section class="section-sm"> 42 43 <div class="container-narrow text-center"> 43 44 <h2 class="text-subsection">{t.developerResources.lottieHeading}</h2> 44 45 <div class="divider" /> ··· 62 63 </div> 63 64 </section> 64 65 65 - <section class="section-sm reveal"> 66 + <section class="section-sm"> 67 + <div class="container-narrow text-center"> 68 + <h2 class="text-subsection">{t.developerResources.icons.heading}</h2> 69 + <div class="divider" /> 70 + <p class="text-body mt-2 mb-3"> 71 + {t.developerResources.icons.intro} 72 + </p> 73 + <SvgIconDownloads /> 74 + </div> 75 + </section> 76 + 77 + <section class="section-sm"> 66 78 <div class="container-narrow text-center"> 67 79 <h2 class="text-subsection">{t.developerResources.schemaHeading}</h2> 68 80 <div class="divider" />
+5 -2
components/Nav.tsx
··· 24 24 rememberedAccounts?: { did: string; handle: string }[]; 25 25 }; 26 26 showEffects?: boolean; 27 + disableScrollEffects?: boolean; 27 28 } 28 29 29 - export default function Nav({ account, showEffects = false }: NavProps = {}) { 30 + export default function Nav( 31 + { account, showEffects = false, disableScrollEffects = false }: NavProps = {}, 32 + ) { 30 33 const t = useT(); 31 34 return ( 32 35 <> ··· 74 77 </label> 75 78 </div> 76 79 )} 77 - {!showEffects && <NavScroll />} 80 + {!showEffects && !disableScrollEffects && <NavScroll />} 78 81 </> 79 82 ); 80 83 }
+15
i18n/messages/en.tsx
··· 293 293 "The Lottie animation and the image assets embedded inside it (logos and artwork used in the sequence).", 294 294 downloadLottie: "Download Lottie (JSON)", 295 295 downloadIcons: "Download icons (ZIP)", 296 + icons: { 297 + heading: "Project SVG icons", 298 + intro: 299 + "Current verified project icons for developers building sign-in flows, app showcases, and directory experiences. This list follows profile updates automatically.", 300 + searchLabel: "Search icons", 301 + searchPlaceholder: "Search by project or handle", 302 + downloadZip: "Download all SVGs (ZIP)", 303 + downloadSvg: "Download SVG", 304 + loading: "Loading current project icons...", 305 + count: "{count} SVG icons available", 306 + empty: "No verified project SVG icons are available yet.", 307 + noResults: "No icons match that search.", 308 + error: "Could not load icons: {error}", 309 + iconAlt: "{name} SVG icon", 310 + }, 296 311 schemaHeading: "Profile schema", 297 312 schemaBody: 298 313 "The registry profile schema is maintained in the open source repo. View the canonical AT Protocol lexicon on Tangled.",
+126
islands/SvgIconDownloads.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { useEffect } from "preact/hooks"; 3 + import { useT } from "../i18n/mod.ts"; 4 + 5 + interface IconDownload { 6 + did: string; 7 + handle: string; 8 + name: string; 9 + iconUrl: string; 10 + downloadFilename: string; 11 + indexedAt: number; 12 + } 13 + 14 + export default function SvgIconDownloads() { 15 + const t = useT().developerResources.icons; 16 + const icons = useSignal<IconDownload[]>([]); 17 + const query = useSignal(""); 18 + const loading = useSignal(true); 19 + const error = useSignal<string | null>(null); 20 + 21 + useEffect(() => { 22 + let cancelled = false; 23 + async function loadIcons() { 24 + loading.value = true; 25 + error.value = null; 26 + try { 27 + const res = await fetch("/api/registry/icons", { 28 + headers: { accept: "application/json" }, 29 + }); 30 + if (!res.ok) throw new Error(`HTTP ${res.status}`); 31 + const json = await res.json() as { icons?: IconDownload[] }; 32 + if (!cancelled) { 33 + icons.value = Array.isArray(json.icons) ? json.icons : []; 34 + } 35 + } catch (err) { 36 + if (!cancelled) { 37 + error.value = err instanceof Error ? err.message : String(err); 38 + } 39 + } finally { 40 + if (!cancelled) loading.value = false; 41 + } 42 + } 43 + loadIcons(); 44 + return () => { 45 + cancelled = true; 46 + }; 47 + }, []); 48 + 49 + 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; 55 + 56 + return ( 57 + <div class="svg-download-tool"> 58 + <div class="svg-download-toolbar"> 59 + <label class="api-playground-field svg-download-search"> 60 + <span class="api-playground-label">{t.searchLabel}</span> 61 + <input 62 + type="search" 63 + class="api-playground-input" 64 + placeholder={t.searchPlaceholder} 65 + value={query.value} 66 + onInput={( 67 + e, 68 + ) => (query.value = (e.currentTarget as HTMLInputElement).value)} 69 + /> 70 + </label> 71 + <a 72 + href="/api/registry/icons.zip" 73 + download="atmosphere-project-icons.zip" 74 + class="badge-download-btn font-mono svg-download-zip" 75 + > 76 + {t.downloadZip} 77 + </a> 78 + </div> 79 + 80 + <div class="svg-download-meta"> 81 + {loading.value 82 + ? t.loading 83 + : t.count.replace("{count}", String(filtered.length))} 84 + </div> 85 + 86 + {error.value && ( 87 + <p class="api-playground-error"> 88 + {t.error.replace("{error}", error.value)} 89 + </p> 90 + )} 91 + 92 + {!loading.value && !error.value && filtered.length === 0 && ( 93 + <p class="text-body-sm svg-download-empty"> 94 + {icons.value.length === 0 ? t.empty : t.noResults} 95 + </p> 96 + )} 97 + 98 + {filtered.length > 0 && ( 99 + <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" 117 + > 118 + {t.downloadSvg} 119 + </a> 120 + </article> 121 + ))} 122 + </div> 123 + )} 124 + </div> 125 + ); 126 + }
+23
lib/registry.ts
··· 1009 1009 }); 1010 1010 } 1011 1011 1012 + /** 1013 + * Public projection of every approved developer SVG icon. Used by the 1014 + * developer resources download tool and ZIP endpoint. 1015 + */ 1016 + export async function listApprovedSvgIconProfiles(): Promise<ProfileRow[]> { 1017 + return await withDb(async (c) => { 1018 + const r = await c.execute({ 1019 + sql: ` 1020 + ${SELECT_PROFILE} 1021 + WHERE p.takedown_status IS NULL 1022 + AND p.profile_type = 'project' 1023 + 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' 1027 + ORDER BY LOWER(p.name) ASC, LOWER(p.handle) ASC 1028 + `, 1029 + args: [], 1030 + }); 1031 + return r.rows.map((row) => rowToProfile(row as unknown as RawProfileRow)); 1032 + }); 1033 + } 1034 + 1012 1035 export async function listFeaturedProfiles(limit = 12): Promise<ProfileRow[]> { 1013 1036 return await withDb(async (c) => { 1014 1037 const r = await c.execute({
+66
lib/svg-icon-downloads.ts
··· 1 + import type { ProfileRow } from "./registry.ts"; 2 + 3 + export interface PublicSvgIconDownload { 4 + did: string; 5 + handle: string; 6 + name: string; 7 + iconUrl: string; 8 + downloadFilename: string; 9 + indexedAt: number; 10 + } 11 + 12 + function slugifyFilenamePart(value: string): string { 13 + const slug = value 14 + .toLowerCase() 15 + .normalize("NFKD") 16 + .replace(/[\u0300-\u036f]/g, "") 17 + .replace(/[^a-z0-9]+/g, "-") 18 + .replace(/^-+|-+$/g, "") 19 + .slice(0, 80); 20 + return slug || "icon"; 21 + } 22 + 23 + export function svgIconDownloadFilename(profile: ProfileRow): string { 24 + const label = profile.name.trim() || profile.handle; 25 + return `${slugifyFilenamePart(label)}.svg`; 26 + } 27 + 28 + export function publicSvgIconDownload( 29 + profile: ProfileRow, 30 + origin: string, 31 + ): PublicSvgIconDownload { 32 + return { 33 + did: profile.did, 34 + handle: profile.handle, 35 + name: profile.name, 36 + iconUrl: `${origin}/api/registry/icon/${encodeURIComponent(profile.did)}`, 37 + downloadFilename: svgIconDownloadFilename(profile), 38 + indexedAt: profile.indexedAt, 39 + }; 40 + } 41 + 42 + export function uniqueZipFilename( 43 + profile: ProfileRow, 44 + used: Set<string>, 45 + ): string { 46 + const base = svgIconDownloadFilename(profile).replace(/\.svg$/i, ""); 47 + const didPart = profile.did.split(":").pop() ?? "icon"; 48 + const candidates = [ 49 + `${base}.svg`, 50 + `${base}-${slugifyFilenamePart(profile.handle)}.svg`, 51 + `${base}-${slugifyFilenamePart(didPart)}.svg`, 52 + ]; 53 + 54 + for (const candidate of candidates) { 55 + if (!used.has(candidate)) { 56 + used.add(candidate); 57 + return candidate; 58 + } 59 + } 60 + 61 + let i = 2; 62 + while (used.has(`${base}-${i}.svg`)) i++; 63 + const filename = `${base}-${i}.svg`; 64 + used.add(filename); 65 + return filename; 66 + }
+131
lib/zip.ts
··· 1 + const textEncoder = new TextEncoder(); 2 + 3 + export interface ZipEntry { 4 + name: string; 5 + data: Uint8Array; 6 + modifiedAt?: Date; 7 + } 8 + 9 + let crcTable: Uint32Array | null = null; 10 + 11 + function getCrcTable(): Uint32Array { 12 + if (crcTable) return crcTable; 13 + const table = new Uint32Array(256); 14 + for (let n = 0; n < 256; n++) { 15 + let c = n; 16 + for (let k = 0; k < 8; k++) { 17 + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; 18 + } 19 + table[n] = c >>> 0; 20 + } 21 + crcTable = table; 22 + return table; 23 + } 24 + 25 + function crc32(bytes: Uint8Array): number { 26 + const table = getCrcTable(); 27 + let c = 0xffffffff; 28 + for (const b of bytes) c = table[(c ^ b) & 0xff] ^ (c >>> 8); 29 + return (c ^ 0xffffffff) >>> 0; 30 + } 31 + 32 + function dosDateTime(date: Date): { date: number; time: number } { 33 + const year = Math.max(1980, date.getFullYear()); 34 + return { 35 + date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | 36 + date.getDate(), 37 + time: (date.getHours() << 11) | (date.getMinutes() << 5) | 38 + Math.floor(date.getSeconds() / 2), 39 + }; 40 + } 41 + 42 + function writeU16(view: DataView, offset: number, value: number): void { 43 + view.setUint16(offset, value, true); 44 + } 45 + 46 + function writeU32(view: DataView, offset: number, value: number): void { 47 + view.setUint32(offset, value >>> 0, true); 48 + } 49 + 50 + function concat(parts: Uint8Array[]): Uint8Array { 51 + const total = parts.reduce((n, part) => n + part.byteLength, 0); 52 + const out = new Uint8Array(total); 53 + let offset = 0; 54 + for (const part of parts) { 55 + out.set(part, offset); 56 + offset += part.byteLength; 57 + } 58 + return out; 59 + } 60 + 61 + /** 62 + * Build a ZIP archive using stored entries (no compression). SVGs are 63 + * already tiny text files, and this keeps the endpoint dependency-free. 64 + */ 65 + export function createZip(entries: ZipEntry[]): Uint8Array { 66 + const localParts: Uint8Array[] = []; 67 + const centralParts: Uint8Array[] = []; 68 + let offset = 0; 69 + 70 + for (const entry of entries) { 71 + const name = textEncoder.encode(entry.name); 72 + const data = entry.data; 73 + const crc = crc32(data); 74 + const stamp = dosDateTime(entry.modifiedAt ?? new Date()); 75 + 76 + const local = new Uint8Array(30 + name.byteLength); 77 + const localView = new DataView(local.buffer); 78 + writeU32(localView, 0, 0x04034b50); 79 + writeU16(localView, 4, 20); 80 + writeU16(localView, 6, 0x0800); 81 + writeU16(localView, 8, 0); 82 + writeU16(localView, 10, stamp.time); 83 + writeU16(localView, 12, stamp.date); 84 + writeU32(localView, 14, crc); 85 + writeU32(localView, 18, data.byteLength); 86 + writeU32(localView, 22, data.byteLength); 87 + writeU16(localView, 26, name.byteLength); 88 + writeU16(localView, 28, 0); 89 + local.set(name, 30); 90 + localParts.push(local, data); 91 + 92 + const central = new Uint8Array(46 + name.byteLength); 93 + const centralView = new DataView(central.buffer); 94 + writeU32(centralView, 0, 0x02014b50); 95 + writeU16(centralView, 4, 20); 96 + writeU16(centralView, 6, 20); 97 + writeU16(centralView, 8, 0x0800); 98 + writeU16(centralView, 10, 0); 99 + writeU16(centralView, 12, stamp.time); 100 + writeU16(centralView, 14, stamp.date); 101 + writeU32(centralView, 16, crc); 102 + writeU32(centralView, 20, data.byteLength); 103 + writeU32(centralView, 24, data.byteLength); 104 + writeU16(centralView, 28, name.byteLength); 105 + writeU16(centralView, 30, 0); 106 + writeU16(centralView, 32, 0); 107 + writeU16(centralView, 34, 0); 108 + writeU16(centralView, 36, 0); 109 + writeU32(centralView, 38, 0); 110 + writeU32(centralView, 42, offset); 111 + central.set(name, 46); 112 + centralParts.push(central); 113 + 114 + offset += local.byteLength + data.byteLength; 115 + } 116 + 117 + const centralOffset = offset; 118 + const centralDirectory = concat(centralParts); 119 + const end = new Uint8Array(22); 120 + const endView = new DataView(end.buffer); 121 + writeU32(endView, 0, 0x06054b50); 122 + writeU16(endView, 4, 0); 123 + writeU16(endView, 6, 0); 124 + writeU16(endView, 8, entries.length); 125 + writeU16(endView, 10, entries.length); 126 + writeU32(endView, 12, centralDirectory.byteLength); 127 + writeU32(endView, 16, centralOffset); 128 + writeU16(endView, 20, 0); 129 + 130 + return concat([...localParts, centralDirectory, end]); 131 + }
+21
routes/api/registry/icons.ts
··· 1 + import { define } from "../../../utils.ts"; 2 + import { listApprovedSvgIconProfiles } from "../../../lib/registry.ts"; 3 + import { publicSvgIconDownload } from "../../../lib/svg-icon-downloads.ts"; 4 + import { withRateLimit } from "../../../lib/rate-limit.ts"; 5 + 6 + export const handler = define.handlers({ 7 + GET: withRateLimit(async (ctx) => { 8 + const origin = new URL(ctx.req.url).origin; 9 + const profiles = await listApprovedSvgIconProfiles(); 10 + const icons = profiles.map((profile) => 11 + publicSvgIconDownload(profile, origin) 12 + ); 13 + 14 + return new Response(JSON.stringify({ icons }), { 15 + headers: { 16 + "content-type": "application/json; charset=utf-8", 17 + "cache-control": "public, max-age=10, s-maxage=30", 18 + }, 19 + }); 20 + }), 21 + });
+42
routes/api/registry/icons.zip.ts
··· 1 + import { define } from "../../../utils.ts"; 2 + import { fetchBlobPublic } from "../../../lib/pds.ts"; 3 + import { listApprovedSvgIconProfiles } from "../../../lib/registry.ts"; 4 + import { uniqueZipFilename } from "../../../lib/svg-icon-downloads.ts"; 5 + import { withRateLimit } from "../../../lib/rate-limit.ts"; 6 + import { createZip, type ZipEntry } from "../../../lib/zip.ts"; 7 + 8 + export const handler = define.handlers({ 9 + GET: withRateLimit(async () => { 10 + const profiles = await listApprovedSvgIconProfiles(); 11 + const used = new Set<string>(); 12 + const entries: ZipEntry[] = []; 13 + 14 + 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 + }); 28 + } 29 + 30 + const zip = createZip(entries); 31 + const body = new ArrayBuffer(zip.byteLength); 32 + new Uint8Array(body).set(zip); 33 + return new Response(body, { 34 + headers: { 35 + "content-type": "application/zip", 36 + "content-disposition": 37 + 'attachment; filename="atmosphere-project-icons.zip"', 38 + "cache-control": "public, max-age=30, s-maxage=30", 39 + }, 40 + }); 41 + }), 42 + });
+1 -1
routes/developer-resources.tsx
··· 7 7 return ( 8 8 <div id="page-top"> 9 9 <div class="content-layer"> 10 - <Nav /> 10 + <Nav disableScrollEffects /> 11 11 <section style={{ paddingTop: "8rem" }}> 12 12 <DeveloperResources /> 13 13 </section>
+158 -25
static/page-skeleton.js
··· 1 1 const skeletonId = "page-loading-skeleton"; 2 2 let showTimer = 0; 3 3 4 - function isNonHomePage(url) { 5 - return url.origin === globalThis.location.origin && url.pathname !== "/"; 4 + function isSkeletonPage(url) { 5 + return url.origin === globalThis.location.origin && 6 + !url.pathname.startsWith("/api/"); 6 7 } 7 8 8 - function ensureSkeleton() { 9 - let skeleton = document.getElementById(skeletonId); 10 - if (skeleton) return skeleton; 9 + function routeKind(pathname) { 10 + if (pathname === "/") return "home"; 11 + if (pathname === "/developer-resources") return "developer"; 12 + if (pathname === "/explore") return "explore"; 13 + if (pathname.startsWith("/explore/")) return "profile"; 14 + if (pathname.startsWith("/users/")) return "user"; 15 + return "default"; 16 + } 11 17 12 - skeleton = document.createElement("div"); 13 - skeleton.id = skeletonId; 14 - skeleton.className = "page-skeleton"; 15 - skeleton.setAttribute("aria-hidden", "true"); 16 - skeleton.innerHTML = ` 18 + function navMarkup() { 19 + return ` 17 20 <div class="page-skeleton-nav"> 18 21 <span class="page-skeleton-logo"></span> 19 22 <span class="page-skeleton-pill"></span> 20 23 </div> 24 + `; 25 + } 26 + 27 + function line(className = "") { 28 + return `<span class="page-skeleton-block ${className}"></span>`; 29 + } 30 + 31 + function card(className = "") { 32 + return `<span class="page-skeleton-card ${className}"></span>`; 33 + } 34 + 35 + function templateFor(kind) { 36 + if (kind === "home") { 37 + return ` 38 + ${navMarkup()} 39 + <main class="page-skeleton-main page-skeleton-main--home"> 40 + <section class="page-skeleton-hero-split"> 41 + <div> 42 + ${line("page-skeleton-block--eyebrow")} 43 + ${line("page-skeleton-block--title page-skeleton-block--wide")} 44 + ${line("page-skeleton-block--body")} 45 + ${line("page-skeleton-block--body page-skeleton-block--short")} 46 + <div class="page-skeleton-row"> 47 + ${line("page-skeleton-block--button")} 48 + ${ 49 + line("page-skeleton-block--button page-skeleton-block--button-secondary") 50 + } 51 + </div> 52 + </div> 53 + ${card("page-skeleton-card--cloud")} 54 + </section> 55 + <section class="page-skeleton-grid page-skeleton-grid--feature"> 56 + ${card()}${card()}${card()} 57 + </section> 58 + </main> 59 + `; 60 + } 61 + 62 + if (kind === "explore") { 63 + return ` 64 + ${navMarkup()} 65 + <main class="page-skeleton-main"> 66 + <section class="page-skeleton-card page-skeleton-card--hero"> 67 + ${line("page-skeleton-block--title")} 68 + ${line("page-skeleton-block--body")} 69 + ${line("page-skeleton-block--short")} 70 + </section> 71 + <section class="page-skeleton-tabs"> 72 + ${line("page-skeleton-block--tab")} 73 + ${line("page-skeleton-block--tab")} 74 + ${line("page-skeleton-block--tab")} 75 + ${line("page-skeleton-block--tab")} 76 + </section> 77 + <section class="page-skeleton-grid page-skeleton-grid--profiles"> 78 + ${card("page-skeleton-card--profile")} 79 + ${card("page-skeleton-card--profile")} 80 + ${card("page-skeleton-card--profile")} 81 + ${card("page-skeleton-card--profile")} 82 + ${card("page-skeleton-card--profile")} 83 + ${card("page-skeleton-card--profile")} 84 + </section> 85 + </main> 86 + `; 87 + } 88 + 89 + if (kind === "profile" || kind === "user") { 90 + return ` 91 + ${navMarkup()} 92 + <main class="page-skeleton-main page-skeleton-main--profile"> 93 + <section class="page-skeleton-card page-skeleton-profile-card"> 94 + <span class="page-skeleton-avatar"></span> 95 + <div class="page-skeleton-profile-lines"> 96 + ${line("page-skeleton-block--title")} 97 + ${line("page-skeleton-block--short")} 98 + ${line("page-skeleton-block--body")} 99 + </div> 100 + </section> 101 + ${ 102 + kind === "profile" 103 + ? ` 104 + <section class="page-skeleton-grid page-skeleton-grid--screenshots"> 105 + ${card("page-skeleton-card--screenshot")} 106 + ${card("page-skeleton-card--screenshot")} 107 + </section> 108 + ${card("page-skeleton-card--reviews")} 109 + ` 110 + : card("page-skeleton-card--reviews") 111 + } 112 + </main> 113 + `; 114 + } 115 + 116 + if (kind === "developer") { 117 + return ` 118 + ${navMarkup()} 119 + <main class="page-skeleton-main page-skeleton-main--developer"> 120 + <section class="page-skeleton-card page-skeleton-card--resource"> 121 + ${line("page-skeleton-block--title")} 122 + ${line("page-skeleton-block--body")} 123 + ${line("page-skeleton-block--badge")} 124 + <div class="page-skeleton-row"> 125 + ${line("page-skeleton-block--button")} 126 + ${line("page-skeleton-block--button")} 127 + </div> 128 + </section> 129 + <section class="page-skeleton-grid page-skeleton-grid--resources"> 130 + ${card("page-skeleton-card--resource-small")} 131 + ${card("page-skeleton-card--resource-small")} 132 + ${card("page-skeleton-card--resource-small")} 133 + </section> 134 + </main> 135 + `; 136 + } 137 + 138 + return ` 139 + ${navMarkup()} 21 140 <main class="page-skeleton-main"> 22 141 <section class="page-skeleton-card page-skeleton-card--hero"> 23 - <span class="page-skeleton-block page-skeleton-block--title"></span> 24 - <span class="page-skeleton-block page-skeleton-block--body"></span> 25 - <span class="page-skeleton-block page-skeleton-block--short"></span> 142 + ${line("page-skeleton-block--title")} 143 + ${line("page-skeleton-block--body")} 144 + ${line("page-skeleton-block--short")} 26 145 </section> 27 146 <section class="page-skeleton-grid"> 28 - <span class="page-skeleton-card"></span> 29 - <span class="page-skeleton-card"></span> 30 - <span class="page-skeleton-card"></span> 147 + ${card()}${card()}${card()} 31 148 </section> 32 149 </main> 33 150 `; 34 - document.body.appendChild(skeleton); 151 + } 152 + 153 + function ensureSkeleton(kind) { 154 + let skeleton = document.getElementById(skeletonId); 155 + if (!skeleton) { 156 + skeleton = document.createElement("div"); 157 + skeleton.id = skeletonId; 158 + skeleton.className = "page-skeleton"; 159 + skeleton.setAttribute("aria-hidden", "true"); 160 + document.body.appendChild(skeleton); 161 + } 162 + 163 + if (skeleton.dataset.kind !== kind) { 164 + skeleton.dataset.kind = kind; 165 + skeleton.innerHTML = templateFor(kind); 166 + } 35 167 return skeleton; 36 168 } 37 169 38 - function showSkeleton() { 39 - ensureSkeleton().classList.add("page-skeleton--visible"); 170 + function showSkeleton(kind) { 171 + ensureSkeleton(kind).classList.add("page-skeleton--visible"); 40 172 } 41 173 42 - function scheduleSkeleton() { 174 + function scheduleSkeleton(url) { 43 175 clearTimeout(showTimer); 44 - showTimer = globalThis.setTimeout(showSkeleton, 120); 176 + const kind = routeKind(url.pathname); 177 + showTimer = globalThis.setTimeout(() => showSkeleton(kind), 120); 45 178 } 46 179 47 180 function hideSkeleton() { ··· 64 197 65 198 const url = new URL(link.href, globalThis.location.href); 66 199 if (url.hash && url.pathname === globalThis.location.pathname) return; 67 - if (!isNonHomePage(url)) return; 200 + if (!isSkeletonPage(url)) return; 68 201 69 - scheduleSkeleton(); 202 + scheduleSkeleton(url); 70 203 }); 71 204 72 205 document.addEventListener("submit", (event) => { ··· 76 209 globalThis.setTimeout(() => { 77 210 if (event.defaultPrevented) return; 78 211 const url = new URL(form.action || globalThis.location.href); 79 - if (!isNonHomePage(url) || url.pathname.startsWith("/api/")) return; 80 - scheduleSkeleton(); 212 + if (!isSkeletonPage(url)) return; 213 + scheduleSkeleton(url); 81 214 }, 0); 82 215 }); 83 216