this repo has no description
10
fork

Configure Feed

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

feat(explore): per-client SVG icons + live/inactive registry status pill

- Replace the Google S2 favicon service with hand-built per-client icons
shipped from /static/clients (bluesky, blacksky, anisota, deer SVGs;
witchsky.png provided by user). Each icon is on-brand for that client
and the bsky-clients.ts module now exposes a stable iconUrl per entry.
- Profile detail page (ProfileLinks) and the create/manage form picker
both render the new per-client icon so the 'Open on <client>' button
always carries the correct mark.
- Add a live/inactive status pill at the top of CreateProfileForm. When
the user has a published registry record, it shows a green 'Live in
Explore' indicator; after they remove it (or before first publish),
it switches to a neutral 'Not on the registry' indicator with a hint
to publish to add it back. The pill flips client-side immediately on
successful save / delete (no full page reload).
- Save button label now reflects state ('Publish profile' vs 'Update
profile'), and the destructive 'Remove from Explore' button only
shows when the profile is currently live.
- routes/explore/manage.tsx passes initialPublished derived from
getProfileByDid so the SSR pill matches the registry on first paint.

Made-with: Cursor

+237 -43
+75 -10
assets/styles.css
··· 2124 2124 ); 2125 2125 } 2126 2126 .profile-action-icon { 2127 - width: 28px; 2128 - height: 28px; 2129 - border-radius: 8px; 2127 + width: 32px; 2128 + height: 32px; 2129 + border-radius: 9px; 2130 2130 flex-shrink: 0; 2131 - background: #ffffff; 2131 + background: transparent; 2132 2132 object-fit: contain; 2133 - padding: 2px; 2134 2133 } 2135 2134 .profile-action-icon--glyph { 2136 2135 background: rgba(18, 26, 47, 0.08); ··· 2202 2201 padding: 1.75rem; 2203 2202 border-radius: 24px; 2204 2203 } 2204 + 2205 + /* ---- Live / inactive status pill at the top of the form ---- */ 2206 + .profile-status { 2207 + display: flex; 2208 + align-items: flex-start; 2209 + gap: 0.7rem; 2210 + padding: 0.75rem 1rem; 2211 + border-radius: 14px; 2212 + margin-bottom: 1.25rem; 2213 + border: 1px solid transparent; 2214 + } 2215 + .profile-status--live { 2216 + background: rgba(46, 160, 90, 0.1); 2217 + border-color: rgba(46, 160, 90, 0.3); 2218 + color: rgba(20, 90, 50, 1); 2219 + } 2220 + .profile-status--inactive { 2221 + background: rgba(120, 120, 130, 0.08); 2222 + border-color: rgba(18, 26, 47, 0.12); 2223 + color: rgba(60, 70, 90, 1); 2224 + } 2225 + .profile-status-dot { 2226 + width: 10px; 2227 + height: 10px; 2228 + border-radius: 50%; 2229 + margin-top: 6px; 2230 + flex-shrink: 0; 2231 + } 2232 + .profile-status--live .profile-status-dot { 2233 + background: #2ea05a; 2234 + box-shadow: 0 0 0 4px rgba(46, 160, 90, 0.18); 2235 + } 2236 + .profile-status--inactive .profile-status-dot { 2237 + background: #9aa0ad; 2238 + box-shadow: 0 0 0 4px rgba(154, 160, 173, 0.18); 2239 + } 2240 + .profile-status-text { 2241 + display: flex; 2242 + flex-direction: column; 2243 + gap: 0.1rem; 2244 + min-width: 0; 2245 + } 2246 + .profile-status-title { 2247 + font-weight: 600; 2248 + font-size: 0.95rem; 2249 + } 2250 + .profile-status-sub { 2251 + font-size: 0.85rem; 2252 + opacity: 0.85; 2253 + } 2254 + .dark-phase .profile-status--live { 2255 + background: rgba(80, 200, 130, 0.12); 2256 + border-color: rgba(80, 200, 130, 0.35); 2257 + color: rgba(180, 240, 200, 1); 2258 + } 2259 + .dark-phase .profile-status--inactive { 2260 + background: rgba(255, 255, 255, 0.06); 2261 + border-color: rgba(255, 255, 255, 0.14); 2262 + color: rgba(220, 225, 235, 1); 2263 + } 2264 + .dark-phase .profile-status--live .profile-status-dot { 2265 + background: #4cd283; 2266 + box-shadow: 0 0 0 4px rgba(76, 210, 131, 0.2); 2267 + } 2268 + .dark-phase .profile-status--inactive .profile-status-dot { 2269 + background: #b1b6c1; 2270 + box-shadow: 0 0 0 4px rgba(177, 182, 193, 0.18); 2271 + } 2205 2272 .profile-form-row { 2206 2273 display: grid; 2207 2274 grid-template-columns: 200px 1fr; ··· 2317 2384 pointer-events: none; 2318 2385 } 2319 2386 .bsky-client-icon { 2320 - width: 36px; 2321 - height: 36px; 2387 + width: 40px; 2388 + height: 40px; 2322 2389 border-radius: 10px; 2323 2390 flex-shrink: 0; 2324 - background: #ffffff; 2391 + background: transparent; 2325 2392 object-fit: contain; 2326 - padding: 4px; 2327 - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); 2328 2393 } 2329 2394 .bsky-client-meta { 2330 2395 display: flex;
+2 -2
components/explore/ProfileLinks.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 - import { bskyClientFaviconUrl, getBskyClient } from "../../lib/bsky-clients.ts"; 2 + import { getBskyClient } from "../../lib/bsky-clients.ts"; 3 3 import { useT } from "../../i18n/mod.ts"; 4 4 5 5 interface Props { ··· 29 29 rel="noopener noreferrer" 30 30 > 31 31 <img 32 - src={bskyClientFaviconUrl(client.domain)} 32 + src={client.iconUrl} 33 33 alt="" 34 34 class="profile-action-icon" 35 35 loading="lazy"
+8 -2
i18n/messages/en.tsx
··· 360 360 subhead: 361 361 "Your entry shows up across Explore. The form is pre-filled from your Bluesky profile if you have one — change anything you like.", 362 362 pulledFromBsky: "Pulled in from your Bluesky profile.", 363 - saveButton: "Publish profile", 363 + publishButton: "Publish profile", 364 + updateButton: "Update profile", 364 365 savingButton: "Publishing…", 365 366 savedToast: "Saved. It'll appear in the registry shortly.", 366 367 deleteButton: "Remove from Explore", 367 368 deletingButton: "Removing…", 368 - deletedToast: "Removed.", 369 + deletedToast: "Removed from the Explore registry.", 370 + statusLiveTitle: "Live in Explore", 371 + statusLiveSub: "Your profile is on the registry and visible to everyone.", 372 + statusInactiveTitle: "Not on the registry", 373 + statusInactiveSub: 374 + "Publish to add this profile to Explore. Nothing is shared until you do.", 369 375 signOut: "Sign out", 370 376 signedInAs: "Signed in as", 371 377 },
+42 -15
islands/CreateProfileForm.tsx
··· 5 5 CATEGORIES, 6 6 type Category, 7 7 } from "../lib/lexicons.ts"; 8 - import { 9 - BSKY_CLIENTS, 10 - bskyClientFaviconUrl, 11 - DEFAULT_BSKY_CLIENT_ID, 12 - } from "../lib/bsky-clients.ts"; 8 + import { BSKY_CLIENTS, DEFAULT_BSKY_CLIENT_ID } from "../lib/bsky-clients.ts"; 13 9 import { useT } from "../i18n/mod.ts"; 14 10 15 11 interface ExistingProfile { ··· 30 26 /** Direct image URL to show in the avatar slot before any registry record 31 27 * exists (e.g. the user's PDS-hosted Bluesky avatar). */ 32 28 initialAvatarUrl?: string | null; 29 + /** Whether the registry currently has a published record for this user. 30 + * Drives the live/inactive status pill at the top of the form. */ 31 + initialPublished: boolean; 33 32 } 34 33 35 34 interface BlobRefShape { ··· 54 53 } 55 54 56 55 export default function CreateProfileForm( 57 - { did, handle, initial, initialAvatarUrl }: Props, 56 + { did, handle, initial, initialAvatarUrl, initialPublished }: Props, 58 57 ) { 59 58 const t = useT(); 60 59 const tForm = t.forms.profile; 60 + const tManage = t.explore.manage; 61 + /** Live registry status. Flips on save (-> true) and delete (-> false). 62 + * Drives the colored pill that tells the user whether their entry is 63 + * visible in /explore right now. */ 64 + const published = useSignal<boolean>(initialPublished); 61 65 62 66 const name = useSignal(initial?.name ?? ""); 63 67 const description = useSignal(initial?.description ?? ""); ··· 165 169 const text = await res.text(); 166 170 throw new Error(text || `HTTP ${res.status}`); 167 171 } 168 - message.value = { kind: "ok", text: t.explore.manage.savedToast }; 172 + published.value = true; 173 + message.value = { kind: "ok", text: tManage.savedToast }; 169 174 } catch (err) { 170 175 message.value = { 171 176 kind: "error", ··· 183 188 try { 184 189 const res = await fetch("/api/registry/profile", { method: "DELETE" }); 185 190 if (!res.ok) throw new Error(await res.text()); 186 - message.value = { kind: "ok", text: t.explore.manage.deletedToast }; 191 + published.value = false; 192 + message.value = { kind: "ok", text: tManage.deletedToast }; 187 193 } catch (err) { 188 194 message.value = { 189 195 kind: "error", ··· 196 202 197 203 return ( 198 204 <form class="profile-form glass" onSubmit={onSubmit}> 205 + <div 206 + class={`profile-status profile-status--${ 207 + published.value ? "live" : "inactive" 208 + }`} 209 + role="status" 210 + aria-live="polite" 211 + > 212 + <span class="profile-status-dot" aria-hidden="true" /> 213 + <span class="profile-status-text"> 214 + <span class="profile-status-title"> 215 + {published.value 216 + ? tManage.statusLiveTitle 217 + : tManage.statusInactiveTitle} 218 + </span> 219 + <span class="profile-status-sub"> 220 + {published.value 221 + ? tManage.statusLiveSub 222 + : tManage.statusInactiveSub} 223 + </span> 224 + </span> 225 + </div> 199 226 <div class="profile-form-row"> 200 227 <div class="profile-form-avatar"> 201 228 {avatarPreview.value ··· 349 376 onChange={() => bskyClient.value = c.id} 350 377 /> 351 378 <img 352 - src={bskyClientFaviconUrl(c.domain)} 379 + src={c.iconUrl} 353 380 alt="" 354 381 class="bsky-client-icon" 355 382 loading="lazy" ··· 388 415 class="profile-form-button-primary" 389 416 > 390 417 {submitting.value 391 - ? t.explore.manage.savingButton 392 - : t.explore.manage.saveButton} 418 + ? tManage.savingButton 419 + : published.value 420 + ? tManage.updateButton 421 + : tManage.publishButton} 393 422 </button> 394 - {initial && ( 423 + {published.value && ( 395 424 <button 396 425 type="button" 397 426 disabled={deleting.value} 398 427 onClick={onDelete} 399 428 class="profile-form-button-danger" 400 429 > 401 - {deleting.value 402 - ? t.explore.manage.deletingButton 403 - : t.explore.manage.deleteButton} 430 + {deleting.value ? tManage.deletingButton : tManage.deleteButton} 404 431 </button> 405 432 )} 406 433 {message.value && (
+8 -13
lib/bsky-clients.ts
··· 19 19 id: string; 20 20 /** Display name shown to users. */ 21 21 name: string; 22 - /** Bare hostname (used to derive favicon + profile URL). */ 22 + /** Bare hostname (used in UI subtitles + as the profile URL host). */ 23 23 domain: string; 24 + /** Path under /static/clients/ for the rendered button icon. */ 25 + iconUrl: string; 24 26 /** Returns the profile URL for a given handle on this client. */ 25 27 profileUrl: (handle: string) => string; 26 28 } ··· 33 35 id: "bluesky", 34 36 name: "Bluesky", 35 37 domain: "bsky.app", 38 + iconUrl: "/clients/bluesky.svg", 36 39 profileUrl: profileUrlAt("bsky.app"), 37 40 }, 38 41 { 39 42 id: "blacksky", 40 43 name: "Blacksky", 41 44 domain: "blacksky.community", 45 + iconUrl: "/clients/blacksky.svg", 42 46 profileUrl: profileUrlAt("blacksky.community"), 43 47 }, 44 48 { 45 49 id: "anisota", 46 50 name: "Anisota", 47 51 domain: "anisota.net", 52 + iconUrl: "/clients/anisota.svg", 48 53 profileUrl: profileUrlAt("anisota.net"), 49 54 }, 50 55 { 51 56 id: "deer", 52 57 name: "Deer Social", 53 58 domain: "deer.social", 59 + iconUrl: "/clients/deer.svg", 54 60 profileUrl: profileUrlAt("deer.social"), 55 61 }, 56 62 { 57 63 id: "witchsky", 58 64 name: "Witchsky", 59 65 domain: "witchsky.app", 66 + iconUrl: "/clients/witchsky.png", 60 67 profileUrl: profileUrlAt("witchsky.app"), 61 68 }, 62 69 ]; ··· 68 75 export function getBskyClient(id: string | null | undefined): BskyClient { 69 76 return BSKY_CLIENTS.find((c) => c.id === id) ?? BSKY_CLIENTS[0]; 70 77 } 71 - 72 - /** 73 - * URL for the client's favicon. We route through Google's S2 favicon 74 - * service so we never depend on each domain serving CORS-friendly 75 - * `/favicon.ico` and so we get a consistent rendered size. Cached 76 - * aggressively by Google's CDN. 77 - */ 78 - export function bskyClientFaviconUrl(domain: string, size = 64): string { 79 - return `https://www.google.com/s2/favicons?sz=${size}&domain=${ 80 - encodeURIComponent(domain) 81 - }`; 82 - }
+4 -1
routes/explore/manage.tsx
··· 82 82 user={user} 83 83 initial={initial} 84 84 initialAvatarUrl={initialAvatarUrl} 85 + initialPublished={!!existing} 85 86 t={t} 86 87 />, 87 88 ); ··· 92 93 user: { did: string; handle: string }; 93 94 initial: Parameters<typeof CreateProfileForm>[0]["initial"]; 94 95 initialAvatarUrl: string | null; 96 + initialPublished: boolean; 95 97 // deno-lint-ignore no-explicit-any 96 98 t: any; 97 99 } 98 100 99 101 function ManagePage( 100 - { user, initial, initialAvatarUrl, t }: ManagePageProps, 102 + { user, initial, initialAvatarUrl, initialPublished, t }: ManagePageProps, 101 103 ) { 102 104 const explore = t.explore; 103 105 return ( ··· 130 132 handle={user.handle} 131 133 initial={initial} 132 134 initialAvatarUrl={initialAvatarUrl} 135 + initialPublished={initialPublished} 133 136 /> 134 137 </div> 135 138 </div>
+27
static/clients/anisota.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + viewBox="0 0 64 64" 4 + role="img" 5 + aria-label="Anisota" 6 + > 7 + <defs> 8 + <linearGradient id="ani-bg" x1="0" y1="0" x2="1" y2="1"> 9 + <stop offset="0" stop-color="#f6e1b8" /> 10 + <stop offset="1" stop-color="#e3a85a" /> 11 + </linearGradient> 12 + </defs> 13 + <rect width="64" height="64" rx="14" fill="url(#ani-bg)" /> 14 + <g fill="#3b1d0a"> 15 + <ellipse cx="20" cy="26" rx="11" ry="8" transform="rotate(-22 20 26)" /> 16 + <ellipse cx="44" cy="26" rx="11" ry="8" transform="rotate(22 44 26)" /> 17 + <ellipse cx="22" cy="42" rx="9" ry="6" transform="rotate(20 22 42)" /> 18 + <ellipse cx="42" cy="42" rx="9" ry="6" transform="rotate(-20 42 42)" /> 19 + <ellipse cx="32" cy="34" rx="3" ry="14" /> 20 + </g> 21 + <g fill="#f8e6c2"> 22 + <circle cx="18" cy="24" r="2" /> 23 + <circle cx="46" cy="24" r="2" /> 24 + <circle cx="22" cy="42" r="1.5" /> 25 + <circle cx="42" cy="42" r="1.5" /> 26 + </g> 27 + </svg>
+20
static/clients/blacksky.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + viewBox="0 0 64 64" 4 + role="img" 5 + aria-label="Blacksky" 6 + > 7 + <rect width="64" height="64" rx="14" fill="#0a0a0a" /> 8 + <g stroke="#ffffff" stroke-width="2.4" stroke-linecap="round" fill="none"> 9 + <line x1="32" y1="10" x2="32" y2="20" /> 10 + <line x1="32" y1="44" x2="32" y2="54" /> 11 + <line x1="10" y1="32" x2="20" y2="32" /> 12 + <line x1="44" y1="32" x2="54" y2="32" /> 13 + <line x1="16.5" y1="16.5" x2="23.5" y2="23.5" /> 14 + <line x1="40.5" y1="40.5" x2="47.5" y2="47.5" /> 15 + <line x1="47.5" y1="16.5" x2="40.5" y2="23.5" /> 16 + <line x1="23.5" y1="40.5" x2="16.5" y2="47.5" /> 17 + </g> 18 + <circle cx="32" cy="32" r="8" fill="#ffffff" /> 19 + <circle cx="32" cy="32" r="4.5" fill="#0a0a0a" /> 20 + </svg>
+18
static/clients/bluesky.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + viewBox="0 0 64 64" 4 + role="img" 5 + aria-label="Bluesky" 6 + > 7 + <defs> 8 + <linearGradient id="bsky-bg" x1="0" y1="0" x2="0" y2="1"> 9 + <stop offset="0" stop-color="#7ec0ff" /> 10 + <stop offset="1" stop-color="#1185fe" /> 11 + </linearGradient> 12 + </defs> 13 + <rect width="64" height="64" rx="14" fill="url(#bsky-bg)" /> 14 + <path 15 + fill="#ffffff" 16 + d="M32 24c-3.6-6.4-9.6-10.5-13.5-10.5-3 0-4.5 2.4-4.5 5.6 0 3 1 8.6 2.1 11.7 1.5 4.4 4.4 5.4 9.4 5 .5 0 .9 0 1.3-.1-.4.1-.8.2-1.3.3-7.6 1.6-9.8 5-5.3 12.6.6 1 4.5 4.4 8.4-3.4l3.4-6.7 3.4 6.7c3.9 7.8 7.8 4.4 8.4 3.4 4.5-7.6 2.3-11-5.3-12.6-.5-.1-.9-.2-1.3-.3.4 0 .8.1 1.3.1 5 .4 7.9-.6 9.4-5 1.1-3.1 2.1-8.7 2.1-11.7 0-3.2-1.5-5.6-4.5-5.6C41.6 13.5 35.6 17.6 32 24z" 17 + /> 18 + </svg>
+33
static/clients/deer.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + viewBox="0 0 64 64" 4 + role="img" 5 + aria-label="Deer Social" 6 + > 7 + <defs> 8 + <linearGradient id="deer-bg" x1="0" y1="0" x2="0" y2="1"> 9 + <stop offset="0" stop-color="#2f5a3a" /> 10 + <stop offset="1" stop-color="#16331f" /> 11 + </linearGradient> 12 + </defs> 13 + <rect width="64" height="64" rx="14" fill="url(#deer-bg)" /> 14 + <g 15 + fill="#dcc8a0" 16 + stroke="#dcc8a0" 17 + stroke-linejoin="round" 18 + stroke-linecap="round" 19 + > 20 + <path 21 + d="M32 50c-6 0-10-4.5-10-10v-7c0-3.5 2-7 4-9 1-1 1.5-2 1-4-1-3 0-5 2-5 1 0 2 .8 3 2 1-1.2 2-2 3-2 2 0 3 2 2 5-.5 2 0 3 1 4 2 2 4 5.5 4 9v7c0 5.5-4 10-10 10z" 22 + /> 23 + <g fill="none" stroke-width="2.6"> 24 + <path d="M22 28c-1-3-3-5-5-7m5 7c-2-1-4-1-6-3m6 3c-1-2-1-4-2-6" /> 25 + <path d="M42 28c1-3 3-5 5-7m-5 7c2-1 4-1 6-3m-6 3c1-2 1-4 2-6" /> 26 + </g> 27 + </g> 28 + <g fill="#16331f"> 29 + <circle cx="27" cy="38" r="1.6" /> 30 + <circle cx="37" cy="38" r="1.6" /> 31 + <ellipse cx="32" cy="44.5" rx="2" ry="1.3" /> 32 + </g> 33 + </svg>
static/clients/witchsky.png

This is a binary file and will not be displayed.