this repo has no description
10
fork

Configure Feed

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

refactor(registry): atmosphere links + drop license

Replace per-kind links[] schema with a small atmosphere-link model
(bsky / tangled / supper, plus website + custom "other") and remove
the standalone license record entirely.

- lexicon: simplify linkEntry to {kind, url?, clientId?, label?}; drop
bskyClient root field; delete license.json
- bsky URLs are derived from the user's handle + selected clientId at
render time; tangled / supper accept an optional URL override
- form: atmosphere toggles with a stacked-icon Bluesky row + gear that
opens a centered modal popup for multi-selecting bsky clients;
dedicated website field + custom-link editor with delete buttons
- supper toggle hidden until supper.support is live (lexicon kept)
- ProfileLinks renders via lib/atmosphere-links#resolveLink so the
detail page no longer branches per kind
- drop license / openSource badge from cards/hero, drop license + bsky
client copy from i18n, drop license + link-editor styles, add
atmosphere-toggle / modal / custom-link styles
- worker: drop LICENSE_NSID + handleLicenseEvent; index profiles
without bskyClient
- db: drop license table + bsky_client column from profile schema

Made-with: Cursor

+1295 -1264
+312 -63
assets/styles.css
··· 1987 1987 color: rgba(255, 255, 255, 0.85); 1988 1988 border-color: rgba(255, 255, 255, 0.15); 1989 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 - } 2006 - 2007 1990 .profile-badge { 2008 1991 display: inline-flex; 2009 1992 align-items: center; ··· 2361 2344 color: rgba(255, 255, 255, 0.95); 2362 2345 } 2363 2346 2364 - /* ---- Bluesky client picker (radio-card list, mimics native modal) ---- */ 2347 + /* ---- Bluesky client picker (used inside the modal for multi-select) ---- */ 2365 2348 .bsky-client-list { 2366 2349 display: flex; 2367 2350 flex-direction: column; ··· 2371 2354 border-radius: 16px; 2372 2355 background: rgba(18, 26, 47, 0.04); 2373 2356 border: 1px solid rgba(18, 26, 47, 0.06); 2357 + list-style: none; 2374 2358 } 2375 2359 .dark-phase .bsky-client-list { 2376 2360 background: rgba(255, 255, 255, 0.04); 2377 2361 border-color: rgba(255, 255, 255, 0.08); 2378 2362 } 2363 + .bsky-client-list li { 2364 + list-style: none; 2365 + } 2379 2366 .bsky-client-row { 2380 2367 display: flex; 2381 2368 align-items: center; ··· 2401 2388 background: rgba(120, 170, 255, 0.12); 2402 2389 border-color: rgba(120, 170, 255, 0.5); 2403 2390 } 2404 - .bsky-client-row > input[type="radio"] { 2391 + .bsky-client-row > input[type="radio"], 2392 + .bsky-client-row > input[type="checkbox"] { 2405 2393 position: absolute; 2406 2394 opacity: 0; 2407 2395 pointer-events: none; ··· 2444 2432 .dark-phase .bsky-client-domain { 2445 2433 color: rgba(255, 255, 255, 0.55); 2446 2434 } 2447 - .bsky-client-radio { 2435 + .bsky-client-radio, 2436 + .bsky-client-check { 2448 2437 width: 18px; 2449 2438 height: 18px; 2450 - border-radius: 50%; 2439 + border-radius: 4px; 2451 2440 border: 2px solid rgba(18, 26, 47, 0.25); 2452 2441 background: transparent; 2453 2442 flex-shrink: 0; 2454 2443 position: relative; 2455 2444 transition: border-color 0.12s ease, background 0.12s ease; 2456 2445 } 2457 - .bsky-client-row.is-selected .bsky-client-radio { 2446 + .bsky-client-radio { 2447 + border-radius: 50%; 2448 + } 2449 + .bsky-client-row.is-selected .bsky-client-radio, 2450 + .bsky-client-row.is-selected .bsky-client-check { 2458 2451 border-color: rgba(42, 90, 168, 1); 2459 2452 background: rgba(42, 90, 168, 1); 2453 + } 2454 + .bsky-client-row.is-selected .bsky-client-radio { 2460 2455 box-shadow: inset 0 0 0 3px #ffffff; 2461 2456 } 2462 - .dark-phase .bsky-client-radio { 2457 + .bsky-client-row.is-selected .bsky-client-check::after { 2458 + content: ""; 2459 + position: absolute; 2460 + left: 4px; 2461 + top: 0px; 2462 + width: 5px; 2463 + height: 10px; 2464 + border-right: 2px solid #fff; 2465 + border-bottom: 2px solid #fff; 2466 + transform: rotate(45deg); 2467 + } 2468 + .dark-phase .bsky-client-radio, 2469 + .dark-phase .bsky-client-check { 2463 2470 border-color: rgba(255, 255, 255, 0.35); 2464 2471 } 2465 - .dark-phase .bsky-client-row.is-selected .bsky-client-radio { 2472 + .dark-phase .bsky-client-row.is-selected .bsky-client-radio, 2473 + .dark-phase .bsky-client-row.is-selected .bsky-client-check { 2466 2474 border-color: rgba(160, 200, 255, 1); 2467 2475 background: rgba(160, 200, 255, 1); 2476 + } 2477 + .dark-phase .bsky-client-row.is-selected .bsky-client-radio { 2468 2478 box-shadow: inset 0 0 0 3px rgba(20, 26, 50, 1); 2479 + } 2480 + .dark-phase .bsky-client-row.is-selected .bsky-client-check::after { 2481 + border-color: rgba(20, 26, 50, 1); 2469 2482 } 2470 2483 .profile-form-field { 2471 2484 display: flex; ··· 2601 2614 color: rgba(255, 255, 255, 0.55); 2602 2615 } 2603 2616 2617 + /* ------------------------------------------------------------------ * 2618 + * Atmosphere link toggles 2619 + * 2620 + * Each row is `[ toggle ] [ icon + label ] [ gear / url ]`. The 2621 + * row gets `is-on` when active so we can colour the toggle track and 2622 + * lift the icon. The Bluesky row optionally shows a stack of mini 2623 + * favicons when more than one client is selected — see 2624 + * `.atmosphere-icon-stack`. 2625 + * ------------------------------------------------------------------ */ 2626 + .atmosphere-toggles { 2627 + display: flex; 2628 + flex-direction: column; 2629 + gap: 0.55rem; 2630 + margin-top: 0.4rem; 2631 + } 2632 + .atmosphere-row { 2633 + display: grid; 2634 + grid-template-columns: auto 1fr auto; 2635 + gap: 0.85rem; 2636 + align-items: center; 2637 + padding: 0.7rem 0.85rem; 2638 + border-radius: 0.8rem; 2639 + border: 1px solid rgba(18, 26, 47, 0.12); 2640 + background: rgba(255, 255, 255, 0.45); 2641 + } 2642 + .atmosphere-row.is-on { 2643 + background: rgba(255, 255, 255, 0.7); 2644 + border-color: rgba(42, 90, 168, 0.35); 2645 + } 2646 + .dark-phase .atmosphere-row { 2647 + background: rgba(255, 255, 255, 0.04); 2648 + border-color: rgba(255, 255, 255, 0.12); 2649 + } 2650 + .dark-phase .atmosphere-row.is-on { 2651 + background: rgba(255, 255, 255, 0.08); 2652 + border-color: rgba(96, 165, 250, 0.45); 2653 + } 2654 + 2655 + .atmosphere-row-toggle { 2656 + position: relative; 2657 + display: inline-flex; 2658 + cursor: pointer; 2659 + } 2660 + .atmosphere-row-toggle input { 2661 + position: absolute; 2662 + inset: 0; 2663 + opacity: 0; 2664 + cursor: pointer; 2665 + } 2666 + .atmosphere-toggle-track { 2667 + display: inline-block; 2668 + width: 2.4rem; 2669 + height: 1.35rem; 2670 + border-radius: 999px; 2671 + background: rgba(18, 26, 47, 0.18); 2672 + position: relative; 2673 + transition: background 0.15s ease; 2674 + } 2675 + .atmosphere-toggle-thumb { 2676 + position: absolute; 2677 + top: 0.15rem; 2678 + left: 0.15rem; 2679 + width: 1.05rem; 2680 + height: 1.05rem; 2681 + border-radius: 50%; 2682 + background: #fff; 2683 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); 2684 + transition: transform 0.15s ease; 2685 + } 2686 + .atmosphere-row.is-on .atmosphere-toggle-track { 2687 + background: rgba(42, 90, 168, 0.92); 2688 + } 2689 + .atmosphere-row.is-on .atmosphere-toggle-thumb { 2690 + transform: translateX(1.05rem); 2691 + } 2692 + 2693 + .atmosphere-row-body { 2694 + display: flex; 2695 + align-items: center; 2696 + gap: 0.7rem; 2697 + min-width: 0; 2698 + } 2699 + .atmosphere-row-icon { 2700 + flex: 0 0 auto; 2701 + display: inline-flex; 2702 + align-items: center; 2703 + justify-content: center; 2704 + } 2705 + .atmosphere-icon { 2706 + width: 28px; 2707 + height: 28px; 2708 + border-radius: 6px; 2709 + object-fit: contain; 2710 + background: #fff; 2711 + padding: 2px; 2712 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); 2713 + } 2714 + .atmosphere-icon-glyph { 2715 + width: 28px; 2716 + height: 28px; 2717 + border-radius: 6px; 2718 + display: inline-flex; 2719 + align-items: center; 2720 + justify-content: center; 2721 + background: rgba(18, 26, 47, 0.08); 2722 + font-weight: 700; 2723 + font-size: 0.85rem; 2724 + } 2604 2725 /** 2605 - * Links editor: each row is a 3-column grid (kind | url | label) with a 2606 - * trailing "Remove" link. On narrow screens the grid collapses to a 2607 - * single column so the inputs stay usable on phones. 2726 + * Stacked mini-icons used when more than one Bluesky client is 2727 + * selected. Each item overlaps the previous by negative margin and 2728 + * uses descending z-index so the leftmost (primary client) sits on 2729 + * top of the stack. 2608 2730 */ 2609 - .link-editor-list { 2731 + .atmosphere-icon-stack { 2732 + display: inline-flex; 2733 + align-items: center; 2734 + } 2735 + .atmosphere-icon-stack-item { 2736 + width: 26px; 2737 + height: 26px; 2738 + border-radius: 50%; 2739 + object-fit: contain; 2740 + background: #fff; 2741 + padding: 2px; 2742 + border: 2px solid #fff; 2743 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); 2744 + } 2745 + .dark-phase .atmosphere-icon-stack-item { 2746 + border-color: rgba(20, 28, 50, 0.95); 2747 + } 2748 + .atmosphere-row-meta { 2610 2749 display: flex; 2611 2750 flex-direction: column; 2612 - gap: 0.55rem; 2751 + min-width: 0; 2752 + } 2753 + .atmosphere-row-name { 2754 + font-weight: 600; 2755 + font-size: 0.95rem; 2756 + color: rgba(18, 26, 47, 0.95); 2757 + } 2758 + .atmosphere-row-desc { 2759 + font-size: 0.8rem; 2760 + color: rgba(18, 26, 47, 0.6); 2761 + } 2762 + .dark-phase .atmosphere-row-name { 2763 + color: rgba(255, 255, 255, 0.95); 2764 + } 2765 + .dark-phase .atmosphere-row-desc { 2766 + color: rgba(255, 255, 255, 0.6); 2767 + } 2768 + 2769 + .atmosphere-row-gear { 2770 + appearance: none; 2771 + border: none; 2772 + background: transparent; 2773 + font-size: 1.1rem; 2774 + cursor: pointer; 2775 + padding: 0.3rem 0.4rem; 2776 + border-radius: 0.5rem; 2777 + color: rgba(18, 26, 47, 0.6); 2778 + } 2779 + .atmosphere-row-gear:hover { 2780 + background: rgba(18, 26, 47, 0.08); 2781 + color: rgba(18, 26, 47, 0.9); 2782 + } 2783 + .dark-phase .atmosphere-row-gear { 2784 + color: rgba(255, 255, 255, 0.65); 2785 + } 2786 + .dark-phase .atmosphere-row-gear:hover { 2787 + background: rgba(255, 255, 255, 0.08); 2788 + color: #fff; 2789 + } 2790 + 2791 + .atmosphere-row-url { 2792 + grid-column: 1 / -1; 2793 + margin-top: 0.5rem; 2794 + } 2795 + 2796 + /* ------------------------------------------------------------------ * 2797 + * Custom links editor 2798 + * 2799 + * Two columns (label | url) plus a small × delete button. Collapses 2800 + * to a single column on narrow screens. 2801 + * ------------------------------------------------------------------ */ 2802 + .custom-link-list { 2803 + display: flex; 2804 + flex-direction: column; 2805 + gap: 0.5rem; 2613 2806 margin-top: 0.4rem; 2614 2807 } 2615 - .link-editor-row { 2808 + .custom-link-row { 2616 2809 display: grid; 2617 - grid-template-columns: 9rem minmax(0, 2fr) minmax(0, 1.5fr) auto; 2810 + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto; 2618 2811 gap: 0.45rem; 2619 2812 align-items: center; 2620 - padding: 0.55rem; 2621 - border-radius: 0.7rem; 2622 - border: 1px solid rgba(18, 26, 47, 0.12); 2813 + } 2814 + .custom-link-label, 2815 + .custom-link-url { 2816 + min-width: 0; 2817 + } 2818 + .custom-link-remove { 2819 + appearance: none; 2820 + border: 1px solid rgba(18, 26, 47, 0.15); 2623 2821 background: rgba(255, 255, 255, 0.4); 2822 + border-radius: 0.5rem; 2823 + width: 2rem; 2824 + height: 2rem; 2825 + font-size: 1.1rem; 2826 + line-height: 1; 2827 + cursor: pointer; 2828 + color: rgba(18, 26, 47, 0.7); 2624 2829 } 2625 - .dark-phase .link-editor-row { 2626 - background: rgba(255, 255, 255, 0.05); 2830 + .custom-link-remove:hover { 2831 + background: rgba(220, 38, 38, 0.08); 2832 + color: rgba(185, 28, 28, 0.95); 2833 + border-color: rgba(220, 38, 38, 0.4); 2834 + } 2835 + .dark-phase .custom-link-remove { 2836 + background: rgba(255, 255, 255, 0.04); 2627 2837 border-color: rgba(255, 255, 255, 0.15); 2628 - } 2629 - .link-editor-kind, 2630 - .link-editor-url, 2631 - .link-editor-label { 2632 - min-width: 0; 2838 + color: rgba(255, 255, 255, 0.75); 2633 2839 } 2634 - .link-editor-remove { 2635 - white-space: nowrap; 2636 - font-size: 0.8rem; 2840 + .dark-phase .custom-link-remove:hover { 2841 + background: rgba(248, 113, 113, 0.15); 2842 + color: rgba(254, 202, 202, 0.95); 2637 2843 } 2638 - .link-editor-add { 2639 - margin-top: 0.6rem; 2844 + .custom-link-add { 2845 + margin-top: 0.55rem; 2640 2846 align-self: flex-start; 2641 2847 } 2642 2848 @media (max-width: 640px) { 2643 - .link-editor-row { 2644 - grid-template-columns: 1fr; 2645 - gap: 0.35rem; 2849 + .custom-link-row { 2850 + grid-template-columns: 1fr auto; 2646 2851 } 2647 - .link-editor-remove { 2648 - justify-self: flex-end; 2852 + .custom-link-url { 2853 + grid-column: 1 / -1; 2649 2854 } 2650 2855 } 2651 2856 2652 - /** 2653 - * License section sits inside the same fields column as the rest of the 2654 - * form. The faint inset background visually groups its sub-fields without 2655 - * adding a heavy border that would clash with the glass card. 2656 - */ 2657 - .profile-form-license { 2658 - padding: 0.85rem 0.95rem; 2659 - border-radius: 0.7rem; 2660 - background: rgba(255, 255, 255, 0.32); 2661 - border: 1px solid rgba(18, 26, 47, 0.08); 2857 + /* ------------------------------------------------------------------ * 2858 + * Modal popup (used by BskyClientPickerModal) 2859 + * ------------------------------------------------------------------ */ 2860 + .modal-backdrop { 2861 + position: fixed; 2862 + inset: 0; 2863 + background: rgba(8, 14, 31, 0.55); 2864 + backdrop-filter: blur(4px); 2865 + display: flex; 2866 + align-items: center; 2867 + justify-content: center; 2868 + z-index: 200; 2869 + padding: 1rem; 2870 + } 2871 + .modal-card { 2872 + width: min(440px, 100%); 2873 + max-height: calc(100vh - 2rem); 2874 + overflow-y: auto; 2875 + background: rgba(255, 255, 255, 0.96); 2876 + border-radius: 1rem; 2877 + border: 1px solid rgba(255, 255, 255, 0.6); 2878 + padding: 1.4rem 1.4rem 1rem; 2879 + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); 2662 2880 } 2663 - .dark-phase .profile-form-license { 2664 - background: rgba(255, 255, 255, 0.04); 2881 + .dark-phase .modal-card { 2882 + background: rgba(20, 28, 50, 0.96); 2665 2883 border-color: rgba(255, 255, 255, 0.1); 2884 + color: rgba(255, 255, 255, 0.95); 2885 + } 2886 + .modal-header { 2887 + margin-bottom: 1rem; 2888 + } 2889 + .modal-title { 2890 + font-size: 1.05rem; 2891 + font-weight: 700; 2892 + margin: 0 0 0.4rem; 2893 + } 2894 + .modal-body-text { 2895 + font-size: 0.85rem; 2896 + color: rgba(18, 26, 47, 0.7); 2897 + margin: 0; 2898 + } 2899 + .dark-phase .modal-body-text { 2900 + color: rgba(255, 255, 255, 0.7); 2901 + } 2902 + .modal-footnote { 2903 + margin: 0.6rem 0 0; 2904 + font-size: 0.78rem; 2905 + color: rgba(185, 28, 28, 0.95); 2906 + } 2907 + .dark-phase .modal-footnote { 2908 + color: rgba(254, 202, 202, 0.95); 2909 + } 2910 + .modal-footer { 2911 + display: flex; 2912 + gap: 0.55rem; 2913 + justify-content: flex-end; 2914 + margin-top: 1rem; 2666 2915 } 2667 2916 2668 2917 .profile-form-chips {
-5
components/explore/ProfileCard.tsx
··· 55 55 {tCat[c] ?? c} 56 56 </span> 57 57 ))} 58 - {profile.license?.type === "openSource" && ( 59 - <span class="profile-card-sub profile-card-sub--oss"> 60 - {t.explore.detail.openSourceBadge} 61 - </span> 62 - )} 63 58 {profile.subcategories.slice(0, 2).map((s) => { 64 59 const sub = (t.subcategories as Record<string, string>)[s] ?? s; 65 60 return (
-5
components/explore/ProfileHero.tsx
··· 51 51 {tCat[c] ?? c} 52 52 </span> 53 53 ))} 54 - {profile.license?.type === "openSource" && ( 55 - <span class="profile-card-sub profile-card-sub--oss"> 56 - {t.explore.detail.openSourceBadge} 57 - </span> 58 - )} 59 54 {profile.subcategories.map((s) => ( 60 55 <span key={s} class="profile-card-sub"> 61 56 {tSub[s] ?? s}
+36 -79
components/explore/ProfileLinks.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 - import { getBskyClient } from "../../lib/bsky-clients.ts"; 3 - import { resolveLink } from "../../lib/link-kinds.ts"; 2 + import { resolveLink } from "../../lib/atmosphere-links.ts"; 4 3 import { useT } from "../../i18n/mod.ts"; 5 4 6 5 interface Props { ··· 8 7 } 9 8 10 9 /** 11 - * Renders the public profile's action buttons: 12 - * 1. The Bluesky button (always shown — every Atmosphere account has one). 13 - * 2. Each entry from `profile.links`, in author-defined order. 14 - * 3. A "View license" button when the joined license row provides a URL. 10 + * Renders the public profile's action buttons. We iterate `profile.links` 11 + * in author-defined order and resolve each entry to a render-ready 12 + * bundle via `resolveLink` (which knows about atmosphere kinds, custom 13 + * websites, etc.). The handle is passed in so atmosphere kinds can 14 + * derive their default URL from it. 15 15 * 16 - * Link kind → icon/label mapping lives in `lib/link-kinds.ts` so the 17 - * editor (CreateProfileForm) and this view stay in sync. 16 + * Buttons are visually consistent — the first one in the list naturally 17 + * becomes the "primary" CTA via the `:first-child` selector in CSS. 18 18 */ 19 19 export default function ProfileLinks({ profile }: Props) { 20 20 const t = useT(); 21 - const tDetail = t.explore.detail; 22 21 const tLink = t.linkKinds; 23 - const client = getBskyClient(profile.bskyClient); 24 - const bskyHref = client.profileUrl(profile.handle); 22 + 23 + const resolved = profile.links 24 + .map((entry) => resolveLink(entry, profile.handle, tLink)) 25 + .filter((r): r is NonNullable<typeof r> => r !== null); 26 + 27 + if (resolved.length === 0) return null; 25 28 26 29 return ( 27 30 <div class="profile-actions"> 28 - <a 29 - class="profile-action profile-action--primary" 30 - href={bskyHref} 31 - target="_blank" 32 - rel="noopener noreferrer" 33 - > 34 - <img 35 - src={client.iconUrl} 36 - alt="" 37 - class="profile-action-icon" 38 - loading="lazy" 39 - decoding="async" 40 - /> 41 - <span class="profile-action-label"> 42 - <span class="profile-action-title"> 43 - {tDetail.openOn} {client.name} 44 - </span> 45 - <span class="profile-action-sub">{client.domain}</span> 46 - </span> 47 - </a> 48 - 49 - {profile.links.map((entry) => { 50 - const r = resolveLink(entry, tLink); 51 - return ( 52 - <a 53 - class="profile-action" 54 - href={r.url} 55 - target="_blank" 56 - rel="noopener noreferrer" 57 - key={r.url} 58 - > 59 - {r.iconUrl 60 - ? ( 61 - <img 62 - src={r.iconUrl} 63 - alt="" 64 - class="profile-action-icon" 65 - loading="lazy" 66 - decoding="async" 67 - /> 68 - ) 69 - : ( 70 - <span class="profile-action-icon profile-action-icon--glyph"> 71 - {r.glyph} 72 - </span> 73 - )} 74 - <span class="profile-action-label"> 75 - <span class="profile-action-title">{r.title}</span> 76 - <span class="profile-action-sub">{r.subtitle}</span> 77 - </span> 78 - </a> 79 - ); 80 - })} 81 - 82 - {profile.license?.licenseUrl && ( 31 + {resolved.map((r, i) => ( 83 32 <a 84 - class="profile-action" 85 - href={profile.license.licenseUrl} 33 + class={i === 0 ? "profile-action profile-action--primary" : "profile-action"} 34 + href={r.href} 86 35 target="_blank" 87 36 rel="noopener noreferrer" 37 + key={`${r.href}-${i}`} 88 38 > 89 - <span class="profile-action-icon profile-action-icon--glyph">©</span> 39 + {r.iconUrl 40 + ? ( 41 + <img 42 + src={r.iconUrl} 43 + alt="" 44 + class="profile-action-icon" 45 + loading="lazy" 46 + decoding="async" 47 + /> 48 + ) 49 + : ( 50 + <span class="profile-action-icon profile-action-icon--glyph"> 51 + {r.glyph} 52 + </span> 53 + )} 90 54 <span class="profile-action-label"> 91 - <span class="profile-action-title"> 92 - {tDetail.license.viewLicense} 93 - </span> 94 - <span class="profile-action-sub"> 95 - {profile.license.spdxId ?? 96 - (t.licenseTypes as Record<string, string>)[ 97 - profile.license.type 98 - ] ?? profile.license.type} 99 - </span> 55 + <span class="profile-action-title">{r.title}</span> 56 + <span class="profile-action-sub">{r.subtitle}</span> 100 57 </span> 101 58 </a> 102 - )} 59 + ))} 103 60 </div> 104 61 ); 105 62 }
+39 -56
i18n/messages/en.tsx
··· 333 333 }, 334 334 335 335 /** 336 - * Display labels for `LinkEntry.kind`. The "repoOnPrefix" / "repoGeneric" 337 - * pair are used when a link kind is "repo" — the resolver appends the 338 - * detected host name ("Source on GitHub") or falls back to the generic 339 - * label when the host can't be identified. 336 + * Display labels used by `lib/atmosphere-links.ts#resolveLink` when 337 + * an entry doesn't carry its own label. 340 338 */ 341 339 linkKinds: { 340 + bsky: "Bluesky", 341 + tangled: "Tangled", 342 + supper: "Supper", 342 343 website: "Website", 343 - repo: "Source repository", 344 - repoOnPrefix: "Source on", 345 - repoGeneric: "Source code", 346 - donate: "Donate", 347 - docs: "Documentation", 348 - mastodon: "Mastodon", 349 - matrix: "Matrix", 350 - discord: "Discord", 351 - contact: "Contact", 352 - other: "Link", 353 - }, 354 - 355 - /** Public-facing labels for `LicenseRecord.type`. */ 356 - licenseTypes: { 357 - openSource: "Open source", 358 - sourceAvailable: "Source available", 359 - proprietary: "Proprietary", 344 + custom: "Link", 360 345 }, 361 346 362 347 explore: { ··· 389 374 categoryLabel: "Category", 390 375 notFoundTitle: "404", 391 376 notFoundBody: "We couldn't find a profile for that handle.", 392 - /** Compact badge on cards/hero. Spelled out for the public profile. */ 393 - openSourceBadge: "Open source", 394 - /** License section on the public profile. */ 395 - license: { 396 - viewLicense: "View license", 397 - }, 398 377 }, 399 378 create: { 400 379 eyebrow: "Add to Explore", ··· 443 422 "Pick all that apply. A project can be both an app and an account provider.", 444 423 subcategoriesLabel: "Subcategories (optional)", 445 424 subcategoriesHint: "For apps. Pick up to a few.", 446 - bskyClientLabel: "Bluesky client", 447 - bskyClientHint: 448 - "Pick which client opens when visitors click the Bluesky button on your profile. Your handle works on all of them.", 449 425 avatarLabel: "Project icon", 450 426 avatarHint: "PNG, JPEG, or WebP. 1MB max. Square works best.", 451 427 avatarReplace: "Replace icon", ··· 454 430 avatarTooLarge: "Avatar must be 1MB or smaller.", 455 431 confirmDelete: "Remove your project from Explore?", 456 432 categoryRequired: "Pick at least one category.", 457 - links: { 458 - sectionLabel: "Links", 459 - sectionHint: 460 - "Website, source repo, donate, docs, chat — anything you want on your profile. Drag to reorder later.", 461 - addButton: "Add link", 462 - removeButton: "Remove", 463 - kindLabel: "Type", 464 - urlLabel: "URL", 433 + atmosphereLinks: { 434 + sectionLabel: "Atmosphere links", 435 + sectionHint: (handle: string): VNode => ( 436 + <> 437 + Toggle which services to show on your page. Links are generated 438 + from your handle <strong>@{handle}</strong>. 439 + </> 440 + ), 441 + bskyDescription: "Decentralised social network", 442 + tangledDescription: "Social coding platform", 443 + supperDescription: "AT Protocol native support page", 444 + configureBskyLabel: "Configure Bluesky clients", 445 + urlOverrideLabel: "Custom URL (optional)", 446 + urlOverridePlaceholder: "https://…", 447 + }, 448 + bskyPicker: { 449 + title: "Bluesky clients", 450 + body: 451 + "Pick the client(s) that open when visitors click the Bluesky button on your profile. Your handle works on all of them — you can show more than one.", 452 + empty: 453 + "Pick at least one client to keep the Bluesky toggle enabled.", 454 + done: "Done", 455 + cancel: "Cancel", 456 + }, 457 + website: { 458 + sectionLabel: "Website", 459 + placeholder: "https://yoursite.com", 460 + }, 461 + customLinks: { 462 + sectionLabel: "Custom links", 463 + addButton: "Add custom link", 464 + labelPlaceholder: "Label", 465 465 urlPlaceholder: "https://…", 466 - labelLabel: "Label", 467 - labelPlaceholderOther: "What is this? (e.g. Press kit)", 468 - labelHelp: 'Optional override. Required for "Other".', 469 - emptyHint: 'No links yet. Click "Add link" to add your website, repo, or anything else.', 470 - }, 471 - license: { 472 - sectionLabel: "License", 473 - sectionHint: 474 - "Tell visitors how the project is licensed. Stored as a separate record on your PDS.", 475 - typeLabel: "License type", 476 - typeNone: "Don't say (no license info shown)", 477 - spdxLabel: "SPDX identifier (optional)", 478 - spdxHint: 'For example: "MIT", "Apache-2.0", "AGPL-3.0", "BUSL-1.1".', 479 - spdxPlaceholder: "MIT", 480 - urlLabel: "License URL (optional)", 481 - urlPlaceholder: "https://github.com/yourorg/yourproject/blob/main/LICENSE", 482 - notesLabel: "Notes (optional)", 483 - notesPlaceholder: 'e.g. "Server is AGPL-3.0; mobile clients are MIT"', 466 + removeAriaLabel: "Remove link", 484 467 }, 485 468 }, 486 469 },
+120
islands/BskyClientPickerModal.tsx
··· 1 + import { useEffect } from "preact/hooks"; 2 + import { useSignal } from "@preact/signals"; 3 + import { BSKY_CLIENTS } from "../lib/bsky-clients.ts"; 4 + import { useT } from "../i18n/mod.ts"; 5 + 6 + interface Props { 7 + /** Currently-selected client ids (controlled by the parent form). */ 8 + selected: string[]; 9 + open: boolean; 10 + /** Called with the new selection when the user clicks Done. */ 11 + onConfirm: (ids: string[]) => void; 12 + onClose: () => void; 13 + } 14 + 15 + /** 16 + * Centered modal popup for picking which Bluesky-compatible client(s) 17 + * appear on the public profile. Multi-select; the first id in the 18 + * returned list is treated as the "primary" by the parent (drives the 19 + * toggle row icon). Local state is committed on Done so cancelling 20 + * leaves the parent unchanged. 21 + */ 22 + export default function BskyClientPickerModal( 23 + { selected, open, onConfirm, onClose }: Props, 24 + ) { 25 + const t = useT().forms.profile.bskyPicker; 26 + const draft = useSignal<string[]>(selected); 27 + 28 + // Re-seed the draft whenever the modal is (re)opened so the user 29 + // always starts from the parent's source of truth. 30 + useEffect(() => { 31 + if (open) draft.value = selected; 32 + }, [open]); 33 + 34 + // Esc to close. 35 + useEffect(() => { 36 + if (!open) return; 37 + const handler = (e: KeyboardEvent) => { 38 + if (e.key === "Escape") onClose(); 39 + }; 40 + globalThis.addEventListener("keydown", handler); 41 + return () => globalThis.removeEventListener("keydown", handler); 42 + }, [open]); 43 + 44 + if (!open) return null; 45 + 46 + const toggle = (id: string) => { 47 + const cur = draft.value; 48 + draft.value = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; 49 + }; 50 + 51 + const empty = draft.value.length === 0; 52 + 53 + return ( 54 + <div 55 + class="modal-backdrop" 56 + role="dialog" 57 + aria-modal="true" 58 + aria-labelledby="bsky-picker-title" 59 + onClick={(e) => { 60 + if (e.target === e.currentTarget) onClose(); 61 + }} 62 + > 63 + <div class="modal-card"> 64 + <header class="modal-header"> 65 + <h2 id="bsky-picker-title" class="modal-title">{t.title}</h2> 66 + <p class="modal-body-text">{t.body}</p> 67 + </header> 68 + <ul class="bsky-client-list" role="listbox" aria-multiselectable="true"> 69 + {BSKY_CLIENTS.map((c) => { 70 + const isSel = draft.value.includes(c.id); 71 + return ( 72 + <li key={c.id}> 73 + <label 74 + class={`bsky-client-row ${isSel ? "is-selected" : ""}`} 75 + > 76 + <input 77 + type="checkbox" 78 + name="bskyClient" 79 + value={c.id} 80 + checked={isSel} 81 + onChange={() => toggle(c.id)} 82 + /> 83 + <img 84 + src={c.iconUrl} 85 + alt="" 86 + class="bsky-client-icon" 87 + loading="lazy" 88 + decoding="async" 89 + /> 90 + <span class="bsky-client-meta"> 91 + <span class="bsky-client-name">{c.name}</span> 92 + <span class="bsky-client-domain">{c.domain}</span> 93 + </span> 94 + <span class="bsky-client-check" aria-hidden="true" /> 95 + </label> 96 + </li> 97 + ); 98 + })} 99 + </ul> 100 + {empty && <p class="modal-footnote">{t.empty}</p>} 101 + <footer class="modal-footer"> 102 + <button 103 + type="button" 104 + class="profile-form-button-secondary" 105 + onClick={onClose} 106 + > 107 + {t.cancel} 108 + </button> 109 + <button 110 + type="button" 111 + class="profile-form-button-primary" 112 + onClick={() => onConfirm(draft.value)} 113 + > 114 + {t.done} 115 + </button> 116 + </footer> 117 + </div> 118 + </div> 119 + ); 120 + }
+386 -245
islands/CreateProfileForm.tsx
··· 4 4 APP_SUBCATEGORIES, 5 5 CATEGORIES, 6 6 type Category, 7 - LICENSE_TYPES, 8 7 type LinkEntry, 9 8 } from "../lib/lexicons.ts"; 10 - import { LINK_KIND_ORDER } from "../lib/link-kinds.ts"; 11 - import { BSKY_CLIENTS, DEFAULT_BSKY_CLIENT_ID } from "../lib/bsky-clients.ts"; 9 + import { 10 + type AtmosphereService, 11 + visibleAtmosphereServices, 12 + } from "../lib/atmosphere-links.ts"; 13 + import { BSKY_CLIENTS, getBskyClient } from "../lib/bsky-clients.ts"; 12 14 import { useT } from "../i18n/mod.ts"; 13 - 14 - interface ExistingLicense { 15 - type: string; 16 - spdxId: string | null; 17 - licenseUrl: string | null; 18 - notes: string | null; 19 - } 15 + import BskyClientPickerModal from "./BskyClientPickerModal.tsx"; 20 16 21 17 interface ExistingProfile { 22 18 name: string; ··· 26 22 categories: string[]; 27 23 subcategories: string[]; 28 24 links: LinkEntry[]; 29 - bskyClient: string | null; 30 25 avatar: { ref: string; mime: string } | null; 31 - /** Joined license record, if the user has published one. */ 32 - license: ExistingLicense | null; 33 26 } 34 27 35 28 interface Props { ··· 65 58 return btoa(binary); 66 59 } 67 60 61 + interface CustomLinkRow { 62 + label: string; 63 + url: string; 64 + } 65 + 66 + /** Collapse the saved `LinkEntry[]` into the form's working state. */ 67 + function splitInitialLinks(links: LinkEntry[]): { 68 + bskyClientIds: string[]; 69 + tangledOverride: string; 70 + tangledOn: boolean; 71 + supperOverride: string; 72 + supperOn: boolean; 73 + website: string; 74 + custom: CustomLinkRow[]; 75 + } { 76 + const bskyClientIds: string[] = []; 77 + let tangledOverride = ""; 78 + let tangledOn = false; 79 + let supperOverride = ""; 80 + let supperOn = false; 81 + let website = ""; 82 + const custom: CustomLinkRow[] = []; 83 + 84 + for (const e of links) { 85 + switch (e.kind) { 86 + case "bsky": 87 + if (e.clientId) bskyClientIds.push(e.clientId); 88 + break; 89 + case "tangled": 90 + tangledOn = true; 91 + if (e.url) tangledOverride = e.url; 92 + break; 93 + case "supper": 94 + supperOn = true; 95 + if (e.url) supperOverride = e.url; 96 + break; 97 + case "website": 98 + if (e.url) website = e.url; 99 + break; 100 + case "other": 101 + if (e.url) custom.push({ label: e.label ?? "", url: e.url }); 102 + break; 103 + } 104 + } 105 + return { 106 + bskyClientIds, 107 + tangledOverride, 108 + tangledOn, 109 + supperOverride, 110 + supperOn, 111 + website, 112 + custom, 113 + }; 114 + } 115 + 68 116 export default function CreateProfileForm( 69 117 { did, handle, initial, initialAvatarUrl, initialPublished }: Props, 70 118 ) { 71 119 const t = useT(); 72 120 const tForm = t.forms.profile; 73 - const tLink = t.linkKinds; 74 - const tLicense = t.licenseTypes as Record<string, string>; 121 + const tAtmos = tForm.atmosphereLinks; 122 + const tCustom = tForm.customLinks; 123 + const tWebsite = tForm.website; 75 124 const tManage = t.explore.manage; 76 - /** Live registry status. Flips on save (-> true) and delete (-> false). 77 - * Drives the colored pill that tells the user whether their entry is 78 - * visible in /explore right now. */ 125 + /** Live registry status. Flips on save (-> true) and delete (-> false). */ 79 126 const published = useSignal<boolean>(initialPublished); 80 127 128 + const initialSplit = splitInitialLinks(initial?.links ?? []); 129 + 81 130 const name = useSignal(initial?.name ?? ""); 82 131 const description = useSignal(initial?.description ?? ""); 83 - // categories is the source of truth — the lexicon requires it to be a 84 - // non-empty array. The first item is treated as the primary category. 85 132 const categories = useSignal<string[]>( 86 133 initial?.categories?.length ? initial.categories : ["app"], 87 134 ); 88 135 const subcategories = useSignal<string[]>(initial?.subcategories ?? []); 89 - /** Local-only signal: signals don't deep-track array element mutations, 90 - * so each edit replaces the entire array. */ 91 - const links = useSignal<LinkEntry[]>(initial?.links ?? []); 92 - const bskyClient = useSignal<string>( 93 - initial?.bskyClient ?? DEFAULT_BSKY_CLIENT_ID, 94 - ); 136 + 137 + /* ---------------- Atmosphere link signals ----------------------------- */ 95 138 /** 96 - * License state. `licenseType === ""` means "don't publish a license 97 - * record" — the form sends `license: null` in that case so the API 98 - * deletes any existing record. 139 + * Bluesky toggle is "on" iff there's at least one selected client. The 140 + * gear opens the modal where users add/remove clients; the row's icon 141 + * stack mirrors the selection. 99 142 */ 100 - const licenseType = useSignal<string>(initial?.license?.type ?? ""); 101 - const licenseSpdx = useSignal<string>(initial?.license?.spdxId ?? ""); 102 - const licenseUrl = useSignal<string>(initial?.license?.licenseUrl ?? ""); 103 - const licenseNotes = useSignal<string>(initial?.license?.notes ?? ""); 143 + const bskyClientIds = useSignal<string[]>(initialSplit.bskyClientIds); 144 + const bskyPickerOpen = useSignal<boolean>(false); 145 + 146 + const tangledOn = useSignal<boolean>(initialSplit.tangledOn); 147 + const tangledUrl = useSignal<string>(initialSplit.tangledOverride); 148 + 149 + const supperOn = useSignal<boolean>(initialSplit.supperOn); 150 + const supperUrl = useSignal<string>(initialSplit.supperOverride); 151 + 152 + const website = useSignal<string>(initialSplit.website); 153 + const customLinks = useSignal<CustomLinkRow[]>(initialSplit.custom); 104 154 105 155 const avatarKeep = useSignal<BlobRefShape | null>(null); 106 - /** Preview URL precedence: locally-picked file blob > existing registry 107 - * record (cached proxy) > prefill source (Bluesky PDS getBlob) > none. */ 156 + /** Preview URL precedence: locally-picked file > existing registry 157 + * record (cached proxy) > prefill source (Bluesky PDS) > none. */ 108 158 const avatarPreview = useSignal<string | null>( 109 159 initial?.avatar 110 160 ? `/api/registry/avatar/${encodeURIComponent(did)}` ··· 142 192 const toggleCategory = (key: string) => { 143 193 const current = categories.value; 144 194 if (current.includes(key)) { 145 - // Don't let the user unselect their last remaining category — at 146 - // least one is required by the lexicon. 147 195 if (current.length <= 1) return; 148 196 categories.value = current.filter((k) => k !== key); 149 197 } else { ··· 152 200 } 153 201 }; 154 202 155 - /** 156 - * `app` is the only category with subcategories defined right now. If 157 - * the user deselects `app`, hide the subcategory chips by clearing the 158 - * underlying selection (kept as an effect-like helper so the form's 159 - * payload doesn't carry stale subcategories on submit). 160 - */ 161 203 const showSubcategories = categories.value.includes("app"); 162 204 163 - /* ---------- Links editor helpers --------------------------------------- */ 164 - const addLink = (kind: string = "website") => { 165 - if (links.value.length >= 12) return; 166 - links.value = [...links.value, { kind, url: "", label: "" }]; 205 + /* ---------------- Custom link helpers --------------------------------- */ 206 + const addCustomLink = () => { 207 + if (customLinks.value.length >= 8) return; 208 + customLinks.value = [...customLinks.value, { label: "", url: "" }]; 167 209 }; 168 - const removeLink = (index: number) => { 169 - links.value = links.value.filter((_, i) => i !== index); 210 + const removeCustomLink = (i: number) => { 211 + customLinks.value = customLinks.value.filter((_, idx) => idx !== i); 170 212 }; 171 - const updateLink = (index: number, patch: Partial<LinkEntry>) => { 172 - links.value = links.value.map((entry, i) => 173 - i === index ? { ...entry, ...patch } : entry 213 + const updateCustomLink = (i: number, patch: Partial<CustomLinkRow>) => { 214 + customLinks.value = customLinks.value.map((row, idx) => 215 + idx === i ? { ...row, ...patch } : row 174 216 ); 217 + }; 218 + 219 + /* ---------------- Atmosphere helpers ---------------------------------- */ 220 + const onBskyConfirm = (ids: string[]) => { 221 + bskyClientIds.value = ids; 222 + bskyPickerOpen.value = false; 175 223 }; 176 224 177 225 const onAvatarChange = (event: Event) => { ··· 195 243 avatarPreview.value = null; 196 244 }; 197 245 246 + /** 247 + * Reduce the form's working state into the lexicon-shaped LinkEntry[] 248 + * we send to the API. Order matters — we put atmosphere links first 249 + * (in service order, with the user's chosen primary bsky client at the 250 + * head), then website, then custom links in display order. 251 + */ 252 + const buildLinksPayload = (): LinkEntry[] => { 253 + const out: LinkEntry[] = []; 254 + 255 + for (const id of bskyClientIds.value) { 256 + out.push({ kind: "bsky", clientId: id }); 257 + } 258 + if (tangledOn.value) { 259 + const entry: LinkEntry = { kind: "tangled" }; 260 + const u = tangledUrl.value.trim(); 261 + if (u) entry.url = u; 262 + out.push(entry); 263 + } 264 + if (supperOn.value) { 265 + const entry: LinkEntry = { kind: "supper" }; 266 + const u = supperUrl.value.trim(); 267 + if (u) entry.url = u; 268 + out.push(entry); 269 + } 270 + const w = website.value.trim(); 271 + if (w) out.push({ kind: "website", url: w }); 272 + for (const row of customLinks.value) { 273 + const url = row.url.trim(); 274 + const label = row.label.trim(); 275 + if (!url || !label) continue; 276 + out.push({ kind: "other", url, label }); 277 + } 278 + return out; 279 + }; 280 + 198 281 const onSubmit = async (event: Event) => { 199 282 event.preventDefault(); 200 283 if (submitting.value) return; ··· 206 289 message.value = null; 207 290 208 291 try { 209 - // Sanitise links: drop empty rows; require URL on every kept row; 210 - // for kind="other", require a label. Mirroring the validator means 211 - // the user gets fast client-side feedback. 212 - const cleanedLinks: LinkEntry[] = []; 213 - for (const l of links.value) { 214 - const url = (l.url ?? "").trim(); 215 - if (!url) continue; 216 - const kind = (l.kind ?? "").trim() || "other"; 217 - const label = (l.label ?? "").trim(); 218 - if (kind === "other" && !label) { 219 - throw new Error(`Add a label for the "${tLink.other}" link or remove it.`); 220 - } 221 - const entry: LinkEntry = { kind, url }; 222 - if (label) entry.label = label; 223 - cleanedLinks.push(entry); 224 - } 292 + const cleanedLinks = buildLinksPayload(); 225 293 226 294 const payload: Record<string, unknown> = { 227 295 name: name.value.trim(), ··· 229 297 categories: categories.value, 230 298 subcategories: showSubcategories ? subcategories.value : [], 231 299 links: cleanedLinks, 232 - bskyClient: bskyClient.value || undefined, 233 300 }; 234 301 if (avatarFile.value) { 235 302 payload.avatarUpload = { ··· 242 309 payload.avatar = null; 243 310 } 244 311 245 - // License sub-record. Empty type = "don't publish" → null tells the 246 - // API to delete any existing license record so the badge goes away. 247 - if (licenseType.value) { 248 - payload.license = { 249 - type: licenseType.value, 250 - spdxId: licenseSpdx.value.trim() || undefined, 251 - licenseUrl: licenseUrl.value.trim() || undefined, 252 - notes: licenseNotes.value.trim() || undefined, 253 - }; 254 - } else { 255 - payload.license = null; 256 - } 257 - 258 312 const res = await fetch("/api/registry/profile", { 259 313 method: "PUT", 260 314 headers: { "content-type": "application/json" }, ··· 264 318 const text = await res.text(); 265 319 throw new Error(text || `HTTP ${res.status}`); 266 320 } 267 - const json = await res.json().catch(() => ({})) as { 268 - licenseWarning?: string | null; 269 - }; 270 321 published.value = true; 271 - message.value = json.licenseWarning 272 - ? { kind: "error", text: json.licenseWarning } 273 - : { kind: "ok", text: tManage.savedToast }; 322 + message.value = { kind: "ok", text: tManage.savedToast }; 274 323 } catch (err) { 275 324 message.value = { 276 325 kind: "error", ··· 323 372 </span> 324 373 </span> 325 374 </div> 375 + 326 376 <div class="profile-form-row"> 327 377 <div class="profile-form-avatar"> 328 378 {avatarPreview.value ··· 448 498 </fieldset> 449 499 )} 450 500 451 - {/* ---------------- Links editor ----------------------------- */} 501 + {/* ---------------- Atmosphere links ----------------------- */} 452 502 <fieldset class="profile-form-field"> 453 - <legend class="profile-form-label">{tForm.links.sectionLabel}</legend> 454 - <p class="profile-form-hint">{tForm.links.sectionHint}</p> 455 - {links.value.length === 0 && ( 456 - <p class="profile-form-empty">{tForm.links.emptyHint}</p> 457 - )} 458 - <div class="link-editor-list"> 459 - {links.value.map((entry, i) => ( 460 - <div class="link-editor-row" key={i}> 461 - <select 462 - class="profile-form-input link-editor-kind" 463 - value={entry.kind} 464 - onChange={(e) => 465 - updateLink(i, { 466 - kind: (e.currentTarget as HTMLSelectElement).value, 467 - })} 468 - aria-label={tForm.links.kindLabel} 469 - > 470 - {LINK_KIND_ORDER.map((k) => ( 471 - <option value={k} key={k}> 472 - {tLink[k] ?? k} 473 - </option> 474 - ))} 475 - </select> 503 + <legend class="profile-form-label">{tAtmos.sectionLabel}</legend> 504 + <p class="profile-form-hint">{tAtmos.sectionHint(handle)}</p> 505 + 506 + <div class="atmosphere-toggles"> 507 + {visibleAtmosphereServices().map((svc) => 508 + renderAtmosphereRow(svc, { 509 + bskyClientIds, 510 + bskyPickerOpen, 511 + tangledOn, 512 + tangledUrl, 513 + supperOn, 514 + supperUrl, 515 + tAtmos, 516 + }) 517 + )} 518 + </div> 519 + </fieldset> 520 + 521 + {/* ---------------- Website ------------------------------- */} 522 + <label class="profile-form-field"> 523 + <span class="profile-form-label">{tWebsite.sectionLabel}</span> 524 + <input 525 + type="url" 526 + class="profile-form-input" 527 + placeholder={tWebsite.placeholder} 528 + value={website.value} 529 + onInput={(e) => 530 + website.value = (e.currentTarget as HTMLInputElement).value} 531 + /> 532 + </label> 533 + 534 + {/* ---------------- Custom links -------------------------- */} 535 + <fieldset class="profile-form-field"> 536 + <legend class="profile-form-label">{tCustom.sectionLabel}</legend> 537 + <div class="custom-link-list"> 538 + {customLinks.value.map((row, i) => ( 539 + <div class="custom-link-row" key={i}> 476 540 <input 477 - type="url" 478 - class="profile-form-input link-editor-url" 479 - placeholder={tForm.links.urlPlaceholder} 480 - value={entry.url} 541 + type="text" 542 + class="profile-form-input custom-link-label" 543 + placeholder={tCustom.labelPlaceholder} 544 + value={row.label} 545 + maxLength={64} 481 546 onInput={(e) => 482 - updateLink(i, { 483 - url: (e.currentTarget as HTMLInputElement).value, 547 + updateCustomLink(i, { 548 + label: (e.currentTarget as HTMLInputElement).value, 484 549 })} 485 - aria-label={tForm.links.urlLabel} 486 550 /> 487 551 <input 488 - type="text" 489 - class="profile-form-input link-editor-label" 490 - placeholder={entry.kind === "other" 491 - ? tForm.links.labelPlaceholderOther 492 - : tForm.links.labelLabel} 493 - value={entry.label ?? ""} 494 - maxLength={64} 552 + type="url" 553 + class="profile-form-input custom-link-url" 554 + placeholder={tCustom.urlPlaceholder} 555 + value={row.url} 495 556 onInput={(e) => 496 - updateLink(i, { 497 - label: (e.currentTarget as HTMLInputElement).value, 557 + updateCustomLink(i, { 558 + url: (e.currentTarget as HTMLInputElement).value, 498 559 })} 499 - aria-label={tForm.links.labelLabel} 500 560 /> 501 561 <button 502 562 type="button" 503 - class="profile-form-button-link link-editor-remove" 504 - onClick={() => removeLink(i)} 563 + class="custom-link-remove" 564 + aria-label={tCustom.removeAriaLabel} 565 + onClick={() => removeCustomLink(i)} 505 566 > 506 - {tForm.links.removeButton} 567 + × 507 568 </button> 508 569 </div> 509 570 ))} 510 571 </div> 511 572 <button 512 573 type="button" 513 - class="profile-form-button-secondary link-editor-add" 514 - onClick={() => addLink("website")} 515 - disabled={links.value.length >= 12} 574 + class="profile-form-button-secondary custom-link-add" 575 + onClick={addCustomLink} 576 + disabled={customLinks.value.length >= 8} 516 577 > 517 - + {tForm.links.addButton} 578 + + {tCustom.addButton} 518 579 </button> 519 580 </fieldset> 520 - 521 - {/* ---------------- License section ------------------------- */} 522 - <fieldset class="profile-form-field profile-form-license"> 523 - <legend class="profile-form-label">{tForm.license.sectionLabel}</legend> 524 - <p class="profile-form-hint">{tForm.license.sectionHint}</p> 525 - <label class="profile-form-field"> 526 - <span class="profile-form-label profile-form-label--small"> 527 - {tForm.license.typeLabel} 528 - </span> 529 - <select 530 - class="profile-form-input" 531 - value={licenseType.value} 532 - onChange={(e) => 533 - licenseType.value = 534 - (e.currentTarget as HTMLSelectElement).value} 535 - > 536 - <option value="">{tForm.license.typeNone}</option> 537 - {LICENSE_TYPES.map((lt) => ( 538 - <option value={lt} key={lt}>{tLicense[lt] ?? lt}</option> 539 - ))} 540 - </select> 541 - </label> 542 - 543 - {licenseType.value && ( 544 - <> 545 - <label class="profile-form-field"> 546 - <span class="profile-form-label profile-form-label--small"> 547 - {tForm.license.spdxLabel} 548 - </span> 549 - <input 550 - type="text" 551 - class="profile-form-input" 552 - placeholder={tForm.license.spdxPlaceholder} 553 - maxLength={64} 554 - value={licenseSpdx.value} 555 - onInput={(e) => 556 - licenseSpdx.value = 557 - (e.currentTarget as HTMLInputElement).value} 558 - /> 559 - <p class="profile-form-hint">{tForm.license.spdxHint}</p> 560 - </label> 561 - <label class="profile-form-field"> 562 - <span class="profile-form-label profile-form-label--small"> 563 - {tForm.license.urlLabel} 564 - </span> 565 - <input 566 - type="url" 567 - class="profile-form-input" 568 - placeholder={tForm.license.urlPlaceholder} 569 - value={licenseUrl.value} 570 - onInput={(e) => 571 - licenseUrl.value = 572 - (e.currentTarget as HTMLInputElement).value} 573 - /> 574 - </label> 575 - <label class="profile-form-field"> 576 - <span class="profile-form-label profile-form-label--small"> 577 - {tForm.license.notesLabel} 578 - </span> 579 - <input 580 - type="text" 581 - class="profile-form-input" 582 - placeholder={tForm.license.notesPlaceholder} 583 - maxLength={280} 584 - value={licenseNotes.value} 585 - onInput={(e) => 586 - licenseNotes.value = 587 - (e.currentTarget as HTMLInputElement).value} 588 - /> 589 - </label> 590 - </> 591 - )} 592 - </fieldset> 593 - 594 - <fieldset class="profile-form-field"> 595 - <legend class="profile-form-label">{tForm.bskyClientLabel}</legend> 596 - <p class="profile-form-hint">{tForm.bskyClientHint}</p> 597 - <div class="bsky-client-list"> 598 - {BSKY_CLIENTS.map((c) => { 599 - const selected = bskyClient.value === c.id; 600 - return ( 601 - <label 602 - key={c.id} 603 - class={`bsky-client-row ${selected ? "is-selected" : ""}`} 604 - > 605 - <input 606 - type="radio" 607 - name="bskyClient" 608 - value={c.id} 609 - checked={selected} 610 - onChange={() => bskyClient.value = c.id} 611 - /> 612 - <img 613 - src={c.iconUrl} 614 - alt="" 615 - class="bsky-client-icon" 616 - loading="lazy" 617 - decoding="async" 618 - /> 619 - <span class="bsky-client-meta"> 620 - <span class="bsky-client-name">{c.name}</span> 621 - <span class="bsky-client-domain">{c.domain}</span> 622 - </span> 623 - <span class="bsky-client-radio" aria-hidden="true" /> 624 - </label> 625 - ); 626 - })} 627 - </div> 628 - </fieldset> 629 581 </div> 630 582 </div> 631 583 ··· 660 612 </span> 661 613 )} 662 614 </div> 615 + 616 + <BskyClientPickerModal 617 + open={bskyPickerOpen.value} 618 + selected={bskyClientIds.value} 619 + onConfirm={onBskyConfirm} 620 + onClose={() => (bskyPickerOpen.value = false)} 621 + /> 663 622 </form> 664 623 ); 665 624 } 625 + 626 + /* ----------------------- Atmosphere row renderer ------------------------ */ 627 + 628 + interface AtmosphereRowCtx { 629 + bskyClientIds: { value: string[] }; 630 + bskyPickerOpen: { value: boolean }; 631 + tangledOn: { value: boolean }; 632 + tangledUrl: { value: string }; 633 + supperOn: { value: boolean }; 634 + supperUrl: { value: string }; 635 + tAtmos: ReturnType<typeof useT>["forms"]["profile"]["atmosphereLinks"]; 636 + } 637 + 638 + function renderAtmosphereRow(svc: AtmosphereService, ctx: AtmosphereRowCtx) { 639 + if (svc.id === "bsky") return <BskyAtmosphereRow ctx={ctx} svc={svc} />; 640 + if (svc.id === "tangled") { 641 + return ( 642 + <SimpleAtmosphereRow 643 + ctx={ctx} 644 + svc={svc} 645 + on={ctx.tangledOn} 646 + url={ctx.tangledUrl} 647 + /> 648 + ); 649 + } 650 + if (svc.id === "supper") { 651 + return ( 652 + <SimpleAtmosphereRow 653 + ctx={ctx} 654 + svc={svc} 655 + on={ctx.supperOn} 656 + url={ctx.supperUrl} 657 + /> 658 + ); 659 + } 660 + return null; 661 + } 662 + 663 + interface BskyRowProps { 664 + ctx: AtmosphereRowCtx; 665 + svc: AtmosphereService; 666 + } 667 + 668 + function BskyAtmosphereRow({ ctx, svc }: BskyRowProps) { 669 + const ids = ctx.bskyClientIds.value; 670 + const isOn = ids.length > 0; 671 + const primaryClient = isOn ? getBskyClient(ids[0]) : null; 672 + const stack = ids.slice(0, 4); 673 + 674 + return ( 675 + <div class={`atmosphere-row ${isOn ? "is-on" : ""}`}> 676 + <label class="atmosphere-row-toggle"> 677 + <input 678 + type="checkbox" 679 + checked={isOn} 680 + onChange={(e) => { 681 + const next = (e.currentTarget as HTMLInputElement).checked; 682 + if (next) { 683 + if (ctx.bskyClientIds.value.length === 0) { 684 + ctx.bskyPickerOpen.value = true; 685 + } 686 + } else { 687 + ctx.bskyClientIds.value = []; 688 + } 689 + }} 690 + /> 691 + <span class="atmosphere-toggle-track" aria-hidden="true"> 692 + <span class="atmosphere-toggle-thumb" /> 693 + </span> 694 + </label> 695 + <div class="atmosphere-row-body"> 696 + <div class="atmosphere-row-icon"> 697 + {ids.length > 1 698 + ? ( 699 + <span class="atmosphere-icon-stack"> 700 + {stack.map((id, i) => { 701 + const c = getBskyClient(id); 702 + return ( 703 + <img 704 + key={id} 705 + src={c.iconUrl} 706 + alt="" 707 + class="atmosphere-icon-stack-item" 708 + style={{ 709 + zIndex: stack.length - i, 710 + marginLeft: i === 0 ? 0 : "-10px", 711 + }} 712 + loading="lazy" 713 + decoding="async" 714 + /> 715 + ); 716 + })} 717 + </span> 718 + ) 719 + : ( 720 + <img 721 + src={primaryClient?.iconUrl ?? svc.iconUrl ?? ""} 722 + alt="" 723 + class="atmosphere-icon" 724 + loading="lazy" 725 + decoding="async" 726 + /> 727 + )} 728 + </div> 729 + <div class="atmosphere-row-meta"> 730 + <span class="atmosphere-row-name"> 731 + {primaryClient?.name ?? svc.name} 732 + </span> 733 + <span class="atmosphere-row-desc"> 734 + {ids.length > 1 735 + ? `${BSKY_CLIENTS.find((c) => c.id === ids[0])?.name ?? svc.name}` + 736 + ` + ${ids.length - 1} more` 737 + : svc.description} 738 + </span> 739 + </div> 740 + </div> 741 + <button 742 + type="button" 743 + class="atmosphere-row-gear" 744 + onClick={() => (ctx.bskyPickerOpen.value = true)} 745 + aria-label={ctx.tAtmos.configureBskyLabel} 746 + > 747 + 748 + </button> 749 + </div> 750 + ); 751 + } 752 + 753 + interface SimpleRowProps { 754 + ctx: AtmosphereRowCtx; 755 + svc: AtmosphereService; 756 + on: { value: boolean }; 757 + url: { value: string }; 758 + } 759 + 760 + function SimpleAtmosphereRow({ svc, on, url, ctx }: SimpleRowProps) { 761 + return ( 762 + <div class={`atmosphere-row ${on.value ? "is-on" : ""}`}> 763 + <label class="atmosphere-row-toggle"> 764 + <input 765 + type="checkbox" 766 + checked={on.value} 767 + onChange={(e) => 768 + (on.value = (e.currentTarget as HTMLInputElement).checked)} 769 + /> 770 + <span class="atmosphere-toggle-track" aria-hidden="true"> 771 + <span class="atmosphere-toggle-thumb" /> 772 + </span> 773 + </label> 774 + <div class="atmosphere-row-body"> 775 + <div class="atmosphere-row-icon"> 776 + {svc.iconUrl 777 + ? ( 778 + <img 779 + src={svc.iconUrl} 780 + alt="" 781 + class="atmosphere-icon" 782 + loading="lazy" 783 + decoding="async" 784 + /> 785 + ) 786 + : <span class="atmosphere-icon-glyph">{svc.name.slice(0, 1)}</span>} 787 + </div> 788 + <div class="atmosphere-row-meta"> 789 + <span class="atmosphere-row-name">{svc.name}</span> 790 + <span class="atmosphere-row-desc">{svc.description}</span> 791 + </div> 792 + </div> 793 + {svc.allowUrlOverride && on.value && ( 794 + <input 795 + type="url" 796 + class="profile-form-input atmosphere-row-url" 797 + placeholder={ctx.tAtmos.urlOverridePlaceholder} 798 + value={url.value} 799 + onInput={(e) => 800 + (url.value = (e.currentTarget as HTMLInputElement).value)} 801 + aria-label={ctx.tAtmos.urlOverrideLabel} 802 + /> 803 + )} 804 + </div> 805 + ); 806 + }
-46
lexicons/com/atmosphereaccount/registry/license.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "com.atmosphereaccount.registry.license", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "License / source-availability declaration for a project in the Atmosphere registry. Intentionally separate from the profile record so the profile stays focused on identity/branding and so this can be extended (e.g. third-party attestations) without touching the profile lexicon. One record per project, on the project's own PDS.", 8 - "key": "literal:self", 9 - "record": { 10 - "type": "object", 11 - "required": ["type", "createdAt"], 12 - "properties": { 13 - "type": { 14 - "type": "string", 15 - "knownValues": [ 16 - "openSource", 17 - "sourceAvailable", 18 - "proprietary" 19 - ], 20 - "description": "Distribution model. \"openSource\" = OSI-style FOSS (free to fork/redistribute). \"sourceAvailable\" = code is public but not freely redistributable (e.g. BSL, SSPL, FUSL). \"proprietary\" = closed source / commercial." 21 - }, 22 - "spdxId": { 23 - "type": "string", 24 - "maxLength": 64, 25 - "description": "SPDX license identifier when applicable (e.g. \"MIT\", \"Apache-2.0\", \"AGPL-3.0\", \"BUSL-1.1\"). Optional, but encouraged for openSource and sourceAvailable." 26 - }, 27 - "licenseUrl": { 28 - "type": "string", 29 - "format": "uri", 30 - "maxLength": 256, 31 - "description": "Direct link to the project's LICENSE file or license page." 32 - }, 33 - "notes": { 34 - "type": "string", 35 - "maxLength": 280, 36 - "description": "Optional free-form note (e.g. \"server is AGPL-3.0; mobile clients are MIT\")." 37 - }, 38 - "createdAt": { 39 - "type": "string", 40 - "format": "datetime" 41 - } 42 - } 43 - } 44 - } 45 - } 46 - }
+15 -24
lexicons/com/atmosphereaccount/registry/profile.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "A project's profile in the Atmosphere registry. Created by the project's own account on its PDS; one record per account. Kept intentionally minimal — anything that could plausibly stand on its own (license, reviews, age ratings, etc.) is published as a sibling record under com.atmosphereaccount.registry.* so this lexicon can stay stable.", 7 + "description": "A project's profile in the Atmosphere registry. Created by the project's own account on its PDS; one record per account. Kept intentionally minimal so future additions (reviews, age ratings, donations metadata, etc.) live in sibling com.atmosphereaccount.registry.* records.", 8 8 "key": "literal:self", 9 9 "record": { 10 10 "type": "object", ··· 62 62 "type": "ref", 63 63 "ref": "#linkEntry" 64 64 }, 65 - "description": "Outbound links shown on the public profile (website, source repo, donate, docs, fediverse, chat, etc.). Order is preserved; the first \"website\" is treated as the primary." 66 - }, 67 - "bskyClient": { 68 - "type": "string", 69 - "knownValues": [ 70 - "bluesky", 71 - "blacksky", 72 - "anisota", 73 - "deer", 74 - "witchsky" 75 - ], 76 - "description": "Preferred Bluesky-compatible client to open when visitors click the project's Bluesky link. Identity (handle) is the same across all clients." 65 + "description": "Outbound buttons shown on the public profile. Atmosphere links (kind = bsky / tangled / supper) derive their URL from the project's current handle by default; a `url` override is allowed for tangled / supper when the canonical destination differs from the handle. Custom link kinds (kind = website / other) always carry their `url`." 77 66 }, 78 67 "createdAt": { 79 68 "type": "string", ··· 84 73 }, 85 74 "linkEntry": { 86 75 "type": "object", 87 - "required": ["kind", "url"], 76 + "required": ["kind"], 88 77 "properties": { 89 78 "kind": { 90 79 "type": "string", 91 80 "knownValues": [ 81 + "bsky", 82 + "tangled", 83 + "supper", 92 84 "website", 93 - "repo", 94 - "donate", 95 - "docs", 96 - "mastodon", 97 - "matrix", 98 - "discord", 99 - "contact", 100 85 "other" 101 86 ], 102 - "description": "Link kind. Drives the icon/label shown on the profile. \"other\" falls back to a generic glyph and uses `label` for the title." 87 + "description": "Link kind. Drives the icon/label and how `url` / `clientId` are resolved at render time. New atmosphere services can be added to knownValues without breaking older records." 103 88 }, 104 89 "url": { 105 90 "type": "string", 106 91 "format": "uri", 107 - "maxLength": 512 92 + "maxLength": 512, 93 + "description": "Optional for atmosphere kinds (bsky derives URL from clientId+handle; tangled/supper default to the canonical handle URL but accept an override). Required for `website` and `other`." 94 + }, 95 + "clientId": { 96 + "type": "string", 97 + "maxLength": 64, 98 + "description": "Required when kind=\"bsky\". Picks which Bluesky-compatible web client should host the link (bluesky, blacksky, deer, …)." 108 99 }, 109 100 "label": { 110 101 "type": "string", 111 102 "maxLength": 64, 112 - "description": "Optional override label. Required when kind=\"other\"; defaults to the kind's display name otherwise." 103 + "description": "Required when kind=\"other\". Used as the visible button title for custom links." 113 104 } 114 105 } 115 106 }
+236
lib/atmosphere-links.ts
··· 1 + /** 2 + * Atmosphere link metadata + per-LinkEntry resolution. 3 + * 4 + * Two halves: 5 + * 6 + * 1. The catalog of "Atmosphere" services a profile can opt in to — 7 + * Bluesky-style profile buttons (one per client), Tangled, Supper. 8 + * Each has its own icon + default URL template (derived from the 9 + * project's current handle). New atmosphere services slot in here 10 + * without touching the lexicon or the form. 11 + * 12 + * 2. `resolveLink(entry, handle, labels)` — turns a stored `LinkEntry` 13 + * into render-ready `{title, subtitle, iconUrl, glyph, href}` data 14 + * so `ProfileLinks.tsx` can iterate `profile.links` without caring 15 + * about per-kind branching. 16 + * 17 + * Single source of truth used by: 18 + * - the create / manage form (islands/CreateProfileForm.tsx) 19 + * - the public profile detail page (components/explore/ProfileLinks.tsx) 20 + * - lexicon validation (lib/lexicons.ts via clientId / kind constants) 21 + */ 22 + import { BSKY_CLIENTS, getBskyClient } from "./bsky-clients.ts"; 23 + import type { LinkEntry } from "./lexicons.ts"; 24 + 25 + const faviconFor = (domain: string, size = 64): string => 26 + `https://www.google.com/s2/favicons?sz=${size}&domain=${ 27 + encodeURIComponent(domain) 28 + }`; 29 + 30 + /* -------------------------------------------------------------------------- * 31 + * Atmosphere service catalog * 32 + * -------------------------------------------------------------------------- */ 33 + 34 + export type AtmosphereServiceId = "bsky" | "tangled" | "supper"; 35 + 36 + export interface AtmosphereService { 37 + /** Lexicon `kind` value. */ 38 + id: AtmosphereServiceId; 39 + /** Display name shown in the form toggle row + the public button. */ 40 + name: string; 41 + /** Short description shown under the name in the form toggle row. */ 42 + description: string; 43 + /** 44 + * Whether the service is currently enabled in the form's toggle list. 45 + * Hidden services still validate as `kind` values so older records 46 + * remain readable. 47 + */ 48 + visible: boolean; 49 + /** 50 + * Whether this service should accept a custom URL override on the 51 + * profile. `bsky` derives URL solely from clientId; `tangled` / 52 + * `supper` accept an override (e.g. point Tangled at a specific repo 53 + * page rather than the default `@handle` profile). 54 + */ 55 + allowUrlOverride: boolean; 56 + /** Default URL when no override is provided. */ 57 + defaultUrl: (handle: string) => string; 58 + /** Icon for the toggle / button. Falls back to a glyph if null. */ 59 + iconUrl: string | null; 60 + } 61 + 62 + /** Default Tangled domain for the user-profile URL pattern. */ 63 + const TANGLED_DOMAIN = "tangled.sh"; 64 + /** Default Supper domain. */ 65 + const SUPPER_DOMAIN = "supper.support"; 66 + 67 + export const ATMOSPHERE_SERVICES: AtmosphereService[] = [ 68 + { 69 + id: "bsky", 70 + name: "Bluesky", 71 + description: "Decentralised social network", 72 + visible: true, 73 + allowUrlOverride: false, 74 + /** 75 + * Bsky URLs are always resolved from the chosen client's 76 + * `profileUrl(handle)` — this default is only used as a last-resort 77 + * fallback when a client lookup fails. 78 + */ 79 + defaultUrl: (handle: string) => `https://bsky.app/profile/${handle}`, 80 + iconUrl: faviconFor("bsky.app"), 81 + }, 82 + { 83 + id: "tangled", 84 + name: "Tangled", 85 + description: "Social coding platform", 86 + visible: true, 87 + allowUrlOverride: true, 88 + defaultUrl: (handle: string) => `https://${TANGLED_DOMAIN}/@${handle}`, 89 + iconUrl: faviconFor(TANGLED_DOMAIN), 90 + }, 91 + { 92 + /** 93 + * Hidden until supper.support is live — kept in the catalog so older 94 + * records still validate / render, and so flipping `visible: true` 95 + * here is the only change needed when the service launches. 96 + */ 97 + id: "supper", 98 + name: "Supper", 99 + description: "AT Protocol native support page", 100 + visible: false, 101 + allowUrlOverride: true, 102 + defaultUrl: (handle: string) => `https://${SUPPER_DOMAIN}/${handle}`, 103 + iconUrl: faviconFor(SUPPER_DOMAIN), 104 + }, 105 + ]; 106 + 107 + export function getAtmosphereService( 108 + id: string | null | undefined, 109 + ): AtmosphereService | null { 110 + return ATMOSPHERE_SERVICES.find((s) => s.id === id) ?? null; 111 + } 112 + 113 + /** Visible services in the order the form should render them. */ 114 + export function visibleAtmosphereServices(): AtmosphereService[] { 115 + return ATMOSPHERE_SERVICES.filter((s) => s.visible); 116 + } 117 + 118 + /* -------------------------------------------------------------------------- * 119 + * LinkEntry resolution * 120 + * -------------------------------------------------------------------------- */ 121 + 122 + /** 123 + * The labels used by the resolver. Mirrors the i18n catalog so callers 124 + * can pass `t.linkKinds` straight in. 125 + */ 126 + export interface LinkKindLabels { 127 + bsky: string; 128 + tangled: string; 129 + supper: string; 130 + website: string; 131 + /** Title used when a custom link entry doesn't supply its own label. */ 132 + custom: string; 133 + } 134 + 135 + export interface ResolvedLink { 136 + /** Display title for the button. */ 137 + title: string; 138 + /** Subtitle (host + path of the URL). */ 139 + subtitle: string; 140 + /** Icon URL, when available. */ 141 + iconUrl: string | null; 142 + /** Inline glyph fallback. */ 143 + glyph: string; 144 + /** The final href the user navigates to. */ 145 + href: string; 146 + } 147 + 148 + function trimUrlForDisplay(url: string): string { 149 + try { 150 + const u = new URL(url); 151 + return `${u.host}${u.pathname.replace(/\/$/, "")}`; 152 + } catch { 153 + return url; 154 + } 155 + } 156 + 157 + /** 158 + * Resolve a stored LinkEntry into a render-ready bundle. 159 + * 160 + * - `bsky` looks up the chosen client (defaults to the first client if 161 + * unknown so legacy records still render) and uses its profileUrl. 162 + * - `tangled` / `supper` use their `url` override when present, else 163 + * derive from the handle. 164 + * - `website` uses the URL as-is. 165 + * - `other` uses URL + label, falling back to a generic glyph. 166 + * - Unknown kinds render as a generic external link if a URL exists. 167 + */ 168 + export function resolveLink( 169 + entry: LinkEntry, 170 + handle: string, 171 + labels: LinkKindLabels, 172 + ): ResolvedLink | null { 173 + const kind = entry.kind; 174 + 175 + if (kind === "bsky") { 176 + const client = getBskyClient(entry.clientId); 177 + const href = client.profileUrl(handle); 178 + return { 179 + title: client.name, 180 + subtitle: trimUrlForDisplay(href), 181 + iconUrl: client.iconUrl, 182 + glyph: "B", 183 + href, 184 + }; 185 + } 186 + 187 + if (kind === "tangled" || kind === "supper") { 188 + const svc = getAtmosphereService(kind)!; 189 + const href = entry.url || svc.defaultUrl(handle); 190 + return { 191 + title: svc.name, 192 + subtitle: trimUrlForDisplay(href), 193 + iconUrl: svc.iconUrl, 194 + glyph: svc.name.slice(0, 1), 195 + href, 196 + }; 197 + } 198 + 199 + if (kind === "website") { 200 + if (!entry.url) return null; 201 + return { 202 + title: entry.label || labels.website, 203 + subtitle: trimUrlForDisplay(entry.url), 204 + iconUrl: null, 205 + glyph: "↗", 206 + href: entry.url, 207 + }; 208 + } 209 + 210 + if (kind === "other") { 211 + if (!entry.url) return null; 212 + return { 213 + title: entry.label || labels.custom, 214 + subtitle: trimUrlForDisplay(entry.url), 215 + iconUrl: null, 216 + glyph: "↗", 217 + href: entry.url, 218 + }; 219 + } 220 + 221 + // Unknown future kind: render as a generic external link if it has a 222 + // URL, otherwise drop it. Keeps the lexicon forward-compatible. 223 + if (entry.url) { 224 + return { 225 + title: entry.label || labels.custom, 226 + subtitle: trimUrlForDisplay(entry.url), 227 + iconUrl: null, 228 + glyph: "↗", 229 + href: entry.url, 230 + }; 231 + } 232 + return null; 233 + } 234 + 235 + /** Re-export the underlying client list so the form's picker can iterate it. */ 236 + export { BSKY_CLIENTS };
+3 -26
lib/db.ts
··· 73 73 categories TEXT NOT NULL DEFAULT '[]', 74 74 subcategories TEXT NOT NULL DEFAULT '[]', 75 75 links TEXT NOT NULL DEFAULT '[]', 76 - bsky_client TEXT, 77 76 avatar_cid TEXT, 78 77 avatar_mime TEXT, 79 78 pds_url TEXT NOT NULL, ··· 100 99 INSERT INTO profile_fts(rowid, name, description) 101 100 VALUES (new.rowid, new.name, new.description); 102 101 END`, 103 - // License is its own record on the project's PDS (com.atmosphereaccount 104 - // .registry.license/self), joined to profile by DID. Splitting it out 105 - // keeps the profile lexicon small and lets us extend license metadata 106 - // (or add other sibling records like reviews / age ratings) without 107 - // touching the profile schema. 108 - `CREATE TABLE IF NOT EXISTS license ( 109 - did TEXT PRIMARY KEY, 110 - type TEXT NOT NULL, 111 - spdx_id TEXT, 112 - license_url TEXT, 113 - notes TEXT, 114 - pds_url TEXT NOT NULL, 115 - record_cid TEXT NOT NULL, 116 - record_rev TEXT NOT NULL, 117 - created_at INTEGER NOT NULL, 118 - indexed_at INTEGER NOT NULL 119 - )`, 120 102 `CREATE TABLE IF NOT EXISTS featured ( 121 103 did TEXT PRIMARY KEY, 122 104 badges TEXT NOT NULL DEFAULT '[]', ··· 157 139 * `ADD COLUMN IF NOT EXISTS`, so we attempt the ALTER and swallow the 158 140 * "duplicate column" error. SQLite makes column drops painful, so legacy 159 141 * columns we no longer use (e.g. the old single-value `category`, `tags`, 160 - * the pre-`links[]` `website`/`repo_url`/`open_source`) are just left 161 - * around and ignored — running `scripts/wipe-registry.ts` recreates the 162 - * table cleanly when desired. 142 + * the pre-`links[]` `website`/`repo_url`/`open_source`, `bsky_client`) 143 + * are just left around and ignored — running `scripts/wipe-registry.ts` 144 + * recreates the table cleanly when desired. 163 145 */ 164 146 async function applyAdditiveMigrations( 165 147 c: { execute: (s: string) => Promise<unknown> }, 166 148 ): Promise<void> { 167 149 const additiveColumns: Array<{ table: string; column: string; ddl: string }> = 168 150 [ 169 - { 170 - table: "profile", 171 - column: "bsky_client", 172 - ddl: "ALTER TABLE profile ADD COLUMN bsky_client TEXT", 173 - }, 174 151 { 175 152 table: "profile", 176 153 column: "categories",
+97 -102
lib/lexicons.ts
··· 9 9 10 10 export const PROFILE_NSID = "com.atmosphereaccount.registry.profile"; 11 11 export const FEATURED_NSID = "com.atmosphereaccount.registry.featured"; 12 - export const LICENSE_NSID = "com.atmosphereaccount.registry.license"; 13 12 14 13 export const REGISTRY_NSIDS = [ 15 14 PROFILE_NSID, 16 15 FEATURED_NSID, 17 - LICENSE_NSID, 18 16 ] as const; 19 17 20 18 export const CATEGORIES = [ ··· 46 44 47 45 /** 48 46 * Recognised link kinds. The lexicon stores `kind` as an open string with 49 - * `knownValues`, so adding new kinds later is a non-breaking change — old 50 - * records with unknown kinds just render with the "other" fallback icon. 47 + * `knownValues`, so adding more atmosphere services or custom kinds later 48 + * is a non-breaking change. 49 + * 50 + * bsky — a Bluesky-style profile button. Requires `clientId`; URL is 51 + * derived from clientId + the user's current handle. 52 + * tangled — a Tangled profile button. URL defaults to tangled.sh/@handle 53 + * but the user may override (`url`) to point at a project repo. 54 + * supper — a Supper (supper.support/@handle) button. URL is derived; 55 + * `url` override is allowed. 56 + * website — a plain external website button. Requires `url`. 57 + * other — a custom button with a user-provided `label` + `url`. 51 58 */ 52 59 export const LINK_KINDS = [ 60 + "bsky", 61 + "tangled", 62 + "supper", 53 63 "website", 54 - "repo", 55 - "donate", 56 - "docs", 57 - "mastodon", 58 - "matrix", 59 - "discord", 60 - "contact", 61 64 "other", 62 65 ] as const; 63 66 export type LinkKind = typeof LINK_KINDS[number]; 64 67 68 + /** 69 + * Atmosphere kinds derive their URL from the user's handle. They may 70 + * still carry an explicit `url` override (tangled / supper). `bsky` 71 + * additionally requires `clientId` to pick which web client to send 72 + * visitors to. 73 + */ 74 + export const ATMOSPHERE_LINK_KINDS = ["bsky", "tangled", "supper"] as const; 75 + export type AtmosphereLinkKind = typeof ATMOSPHERE_LINK_KINDS[number]; 76 + 65 77 export interface LinkEntry { 66 78 kind: string; 67 - url: string; 68 - /** Optional display override; required for kind="other". */ 79 + /** Required for kind="website" / "other"; optional for atmosphere kinds. */ 80 + url?: string; 81 + /** Required for kind="bsky". Identifies the Bluesky-compatible client. */ 82 + clientId?: string; 83 + /** Required for kind="other"; ignored for atmosphere kinds. */ 69 84 label?: string; 70 85 } 71 - 72 - export const LICENSE_TYPES = [ 73 - "openSource", 74 - "sourceAvailable", 75 - "proprietary", 76 - ] as const; 77 - export type LicenseType = typeof LICENSE_TYPES[number]; 78 86 79 87 export interface BlobRef { 80 88 $type: "blob"; ··· 92 100 * primary category used for sort/grouping in lists. */ 93 101 categories: string[]; 94 102 subcategories?: string[]; 95 - /** Outbound links shown on the public profile (website, repo, donate, …). */ 103 + /** Outbound buttons shown on the public profile. */ 96 104 links?: LinkEntry[]; 97 - /** Preferred Bluesky client (bluesky | blacksky | anisota | deer | witchsky). */ 98 - bskyClient?: string; 99 - createdAt: string; 100 - } 101 - 102 - export interface LicenseRecord { 103 - $type?: typeof LICENSE_NSID; 104 - type: string; 105 - spdxId?: string; 106 - licenseUrl?: string; 107 - notes?: string; 108 105 createdAt: string; 109 106 } 110 107 ··· 118 115 $type?: typeof FEATURED_NSID; 119 116 entries: FeaturedEntry[]; 120 117 } 121 - 122 - import { BSKY_CLIENT_IDS } from "./bsky-clients.ts"; 123 118 124 119 const DID_RE = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 125 120 ··· 157 152 } 158 153 159 154 /** 160 - * Normalise + validate a links[] array. Drops empties, dedupes by URL, 161 - * caps at 12 to match the lexicon, and enforces the "other requires 162 - * label" rule. Unknown kinds are accepted (lexicon `knownValues` is a 163 - * hint, not a constraint). 155 + * Normalise + validate a links[] array. 156 + * - Drops obviously empty entries. 157 + * - Caps at 12 to match the lexicon. 158 + * - Enforces per-kind constraints: clientId required for "bsky", 159 + * url required for "website" / "other" / unknown kinds, label 160 + * required for "other". 161 + * - Dedupes bsky entries by clientId; dedupes URL-bearing entries 162 + * by URL. Atmosphere kinds without a URL are kept as-is (their 163 + * identity is `kind` for tangled/supper, or `kind+clientId` for 164 + * bsky). 164 165 */ 165 166 function normalizeLinks(input: unknown): { 166 167 ok: true; ··· 171 172 return { ok: false, error: "links: must be an array" }; 172 173 } 173 174 if (input.length > 12) return { ok: false, error: "links: at most 12" }; 174 - const seen = new Set<string>(); 175 + 176 + const seenUrls = new Set<string>(); 177 + const seenBskyClients = new Set<string>(); 178 + const seenAtmosphereKinds = new Set<string>(); // tangled / supper without url 175 179 const out: LinkEntry[] = []; 180 + 176 181 for (const raw of input) { 177 182 if (!raw || typeof raw !== "object") { 178 183 return { ok: false, error: "links: items must be objects" }; 179 184 } 180 185 const e = raw as Record<string, unknown>; 186 + 181 187 if (!isStr(e.kind, 32)) { 182 188 return { ok: false, error: "links[].kind: string required" }; 183 189 } 184 - if (!isUrl(e.url)) { 185 - return { ok: false, error: "links[].url: must be http(s) URL" }; 190 + const kind = (e.kind as string).trim(); 191 + if (!kind) { 192 + return { ok: false, error: "links[].kind: non-empty string required" }; 193 + } 194 + 195 + const entry: LinkEntry = { kind }; 196 + 197 + // url: required for website/other (and unknown kinds); optional for 198 + // atmosphere kinds. 199 + const isAtmosphere = (ATMOSPHERE_LINK_KINDS as readonly string[]) 200 + .includes(kind); 201 + if (e.url !== undefined && e.url !== null && e.url !== "") { 202 + if (!isUrl(e.url)) { 203 + return { ok: false, error: `links[].url (${kind}): must be http(s) URL` }; 204 + } 205 + entry.url = (e.url as string).trim(); 206 + } else if (!isAtmosphere) { 207 + return { 208 + ok: false, 209 + error: `links[]: kind="${kind}" requires a url`, 210 + }; 211 + } 212 + 213 + // clientId: required for bsky, ignored otherwise. 214 + if (kind === "bsky") { 215 + if (!isStr(e.clientId, 64) || !(e.clientId as string).trim()) { 216 + return { 217 + ok: false, 218 + error: 'links[]: kind="bsky" requires clientId', 219 + }; 220 + } 221 + entry.clientId = (e.clientId as string).trim(); 186 222 } 187 - const url = (e.url as string).trim(); 188 - if (seen.has(url)) continue; 189 - seen.add(url); 190 - const entry: LinkEntry = { kind: e.kind as string, url }; 191 - if (e.label !== undefined) { 223 + 224 + // label: required for other, optional otherwise. 225 + if (e.label !== undefined && e.label !== null && e.label !== "") { 192 226 if (!isStr(e.label, 64)) { 193 227 return { ok: false, error: "links[].label: string <=64" }; 194 228 } 195 - const label = (e.label as string).trim(); 196 - if (label) entry.label = label; 229 + entry.label = (e.label as string).trim(); 197 230 } 198 - if (entry.kind === "other" && !entry.label) { 231 + if (kind === "other" && !entry.label) { 199 232 return { 200 233 ok: false, 201 234 error: 'links[]: kind="other" requires a label', 202 235 }; 203 236 } 237 + 238 + // Dedupe. 239 + if (kind === "bsky") { 240 + const key = `bsky:${entry.clientId}`; 241 + if (seenBskyClients.has(key)) continue; 242 + seenBskyClients.add(key); 243 + } else if (entry.url) { 244 + if (seenUrls.has(entry.url)) continue; 245 + seenUrls.add(entry.url); 246 + } else if (isAtmosphere) { 247 + if (seenAtmosphereKinds.has(kind)) continue; 248 + seenAtmosphereKinds.add(kind); 249 + } 250 + 204 251 out.push(entry); 205 252 } 253 + 206 254 return { ok: true, value: out }; 207 255 } 208 256 ··· 260 308 } 261 309 const linksRes = normalizeLinks(v.links); 262 310 if (!linksRes.ok) return { ok: false, error: linksRes.error }; 263 - if ( 264 - v.bskyClient !== undefined && 265 - (!isStr(v.bskyClient) || 266 - !(BSKY_CLIENT_IDS as readonly string[]).includes(v.bskyClient as string)) 267 - ) { 268 - return { 269 - ok: false, 270 - error: `bskyClient: must be one of ${BSKY_CLIENT_IDS.join(", ")}`, 271 - }; 272 - } 273 311 if (v.subcategories !== undefined) { 274 312 if (!Array.isArray(v.subcategories) || v.subcategories.length > 10) { 275 313 return { ok: false, error: "subcategories: array of <=10 strings" }; ··· 294 332 categories: normalizedCategories, 295 333 subcategories: v.subcategories as string[] | undefined, 296 334 links: linksRes.value.length > 0 ? linksRes.value : undefined, 297 - bskyClient: v.bskyClient as string | undefined, 298 - createdAt: v.createdAt as string, 299 - }, 300 - }; 301 - } 302 - 303 - export function validateLicense( 304 - input: unknown, 305 - ): ValidationResult<LicenseRecord> { 306 - if (!input || typeof input !== "object") { 307 - return { ok: false, error: "record must be an object" }; 308 - } 309 - const v = input as Record<string, unknown>; 310 - if ( 311 - !isStr(v.type) || 312 - !(LICENSE_TYPES as readonly string[]).includes(v.type as string) 313 - ) { 314 - return { 315 - ok: false, 316 - error: `type: must be one of ${LICENSE_TYPES.join(", ")}`, 317 - }; 318 - } 319 - if (!isStr(v.createdAt)) { 320 - return { ok: false, error: "createdAt required (ISO 8601)" }; 321 - } 322 - if (v.spdxId !== undefined && !isStr(v.spdxId, 64)) { 323 - return { ok: false, error: "spdxId: string <=64" }; 324 - } 325 - if (v.licenseUrl !== undefined && !isUrl(v.licenseUrl)) { 326 - return { ok: false, error: "licenseUrl: must be http(s) URL" }; 327 - } 328 - if (v.notes !== undefined && !isStr(v.notes, 280)) { 329 - return { ok: false, error: "notes: string <=280" }; 330 - } 331 - return { 332 - ok: true, 333 - value: { 334 - $type: LICENSE_NSID, 335 - type: v.type as string, 336 - spdxId: v.spdxId as string | undefined, 337 - licenseUrl: v.licenseUrl as string | undefined, 338 - notes: v.notes as string | undefined, 339 335 createdAt: v.createdAt as string, 340 336 }, 341 337 }; ··· 400 396 const fileMap: Record<string, string> = { 401 397 [PROFILE_NSID]: "profile.json", 402 398 [FEATURED_NSID]: "featured.json", 403 - [LICENSE_NSID]: "license.json", 404 399 }; 405 400 const filename = fileMap[nsid]; 406 401 const url = new URL(
+11 -151
lib/registry.ts
··· 15 15 * primary category used for sort/grouping in lists. */ 16 16 categories: string[]; 17 17 subcategories: string[]; 18 - /** Outbound links (website, repo, donate, …) in author-defined order. */ 18 + /** Outbound links (atmosphere services, website, custom) in author-defined order. */ 19 19 links: LinkEntry[]; 20 - bskyClient: string | null; 21 20 avatarCid: string | null; 22 21 avatarMime: string | null; 23 22 pdsUrl: string; ··· 29 28 featured?: { 30 29 badges: FeaturedBadge[] | string[]; 31 30 position: number; 32 - }; 33 - /** Populated when joined with the license table. Absent = no license 34 - * record published. */ 35 - license?: { 36 - type: string; 37 - spdxId: string | null; 38 - licenseUrl: string | null; 39 - notes: string | null; 40 31 }; 41 32 } 42 33 ··· 48 39 categories: string; 49 40 subcategories: string; 50 41 links: string | null; 51 - bsky_client: string | null; 52 42 avatar_cid: string | null; 53 43 avatar_mime: string | null; 54 44 pds_url: string; ··· 58 48 indexed_at: number; 59 49 featured_badges?: string | null; 60 50 featured_position?: number | null; 61 - license_type?: string | null; 62 - license_spdx_id?: string | null; 63 - license_url?: string | null; 64 - license_notes?: string | null; 65 51 } 66 52 67 53 function safeJsonArray(text: string | null | undefined): string[] { ··· 80 66 const v = JSON.parse(text); 81 67 if (!Array.isArray(v)) return []; 82 68 return v 83 - .filter((x): x is { kind: unknown; url: unknown; label?: unknown } => 69 + .filter((x): x is Record<string, unknown> => 84 70 !!x && typeof x === "object" 85 71 ) 86 - .filter((x) => typeof x.kind === "string" && typeof x.url === "string") 72 + .filter((x) => typeof x.kind === "string") 87 73 .map((x) => { 88 - const e: LinkEntry = { kind: x.kind as string, url: x.url as string }; 74 + const e: LinkEntry = { kind: x.kind as string }; 75 + if (typeof x.url === "string" && x.url) e.url = x.url; 76 + if (typeof x.clientId === "string" && x.clientId) { 77 + e.clientId = x.clientId; 78 + } 89 79 if (typeof x.label === "string" && x.label) e.label = x.label; 90 80 return e; 91 81 }); ··· 103 93 categories: safeJsonArray(r.categories), 104 94 subcategories: safeJsonArray(r.subcategories), 105 95 links: safeJsonLinks(r.links), 106 - bskyClient: r.bsky_client, 107 96 avatarCid: r.avatar_cid, 108 97 avatarMime: r.avatar_mime, 109 98 pdsUrl: r.pds_url, ··· 118 107 position: Number(r.featured_position ?? 0), 119 108 }; 120 109 } 121 - if (r.license_type) { 122 - out.license = { 123 - type: r.license_type, 124 - spdxId: r.license_spdx_id ?? null, 125 - licenseUrl: r.license_url ?? null, 126 - notes: r.license_notes ?? null, 127 - }; 128 - } 129 110 return out; 130 111 } 131 112 ··· 138 119 categories: string[]; 139 120 subcategories: string[]; 140 121 links?: LinkEntry[] | null; 141 - bskyClient?: string | null; 142 122 avatarCid?: string | null; 143 123 avatarMime?: string | null; 144 124 pdsUrl: string; ··· 172 152 sql: ` 173 153 INSERT INTO profile ( 174 154 did, handle, name, description, categories, subcategories, links, 175 - bsky_client, avatar_cid, avatar_mime, pds_url, record_cid, 155 + avatar_cid, avatar_mime, pds_url, record_cid, 176 156 record_rev, created_at, indexed_at 177 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 157 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 178 158 ON CONFLICT(did) DO UPDATE SET 179 159 handle=excluded.handle, 180 160 name=excluded.name, ··· 182 162 categories=excluded.categories, 183 163 subcategories=excluded.subcategories, 184 164 links=excluded.links, 185 - bsky_client=excluded.bsky_client, 186 165 avatar_cid=excluded.avatar_cid, 187 166 avatar_mime=excluded.avatar_mime, 188 167 pds_url=excluded.pds_url, ··· 199 178 JSON.stringify(cats), 200 179 JSON.stringify(input.subcategories ?? []), 201 180 JSON.stringify(input.links ?? []), 202 - input.bskyClient ?? null, 203 181 input.avatarCid ?? null, 204 182 input.avatarMime ?? null, 205 183 input.pdsUrl, ··· 214 192 215 193 export async function deleteProfile(did: string): Promise<void> { 216 194 await withDb(async (c) => { 217 - // License is paired with the profile from the user's POV; cleaning it 218 - // up here keeps the index from carrying orphan rows after a removal. 219 - await c.execute({ sql: `DELETE FROM license WHERE did = ?`, args: [did] }); 220 195 await c.execute({ sql: `DELETE FROM profile WHERE did = ?`, args: [did] }); 221 196 }); 222 197 } ··· 224 199 const SELECT_PROFILE = ` 225 200 SELECT p.*, 226 201 f.badges AS featured_badges, 227 - f.position AS featured_position, 228 - l.type AS license_type, 229 - l.spdx_id AS license_spdx_id, 230 - l.license_url AS license_url, 231 - l.notes AS license_notes 202 + f.position AS featured_position 232 203 FROM profile p 233 204 LEFT JOIN featured f ON f.did = p.did 234 - LEFT JOIN license l ON l.did = p.did 235 205 `; 236 206 237 207 export async function getProfileByDid(did: string): Promise<ProfileRow | null> { ··· 370 340 args: [e.did, JSON.stringify(e.badges ?? []), e.position, now], 371 341 }); 372 342 } 373 - }); 374 - } 375 - 376 - /* -------------------------------------------------------------------------- * 377 - * License (com.atmosphereaccount.registry.license/self) * 378 - * -------------------------------------------------------------------------- */ 379 - 380 - export interface LicenseRow { 381 - did: string; 382 - type: string; 383 - spdxId: string | null; 384 - licenseUrl: string | null; 385 - notes: string | null; 386 - pdsUrl: string; 387 - recordCid: string; 388 - recordRev: string; 389 - createdAt: number; 390 - indexedAt: number; 391 - } 392 - 393 - interface RawLicenseRow { 394 - did: string; 395 - type: string; 396 - spdx_id: string | null; 397 - license_url: string | null; 398 - notes: string | null; 399 - pds_url: string; 400 - record_cid: string; 401 - record_rev: string; 402 - created_at: number; 403 - indexed_at: number; 404 - } 405 - 406 - function rowToLicense(r: RawLicenseRow): LicenseRow { 407 - return { 408 - did: r.did, 409 - type: r.type, 410 - spdxId: r.spdx_id, 411 - licenseUrl: r.license_url, 412 - notes: r.notes, 413 - pdsUrl: r.pds_url, 414 - recordCid: r.record_cid, 415 - recordRev: r.record_rev, 416 - createdAt: Number(r.created_at), 417 - indexedAt: Number(r.indexed_at), 418 - }; 419 - } 420 - 421 - export interface UpsertLicenseInput { 422 - did: string; 423 - type: string; 424 - spdxId?: string | null; 425 - licenseUrl?: string | null; 426 - notes?: string | null; 427 - pdsUrl: string; 428 - recordCid: string; 429 - recordRev: string; 430 - createdAt: number; 431 - } 432 - 433 - export async function upsertLicense(input: UpsertLicenseInput): Promise<void> { 434 - const now = Date.now(); 435 - await withDb(async (c) => { 436 - await c.execute({ 437 - sql: ` 438 - INSERT INTO license ( 439 - did, type, spdx_id, license_url, notes, 440 - pds_url, record_cid, record_rev, created_at, indexed_at 441 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 442 - ON CONFLICT(did) DO UPDATE SET 443 - type=excluded.type, 444 - spdx_id=excluded.spdx_id, 445 - license_url=excluded.license_url, 446 - notes=excluded.notes, 447 - pds_url=excluded.pds_url, 448 - record_cid=excluded.record_cid, 449 - record_rev=excluded.record_rev, 450 - created_at=excluded.created_at, 451 - indexed_at=excluded.indexed_at 452 - `, 453 - args: [ 454 - input.did, 455 - input.type, 456 - input.spdxId ?? null, 457 - input.licenseUrl ?? null, 458 - input.notes ?? null, 459 - input.pdsUrl, 460 - input.recordCid, 461 - input.recordRev, 462 - input.createdAt, 463 - now, 464 - ], 465 - }); 466 - }); 467 - } 468 - 469 - export async function deleteLicense(did: string): Promise<void> { 470 - await withDb(async (c) => { 471 - await c.execute({ sql: `DELETE FROM license WHERE did = ?`, args: [did] }); 472 - }); 473 - } 474 - 475 - export async function getLicenseByDid(did: string): Promise<LicenseRow | null> { 476 - return await withDb(async (c) => { 477 - const r = await c.execute({ 478 - sql: `SELECT * FROM license WHERE did = ? LIMIT 1`, 479 - args: [did], 480 - }); 481 - if (r.rows.length === 0) return null; 482 - return rowToLicense(r.rows[0] as unknown as RawLicenseRow); 483 343 }); 484 344 } 485 345
-100
lib/repo-hosts.ts
··· 1 - /** 2 - * Auto-detected source-repo hosts. A project's profile can publish any 3 - * number of `links` entries with kind="repo"; we pick the right icon + 4 - * label based on the URL host so the UI stays simple (no extra picker 5 - * the way Bluesky-clients have one). 6 - * 7 - * Currently recognised: 8 - * - GitHub (github.com) 9 - * - Tangled (tangled.org / *.tngl.sh — the AT Protocol "social coding" 10 - * platform: https://tangled.org/) 11 - * 12 - * Anything else falls back to a generic "code" host with the website-style 13 - * arrow glyph so the button still works for self-hosted Forgejo/Gitea/etc. 14 - * 15 - * Used by `lib/link-kinds.ts` when resolving a `repo`-kind link entry 16 - * into render-ready data for components/explore/ProfileLinks.tsx. 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 - }
+37 -121
routes/api/registry/profile.ts
··· 1 1 /** 2 2 * Authenticated registry profile mutations. The session must hold a 3 - * valid OAuth session for the authoring DID; we then write the record(s) 4 - * directly to the user's PDS via DPoP-bound XRPC and mirror them into 5 - * the local index. The Jetstream-fed indexer picks up the same writes 6 - * shortly after for any other consumers of the registry. 7 - * 8 - * PUT /api/registry/profile (create/update profile + license) 9 - * DELETE /api/registry/profile (delete both records) 3 + * valid OAuth session for the authoring DID; we then write the profile 4 + * record directly to the user's PDS via DPoP-bound XRPC and mirror it 5 + * into the local index. The Jetstream-fed indexer picks up the same 6 + * write shortly after for any other consumers of the registry. 10 7 * 11 - * Note: the request body carries an optional `license` sub-object which 12 - * is published as a sibling `com.atmosphereaccount.registry.license` 13 - * record. Splitting it out keeps the profile lexicon minimal — the form 14 - * still presents both as a single Save action for UX simplicity. 8 + * PUT /api/registry/profile (create/update profile) 9 + * DELETE /api/registry/profile (delete profile) 15 10 */ 16 11 import { define } from "../../../utils.ts"; 17 12 import { loadSession } from "../../../lib/oauth.ts"; 18 13 import { 19 14 deleteProfileRecord, 20 - deleteRecord, 21 15 putProfileRecord, 22 - putRecord, 23 16 uploadBlob, 24 17 } from "../../../lib/pds.ts"; 25 18 import { 26 - LICENSE_NSID, 27 - type LicenseRecord, 19 + ATMOSPHERE_LINK_KINDS, 28 20 type LinkEntry, 29 21 type ProfileRecord, 30 - validateLicense, 31 22 validateProfile, 32 23 } from "../../../lib/lexicons.ts"; 33 - import { 34 - deleteLicense, 35 - deleteProfile, 36 - upsertLicense, 37 - upsertProfile, 38 - } from "../../../lib/registry.ts"; 39 - 40 - interface LicensePayload { 41 - type?: string; 42 - spdxId?: string; 43 - licenseUrl?: string; 44 - notes?: string; 45 - } 24 + import { deleteProfile, upsertProfile } from "../../../lib/registry.ts"; 46 25 47 26 interface LinkPayload { 48 27 kind?: string; 49 28 url?: string; 29 + clientId?: string; 50 30 label?: string; 51 31 } 52 32 ··· 57 37 categories?: string[]; 58 38 subcategories?: string[]; 59 39 links?: LinkPayload[]; 60 - bskyClient?: string; 61 40 /** Either keep an existing avatar (passed as the BlobRef) or upload new bytes */ 62 41 avatar?: { 63 42 $type: "blob"; ··· 66 45 size: number; 67 46 } | null; 68 47 avatarUpload?: { dataBase64: string; mimeType: string }; 69 - /** 70 - * Optional license sub-record. `null` means "remove any existing license 71 - * record"; `undefined` means "leave it alone". 72 - */ 73 - license?: LicensePayload | null; 74 48 } 75 49 76 50 function trimOrNull(s: unknown): string | undefined { ··· 87 61 .map((x) => x.trim().slice(0, 32)); 88 62 } 89 63 64 + /** 65 + * Coerce the form's `links` payload into the lexicon shape. We do 66 + * minimal cleanup here (trim + drop entries that don't carry the 67 + * fields they need); deeper validation happens in `validateProfile`. 68 + */ 90 69 function normalizeLinksPayload(input: unknown): LinkEntry[] { 91 70 if (!Array.isArray(input)) return []; 92 - const seen = new Set<string>(); 93 71 const out: LinkEntry[] = []; 94 72 for (const raw of input) { 95 73 if (!raw || typeof raw !== "object") continue; 96 74 const e = raw as LinkPayload; 97 75 const kind = trimOrNull(e.kind); 76 + if (!kind) continue; 77 + 78 + const entry: LinkEntry = { kind }; 98 79 const url = trimOrNull(e.url); 99 - if (!kind || !url) continue; 100 - if (seen.has(url)) continue; 101 - seen.add(url); 102 - const entry: LinkEntry = { kind, url }; 80 + if (url) entry.url = url; 81 + const clientId = trimOrNull(e.clientId); 82 + if (clientId) entry.clientId = clientId; 103 83 const label = trimOrNull(e.label); 104 84 if (label) entry.label = label; 85 + 86 + // Skip entries that obviously can't render: non-atmosphere kinds 87 + // need a url; bsky needs a clientId. The lexicon validator below 88 + // will surface cleaner errors for malformed entries that slip 89 + // through, but dropping the no-op ones here keeps "Save" idempotent 90 + // when the form hasn't filled a row in yet. 91 + const isAtmosphere = 92 + (ATMOSPHERE_LINK_KINDS as readonly string[]).includes(kind); 93 + if (!isAtmosphere && !entry.url) continue; 94 + if (kind === "bsky" && !entry.clientId) continue; 95 + 105 96 out.push(entry); 106 97 } 107 98 return out; ··· 177 168 categories: normalizedCategories, 178 169 subcategories: asArray(body.subcategories), 179 170 links: links.length > 0 ? links : undefined, 180 - bskyClient: trimOrNull(body.bskyClient), 181 171 avatar: avatar ?? undefined, 182 172 createdAt: new Date().toISOString(), 183 173 }; ··· 203 193 204 194 /** 205 195 * Index inline so the new entry appears in /explore the moment the 206 - * user hits Publish, without depending on the Jetstream worker. The 207 - * worker is still useful for picking up records authored outside 208 - * this app (e.g. by other tooling), but it isn't on the critical 209 - * path for the user-facing flow. 210 - * 211 - * Both the PDS write and this index write are idempotent (rkey is 212 - * fixed at "self"; the SQL is ON CONFLICT DO UPDATE), so retrying a 213 - * failed publish is always safe. 196 + * user hits Publish, without depending on the Jetstream worker. 197 + * Both the PDS write and this index write are idempotent so retrying 198 + * a failed publish is always safe. 214 199 */ 215 200 try { 216 201 await upsertProfile({ ··· 221 206 categories: validation.value.categories, 222 207 subcategories: validation.value.subcategories ?? [], 223 208 links: validation.value.links ?? [], 224 - bskyClient: validation.value.bskyClient ?? null, 225 209 avatarCid: validation.value.avatar?.ref.$link ?? null, 226 210 avatarMime: validation.value.avatar?.mimeType ?? null, 227 211 pdsUrl: session.pdsUrl, ··· 239 223 ); 240 224 } 241 225 242 - /** 243 - * Optional license sub-record handling. The form sends: 244 - * - `license: undefined` → leave any existing record alone 245 - * - `license: null` → delete any existing record 246 - * - `license: { ... }` → upsert 247 - * 248 - * Failures here are treated as soft errors: the profile is already 249 - * saved, so we still return 200 but include a warning the form can 250 - * surface. 251 - */ 252 - let licenseWarning: string | null = null; 253 - if (body.license === null) { 254 - try { 255 - await deleteRecord(user.did, session.pdsUrl, LICENSE_NSID, "self"); 256 - await deleteLicense(user.did); 257 - } catch (err) { 258 - licenseWarning = err instanceof Error ? err.message : String(err); 259 - console.error("[registry] license delete failed:", err); 260 - } 261 - } else if (body.license && typeof body.license === "object") { 262 - const lp = body.license; 263 - const licenseDraft: LicenseRecord = { 264 - type: trimOrNull(lp.type) ?? "", 265 - spdxId: trimOrNull(lp.spdxId), 266 - licenseUrl: trimOrNull(lp.licenseUrl), 267 - notes: trimOrNull(lp.notes), 268 - createdAt: new Date().toISOString(), 269 - }; 270 - const lv = validateLicense(licenseDraft); 271 - if (!lv.ok || !lv.value) { 272 - licenseWarning = `Profile saved, but license rejected: ${lv.error}`; 273 - } else { 274 - try { 275 - const lr = await putRecord( 276 - user.did, 277 - session.pdsUrl, 278 - LICENSE_NSID, 279 - "self", 280 - lv.value as unknown as Record<string, unknown>, 281 - ); 282 - await upsertLicense({ 283 - did: user.did, 284 - type: lv.value.type, 285 - spdxId: lv.value.spdxId ?? null, 286 - licenseUrl: lv.value.licenseUrl ?? null, 287 - notes: lv.value.notes ?? null, 288 - pdsUrl: session.pdsUrl, 289 - recordCid: lr.cid, 290 - recordRev: lr.commit?.rev ?? lr.cid, 291 - createdAt: Date.parse(lv.value.createdAt) || Date.now(), 292 - }); 293 - } catch (err) { 294 - licenseWarning = err instanceof Error ? err.message : String(err); 295 - console.error("[registry] license upsert failed:", err); 296 - } 297 - } 298 - } 299 - 300 226 return new Response( 301 227 JSON.stringify({ 302 228 ok: true, 303 229 uri: result.uri, 304 230 cid: result.cid, 305 - licenseWarning, 306 231 }), 307 232 { status: 200, headers: { "content-type": "application/json" } }, 308 233 ); ··· 321 246 const m = err instanceof Error ? err.message : String(err); 322 247 return new Response(`deleteRecord failed: ${m}`, { status: 502 }); 323 248 } 324 - // Removing from Explore implies removing the paired license record 325 - // too — there's no orphaned-license UX, and the user can re-add 326 - // both by republishing. 327 - try { 328 - await deleteRecord(user.did, session.pdsUrl, LICENSE_NSID, "self"); 329 - } catch (err) { 330 - console.warn("[registry] license deleteRecord (best effort) failed:", err); 331 - } 332 249 333 - /** Mirror the deletes in our local index so /explore stops listing it 334 - * immediately. As above, the Jetstream worker would eventually do 335 - * this too, but we don't want to wait. `deleteProfile` cascades to 336 - * the license row. */ 250 + /** Mirror the delete in our local index so /explore stops listing it 251 + * immediately. The Jetstream worker would eventually do this too, 252 + * but we don't want to wait. */ 337 253 try { 338 254 await deleteProfile(user.did); 339 255 } catch (err) {
+2 -16
routes/explore/manage.tsx
··· 4 4 import Footer from "../../components/Footer.tsx"; 5 5 import CreateProfileForm from "../../islands/CreateProfileForm.tsx"; 6 6 import { getMessages } from "../../i18n/mod.ts"; 7 - import { getLicenseByDid, getProfileByDid } from "../../lib/registry.ts"; 7 + import { getProfileByDid } from "../../lib/registry.ts"; 8 8 import { loadSession } from "../../lib/oauth.ts"; 9 9 import { getBskyProfile } from "../../lib/pds.ts"; 10 10 ··· 34 34 * registry record exists, the form switches to the cached 35 35 * /api/registry/avatar/:did proxy. */ 36 36 let initialAvatarUrl: string | null = null; 37 - const [existing, license] = await Promise.all([ 38 - getProfileByDid(user.did).catch(() => null), 39 - getLicenseByDid(user.did).catch(() => null), 40 - ]); 37 + const existing = await getProfileByDid(user.did).catch(() => null); 41 38 if (existing) { 42 39 initial = { 43 40 name: existing.name, ··· 45 42 categories: existing.categories, 46 43 subcategories: existing.subcategories, 47 44 links: existing.links, 48 - bskyClient: existing.bskyClient, 49 45 avatar: existing.avatarCid && existing.avatarMime 50 46 ? { ref: existing.avatarCid, mime: existing.avatarMime } 51 47 : null, 52 - license: license 53 - ? { 54 - type: license.type, 55 - spdxId: license.spdxId, 56 - licenseUrl: license.licenseUrl, 57 - notes: license.notes, 58 - } 59 - : null, 60 48 }; 61 49 } else { 62 50 const session = await loadSession(user.did); ··· 71 59 categories: ["app"], 72 60 subcategories: [], 73 61 links: [], 74 - bskyClient: null, 75 62 avatar: bsky.avatar 76 63 ? { 77 64 ref: bsky.avatar.ref.$link, 78 65 mime: bsky.avatar.mimeType, 79 66 } 80 67 : null, 81 - license: null, 82 68 }; 83 69 if (bsky.avatar) { 84 70 initialAvatarUrl = ME_AVATAR_PROXY;
+1 -49
worker/indexer.ts
··· 14 14 */ 15 15 import { 16 16 FEATURED_NSID, 17 - LICENSE_NSID, 18 17 PROFILE_NSID, 19 18 validateFeatured, 20 - validateLicense, 21 19 validateProfile, 22 20 } from "../lib/lexicons.ts"; 23 21 import { 24 - deleteLicense, 25 22 deleteProfile, 26 23 getJetstreamCursor, 27 24 replaceFeatured, 28 25 setJetstreamCursor, 29 - upsertLicense, 30 26 upsertProfile, 31 27 } from "../lib/registry.ts"; 32 28 import { findPdsEndpoint, resolveDidDocument } from "../lib/identity.ts"; ··· 49 45 commit?: JetstreamCommit; 50 46 } 51 47 52 - const COLLECTIONS = [PROFILE_NSID, FEATURED_NSID, LICENSE_NSID]; 48 + const COLLECTIONS = [PROFILE_NSID, FEATURED_NSID]; 53 49 const RECONNECT_DELAY_MS = 5_000; 54 50 const CURSOR_PERSIST_INTERVAL_MS = 5_000; 55 51 ··· 117 113 categories: r.categories, 118 114 subcategories: r.subcategories ?? [], 119 115 links: r.links ?? [], 120 - bskyClient: r.bskyClient ?? null, 121 116 avatarCid: r.avatar?.ref.$link ?? null, 122 117 avatarMime: r.avatar?.mimeType ?? null, 123 118 pdsUrl, ··· 128 123 console.log(`[indexer] upsert profile ${handle} (${event.did})`); 129 124 } 130 125 131 - async function handleLicenseEvent(event: JetstreamEvent): Promise<void> { 132 - const commit = event.commit; 133 - if (!commit) return; 134 - 135 - if (commit.operation === "delete") { 136 - await deleteLicense(event.did); 137 - return; 138 - } 139 - 140 - const pdsUrl = await resolvePdsForDid(event.did); 141 - const fetched = await getRecordPublic( 142 - pdsUrl, 143 - event.did, 144 - LICENSE_NSID, 145 - "self", 146 - ); 147 - if (!fetched) return; 148 - 149 - const validation = validateLicense(fetched.value); 150 - if (!validation.ok || !validation.value) { 151 - console.warn( 152 - `[indexer] invalid license from ${event.did}: ${validation.error}`, 153 - ); 154 - return; 155 - } 156 - const r = validation.value; 157 - 158 - await upsertLicense({ 159 - did: event.did, 160 - type: r.type, 161 - spdxId: r.spdxId ?? null, 162 - licenseUrl: r.licenseUrl ?? null, 163 - notes: r.notes ?? null, 164 - pdsUrl, 165 - recordCid: fetched.cid, 166 - recordRev: commit.rev, 167 - createdAt: Date.parse(r.createdAt) || Date.now(), 168 - }); 169 - console.log(`[indexer] upsert license ${event.did} (${r.type})`); 170 - } 171 - 172 126 async function handleFeaturedEvent(event: JetstreamEvent): Promise<void> { 173 127 const commit = event.commit; 174 128 if (!commit) return; ··· 222 176 await handleProfileEvent(event); 223 177 } else if (collection === FEATURED_NSID) { 224 178 await handleFeaturedEvent(event); 225 - } else if (collection === LICENSE_NSID) { 226 - await handleLicenseEvent(event); 227 179 } 228 180 } catch (err) { 229 181 console.error(`[indexer] handler error for ${collection}:`, err);