your personal website on atproto - mirror blento.app
25
fork

Configure Feed

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

feat: adds ediatble pronoun sets to profile

Grisha c85e23fc dfd6471a

+234 -11
+2 -1
src/lib/atproto/settings.ts
··· 24 24 'site.standard.document', 25 25 'xyz.statusphere.status', 26 26 'community.lexicon.calendar.rsvp', 27 - 'community.lexicon.calendar.event' 27 + 'community.lexicon.calendar.event', 28 + 'app.nearhorizon.actor.pronouns' 28 29 ], 29 30 30 31 // what types of authenticated proxied requests you can make to services
+22
src/lib/helper.ts
··· 291 291 console.log('updating or adding publication', data.publication); 292 292 } 293 293 294 + // check if pronouns edited and save 295 + if (data.pronounsRecord?.value?.sets?.length) { 296 + const existing = data.pronounsRecord.value; 297 + const now = new Date().toISOString(); 298 + const record: Record<string, unknown> = { 299 + $type: 'app.nearhorizon.actor.pronouns', 300 + sets: existing.sets, 301 + displayMode: existing.displayMode ?? 'all', 302 + createdAt: existing.createdAt ?? now 303 + }; 304 + if (existing.createdAt) { 305 + record.updatedAt = now; 306 + } 307 + promises.push( 308 + putRecord({ 309 + collection: 'app.nearhorizon.actor.pronouns', 310 + rkey: 'self', 311 + record 312 + }) 313 + ); 314 + } 315 + 294 316 await Promise.all(promises); 295 317 } 296 318
+15
src/lib/types.ts
··· 27 27 page?: string; 28 28 }; 29 29 30 + export type PronounSet = { 31 + forms: string[]; 32 + }; 33 + 34 + export type PronounsRecord = { 35 + value?: { 36 + sets?: PronounSet[]; 37 + displayMode?: string; 38 + createdAt?: string; 39 + updatedAt?: string; 40 + }; 41 + }; 42 + 30 43 export type WebsiteData = { 31 44 page: string; 32 45 did: string; ··· 61 74 }; 62 75 }; 63 76 profile: AppBskyActorDefs.ProfileViewDetailed; 77 + pronouns?: string; 78 + pronounsRecord?: PronounsRecord; 64 79 65 80 additionalData: Record<string, unknown>; 66 81 updatedAt: number;
+138 -2
src/lib/website/EditableProfile.svelte
··· 1 1 <script lang="ts"> 2 - import type { WebsiteData } from '$lib/types'; 2 + import type { WebsiteData, PronounSet } from '$lib/types'; 3 3 import { getImage, compressImage, getProfilePosition } from '$lib/helper'; 4 4 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 5 5 import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 - import { Avatar } from '@foxui/core'; 6 + import { Avatar, Switch, Label } from '@foxui/core'; 7 7 import MadeWithBlento from './MadeWithBlento.svelte'; 8 8 9 9 let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } = 10 10 $props(); 11 11 12 12 let fileInput: HTMLInputElement; 13 + let setsContainer: HTMLDivElement; 13 14 let isHoveringAvatar = $state(false); 15 + let editingPronouns = $state(false); 16 + let pronounSets: PronounSet[] = $state(getInitialPronounSets()); 17 + let displayMode: 'all' | 'firstOnly' = $state( 18 + data.pronounsRecord?.value?.displayMode === 'firstOnly' ? 'firstOnly' : 'all' 19 + ); 20 + 21 + function getInitialPronounSets(): PronounSet[] { 22 + if (data.pronounsRecord?.value?.sets?.length) { 23 + return structuredClone(data.pronounsRecord.value.sets); 24 + } 25 + if (data.pronouns) { 26 + return [{ forms: data.pronouns.split('/').map((s) => s.trim()) }]; 27 + } 28 + return [{ forms: [''] }]; 29 + } 14 30 15 31 async function handleAvatarChange(event: Event) { 16 32 const target = event.target as HTMLInputElement; ··· 47 63 function onTextUpdate() { 48 64 data = { ...data }; 49 65 } 66 + 67 + function addSet() { 68 + pronounSets = [...pronounSets, { forms: [''] }]; 69 + requestAnimationFrame(() => { 70 + setsContainer?.scrollTo({ top: setsContainer.scrollHeight, behavior: 'smooth' }); 71 + }); 72 + } 73 + 74 + function removeSet(index: number) { 75 + pronounSets = pronounSets.filter((_, i) => i !== index); 76 + updatePronouns(); 77 + } 78 + 79 + function updateSetInput(index: number, value: string) { 80 + pronounSets[index] = { forms: value.split('/').map((s) => s.trim()) }; 81 + updatePronouns(); 82 + } 83 + 84 + function updatePronouns() { 85 + const validSets = pronounSets.filter((set) => set.forms.some((form) => form.length > 0)); 86 + if (validSets.length > 0) { 87 + const setsToShow = displayMode === 'firstOnly' ? validSets.slice(0, 1) : validSets; 88 + data.pronouns = setsToShow.map((set) => set.forms.join('/')).join(' · '); 89 + data.pronounsRecord = { 90 + value: { 91 + sets: validSets, 92 + displayMode 93 + } 94 + }; 95 + } else { 96 + data.pronouns = undefined; 97 + data.pronounsRecord = undefined; 98 + } 99 + data = { ...data }; 100 + } 50 101 </script> 51 102 52 103 <div ··· 132 183 onupdate={onTextUpdate} 133 184 /> 134 185 </div> 186 + {/if} 187 + 188 + {#if editingPronouns} 189 + <div class="-mt-2 flex flex-col gap-2"> 190 + <div bind:this={setsContainer} class="flex max-h-48 flex-col gap-2 overflow-y-auto"> 191 + {#each pronounSets as set, i (i)} 192 + <div 193 + class={[ 194 + 'flex items-center gap-1', 195 + displayMode === 'firstOnly' && i > 0 && 'opacity-40' 196 + ]} 197 + > 198 + <input 199 + type="text" 200 + value={set.forms.join('/')} 201 + oninput={(e) => updateSetInput(i, e.currentTarget.value)} 202 + placeholder="e.g. she/her" 203 + class="bg-base-100 dark:bg-base-800 text-base-600 dark:text-base-300 w-full rounded px-2 py-1 text-sm outline-none" 204 + /> 205 + {#if pronounSets.length > 1} 206 + <button 207 + type="button" 208 + class="text-base-400 hover:text-red-500 cursor-pointer text-sm transition-colors" 209 + onclick={() => removeSet(i)} 210 + > 211 + &times; 212 + </button> 213 + {/if} 214 + </div> 215 + {/each} 216 + </div> 217 + {#if pronounSets.length > 1} 218 + <div class="flex items-center gap-1.5"> 219 + <Switch 220 + checked={displayMode === 'firstOnly'} 221 + onCheckedChange={(checked) => { 222 + displayMode = checked ? 'firstOnly' : 'all'; 223 + updatePronouns(); 224 + }} 225 + class="scale-75" 226 + /> 227 + <Label class="text-base-500 text-xs">show first only</Label> 228 + </div> 229 + {/if} 230 + <div class="flex gap-2"> 231 + {#if pronounSets.length < 10} 232 + <button 233 + type="button" 234 + class="text-base-500 hover:text-base-300 cursor-pointer text-xs" 235 + onclick={addSet} 236 + > 237 + + add set 238 + </button> 239 + {/if} 240 + <button 241 + type="button" 242 + class="text-base-500 hover:text-base-300 cursor-pointer text-xs" 243 + onclick={() => (editingPronouns = false)} 244 + > 245 + preview 246 + </button> 247 + </div> 248 + </div> 249 + {:else} 250 + <button 251 + type="button" 252 + class="border-base-500/30 hover:border-base-400 text-base-500 dark:text-base-400 hover:text-base-300 -mt-2 flex cursor-pointer items-center gap-1 rounded border border-dashed px-2 py-0.5 text-left text-sm transition-colors" 253 + onclick={() => (editingPronouns = true)} 254 + > 255 + <svg 256 + xmlns="http://www.w3.org/2000/svg" 257 + fill="none" 258 + viewBox="0 0 24 24" 259 + stroke-width="1.5" 260 + stroke="currentColor" 261 + class="size-3" 262 + > 263 + <path 264 + stroke-linecap="round" 265 + stroke-linejoin="round" 266 + d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" 267 + /> 268 + </svg> 269 + {data.pronouns || 'add pronouns'} 270 + </button> 135 271 {/if} 136 272 137 273 <!-- Editable Description -->
+6 -1
src/lib/website/EditableWebsite.svelte
··· 65 65 // svelte-ignore state_referenced_locally 66 66 let savedItemsSnapshot = JSON.stringify(data.cards); 67 67 68 + // svelte-ignore state_referenced_locally 69 + let savedPronouns = $state(JSON.stringify(data.pronounsRecord)); 70 + 68 71 let hasUnsavedChanges = $state(false); 69 72 70 73 // Detect card content and publication changes (e.g. sidebar edits) ··· 75 78 if (hasUnsavedChanges) return; 76 79 if ( 77 80 JSON.stringify(items) !== savedItemsSnapshot || 78 - JSON.stringify(data.publication) !== publication 81 + JSON.stringify(data.publication) !== publication || 82 + JSON.stringify(data.pronounsRecord) !== savedPronouns 79 83 ) { 80 84 hasUnsavedChanges = true; 81 85 } ··· 226 230 await savePage(data, items, publication); 227 231 228 232 publication = JSON.stringify(data.publication); 233 + savedPronouns = JSON.stringify(data.pronounsRecord); 229 234 230 235 savedItemsSnapshot = JSON.stringify(items); 231 236 hasUnsavedChanges = false;
+6
src/lib/website/Profile.svelte
··· 61 61 {getName(data)} 62 62 </div> 63 63 64 + {#if data.pronouns} 65 + <div class="text-base-500 dark:text-base-400 -mt-2 text-sm"> 66 + {data.pronouns} 67 + </div> 68 + {/if} 69 + 64 70 <div class="scrollbar -mx-4 grow overflow-x-hidden overflow-y-scroll px-4"> 65 71 <div 66 72 class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline"
+45 -7
src/lib/website/load.ts
··· 2 2 import { CardDefinitionsByType } from '$lib/cards'; 3 3 import type { CacheService } from '$lib/cache'; 4 4 import { createEmptyCard } from '$lib/helper'; 5 - import type { Item, WebsiteData } from '$lib/types'; 5 + import type { Item, PronounsRecord, WebsiteData } from '$lib/types'; 6 6 import { error } from '@sveltejs/kit'; 7 7 import type { ActorIdentifier, Did } from '@atcute/lexicons'; 8 8 ··· 10 10 import { fixAllCollisions, compactItems } from '$lib/layout'; 11 11 12 12 const CURRENT_CACHE_VERSION = 1; 13 + 14 + function formatPronouns( 15 + record: PronounsRecord | undefined, 16 + profile: WebsiteData['profile'] | undefined 17 + ): string | undefined { 18 + // nearhorizon.actor.pronouns - https://github.com/skydeval/atproto-pronouns 19 + if (record?.value?.sets?.length) { 20 + const sets = record.value.sets; 21 + const displayMode = record.value.displayMode ?? 'all'; 22 + const setsToShow = displayMode === 'firstOnly' ? sets.slice(0, 1) : sets; 23 + return setsToShow.map((s) => s.forms.join('/')).join(' · '); 24 + } 25 + // fallback to bsky pronouns 26 + const pronouns = (profile as Record<string, unknown>)?.pronouns; 27 + if (pronouns && typeof pronouns === 'string') return pronouns; 28 + return undefined; 29 + } 13 30 14 31 export async function getCache(identifier: ActorIdentifier, page: string, cache?: CacheService) { 15 32 try { ··· 66 83 throw error(404); 67 84 } 68 85 69 - const [cards, mainPublication, pages, profile] = await Promise.all([ 86 + const [cards, mainPublication, pages, profile, pronounsRecord] = await Promise.all([ 70 87 listRecords({ did, collection: 'app.blento.card', limit: 0 }).catch((e) => { 71 88 console.error('error getting records for collection app.blento.card', e); 72 89 return [] as Awaited<ReturnType<typeof listRecords>>; ··· 83 100 console.error('error getting records for collection app.blento.page'); 84 101 return [] as Awaited<ReturnType<typeof listRecords>>; 85 102 }), 86 - getDetailedProfile({ did }) 103 + getDetailedProfile({ did }), 104 + getRecord({ 105 + did, 106 + collection: 'app.nearhorizon.actor.pronouns', 107 + rkey: 'self' 108 + }).catch(() => undefined) 87 109 ]); 88 110 89 111 const additionalData = await loadAdditionalData( ··· 102 124 publications: [mainPublication, ...pages].filter((v) => v), 103 125 additionalData, 104 126 profile, 127 + pronouns: formatPronouns(pronounsRecord, profile), 128 + pronounsRecord: pronounsRecord as PronounsRecord | undefined, 105 129 updatedAt: Date.now(), 106 130 version: CURRENT_CACHE_VERSION 107 131 }; ··· 145 169 throw error(404); 146 170 } 147 171 148 - const [cardRecord, profile] = await Promise.all([ 172 + const [cardRecord, profile, pronounsRecord] = await Promise.all([ 149 173 getRecord({ 150 174 did, 151 175 collection: 'app.blento.card', 152 176 rkey 153 177 }).catch(() => undefined), 154 - getDetailedProfile({ did }) 178 + getDetailedProfile({ did }), 179 + getRecord({ 180 + did, 181 + collection: 'app.nearhorizon.actor.pronouns', 182 + rkey: 'self' 183 + }).catch(() => undefined) 155 184 ]); 156 185 157 186 if (!cardRecord?.value) { ··· 189 218 } as WebsiteData['publication']), 190 219 additionalData, 191 220 profile, 221 + pronouns: formatPronouns(pronounsRecord, profile), 222 + pronounsRecord: pronounsRecord as PronounsRecord | undefined, 192 223 updatedAt: Date.now(), 193 224 version: CURRENT_CACHE_VERSION 194 225 }; ··· 220 251 throw error(404); 221 252 } 222 253 223 - const [publication, profile] = await Promise.all([ 254 + const [publication, profile, pronounsRecord] = await Promise.all([ 224 255 getRecord({ 225 256 did, 226 257 collection: 'site.standard.publication', 227 258 rkey: 'blento.self' 228 259 }).catch(() => undefined), 229 - getDetailedProfile({ did }) 260 + getDetailedProfile({ did }), 261 + getRecord({ 262 + did, 263 + collection: 'app.nearhorizon.actor.pronouns', 264 + rkey: 'self' 265 + }).catch(() => undefined) 230 266 ]); 231 267 232 268 const card = createEmptyCard('blento.self'); ··· 260 296 } as WebsiteData['publication']), 261 297 additionalData, 262 298 profile, 299 + pronouns: formatPronouns(pronounsRecord, profile), 300 + pronounsRecord: pronounsRecord as PronounsRecord | undefined, 263 301 updatedAt: Date.now(), 264 302 version: CURRENT_CACHE_VERSION 265 303 };