this repo has no description
0
fork

Configure Feed

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

feat(explore): clean multi-category lexicon, repo + open-source fields, account menu

Reshape the registry profile around a required `categories[]` array (1-4)
so a project can declare both "App" and "Account provider", and drop the
legacy single `category` and `tags` fields entirely. Categories now read
as singular labels everywhere ("App", not "Apps").

Profile additions:
- `repoUrl` (auto-detects GitHub / Tangled / generic) renders as a third
action button next to Bluesky and Website.
- `openSource` toggle adds an "Open source" badge on cards and the hero.

Sign-in UX:
- New "Sign in with your Atmosphere handle" label above the input.
- Trim placeholder to `yourproject.com`, drop the "we resolve your
handle" helper line.

Top-nav:
- Drop the Protocol button from the top right (still in footer).
- Promote Explore to the glass-button slot.
- Account menu rail under Explore: text "Sign in" when signed out,
avatar pill with View/Manage/Sign-out dropdown when signed in.
- New `/api/me/avatar` route serves the registry avatar (cached) with
a Bluesky PDS fallback for users who haven't published yet.

Home + footer:
- New homepage CTA section ("Explore Apps" glass button after the
moderation/algorithms section).
- Footer gains a `compact` variant: hide tagline + quote + the Explore
link on the explore section.
- Centre footer links under the logo.

Schema migration:
- DB schema drops `category`, `tags`, `tags`-in-FTS; adds `categories`,
`repo_url`, `open_source`.
- New `scripts/wipe-registry.ts` recreates the tables cleanly when the
schema can't be expressed via additive ALTERs.

Made-with: Cursor

+1386 -186
+395 -1
assets/styles.css
··· 1297 1297 display: flex; 1298 1298 flex-wrap: wrap; 1299 1299 justify-content: center; 1300 + align-items: center; 1301 + text-align: center; 1300 1302 gap: 2rem; 1301 - margin-bottom: 2rem; 1303 + margin: 0 auto 2rem; 1304 + /* Constrain so wrapping behaves predictably and the links visually 1305 + * cluster directly beneath the centered logo on every viewport. */ 1306 + max-width: 36rem; 1302 1307 } 1303 1308 1304 1309 .footer-quote { ··· 1982 1987 color: rgba(255, 255, 255, 0.85); 1983 1988 border-color: rgba(255, 255, 255, 0.15); 1984 1989 } 1990 + /** 1991 + * Open-source pill — same shape as the other meta chips, accented with the 1992 + * site's signature blue so it reads as a positive indicator without 1993 + * shouting at the rest of the metadata row. 1994 + */ 1995 + .profile-card-sub--oss { 1996 + background: rgba(59, 130, 246, 0.14); 1997 + color: rgba(29, 78, 216, 0.95); 1998 + border-color: rgba(59, 130, 246, 0.32); 1999 + font-weight: 600; 2000 + } 2001 + .dark-phase .profile-card-sub--oss { 2002 + background: rgba(96, 165, 250, 0.18); 2003 + color: rgba(191, 219, 254, 0.95); 2004 + border-color: rgba(96, 165, 250, 0.35); 2005 + } 1985 2006 1986 2007 .profile-badge { 1987 2008 display: inline-flex; ··· 2506 2527 color: rgba(255, 255, 255, 0.65); 2507 2528 } 2508 2529 2530 + /** 2531 + * Inline checkbox-style toggle row used for the "open source" flag. Lives 2532 + * between regular labelled fields, so the spacing matches `.profile-form-field` 2533 + * (gap inherited from the parent), but the row itself reads as a control 2534 + * with its own affordance. 2535 + */ 2536 + .profile-form-toggle { 2537 + display: flex; 2538 + align-items: flex-start; 2539 + gap: 0.7rem; 2540 + padding: 0.75rem 0.85rem; 2541 + border-radius: 0.7rem; 2542 + border: 1px solid rgba(18, 26, 47, 0.12); 2543 + background: rgba(255, 255, 255, 0.45); 2544 + cursor: pointer; 2545 + } 2546 + .profile-form-toggle input[type="checkbox"] { 2547 + margin-top: 0.15rem; 2548 + width: 1rem; 2549 + height: 1rem; 2550 + accent-color: #2563eb; 2551 + cursor: pointer; 2552 + } 2553 + .profile-form-toggle-body { 2554 + display: flex; 2555 + flex-direction: column; 2556 + gap: 0.15rem; 2557 + min-width: 0; 2558 + } 2559 + .profile-form-toggle-label { 2560 + font-size: 0.92rem; 2561 + font-weight: 600; 2562 + color: rgba(18, 26, 47, 0.92); 2563 + } 2564 + .profile-form-toggle-hint { 2565 + font-size: 0.78rem; 2566 + color: rgba(18, 26, 47, 0.55); 2567 + } 2568 + .dark-phase .profile-form-toggle { 2569 + background: rgba(255, 255, 255, 0.07); 2570 + border-color: rgba(255, 255, 255, 0.18); 2571 + } 2572 + .dark-phase .profile-form-toggle-label { 2573 + color: rgba(255, 255, 255, 0.92); 2574 + } 2575 + .dark-phase .profile-form-toggle-hint { 2576 + color: rgba(255, 255, 255, 0.6); 2577 + } 2578 + 2509 2579 .profile-form-chips { 2510 2580 display: flex; 2511 2581 gap: 0.4rem; ··· 2797 2867 transform: rotate(360deg); 2798 2868 } 2799 2869 } 2870 + 2871 + /* ================================ 2872 + Account menu (explore-page nav rail) 2873 + ================================ */ 2874 + 2875 + /* Fixed rail that hangs directly under the nav, right-aligned, so the 2876 + * trigger sits visually "underneath the Explore button". The 0.55rem 2877 + * offset adds a small visual gap so the rail's contents don't crash 2878 + * into the nav's bottom border (especially once .nav.scrolled paints 2879 + * its glass background). Sits above the effects bar's z-index so the 2880 + * dropdown can paint over hero text. */ 2881 + .account-menu-rail { 2882 + position: fixed; 2883 + top: calc(var(--nav-bar-height) + 0.55rem); 2884 + right: 1.5rem; 2885 + z-index: 110; 2886 + pointer-events: none; 2887 + } 2888 + 2889 + .account-menu-rail .account-menu { 2890 + pointer-events: auto; 2891 + } 2892 + 2893 + /* On the explore section we hide the effects bar entirely, so the rail 2894 + * tucks right up under the nav with a hairline of breathing room. */ 2895 + .account-menu, 2896 + .account-menu-signin { 2897 + pointer-events: auto; 2898 + } 2899 + 2900 + .account-menu { 2901 + position: relative; 2902 + display: inline-block; 2903 + } 2904 + 2905 + /* Signed-out variant: a text-only ghost button (same family as the 2906 + * old Explore button before it was promoted to the glass style). 2907 + * Sized down slightly so it sits comfortably under the Explore CTA 2908 + * without competing visually. */ 2909 + .account-menu-signin { 2910 + display: inline-block; 2911 + padding: 0.35rem 0.95rem; 2912 + font-size: 0.8rem; 2913 + } 2914 + 2915 + .account-menu-trigger { 2916 + display: inline-flex; 2917 + align-items: center; 2918 + gap: 0.4rem; 2919 + padding: 0.3rem 0.55rem 0.3rem 0.35rem; 2920 + border-radius: 100px; 2921 + background: rgba(255, 255, 255, 0.62); 2922 + border: 1px solid rgba(255, 255, 255, 0.78); 2923 + backdrop-filter: blur(14px); 2924 + -webkit-backdrop-filter: blur(14px); 2925 + box-shadow: 0 6px 18px rgba(14, 20, 40, 0.08); 2926 + cursor: pointer; 2927 + color: #0e1428; 2928 + font-family: "IBM Plex Mono", monospace; 2929 + font-size: 0.8rem; 2930 + transition: 2931 + background 0.2s ease, 2932 + box-shadow 0.2s ease, 2933 + transform 0.15s ease; 2934 + } 2935 + 2936 + .account-menu-trigger:hover { 2937 + background: rgba(255, 255, 255, 0.82); 2938 + box-shadow: 0 10px 24px rgba(14, 20, 40, 0.12); 2939 + } 2940 + 2941 + .account-menu-trigger:focus-visible { 2942 + outline: 2px solid rgba(80, 130, 220, 0.6); 2943 + outline-offset: 2px; 2944 + } 2945 + 2946 + .account-menu-avatar { 2947 + width: 30px; 2948 + height: 30px; 2949 + border-radius: 50%; 2950 + overflow: hidden; 2951 + flex-shrink: 0; 2952 + background: linear-gradient(135deg, #aac6f0 0%, #7da4dc 100%); 2953 + display: inline-flex; 2954 + align-items: center; 2955 + justify-content: center; 2956 + color: #fff; 2957 + font-weight: 600; 2958 + font-size: 0.8rem; 2959 + letter-spacing: 0; 2960 + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5); 2961 + } 2962 + 2963 + .account-menu-avatar img { 2964 + width: 100%; 2965 + height: 100%; 2966 + object-fit: cover; 2967 + display: block; 2968 + } 2969 + 2970 + .account-menu-avatar-initial { 2971 + font-family: "IBM Plex Mono", monospace; 2972 + /* Anti-alias against the gradient so the letter doesn't look fuzzy */ 2973 + -webkit-font-smoothing: antialiased; 2974 + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); 2975 + } 2976 + 2977 + .account-menu-chevron { 2978 + font-size: 0.7rem; 2979 + line-height: 1; 2980 + color: rgba(18, 26, 47, 0.7); 2981 + transition: transform 0.15s ease; 2982 + } 2983 + 2984 + .account-menu-trigger[aria-expanded="true"] .account-menu-chevron { 2985 + transform: rotate(180deg); 2986 + } 2987 + 2988 + .account-menu-popup { 2989 + position: absolute; 2990 + top: calc(100% + 0.55rem); 2991 + right: 0; 2992 + min-width: 240px; 2993 + padding: 0.45rem; 2994 + /* Same z-strategy as the sign-in preview: sit above hero text and 2995 + * the footer regardless of any sibling stacking contexts. */ 2996 + z-index: 1000; 2997 + isolation: isolate; 2998 + /* Override the default .glass tint for higher legibility — the menu 2999 + * sits over arbitrary hero gradients and we don't want the items to 3000 + * disappear into the sky. */ 3001 + } 3002 + .account-menu-popup.glass { 3003 + background: rgba(255, 255, 255, 0.94); 3004 + border: 1px solid rgba(18, 26, 47, 0.12); 3005 + backdrop-filter: blur(16px); 3006 + -webkit-backdrop-filter: blur(16px); 3007 + border-radius: 16px; 3008 + box-shadow: 3009 + 0 20px 50px rgba(14, 20, 40, 0.22), 3010 + 0 6px 16px rgba(14, 20, 40, 0.1); 3011 + } 3012 + 3013 + .account-menu-header { 3014 + display: flex; 3015 + flex-direction: column; 3016 + gap: 0.1rem; 3017 + padding: 0.55rem 0.7rem 0.4rem; 3018 + } 3019 + 3020 + .account-menu-header-label { 3021 + font-size: 0.7rem; 3022 + text-transform: uppercase; 3023 + letter-spacing: 0.06em; 3024 + color: rgba(18, 26, 47, 0.55); 3025 + font-family: "IBM Plex Mono", monospace; 3026 + } 3027 + 3028 + .account-menu-header-handle { 3029 + font-size: 0.92rem; 3030 + color: #0e1428; 3031 + font-weight: 600; 3032 + word-break: break-all; 3033 + } 3034 + 3035 + .account-menu-divider { 3036 + height: 1px; 3037 + margin: 0.25rem 0.5rem; 3038 + background: rgba(18, 26, 47, 0.1); 3039 + } 3040 + 3041 + .account-menu-form { 3042 + margin: 0; 3043 + display: contents; 3044 + } 3045 + 3046 + .account-menu-item { 3047 + display: block; 3048 + width: 100%; 3049 + text-align: left; 3050 + padding: 0.55rem 0.7rem; 3051 + border-radius: 10px; 3052 + font-size: 0.88rem; 3053 + color: #0e1428; 3054 + background: transparent; 3055 + border: none; 3056 + cursor: pointer; 3057 + text-decoration: none; 3058 + font-family: inherit; 3059 + transition: background 0.15s ease; 3060 + } 3061 + 3062 + .account-menu-item:hover, 3063 + .account-menu-item:focus-visible { 3064 + background: rgba(18, 26, 47, 0.06); 3065 + outline: none; 3066 + } 3067 + 3068 + .account-menu-item-primary { 3069 + color: #fff; 3070 + background: linear-gradient(135deg, #4a7bd9 0%, #2c5db4 100%); 3071 + font-weight: 600; 3072 + text-align: center; 3073 + margin-top: 0.25rem; 3074 + } 3075 + 3076 + .account-menu-item-primary:hover, 3077 + .account-menu-item-primary:focus-visible { 3078 + background: linear-gradient(135deg, #5a8be9 0%, #3c6dc4 100%); 3079 + } 3080 + 3081 + .account-menu-item-danger { 3082 + color: rgba(146, 32, 32, 0.95); 3083 + } 3084 + 3085 + .account-menu-item-danger:hover, 3086 + .account-menu-item-danger:focus-visible { 3087 + background: rgba(146, 32, 32, 0.08); 3088 + } 3089 + 3090 + .account-menu-hint { 3091 + margin: 0; 3092 + padding: 0.6rem 0.7rem 0.45rem; 3093 + font-size: 0.82rem; 3094 + color: rgba(18, 26, 47, 0.7); 3095 + line-height: 1.4; 3096 + } 3097 + 3098 + /* Dark-phase variants — match the sign-in preview palette so both 3099 + * float on the same hero gradients without re-tinting work. */ 3100 + .dark-phase .account-menu-trigger { 3101 + color: #f3f5fb; 3102 + background: rgba(22, 28, 48, 0.55); 3103 + border-color: rgba(255, 255, 255, 0.22); 3104 + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); 3105 + } 3106 + 3107 + .dark-phase .account-menu-trigger:hover { 3108 + background: rgba(34, 42, 68, 0.7); 3109 + } 3110 + 3111 + .dark-phase .account-menu-chevron { 3112 + color: rgba(255, 255, 255, 0.75); 3113 + } 3114 + 3115 + .dark-phase .account-menu-popup.glass { 3116 + background: rgba(22, 28, 48, 0.94); 3117 + border-color: rgba(255, 255, 255, 0.22); 3118 + box-shadow: 3119 + 0 20px 50px rgba(0, 0, 0, 0.55), 3120 + 0 6px 16px rgba(0, 0, 0, 0.4); 3121 + } 3122 + 3123 + .dark-phase .account-menu-header-label { 3124 + color: rgba(255, 255, 255, 0.6); 3125 + } 3126 + 3127 + .dark-phase .account-menu-header-handle { 3128 + color: #f3f5fb; 3129 + } 3130 + 3131 + .dark-phase .account-menu-divider { 3132 + background: rgba(255, 255, 255, 0.12); 3133 + } 3134 + 3135 + .dark-phase .account-menu-item { 3136 + color: #f3f5fb; 3137 + } 3138 + 3139 + .dark-phase .account-menu-item:hover, 3140 + .dark-phase .account-menu-item:focus-visible { 3141 + background: rgba(255, 255, 255, 0.08); 3142 + } 3143 + 3144 + .dark-phase .account-menu-item-danger { 3145 + color: #f4a3a3; 3146 + } 3147 + 3148 + .dark-phase .account-menu-item-danger:hover, 3149 + .dark-phase .account-menu-item-danger:focus-visible { 3150 + background: rgba(244, 163, 163, 0.12); 3151 + } 3152 + 3153 + .dark-phase .account-menu-hint { 3154 + color: rgba(255, 255, 255, 0.7); 3155 + } 3156 + 3157 + @media (max-width: 480px) { 3158 + .account-menu-rail { 3159 + right: 0.85rem; 3160 + } 3161 + .account-menu-popup { 3162 + min-width: 220px; 3163 + } 3164 + } 3165 + 3166 + /* ================================ 3167 + Homepage closing CTA → /explore 3168 + ================================ */ 3169 + 3170 + .home-explore-cta { 3171 + /* Tight under YourChoice's footnote — tighten the top padding so 3172 + * the section doesn't feel orphaned, and keep generous bottom 3173 + * breathing room before the footer. */ 3174 + padding-top: 2rem; 3175 + } 3176 + 3177 + .home-explore-cta-button { 3178 + /* A bit chunkier than the default explore-cta-primary so it reads 3179 + * as a primary CTA from a distance. */ 3180 + padding: 0.95rem 1.9rem; 3181 + font-size: 1rem; 3182 + font-weight: 600; 3183 + gap: 0.6rem; 3184 + box-shadow: 0 12px 30px rgba(14, 20, 40, 0.1); 3185 + } 3186 + 3187 + .home-explore-cta-button:hover .home-explore-cta-arrow { 3188 + transform: translateX(3px); 3189 + } 3190 + 3191 + .home-explore-cta-arrow { 3192 + transition: transform 0.18s ease; 3193 + }
+19 -9
components/Footer.tsx
··· 1 1 import { useT } from "../i18n/mod.ts"; 2 2 import LocaleSwitcher from "./LocaleSwitcher.tsx"; 3 3 4 - export default function Footer() { 4 + interface FooterProps { 5 + /** 6 + * Compact footer for the explore section: drops the marketing 7 + * tagline and the closing pull-quote (those belong on the homepage) 8 + * but keeps the logo, link rail, locale switcher, and back-to-top 9 + * affordance so the page still has a proper foot. 10 + */ 11 + variant?: "default" | "compact"; 12 + } 13 + 14 + export default function Footer({ variant = "default" }: FooterProps = {}) { 5 15 const t = useT(); 16 + const compact = variant === "compact"; 6 17 return ( 7 18 <footer class="footer reveal"> 8 19 <div class="container text-center"> ··· 19 30 "brightness(0) saturate(100%) invert(12%) sepia(30%) saturate(1500%) hue-rotate(195deg) brightness(95%)", 20 31 }} 21 32 /> 22 - <p class="text-subsection mb-3">{t.footer.tagline}</p> 33 + {!compact && <p class="text-subsection mb-3">{t.footer.tagline}</p>} 23 34 <div class="footer-links"> 24 35 <a 25 36 href="https://atproto.com" ··· 28 39 > 29 40 {t.footer.links.atProtocol} 30 41 </a> 31 - <span 32 - class="footer-coming-soon" 33 - title={t.footer.links.exploreAppsTitle} 34 - > 35 - {t.footer.links.exploreApps} 36 - </span> 42 + {/* Hide on the explore section — visitors are already there, 43 + * so the link would just point at the page they're on. */} 44 + {!compact && ( 45 + <a href="/explore">{t.footer.links.exploreApps}</a> 46 + )} 37 47 <a href="/developer-resources">{t.footer.links.developerResources}</a> 38 48 </div> 39 - <p class="footer-quote">{t.footer.quote()}</p> 49 + {!compact && <p class="footer-quote">{t.footer.quote()}</p>} 40 50 <a href="#page-top" class="back-to-top mt-4"> 41 51 <svg 42 52 width="18"
+49
components/HomeExploreCta.tsx
··· 1 + import { useT } from "../i18n/mod.ts"; 2 + 3 + /** 4 + * Closing call-to-action on the marketing homepage. Sits between the 5 + * "Your account, your choice" section and the footer to give visitors 6 + * a clear next step into the explore registry. 7 + * 8 + * Only used on `/` — the explore pages already are the destination, 9 + * so there's no point repeating the CTA there. 10 + */ 11 + export default function HomeExploreCta() { 12 + const t = useT().homeCta; 13 + return ( 14 + <section class="section reveal home-explore-cta"> 15 + <div class="container text-center"> 16 + <h2 class="text-section">{t.headline}</h2> 17 + <div class="divider" /> 18 + <p 19 + class="text-body mt-2" 20 + style={{ maxWidth: "560px", margin: "1rem auto 0" }} 21 + > 22 + {t.body} 23 + </p> 24 + <p class="mt-4"> 25 + <a 26 + href="/explore" 27 + class="explore-cta-primary home-explore-cta-button" 28 + > 29 + {t.button} 30 + <svg 31 + class="home-explore-cta-arrow" 32 + width="18" 33 + height="18" 34 + viewBox="0 0 24 24" 35 + fill="none" 36 + stroke="currentColor" 37 + stroke-width="2" 38 + stroke-linecap="round" 39 + stroke-linejoin="round" 40 + aria-hidden="true" 41 + > 42 + <path d="M5 12h14M13 5l7 7-7 7" /> 43 + </svg> 44 + </a> 45 + </p> 46 + </div> 47 + </section> 48 + ); 49 + }
+32 -10
components/Nav.tsx
··· 1 1 import { useT } from "../i18n/mod.ts"; 2 + import AccountMenu from "../islands/AccountMenu.tsx"; 2 3 3 - export default function Nav() { 4 + interface NavProps { 5 + /** 6 + * When set, render the explore-page AccountMenu rail directly under 7 + * the protocol button. `user: null` shows a generic avatar with a 8 + * "Sign in" entry; an authenticated user shows their pic + manage / 9 + * sign-out actions. 10 + * 11 + * Pages that don't want the menu (the marketing homepage) just omit 12 + * this prop entirely. 13 + */ 14 + account?: { 15 + user: { did: string; handle: string } | null; 16 + avatarUrl?: string | null; 17 + publicProfileHandle?: string | null; 18 + }; 19 + } 20 + 21 + export default function Nav({ account }: NavProps = {}) { 4 22 const t = useT(); 5 23 return ( 6 24 <> ··· 10 28 <span class="nav-logo-text">{t.nav.brand}</span> 11 29 </a> 12 30 <div class="nav-links"> 13 - <a href="/explore" class="nav-btn nav-btn-ghost"> 31 + {/* Protocol moved to the footer — the top-right slot now 32 + * belongs to Explore (the primary call to action) with the 33 + * account button stacked beneath it via the rail below. */} 34 + <a href="/explore" class="nav-btn nav-btn-glass"> 14 35 {t.nav.explore} 15 36 </a> 16 - <a 17 - href="https://atproto.com" 18 - target="_blank" 19 - rel="noopener noreferrer" 20 - class="nav-btn nav-btn-glass" 21 - > 22 - {t.nav.protocol} 23 - </a> 24 37 </div> 25 38 </nav> 39 + {account !== undefined && ( 40 + <div class="account-menu-rail" id="account-menu-rail"> 41 + <AccountMenu 42 + user={account.user} 43 + avatarUrl={account.avatarUrl ?? null} 44 + publicProfileHandle={account.publicProfileHandle ?? null} 45 + /> 46 + </div> 47 + )} 26 48 <div class="nav-effects-bar" id="nav-effects-bar"> 27 49 <label class="nav-sky-switch-label"> 28 50 <span class="nav-sky-switch-text">{t.nav.effects}</span>
+14 -4
components/explore/ProfileCard.tsx
··· 7 7 8 8 export default function ProfileCard({ profile }: Props) { 9 9 const t = useT(); 10 - const tCat = t.categories; 11 - const categoryLabel = (tCat as Record<string, string>)[profile.category] ?? 12 - profile.category; 10 + const tCat = t.categories as Record<string, string>; 11 + /** Show every category the project applies to (e.g. "App + Account 12 + * provider"), capped to keep the card from getting busy. */ 13 + const cats = profile.categories.slice(0, 3); 13 14 const featured = profile.featured; 14 15 return ( 15 16 <a ··· 49 50 <p class="profile-card-handle">@{profile.handle}</p> 50 51 <p class="profile-card-description">{profile.description}</p> 51 52 <p class="profile-card-meta"> 52 - <span class="profile-card-category">{categoryLabel}</span> 53 + {cats.map((c) => ( 54 + <span key={c} class="profile-card-category"> 55 + {tCat[c] ?? c} 56 + </span> 57 + ))} 58 + {profile.openSource && ( 59 + <span class="profile-card-sub profile-card-sub--oss"> 60 + {t.explore.detail.openSourceBadge} 61 + </span> 62 + )} 53 63 {profile.subcategories.slice(0, 2).map((s) => { 54 64 const sub = (t.subcategories as Record<string, string>)[s] ?? s; 55 65 return (
+11 -3
components/explore/ProfileHero.tsx
··· 11 11 const tSub = t.subcategories as Record<string, string>; 12 12 const tBadges = t.badges; 13 13 const featured = profile.featured; 14 + const cats = profile.categories; 14 15 15 16 return ( 16 17 <div class="profile-hero glass"> ··· 45 46 </div> 46 47 <p class="profile-hero-handle">@{profile.handle}</p> 47 48 <div class="profile-hero-meta"> 48 - <span class="profile-card-category"> 49 - {tCat[profile.category] ?? profile.category} 50 - </span> 49 + {cats.map((c) => ( 50 + <span key={c} class="profile-card-category"> 51 + {tCat[c] ?? c} 52 + </span> 53 + ))} 54 + {profile.openSource && ( 55 + <span class="profile-card-sub profile-card-sub--oss"> 56 + {t.explore.detail.openSourceBadge} 57 + </span> 58 + )} 51 59 {profile.subcategories.map((s) => ( 52 60 <span key={s} class="profile-card-sub"> 53 61 {tSub[s] ?? s}
+40
components/explore/ProfileLinks.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 2 import { getBskyClient } from "../../lib/bsky-clients.ts"; 3 + import { 4 + detectRepoHost, 5 + trimRepoUrlForDisplay, 6 + } from "../../lib/repo-hosts.ts"; 3 7 import { useT } from "../../i18n/mod.ts"; 4 8 5 9 interface Props { ··· 19 23 const t = useT().explore.detail; 20 24 const client = getBskyClient(profile.bskyClient); 21 25 const bskyHref = client.profileUrl(profile.handle); 26 + const repoHost = profile.repoUrl ? detectRepoHost(profile.repoUrl) : null; 22 27 23 28 return ( 24 29 <div class="profile-actions"> ··· 56 61 <span class="profile-action-title">{t.website}</span> 57 62 <span class="profile-action-sub"> 58 63 {trimUrlForDisplay(profile.website)} 64 + </span> 65 + </span> 66 + </a> 67 + )} 68 + {profile.repoUrl && repoHost && ( 69 + <a 70 + class="profile-action" 71 + href={profile.repoUrl} 72 + target="_blank" 73 + rel="noopener noreferrer" 74 + > 75 + {repoHost.iconUrl 76 + ? ( 77 + <img 78 + src={repoHost.iconUrl} 79 + alt="" 80 + class="profile-action-icon" 81 + loading="lazy" 82 + decoding="async" 83 + /> 84 + ) 85 + : ( 86 + <span class="profile-action-icon profile-action-icon--glyph"> 87 + {/* Generic "code" glyph for self-hosted Forgejo / Gitea / etc. */} 88 + {"</>"} 89 + </span> 90 + )} 91 + <span class="profile-action-label"> 92 + <span class="profile-action-title"> 93 + {repoHost.id === "other" 94 + ? t.sourceCode 95 + : `${t.sourceOn} ${repoHost.name}`} 96 + </span> 97 + <span class="profile-action-sub"> 98 + {trimRepoUrlForDisplay(profile.repoUrl)} 59 99 </span> 60 100 </span> 61 101 </a>
+40 -11
i18n/messages/en.tsx
··· 34 34 "Effects on. Turn off to keep colors and clouds fixed like the first screen.", 35 35 effectsOff: 36 36 "Effects off. Sky matches the first-load colors and cloud positions.", 37 + account: { 38 + menuLabel: "Account menu", 39 + signedInAs: "Signed in as", 40 + signIn: "Sign in", 41 + signInHint: "Sign in with your Atmosphere account to publish a profile.", 42 + manageProfile: "Manage profile", 43 + viewProfile: "View profile", 44 + signOut: "Sign out", 45 + avatarAlt: "Account", 46 + }, 37 47 }, 38 48 39 49 hero: { ··· 218 228 "Account ownership, moderation, and algorithmic choice — the system is locked open by design.", 219 229 }, 220 230 231 + homeCta: { 232 + headline: "See what's been built.", 233 + body: 234 + "Browse apps, account providers, moderators, and infrastructure — all built on the same open foundation.", 235 + button: "Explore Apps", 236 + }, 237 + 221 238 footer: { 222 239 logoAlt: "Atmosphere", 223 240 tagline: "Building a better internet, owned by the people.", ··· 282 299 }, 283 300 }, 284 301 302 + /** 303 + * Category labels are intentionally singular — they tag *one* project 304 + * ("App", "Account provider", etc.). The Explore tabs use the same 305 + * labels for consistency; "All" stays as the catch-all entry. 306 + */ 285 307 categories: { 286 - app: "Apps", 287 - accountProvider: "Account providers", 288 - moderator: "Moderators", 308 + app: "App", 309 + accountProvider: "Account provider", 310 + moderator: "Moderator", 289 311 infrastructure: "Infrastructure", 290 312 all: "All", 291 313 }, ··· 332 354 detail: { 333 355 website: "Website", 334 356 openOn: "Open on", 357 + sourceOn: "Source on", 358 + sourceCode: "Source code", 359 + openSourceBadge: "Open source", 335 360 lastUpdated: "Last updated", 336 361 hostedOn: "Hosted on", 337 362 editProfile: "Edit this profile", ··· 346 371 headline: "Sign in with your project's Atmosphere account", 347 372 body: 348 373 "Anyone can list a project. Sign in with the account that controls the project — anyone with that account can publish or update the entry. Nothing else is written to your PDS.", 349 - handlePlaceholder: "yourproject.com or you.bsky.social", 374 + signInLabel: "Sign in with your Atmosphere handle", 375 + handlePlaceholder: "yourproject.com", 350 376 signIn: "Sign in", 351 377 configError: 352 378 "OAuth isn't configured on this deployment yet. Try again shortly.", 353 - whyHandle: 354 - "We resolve your handle to your atproto DID, then redirect you to your account's authorization server.", 355 379 previewLoading: "Looking up account…", 356 380 previewNotFound: "No account found for that handle.", 357 381 }, ··· 384 408 namePlaceholder: "e.g. Bluesky", 385 409 descriptionLabel: "Short description", 386 410 descriptionPlaceholder: "What does it do? Who's it for?", 387 - categoryLabel: "Category", 411 + categoryLabel: "Categories", 388 412 categoryHint: 389 - "Pick the best fit. A project can be both an app and an account provider.", 413 + "Pick all that apply. A project can be both an app and an account provider.", 390 414 subcategoriesLabel: "Subcategories (optional)", 391 415 subcategoriesHint: "For apps. Pick up to a few.", 392 416 websiteLabel: "Website", 393 417 websitePlaceholder: "https://yourproject.com", 418 + repoUrlLabel: "Source repository (optional)", 419 + repoUrlHint: 420 + "Tangled or GitHub URL — we'll show a button on your profile with the matching icon.", 421 + repoUrlPlaceholder: "https://github.com/yourorg/yourproject", 422 + openSourceLabel: "This project is open source", 423 + openSourceHint: 424 + "Adds a small \"Open source\" badge to your profile so visitors know.", 394 425 bskyClientLabel: "Bluesky client", 395 426 bskyClientHint: 396 427 "Pick which client opens when visitors click the Bluesky button on your profile. Your handle works on all of them.", 397 - tagsLabel: "Tags (optional)", 398 - tagsHint: "Comma-separated, up to 10. Helps people find you in search.", 399 - tagsPlaceholder: "open-source, indie, federated", 400 428 avatarLabel: "Project icon", 401 429 avatarHint: "PNG, JPEG, or WebP. 1MB max. Square works best.", 402 430 avatarReplace: "Replace icon", ··· 404 432 requiredHint: "Required", 405 433 avatarTooLarge: "Avatar must be 1MB or smaller.", 406 434 confirmDelete: "Remove your project from Explore?", 435 + categoryRequired: "Pick at least one category.", 407 436 }, 408 437 }, 409 438
+173
islands/AccountMenu.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { useEffect, useRef } from "preact/hooks"; 3 + import { useT } from "../i18n/mod.ts"; 4 + 5 + interface Props { 6 + /** null when signed out — drives whether the menu shows sign-in or 7 + * sign-out + manage actions. */ 8 + user: { did: string; handle: string } | null; 9 + /** 10 + * Server-resolved avatar URL (typically /api/me/avatar). Falls back 11 + * to a handle-initial pill if the image 404s or fails to load. 12 + */ 13 + avatarUrl?: string | null; 14 + /** 15 + * If we already know the user has a registry profile, link to the 16 + * public profile page from the menu so they can preview what others 17 + * see. Otherwise we just show "Manage profile". 18 + */ 19 + publicProfileHandle?: string | null; 20 + } 21 + 22 + export default function AccountMenu( 23 + { user, avatarUrl, publicProfileHandle }: Props, 24 + ) { 25 + const t = useT().nav.account; 26 + 27 + /** Signed out: a plain text link in the same slot the dropdown 28 + * occupies when authenticated. No glass pill — that styling is 29 + * reserved for the Explore CTA above and the avatar trigger that 30 + * appears post-sign-in. */ 31 + if (!user) { 32 + return ( 33 + <a 34 + href="/explore/create" 35 + class="nav-btn nav-btn-ghost account-menu-signin" 36 + > 37 + {t.signIn} 38 + </a> 39 + ); 40 + } 41 + 42 + return <SignedInMenu 43 + user={user} 44 + avatarUrl={avatarUrl ?? null} 45 + publicProfileHandle={publicProfileHandle ?? null} 46 + />; 47 + } 48 + 49 + interface SignedInMenuProps { 50 + user: { did: string; handle: string }; 51 + avatarUrl: string | null; 52 + publicProfileHandle: string | null; 53 + } 54 + 55 + function SignedInMenu( 56 + { user, avatarUrl, publicProfileHandle }: SignedInMenuProps, 57 + ) { 58 + const t = useT().nav.account; 59 + const open = useSignal(false); 60 + const avatarFailed = useSignal(false); 61 + 62 + const wrapRef = useRef<HTMLDivElement | null>(null); 63 + const triggerRef = useRef<HTMLButtonElement | null>(null); 64 + 65 + useEffect(() => { 66 + function onPointerDown(e: PointerEvent) { 67 + if (!wrapRef.current) return; 68 + const node = e.target; 69 + if (node instanceof Node && !wrapRef.current.contains(node)) { 70 + open.value = false; 71 + } 72 + } 73 + function onKey(e: KeyboardEvent) { 74 + if (e.key === "Escape" && open.value) { 75 + open.value = false; 76 + triggerRef.current?.focus(); 77 + } 78 + } 79 + document.addEventListener("pointerdown", onPointerDown); 80 + document.addEventListener("keydown", onKey); 81 + return () => { 82 + document.removeEventListener("pointerdown", onPointerDown); 83 + document.removeEventListener("keydown", onKey); 84 + }; 85 + }, []); 86 + 87 + /** First letter of the handle for the fallback avatar. For DIDs 88 + * (e.g. "did:plc:abc") fall back to "?". */ 89 + const initial = user.handle?.[0]?.toUpperCase() ?? "?"; 90 + const showImage = !!avatarUrl && !avatarFailed.value; 91 + 92 + return ( 93 + <div class="account-menu" ref={wrapRef}> 94 + <button 95 + ref={triggerRef} 96 + type="button" 97 + class="account-menu-trigger" 98 + aria-haspopup="menu" 99 + aria-expanded={open.value} 100 + aria-label={t.menuLabel} 101 + onClick={() => { 102 + open.value = !open.value; 103 + }} 104 + > 105 + <span class="account-menu-avatar" aria-hidden="true"> 106 + {showImage 107 + ? ( 108 + <img 109 + src={avatarUrl!} 110 + alt="" 111 + loading="eager" 112 + decoding="async" 113 + onError={() => { 114 + avatarFailed.value = true; 115 + }} 116 + /> 117 + ) 118 + : <span class="account-menu-avatar-initial">{initial}</span>} 119 + </span> 120 + <span class="account-menu-chevron" aria-hidden="true">▾</span> 121 + </button> 122 + 123 + {open.value && ( 124 + <div class="account-menu-popup glass" role="menu"> 125 + <div class="account-menu-header"> 126 + <span class="account-menu-header-label"> 127 + {t.signedInAs} 128 + </span> 129 + <span class="account-menu-header-handle"> 130 + @{user.handle} 131 + </span> 132 + </div> 133 + <div class="account-menu-divider" aria-hidden="true" /> 134 + {publicProfileHandle && ( 135 + <a 136 + href={`/explore/${encodeURIComponent(publicProfileHandle)}`} 137 + class="account-menu-item" 138 + role="menuitem" 139 + onClick={() => { 140 + open.value = false; 141 + }} 142 + > 143 + {t.viewProfile} 144 + </a> 145 + )} 146 + <a 147 + href="/explore/manage" 148 + class="account-menu-item" 149 + role="menuitem" 150 + onClick={() => { 151 + open.value = false; 152 + }} 153 + > 154 + {t.manageProfile} 155 + </a> 156 + <form 157 + method="POST" 158 + action="/oauth/logout" 159 + class="account-menu-form" 160 + > 161 + <button 162 + type="submit" 163 + class="account-menu-item account-menu-item-danger" 164 + role="menuitem" 165 + > 166 + {t.signOut} 167 + </button> 168 + </form> 169 + </div> 170 + )} 171 + </div> 172 + ); 173 + }
+93 -42
islands/CreateProfileForm.tsx
··· 11 11 interface ExistingProfile { 12 12 name: string; 13 13 description: string; 14 - category: string; 14 + /** All categories that apply to the project (always non-empty). The 15 + * first item is the primary, used for sort/grouping in lists. */ 16 + categories: string[]; 15 17 subcategories: string[]; 16 18 website: string | null; 19 + repoUrl: string | null; 20 + openSource: boolean; 17 21 bskyClient: string | null; 18 - tags: string[]; 19 22 avatar: { ref: string; mime: string } | null; 20 23 } 21 24 ··· 65 68 66 69 const name = useSignal(initial?.name ?? ""); 67 70 const description = useSignal(initial?.description ?? ""); 68 - const category = useSignal<string>(initial?.category ?? "app"); 71 + // categories is the source of truth — the lexicon requires it to be a 72 + // non-empty array. The first item is treated as the primary category. 73 + const categories = useSignal<string[]>( 74 + initial?.categories?.length ? initial.categories : ["app"], 75 + ); 69 76 const subcategories = useSignal<string[]>(initial?.subcategories ?? []); 70 77 const website = useSignal(initial?.website ?? ""); 78 + const repoUrl = useSignal(initial?.repoUrl ?? ""); 79 + const openSource = useSignal<boolean>(initial?.openSource ?? false); 71 80 const bskyClient = useSignal<string>( 72 81 initial?.bskyClient ?? DEFAULT_BSKY_CLIENT_ID, 73 82 ); 74 - const tagsText = useSignal((initial?.tags ?? []).join(", ")); 75 83 const avatarKeep = useSignal<BlobRefShape | null>(null); 76 84 /** Preview URL precedence: locally-picked file blob > existing registry 77 85 * record (cached proxy) > prefill source (Bluesky PDS getBlob) > none. */ ··· 109 117 } 110 118 }; 111 119 120 + const toggleCategory = (key: string) => { 121 + const current = categories.value; 122 + if (current.includes(key)) { 123 + // Don't let the user unselect their last remaining category — at 124 + // least one is required by the lexicon. 125 + if (current.length <= 1) return; 126 + categories.value = current.filter((k) => k !== key); 127 + } else { 128 + if (current.length >= 4) return; 129 + categories.value = [...current, key]; 130 + } 131 + }; 132 + 133 + /** 134 + * `app` is the only category with subcategories defined right now. If 135 + * the user deselects `app`, hide the subcategory chips by clearing the 136 + * underlying selection (kept as an effect-like helper so the form's 137 + * payload doesn't carry stale subcategories on submit). 138 + */ 139 + const showSubcategories = categories.value.includes("app"); 140 + 112 141 const onAvatarChange = (event: Event) => { 113 142 const input = event.currentTarget as HTMLInputElement; 114 143 const file = input.files?.[0]; ··· 133 162 const onSubmit = async (event: Event) => { 134 163 event.preventDefault(); 135 164 if (submitting.value) return; 165 + if (categories.value.length === 0) { 166 + message.value = { kind: "error", text: tForm.categoryRequired }; 167 + return; 168 + } 136 169 submitting.value = true; 137 170 message.value = null; 138 171 139 172 try { 140 - const tags = tagsText.value.split(",").map((s) => s.trim()).filter( 141 - Boolean, 142 - ).slice(0, 10); 143 173 const payload: Record<string, unknown> = { 144 174 name: name.value.trim(), 145 175 description: description.value.trim(), 146 - category: category.value, 147 - subcategories: subcategories.value, 176 + categories: categories.value, 177 + subcategories: showSubcategories ? subcategories.value : [], 148 178 website: website.value.trim() || undefined, 179 + repoUrl: repoUrl.value.trim() || undefined, 180 + openSource: openSource.value, 149 181 bskyClient: bskyClient.value || undefined, 150 - tags, 151 182 }; 152 183 if (avatarFile.value) { 153 184 payload.avatarUpload = { ··· 300 331 301 332 <fieldset class="profile-form-field"> 302 333 <legend class="profile-form-label">{tForm.categoryLabel}</legend> 303 - <div class="profile-form-chips"> 304 - {CATEGORIES.map((c: Category) => ( 305 - <label 306 - key={c} 307 - class={`profile-form-chip ${ 308 - category.value === c ? "is-selected" : "" 309 - }`} 310 - > 311 - <input 312 - type="radio" 313 - name="category" 314 - value={c} 315 - checked={category.value === c} 316 - onChange={() => category.value = c} 317 - /> 318 - <span>{t.categories[c]}</span> 319 - </label> 320 - ))} 334 + <div class="profile-form-chips" role="group"> 335 + {CATEGORIES.map((c: Category) => { 336 + const selected = categories.value.includes(c); 337 + return ( 338 + <label 339 + key={c} 340 + class={`profile-form-chip ${ 341 + selected ? "is-selected" : "" 342 + }`} 343 + > 344 + <input 345 + type="checkbox" 346 + name="categories" 347 + value={c} 348 + checked={selected} 349 + onChange={() => toggleCategory(c)} 350 + /> 351 + <span>{t.categories[c]}</span> 352 + </label> 353 + ); 354 + })} 321 355 </div> 322 356 <p class="profile-form-hint">{tForm.categoryHint}</p> 323 357 </fieldset> 324 358 325 - {category.value === "app" && ( 359 + {showSubcategories && ( 326 360 <fieldset class="profile-form-field"> 327 361 <legend class="profile-form-label"> 328 362 {tForm.subcategoriesLabel} ··· 357 391 /> 358 392 </label> 359 393 394 + <label class="profile-form-field"> 395 + <span class="profile-form-label">{tForm.repoUrlLabel}</span> 396 + <input 397 + type="url" 398 + placeholder={tForm.repoUrlPlaceholder} 399 + value={repoUrl.value} 400 + onInput={(e) => 401 + repoUrl.value = (e.currentTarget as HTMLInputElement).value} 402 + class="profile-form-input" 403 + /> 404 + <p class="profile-form-hint">{tForm.repoUrlHint}</p> 405 + </label> 406 + 407 + <label class="profile-form-toggle"> 408 + <input 409 + type="checkbox" 410 + checked={openSource.value} 411 + onChange={(e) => 412 + openSource.value = (e.currentTarget as HTMLInputElement).checked} 413 + /> 414 + <span class="profile-form-toggle-body"> 415 + <span class="profile-form-toggle-label"> 416 + {tForm.openSourceLabel} 417 + </span> 418 + <span class="profile-form-toggle-hint"> 419 + {tForm.openSourceHint} 420 + </span> 421 + </span> 422 + </label> 423 + 360 424 <fieldset class="profile-form-field"> 361 425 <legend class="profile-form-label">{tForm.bskyClientLabel}</legend> 362 426 <p class="profile-form-hint">{tForm.bskyClientHint}</p> ··· 392 456 })} 393 457 </div> 394 458 </fieldset> 395 - 396 - <label class="profile-form-field"> 397 - <span class="profile-form-label">{tForm.tagsLabel}</span> 398 - <input 399 - type="text" 400 - placeholder={tForm.tagsPlaceholder} 401 - value={tagsText.value} 402 - onInput={(e) => 403 - tagsText.value = (e.currentTarget as HTMLInputElement).value} 404 - class="profile-form-input" 405 - /> 406 - <p class="profile-form-hint">{tForm.tagsHint}</p> 407 - </label> 408 459 </div> 409 460 </div> 410 461
+1 -2
islands/SignInForm.tsx
··· 125 125 > 126 126 <div class="signin-form-preview-wrap" ref={wrapRef}> 127 127 <label class="signin-form-label" for="signin-handle"> 128 - {t.explore.create.handlePlaceholder} 128 + {t.explore.create.signInLabel} 129 129 </label> 130 130 <div class="signin-form-row"> 131 131 <div style={{ flex: 1, minWidth: 0, position: "relative" }}> ··· 249 249 </div> 250 250 </div> 251 251 {error.value && <p class="signin-form-error">{error.value}</p>} 252 - <p class="signin-form-hint">{t.explore.create.whyHandle}</p> 253 252 </form> 254 253 ); 255 254 }
+25 -18
lexicons/com/atmosphereaccount/registry/profile.json
··· 8 8 "key": "literal:self", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["name", "description", "category", "createdAt"], 11 + "required": ["name", "description", "categories", "createdAt"], 12 12 "properties": { 13 13 "name": { 14 14 "type": "string", ··· 30 30 "maxSize": 1000000, 31 31 "description": "Project icon. Recommended 512x512 square." 32 32 }, 33 - "category": { 34 - "type": "string", 35 - "knownValues": [ 36 - "app", 37 - "accountProvider", 38 - "moderator", 39 - "infrastructure" 40 - ], 41 - "description": "Top-level category in the registry." 33 + "categories": { 34 + "type": "array", 35 + "minLength": 1, 36 + "maxLength": 4, 37 + "items": { 38 + "type": "string", 39 + "knownValues": [ 40 + "app", 41 + "accountProvider", 42 + "moderator", 43 + "infrastructure" 44 + ] 45 + }, 46 + "description": "All categories that apply to the project. A project can be both an app and an account provider, etc. The first item is treated as the primary category for sort/grouping." 42 47 }, 43 48 "subcategories": { 44 49 "type": "array", ··· 54 59 "format": "uri", 55 60 "maxLength": 256 56 61 }, 62 + "repoUrl": { 63 + "type": "string", 64 + "format": "uri", 65 + "maxLength": 256, 66 + "description": "Source repository URL. Tangled and GitHub URLs render with the matching icon on the public profile." 67 + }, 68 + "openSource": { 69 + "type": "boolean", 70 + "description": "True if the project is open source. Adds an \"Open source\" badge on the public profile." 71 + }, 57 72 "bskyClient": { 58 73 "type": "string", 59 74 "knownValues": [ ··· 64 79 "witchsky" 65 80 ], 66 81 "description": "Preferred Bluesky-compatible client to open when visitors click the project's Bluesky link. Identity (handle) is the same across all clients." 67 - }, 68 - "tags": { 69 - "type": "array", 70 - "maxLength": 10, 71 - "items": { 72 - "type": "string", 73 - "maxLength": 32 74 - } 75 82 }, 76 83 "createdAt": { 77 84 "type": "string",
+33 -14
lib/db.ts
··· 70 70 handle TEXT NOT NULL, 71 71 name TEXT NOT NULL, 72 72 description TEXT NOT NULL, 73 - category TEXT NOT NULL, 73 + categories TEXT NOT NULL DEFAULT '[]', 74 74 subcategories TEXT NOT NULL DEFAULT '[]', 75 75 website TEXT, 76 + repo_url TEXT, 77 + open_source INTEGER NOT NULL DEFAULT 0, 76 78 bsky_client TEXT, 77 - tags TEXT NOT NULL DEFAULT '[]', 78 79 avatar_cid TEXT, 79 80 avatar_mime TEXT, 80 81 pds_url TEXT NOT NULL, ··· 83 84 created_at INTEGER NOT NULL, 84 85 indexed_at INTEGER NOT NULL 85 86 )`, 86 - `CREATE INDEX IF NOT EXISTS profile_category ON profile(category)`, 87 87 `CREATE INDEX IF NOT EXISTS profile_handle ON profile(handle)`, 88 88 `CREATE VIRTUAL TABLE IF NOT EXISTS profile_fts USING fts5( 89 - name, description, tags, content='profile', content_rowid='rowid' 89 + name, description, content='profile', content_rowid='rowid' 90 90 )`, 91 91 `CREATE TRIGGER IF NOT EXISTS profile_ai AFTER INSERT ON profile BEGIN 92 - INSERT INTO profile_fts(rowid, name, description, tags) 93 - VALUES (new.rowid, new.name, new.description, new.tags); 92 + INSERT INTO profile_fts(rowid, name, description) 93 + VALUES (new.rowid, new.name, new.description); 94 94 END`, 95 95 `CREATE TRIGGER IF NOT EXISTS profile_ad AFTER DELETE ON profile BEGIN 96 - INSERT INTO profile_fts(profile_fts, rowid, name, description, tags) 97 - VALUES('delete', old.rowid, old.name, old.description, old.tags); 96 + INSERT INTO profile_fts(profile_fts, rowid, name, description) 97 + VALUES('delete', old.rowid, old.name, old.description); 98 98 END`, 99 99 `CREATE TRIGGER IF NOT EXISTS profile_au AFTER UPDATE ON profile BEGIN 100 - INSERT INTO profile_fts(profile_fts, rowid, name, description, tags) 101 - VALUES('delete', old.rowid, old.name, old.description, old.tags); 102 - INSERT INTO profile_fts(rowid, name, description, tags) 103 - VALUES (new.rowid, new.name, new.description, new.tags); 100 + INSERT INTO profile_fts(profile_fts, rowid, name, description) 101 + VALUES('delete', old.rowid, old.name, old.description); 102 + INSERT INTO profile_fts(rowid, name, description) 103 + VALUES (new.rowid, new.name, new.description); 104 104 END`, 105 105 `CREATE TABLE IF NOT EXISTS featured ( 106 106 did TEXT PRIMARY KEY, ··· 140 140 /** 141 141 * Additive migrations applied after the base schema. SQLite has no 142 142 * `ADD COLUMN IF NOT EXISTS`, so we attempt the ALTER and swallow the 143 - * "duplicate column" error. Drop columns are no-ops in SQLite (the 144 - * unused legacy columns simply stay around, holding NULLs). 143 + * "duplicate column" error. SQLite makes column drops painful, so legacy 144 + * columns we no longer use (e.g. the old single-value `category`, `tags`) 145 + * are just left around and ignored — running `scripts/wipe-registry.ts` 146 + * recreates the table cleanly when desired. 145 147 */ 146 148 async function applyAdditiveMigrations( 147 149 c: { execute: (s: string) => Promise<unknown> }, ··· 152 154 table: "profile", 153 155 column: "bsky_client", 154 156 ddl: "ALTER TABLE profile ADD COLUMN bsky_client TEXT", 157 + }, 158 + { 159 + table: "profile", 160 + column: "categories", 161 + ddl: 162 + "ALTER TABLE profile ADD COLUMN categories TEXT NOT NULL DEFAULT '[]'", 163 + }, 164 + { 165 + table: "profile", 166 + column: "repo_url", 167 + ddl: "ALTER TABLE profile ADD COLUMN repo_url TEXT", 168 + }, 169 + { 170 + table: "profile", 171 + column: "open_source", 172 + ddl: 173 + "ALTER TABLE profile ADD COLUMN open_source INTEGER NOT NULL DEFAULT 0", 155 174 }, 156 175 ]; 157 176 for (const m of additiveColumns) {
+41 -22
lib/lexicons.ts
··· 50 50 name: string; 51 51 description: string; 52 52 avatar?: BlobRef; 53 - category: Category | string; 53 + /** All categories that apply to the project (1-4). The first item is the 54 + * primary category used for sort/grouping in lists. */ 55 + categories: string[]; 54 56 subcategories?: string[]; 55 57 website?: string; 58 + /** Source repo URL — host (GitHub / Tangled / other) is auto-detected. */ 59 + repoUrl?: string; 60 + /** True if the project is open source (drives the small profile badge). */ 61 + openSource?: boolean; 56 62 /** Preferred Bluesky client (bluesky | blacksky | anisota | deer | witchsky). */ 57 63 bskyClient?: string; 58 - tags?: string[]; 59 64 createdAt: string; 60 65 } 61 66 ··· 127 132 ) { 128 133 return { ok: false, error: "description: 1..500 chars required" }; 129 134 } 130 - if ( 131 - !isStr(v.category) || 132 - !(CATEGORIES as readonly string[]).includes(v.category as string) 133 - ) { 134 - return { 135 - ok: false, 136 - error: `category must be one of ${CATEGORIES.join(", ")}`, 137 - }; 135 + // categories[]: required, deduped, every entry must be a known CATEGORY. 136 + // The first entry is treated as the primary category by the UI. 137 + let normalizedCategories: string[]; 138 + { 139 + if (!Array.isArray(v.categories) || v.categories.length === 0) { 140 + return { ok: false, error: "categories: non-empty array required" }; 141 + } 142 + if (v.categories.length > 4) { 143 + return { ok: false, error: "categories: at most 4" }; 144 + } 145 + const seen = new Set<string>(); 146 + const out: string[] = []; 147 + for (const c of v.categories) { 148 + if (!isStr(c) || !(CATEGORIES as readonly string[]).includes(c)) { 149 + return { 150 + ok: false, 151 + error: `categories: items must be one of ${CATEGORIES.join(", ")}`, 152 + }; 153 + } 154 + if (!seen.has(c)) { 155 + seen.add(c); 156 + out.push(c); 157 + } 158 + } 159 + normalizedCategories = out; 138 160 } 139 161 if (!isStr(v.createdAt)) { 140 162 return { ok: false, error: "createdAt required (ISO 8601)" }; ··· 144 166 } 145 167 if (v.website !== undefined && !isUrl(v.website)) { 146 168 return { ok: false, error: "website: must be http(s) URL" }; 169 + } 170 + if (v.repoUrl !== undefined && !isUrl(v.repoUrl)) { 171 + return { ok: false, error: "repoUrl: must be http(s) URL" }; 172 + } 173 + if (v.openSource !== undefined && typeof v.openSource !== "boolean") { 174 + return { ok: false, error: "openSource: must be boolean" }; 147 175 } 148 176 if ( 149 177 v.bskyClient !== undefined && ··· 168 196 } 169 197 } 170 198 } 171 - if (v.tags !== undefined) { 172 - if (!Array.isArray(v.tags) || v.tags.length > 10) { 173 - return { ok: false, error: "tags: array of <=10 strings" }; 174 - } 175 - for (const s of v.tags) { 176 - if (!isStr(s, 32)) { 177 - return { ok: false, error: "tags: items must be strings <=32" }; 178 - } 179 - } 180 - } 181 199 182 200 return { 183 201 ok: true, ··· 186 204 name: v.name as string, 187 205 description: v.description as string, 188 206 avatar: v.avatar as BlobRef | undefined, 189 - category: v.category as string, 207 + categories: normalizedCategories, 190 208 subcategories: v.subcategories as string[] | undefined, 191 209 website: v.website as string | undefined, 210 + repoUrl: v.repoUrl as string | undefined, 211 + openSource: v.openSource as boolean | undefined, 192 212 bskyClient: v.bskyClient as string | undefined, 193 - tags: v.tags as string[] | undefined, 194 213 createdAt: v.createdAt as string, 195 214 }, 196 215 };
+46 -17
lib/registry.ts
··· 4 4 */ 5 5 import type { InValue } from "@libsql/client"; 6 6 import { withDb } from "./db.ts"; 7 - import type { Category, FeaturedBadge } from "./lexicons.ts"; 7 + import type { FeaturedBadge } from "./lexicons.ts"; 8 8 9 9 export interface ProfileRow { 10 10 did: string; 11 11 handle: string; 12 12 name: string; 13 13 description: string; 14 - category: Category | string; 14 + /** All categories that apply (always non-empty). The first item is the 15 + * primary category used for sort/grouping in lists. */ 16 + categories: string[]; 15 17 subcategories: string[]; 16 18 website: string | null; 19 + repoUrl: string | null; 20 + openSource: boolean; 17 21 bskyClient: string | null; 18 - tags: string[]; 19 22 avatarCid: string | null; 20 23 avatarMime: string | null; 21 24 pdsUrl: string; ··· 35 38 handle: string; 36 39 name: string; 37 40 description: string; 38 - category: string; 41 + categories: string; 39 42 subcategories: string; 40 43 website: string | null; 44 + repo_url: string | null; 45 + open_source: number | null; 41 46 bsky_client: string | null; 42 - tags: string; 43 47 avatar_cid: string | null; 44 48 avatar_mime: string | null; 45 49 pds_url: string; ··· 67 71 handle: r.handle, 68 72 name: r.name, 69 73 description: r.description, 70 - category: r.category, 74 + categories: safeJsonArray(r.categories), 71 75 subcategories: safeJsonArray(r.subcategories), 72 76 website: r.website, 77 + repoUrl: r.repo_url, 78 + openSource: Number(r.open_source ?? 0) === 1, 73 79 bskyClient: r.bsky_client, 74 - tags: safeJsonArray(r.tags), 75 80 avatarCid: r.avatar_cid, 76 81 avatarMime: r.avatar_mime, 77 82 pdsUrl: r.pds_url, ··· 94 99 handle: string; 95 100 name: string; 96 101 description: string; 97 - category: string; 102 + /** Required: 1-4 known category strings. The first is the primary. */ 103 + categories: string[]; 98 104 subcategories: string[]; 99 105 website?: string | null; 106 + repoUrl?: string | null; 107 + openSource?: boolean | null; 100 108 bskyClient?: string | null; 101 - tags: string[]; 102 109 avatarCid?: string | null; 103 110 avatarMime?: string | null; 104 111 pdsUrl: string; ··· 109 116 110 117 export async function upsertProfile(input: UpsertProfileInput): Promise<void> { 111 118 const now = Date.now(); 119 + // Defensive dedupe + drop empties; the lexicon validator already does 120 + // this but the worker also calls upsertProfile from the Jetstream path, 121 + // and the registry invariant (categories non-empty) is worth enforcing 122 + // close to the SQL. 123 + const cats = (() => { 124 + const seen = new Set<string>(); 125 + const out: string[] = []; 126 + for (const c of input.categories) { 127 + if (typeof c === "string" && c && !seen.has(c)) { 128 + seen.add(c); 129 + out.push(c); 130 + } 131 + } 132 + return out; 133 + })(); 134 + if (cats.length === 0) { 135 + throw new Error("upsertProfile: categories[] is required and non-empty"); 136 + } 112 137 await withDb(async (c) => { 113 138 await c.execute({ 114 139 sql: ` 115 140 INSERT INTO profile ( 116 - did, handle, name, description, category, subcategories, 117 - website, bsky_client, tags, 141 + did, handle, name, description, categories, subcategories, 142 + website, repo_url, open_source, bsky_client, 118 143 avatar_cid, avatar_mime, pds_url, record_cid, record_rev, 119 144 created_at, indexed_at 120 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 145 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 121 146 ON CONFLICT(did) DO UPDATE SET 122 147 handle=excluded.handle, 123 148 name=excluded.name, 124 149 description=excluded.description, 125 - category=excluded.category, 150 + categories=excluded.categories, 126 151 subcategories=excluded.subcategories, 127 152 website=excluded.website, 153 + repo_url=excluded.repo_url, 154 + open_source=excluded.open_source, 128 155 bsky_client=excluded.bsky_client, 129 - tags=excluded.tags, 130 156 avatar_cid=excluded.avatar_cid, 131 157 avatar_mime=excluded.avatar_mime, 132 158 pds_url=excluded.pds_url, ··· 140 166 input.handle, 141 167 input.name, 142 168 input.description, 143 - input.category, 169 + JSON.stringify(cats), 144 170 JSON.stringify(input.subcategories ?? []), 145 171 input.website ?? null, 172 + input.repoUrl ?? null, 173 + input.openSource ? 1 : 0, 146 174 input.bskyClient ?? null, 147 - JSON.stringify(input.tags ?? []), 148 175 input.avatarCid ?? null, 149 176 input.avatarMime ?? null, 150 177 input.pdsUrl, ··· 226 253 args.push(`${q}*`); 227 254 } 228 255 if (opts.category) { 229 - where.push(`p.category = ?`); 256 + where.push( 257 + `EXISTS (SELECT 1 FROM json_each(p.categories) WHERE value = ?)`, 258 + ); 230 259 args.push(opts.category); 231 260 } 232 261 if (opts.subcategory) {
+100
lib/repo-hosts.ts
··· 1 + /** 2 + * Auto-detected source-repo hosts. A project's profile carries a single 3 + * `repoUrl`; we pick the right icon + label based on the URL host so the 4 + * UI stays simple (no extra picker the way Bluesky-clients have one). 5 + * 6 + * Currently recognised: 7 + * - GitHub (github.com) 8 + * - Tangled (tangled.org / *.tngl.sh — the AT Protocol "social coding" 9 + * platform: https://tangled.org/) 10 + * 11 + * Anything else falls back to a generic "code" host with the website-style 12 + * arrow glyph so the button still works for self-hosted Forgejo/Gitea/etc. 13 + * 14 + * Single source of truth used by: 15 + * - the public profile detail page (components/explore/ProfileLinks.tsx) 16 + * - the create / manage form helper text (forms.profile.repoUrlHint) 17 + */ 18 + 19 + export type RepoHostId = "github" | "tangled" | "other"; 20 + 21 + export interface RepoHost { 22 + id: RepoHostId; 23 + /** Display name used in the button label ("Source on Tangled"). */ 24 + name: string; 25 + /** Bare hostname-ish label shown in the button subtitle. */ 26 + domain: string; 27 + /** 28 + * Icon for the button. We use Google's S2 favicon CDN for known hosts 29 + * so we don't have to ship per-host artwork; if a host's favicon ever 30 + * misbehaves we can swap in a local asset (mirroring `bsky-clients`). 31 + */ 32 + iconUrl: string | null; 33 + } 34 + 35 + const faviconFor = (domain: string, size = 64): string => 36 + `https://www.google.com/s2/favicons?sz=${size}&domain=${ 37 + encodeURIComponent(domain) 38 + }`; 39 + 40 + const HOSTS: Record<Exclude<RepoHostId, "other">, RepoHost> = { 41 + github: { 42 + id: "github", 43 + name: "GitHub", 44 + domain: "github.com", 45 + iconUrl: faviconFor("github.com"), 46 + }, 47 + tangled: { 48 + id: "tangled", 49 + name: "Tangled", 50 + domain: "tangled.org", 51 + iconUrl: faviconFor("tangled.org"), 52 + }, 53 + }; 54 + 55 + const FALLBACK: RepoHost = { 56 + id: "other", 57 + name: "Source", 58 + domain: "", 59 + iconUrl: null, 60 + }; 61 + 62 + /** 63 + * Detect which known repo host (if any) a URL belongs to. Match is purely 64 + * by hostname suffix so subdomains (`*.tngl.sh`) and locale variants 65 + * (`gh.io`-style mirrors) both work. 66 + */ 67 + export function detectRepoHost(url: string | null | undefined): RepoHost { 68 + if (!url) return FALLBACK; 69 + let host = ""; 70 + try { 71 + host = new URL(url).hostname.toLowerCase(); 72 + } catch { 73 + return FALLBACK; 74 + } 75 + if (host === "github.com" || host.endsWith(".github.com")) { 76 + return HOSTS.github; 77 + } 78 + // Tangled uses both tangled.org (web) and *.tngl.sh (handle-based subdomains 79 + // for Tangled Sites); accept either as the canonical host. 80 + if ( 81 + host === "tangled.org" || 82 + host.endsWith(".tangled.org") || 83 + host === "tngl.sh" || 84 + host.endsWith(".tngl.sh") 85 + ) { 86 + // Show whichever host the URL actually used so the subtitle stays honest. 87 + return { ...HOSTS.tangled, domain: host }; 88 + } 89 + return { ...FALLBACK, domain: host }; 90 + } 91 + 92 + /** Trim a URL down to host + path for the button subtitle. */ 93 + export function trimRepoUrlForDisplay(url: string): string { 94 + try { 95 + const u = new URL(url); 96 + return `${u.host}${u.pathname.replace(/\/$/, "")}`; 97 + } catch { 98 + return url; 99 + } 100 + }
+77
routes/api/me/avatar.ts
··· 1 + /** 2 + * Avatar for the currently signed-in user, used by the explore-page 3 + * AccountMenu. Resolution order: 4 + * 5 + * 1. Registry profile avatar (proxied through /api/registry/avatar/:did, 6 + * so we benefit from its cache headers + ETag). 7 + * 2. Bluesky `app.bsky.actor.profile` avatar fetched from the user's 8 + * PDS via getBlob — covers the case where the user has signed in 9 + * but hasn't published a registry profile yet. 10 + * 3. 404 — the AccountMenu falls back to a handle-initial avatar. 11 + * 12 + * No request body, no params: identity comes from the session cookie via 13 + * `ctx.state.user`. Cached aggressively because avatars rarely change 14 + * and the registry/PDS endpoints already return long-lived blobs. 15 + */ 16 + import { define } from "../../../utils.ts"; 17 + import { getProfileByDid } from "../../../lib/registry.ts"; 18 + import { loadSession } from "../../../lib/oauth.ts"; 19 + import { fetchBlobPublic, getBskyProfile } from "../../../lib/pds.ts"; 20 + 21 + const NOT_FOUND = new Response("not found", { status: 404 }); 22 + 23 + export const handler = define.handlers({ 24 + async GET(ctx) { 25 + const user = ctx.state.user; 26 + if (!user) return NOT_FOUND; 27 + 28 + /** Prefer the registry-cached avatar — it's already proxied with 29 + * long cache headers and an ETag, and works even if the user later 30 + * signs out (still public). Internal redirect (303) keeps this 31 + * route cheap; the browser follows once and then caches the 32 + * resolved URL. */ 33 + const profile = await getProfileByDid(user.did).catch(() => null); 34 + if (profile?.avatarCid) { 35 + return new Response(null, { 36 + status: 302, 37 + headers: { 38 + location: `/api/registry/avatar/${encodeURIComponent(user.did)}`, 39 + "cache-control": 40 + "private, max-age=300, stale-while-revalidate=86400", 41 + }, 42 + }); 43 + } 44 + 45 + /** No registry profile yet — fall back to the user's Bluesky avatar 46 + * on their PDS, so the menu still shows something familiar after 47 + * their first sign-in. We stream the bytes directly because the 48 + * PDS getBlob URL isn't cacheable on its own (it's pinned to the 49 + * CID though, so once we've seen it we can cache it here). */ 50 + const session = await loadSession(user.did).catch(() => null); 51 + if (!session) return NOT_FOUND; 52 + const bsky = await getBskyProfile(session.pdsUrl, user.did).catch(() => 53 + null 54 + ); 55 + const cid = bsky?.avatar?.ref.$link; 56 + if (!bsky || !cid) return NOT_FOUND; 57 + 58 + try { 59 + const upstream = await fetchBlobPublic(session.pdsUrl, user.did, cid); 60 + if (!upstream.ok) return NOT_FOUND; 61 + const headers = new Headers(); 62 + headers.set( 63 + "content-type", 64 + upstream.headers.get("content-type") ?? bsky.avatar?.mimeType ?? 65 + "application/octet-stream", 66 + ); 67 + headers.set( 68 + "cache-control", 69 + "private, max-age=600, stale-while-revalidate=86400", 70 + ); 71 + headers.set("etag", cid); 72 + return new Response(upstream.body, { status: 200, headers }); 73 + } catch { 74 + return NOT_FOUND; 75 + } 76 + }, 77 + });
+31 -6
routes/api/registry/profile.ts
··· 20 20 interface ProfileFormPayload { 21 21 name?: string; 22 22 description?: string; 23 - category?: string; 23 + /** Required multi-select. The first entry is the primary category. */ 24 + categories?: string[]; 24 25 subcategories?: string[]; 25 26 website?: string; 27 + repoUrl?: string; 28 + openSource?: boolean; 26 29 bskyClient?: string; 27 - tags?: string[]; 28 30 /** Either keep an existing avatar (passed as the BlobRef) or upload new bytes */ 29 31 avatar?: { 30 32 $type: "blob"; ··· 92 94 } 93 95 } 94 96 97 + // Dedupe categories defensively. The lexicon validator below also 98 + // does this, but normalising here means we surface a clean 400 ("at 99 + // least one category") instead of a validator error string. 100 + const normalizedCategories = (() => { 101 + const raw = Array.isArray(body.categories) 102 + ? body.categories.filter((x): x is string => typeof x === "string") 103 + : []; 104 + const seen = new Set<string>(); 105 + const out: string[] = []; 106 + for (const c of raw) { 107 + const t = c.trim(); 108 + if (t && !seen.has(t)) { 109 + seen.add(t); 110 + out.push(t); 111 + } 112 + } 113 + return out; 114 + })(); 115 + 95 116 const draft: ProfileRecord = { 96 117 name: trimOrNull(body.name) ?? "", 97 118 description: trimOrNull(body.description) ?? "", 98 - category: trimOrNull(body.category) ?? "", 119 + categories: normalizedCategories, 99 120 subcategories: asArray(body.subcategories), 100 121 website: trimOrNull(body.website), 122 + repoUrl: trimOrNull(body.repoUrl), 123 + openSource: typeof body.openSource === "boolean" 124 + ? body.openSource 125 + : undefined, 101 126 bskyClient: trimOrNull(body.bskyClient), 102 - tags: asArray(body.tags), 103 127 avatar: avatar ?? undefined, 104 128 createdAt: new Date().toISOString(), 105 129 }; ··· 140 164 handle: user.handle, 141 165 name: validation.value.name, 142 166 description: validation.value.description, 143 - category: validation.value.category, 167 + categories: validation.value.categories, 144 168 subcategories: validation.value.subcategories ?? [], 145 169 website: validation.value.website ?? null, 170 + repoUrl: validation.value.repoUrl ?? null, 171 + openSource: validation.value.openSource ?? false, 146 172 bskyClient: validation.value.bskyClient ?? null, 147 - tags: validation.value.tags ?? [], 148 173 avatarCid: validation.value.avatar?.ref.$link ?? null, 149 174 avatarMime: validation.value.avatar?.mimeType ?? null, 150 175 pdsUrl: session.pdsUrl,
+25 -4
routes/explore.tsx
··· 8 8 import FeaturedRail from "../components/explore/FeaturedRail.tsx"; 9 9 import ProfileGrid from "../components/explore/ProfileGrid.tsx"; 10 10 import { 11 + getProfileByDid, 11 12 listFeaturedProfiles, 12 13 type ProfileRow, 13 14 searchProfiles, ··· 24 25 profiles: ProfileRow[]; 25 26 featured: ProfileRow[]; 26 27 signedIn: boolean; 28 + account: { 29 + user: { did: string; handle: string } | null; 30 + avatarUrl: string | null; 31 + publicProfileHandle: string | null; 32 + }; 27 33 } 28 34 29 35 export const handler = define.handlers({ ··· 38 44 const query = url.searchParams.get("q")?.trim() ?? ""; 39 45 const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1); 40 46 41 - const [search, featured] = await Promise.all([ 47 + /** When signed in, look up the registry profile alongside the 48 + * search/featured queries so the AccountMenu can show the user's 49 + * registered handle (and link to their public profile) in one 50 + * round-trip instead of an extra request after page load. */ 51 + const user = ctx.state.user; 52 + const ownerProfilePromise = user 53 + ? getProfileByDid(user.did).catch(() => null) 54 + : Promise.resolve(null); 55 + 56 + const [search, featured, ownerProfile] = await Promise.all([ 42 57 searchProfiles({ 43 58 query: query || undefined, 44 59 category: category ?? undefined, ··· 52 67 !category && !query 53 68 ? listFeaturedProfiles(8).catch(() => []) 54 69 : Promise.resolve([] as ProfileRow[]), 70 + ownerProfilePromise, 55 71 ]); 56 72 57 73 const data: ExploreData = { ··· 63 79 total: search.total, 64 80 profiles: search.profiles, 65 81 featured, 66 - signedIn: !!ctx.state.user, 82 + signedIn: !!user, 83 + account: { 84 + user: user ? { did: user.did, handle: user.handle } : null, 85 + avatarUrl: user ? "/api/me/avatar" : null, 86 + publicProfileHandle: ownerProfile?.handle ?? null, 87 + }, 67 88 }; 68 89 return ctx.render(<ExplorePage data={data} locale={ctx.state.locale} />); 69 90 }, ··· 79 100 <div id="page-top"> 80 101 <GlassClouds /> 81 102 <div class="content-layer"> 82 - <Nav /> 103 + <Nav account={data.account} /> 83 104 <StoreHero 84 105 initialQuery={data.query} 85 106 signedIn={data.signedIn} ··· 104 125 </div> 105 126 </section> 106 127 107 - <Footer /> 128 + <Footer variant="compact" /> 108 129 </div> 109 130 </div> 110 131 );
+56 -12
routes/explore/[handle].tsx
··· 6 6 import ProfileLinks from "../../components/explore/ProfileLinks.tsx"; 7 7 import { getMessages } from "../../i18n/mod.ts"; 8 8 import type { Locale } from "../../i18n/mod.ts"; 9 - import { getProfileByHandle, type ProfileRow } from "../../lib/registry.ts"; 9 + import { 10 + getProfileByDid, 11 + getProfileByHandle, 12 + type ProfileRow, 13 + } from "../../lib/registry.ts"; 10 14 11 15 export const handler = define.handlers({ 12 16 async GET(ctx) { 13 17 const handle = decodeURIComponent(ctx.params.handle).toLowerCase(); 14 - const profile = await getProfileByHandle(handle).catch(() => null); 18 + const user = ctx.state.user; 19 + /** Pull the profile being viewed and (in parallel) the signed-in 20 + * user's own registry entry so the AccountMenu can deep-link to 21 + * their public page. The lookups are cheap and trigger from the 22 + * same DB connection. */ 23 + const [profile, ownerProfile] = await Promise.all([ 24 + getProfileByHandle(handle).catch(() => null), 25 + user ? getProfileByDid(user.did).catch(() => null) : Promise.resolve( 26 + null, 27 + ), 28 + ]); 15 29 return ctx.render( 16 30 <ProfileDetailPage 17 31 profile={profile} 18 - signedInDid={ctx.state.user?.did ?? null} 32 + signedInUser={user 33 + ? { did: user.did, handle: user.handle } 34 + : null} 35 + ownerHandle={ownerProfile?.handle ?? null} 19 36 locale={ctx.state.locale} 20 37 />, 21 38 { status: profile ? 200 : 404 }, ··· 25 42 26 43 interface DetailProps { 27 44 profile: ProfileRow | null; 28 - signedInDid: string | null; 45 + signedInUser: { did: string; handle: string } | null; 46 + ownerHandle: string | null; 29 47 locale: Locale; 30 48 } 31 49 32 - function ProfileDetailPage({ profile, signedInDid, locale }: DetailProps) { 50 + function ProfileDetailPage( 51 + { profile, signedInUser, ownerHandle, locale }: DetailProps, 52 + ) { 33 53 const t = getMessages(locale).explore; 34 - if (!profile) return <NotFound locale={locale} />; 35 - const isOwner = signedInDid === profile.did; 54 + if (!profile) { 55 + return ( 56 + <NotFound 57 + locale={locale} 58 + signedInUser={signedInUser} 59 + ownerHandle={ownerHandle} 60 + /> 61 + ); 62 + } 63 + const isOwner = signedInUser?.did === profile.did; 64 + const account = { 65 + user: signedInUser, 66 + avatarUrl: signedInUser ? "/api/me/avatar" : null, 67 + publicProfileHandle: ownerHandle, 68 + }; 36 69 const lastUpdated = new Date(profile.indexedAt).toISOString().slice(0, 10); 37 70 const pdsHost = (() => { 38 71 try { ··· 45 78 <div id="page-top"> 46 79 <GlassClouds /> 47 80 <div class="content-layer"> 48 - <Nav /> 81 + <Nav account={account} /> 49 82 <section class="explore-profile-detail"> 50 83 <div class="container" style={{ maxWidth: "880px" }}> 51 84 <p> ··· 76 109 </div> 77 110 </div> 78 111 </section> 79 - <Footer /> 112 + <Footer variant="compact" /> 80 113 </div> 81 114 </div> 82 115 ); 83 116 } 84 117 85 - function NotFound({ locale }: { locale: Locale }) { 118 + function NotFound( 119 + { locale, signedInUser, ownerHandle }: { 120 + locale: Locale; 121 + signedInUser: { did: string; handle: string } | null; 122 + ownerHandle: string | null; 123 + }, 124 + ) { 86 125 const t = getMessages(locale).explore.detail; 126 + const account = { 127 + user: signedInUser, 128 + avatarUrl: signedInUser ? "/api/me/avatar" : null, 129 + publicProfileHandle: ownerHandle, 130 + }; 87 131 return ( 88 132 <div id="page-top"> 89 133 <GlassClouds /> 90 134 <div class="content-layer"> 91 - <Nav /> 135 + <Nav account={account} /> 92 136 <section class="explore-profile-detail"> 93 137 <div 94 138 class="container" ··· 103 147 </p> 104 148 </div> 105 149 </section> 106 - <Footer /> 150 + <Footer variant="compact" /> 107 151 </div> 108 152 </div> 109 153 );
+6 -2
routes/explore/create.tsx
··· 21 21 <div id="page-top"> 22 22 <GlassClouds /> 23 23 <div class="content-layer"> 24 - <Nav /> 24 + {/* user is null here (we redirect when signed in), so the menu 25 + * shows the "Sign in" entry — useful if someone lands on this 26 + * page from a deep link and wants the same affordance as the 27 + * rest of the explore section. */} 28 + <Nav account={{ user: null }} /> 25 29 <section class="explore-create" style={{ paddingTop: "8rem" }}> 26 30 <div class="container" style={{ maxWidth: "640px" }}> 27 31 <p class="text-eyebrow">{t.create.eyebrow}</p> ··· 44 48 </div> 45 49 </div> 46 50 </section> 47 - <Footer /> 51 + <Footer variant="compact" /> 48 52 </div> 49 53 </div> 50 54 );
+24 -7
routes/explore/manage.tsx
··· 31 31 initial = { 32 32 name: existing.name, 33 33 description: existing.description, 34 - category: existing.category, 34 + categories: existing.categories, 35 35 subcategories: existing.subcategories, 36 36 website: existing.website, 37 + repoUrl: existing.repoUrl, 38 + openSource: existing.openSource, 37 39 bskyClient: existing.bskyClient, 38 - tags: existing.tags, 39 40 avatar: existing.avatarCid && existing.avatarMime 40 41 ? { ref: existing.avatarCid, mime: existing.avatarMime } 41 42 : null, ··· 50 51 initial = { 51 52 name: bsky.displayName ?? "", 52 53 description: bsky.description ?? "", 53 - category: "app", 54 + categories: ["app"], 54 55 subcategories: [], 55 56 website: null, 57 + repoUrl: null, 58 + openSource: false, 56 59 bskyClient: null, 57 - tags: [], 58 60 avatar: bsky.avatar 59 61 ? { 60 62 ref: bsky.avatar.ref.$link, ··· 83 85 initial={initial} 84 86 initialAvatarUrl={initialAvatarUrl} 85 87 initialPublished={!!existing} 88 + publicProfileHandle={existing?.handle ?? null} 86 89 t={t} 87 90 />, 88 91 ); ··· 94 97 initial: Parameters<typeof CreateProfileForm>[0]["initial"]; 95 98 initialAvatarUrl: string | null; 96 99 initialPublished: boolean; 100 + publicProfileHandle: string | null; 97 101 // deno-lint-ignore no-explicit-any 98 102 t: any; 99 103 } 100 104 101 105 function ManagePage( 102 - { user, initial, initialAvatarUrl, initialPublished, t }: ManagePageProps, 106 + { 107 + user, 108 + initial, 109 + initialAvatarUrl, 110 + initialPublished, 111 + publicProfileHandle, 112 + t, 113 + }: ManagePageProps, 103 114 ) { 104 115 const explore = t.explore; 105 116 return ( 106 117 <div id="page-top"> 107 118 <GlassClouds /> 108 119 <div class="content-layer"> 109 - <Nav /> 120 + <Nav 121 + account={{ 122 + user: { did: user.did, handle: user.handle }, 123 + avatarUrl: "/api/me/avatar", 124 + publicProfileHandle, 125 + }} 126 + /> 110 127 <section class="explore-manage" style={{ paddingTop: "8rem" }}> 111 128 <div class="container" style={{ maxWidth: "920px" }}> 112 129 <div class="manage-header"> ··· 137 154 </div> 138 155 </div> 139 156 </section> 140 - <Footer /> 157 + <Footer variant="compact" /> 141 158 </div> 142 159 </div> 143 160 );
+2
routes/index.tsx
··· 8 8 import BlueskySection from "../components/BlueskySection.tsx"; 9 9 import CrossPollination from "../components/CrossPollination.tsx"; 10 10 import YourChoice from "../components/ModerationAndAlgorithms.tsx"; 11 + import HomeExploreCta from "../components/HomeExploreCta.tsx"; 11 12 import Footer from "../components/Footer.tsx"; 12 13 13 14 export default define.page(function Home() { ··· 23 24 <BlueskySection /> 24 25 <CrossPollination /> 25 26 <YourChoice /> 27 + <HomeExploreCta /> 26 28 <Footer /> 27 29 </div> 28 30 </div>
+50
scripts/wipe-registry.ts
··· 1 + /** 2 + * Wipe the registry tables (profile + profile_fts + featured + jetstream 3 + * cursor) and let `lib/db.ts` recreate them with the current schema. Use 4 + * this whenever the schema changes in a way that ALTER TABLE can't 5 + * express (column drops, FTS column changes, etc.). 6 + * 7 + * Reads TURSO_DATABASE_URL / TURSO_AUTH_TOKEN from the environment. 8 + * 9 + * Usage: 10 + * deno run -A --env-file=.env scripts/wipe-registry.ts 11 + * 12 + * Pass `--keep-oauth` to preserve oauth_session / oauth_state / oauth_key 13 + * tables (default keeps them — wiping those would log everyone out). 14 + */ 15 + import { createClient } from "@libsql/client"; 16 + 17 + const url = Deno.env.get("TURSO_DATABASE_URL"); 18 + const authToken = Deno.env.get("TURSO_AUTH_TOKEN"); 19 + 20 + if (!url) { 21 + console.error("TURSO_DATABASE_URL is not set. Pass --env-file=.env."); 22 + Deno.exit(1); 23 + } 24 + 25 + const client = createClient({ url, authToken }); 26 + 27 + const dropStatements = [ 28 + // Drop FTS triggers first so dropping the source table doesn't fire them. 29 + `DROP TRIGGER IF EXISTS profile_au`, 30 + `DROP TRIGGER IF EXISTS profile_ad`, 31 + `DROP TRIGGER IF EXISTS profile_ai`, 32 + `DROP TABLE IF EXISTS profile_fts`, 33 + `DROP INDEX IF EXISTS profile_handle`, 34 + `DROP INDEX IF EXISTS profile_category`, 35 + `DROP TABLE IF EXISTS profile`, 36 + `DROP TABLE IF EXISTS featured`, 37 + // Reset the Jetstream cursor too so the indexer replays everything from 38 + // scratch on next start (which is what you want after wiping the index). 39 + `DROP TABLE IF EXISTS jetstream_cursor`, 40 + ]; 41 + 42 + console.log(`[wipe] connected to ${url}`); 43 + for (const stmt of dropStatements) { 44 + console.log(`[wipe] ${stmt}`); 45 + await client.execute(stmt); 46 + } 47 + 48 + console.log( 49 + "[wipe] done. The next request to the app will re-run migrations and recreate the schema cleanly.", 50 + );
+3 -2
worker/indexer.ts
··· 110 110 handle, 111 111 name: r.name, 112 112 description: r.description, 113 - category: r.category, 113 + categories: r.categories, 114 114 subcategories: r.subcategories ?? [], 115 115 website: r.website ?? null, 116 + repoUrl: r.repoUrl ?? null, 117 + openSource: r.openSource ?? false, 116 118 bskyClient: r.bskyClient ?? null, 117 - tags: r.tags ?? [], 118 119 avatarCid: r.avatar?.ref.$link ?? null, 119 120 avatarMime: r.avatar?.mimeType ?? null, 120 121 pdsUrl,