your personal website on atproto - mirror blento.app

Merge pull request #248 from grishaLR/feat/adds-pronouns

feat: adds ediatble pronoun sets to profile

authored by

Florian and committed by
GitHub
f2ae4591 522440a0

+243 -10
+2 -1
src/lib/atproto/methods.ts
··· 247 247 } 248 248 }); 249 249 250 - if (!record.ok) throw new Error((record.data as { message?: string })?.message ?? 'Record not found'); 250 + if (!record.ok) 251 + throw new Error((record.data as { message?: string })?.message ?? 'Record not found'); 251 252 252 253 return JSON.parse(JSON.stringify(record.data)); 253 254 }
+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;
+3
src/lib/website/EditableProfile.svelte
··· 5 5 import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 6 6 import { Avatar } from '@foxui/core'; 7 7 import MadeWithBlento from './MadeWithBlento.svelte'; 8 + import Pronouns from './Pronouns.svelte'; 8 9 9 10 let { data = $bindable(), hideBlento = false }: { data: WebsiteData; hideBlento?: boolean } = 10 11 $props(); ··· 133 134 /> 134 135 </div> 135 136 {/if} 137 + 138 + <Pronouns bind:data editing /> 136 139 137 140 <!-- Editable Description --> 138 141 <div class="scrollbar -mx-4 grow overflow-x-hidden overflow-y-scroll px-4">
+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;
+5
src/lib/website/Profile.svelte
··· 7 7 import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 8 8 import MadeWithBlento from './MadeWithBlento.svelte'; 9 9 import { Avatar } from '@foxui/core'; 10 + import Pronouns from './Pronouns.svelte'; 10 11 11 12 let { 12 13 data, ··· 60 61 <div class="text-4xl font-bold wrap-anywhere"> 61 62 {getName(data)} 62 63 </div> 64 + 65 + {#if data.pronounsRecord?.value?.sets?.length} 66 + <Pronouns {data} /> 67 + {/if} 63 68 64 69 <div class="scrollbar -mx-4 grow overflow-x-hidden overflow-y-scroll px-4"> 65 70 <div
+143
src/lib/website/Pronouns.svelte
··· 1 + <script lang="ts"> 2 + import type { WebsiteData, PronounSet } from '$lib/types'; 3 + import { Badge, Button, Input, Switch, Label } from '@foxui/core'; 4 + import Modal from '$lib/components/modal/Modal.svelte'; 5 + 6 + let { 7 + data = $bindable(), 8 + editing = false 9 + }: { 10 + data: WebsiteData; 11 + editing?: boolean; 12 + } = $props(); 13 + 14 + let modalOpen = $state(false); 15 + let editSets: PronounSet[] = $state([]); 16 + let editDisplayMode: 'all' | 'firstOnly' = $state('all'); 17 + 18 + function openModal() { 19 + if (data.pronounsRecord?.value?.sets?.length) { 20 + editSets = JSON.parse(JSON.stringify(data.pronounsRecord.value.sets)); 21 + editDisplayMode = data.pronounsRecord.value.displayMode === 'firstOnly' ? 'firstOnly' : 'all'; 22 + } else { 23 + editSets = [{ forms: [''] }]; 24 + editDisplayMode = 'all'; 25 + } 26 + modalOpen = true; 27 + } 28 + 29 + function save() { 30 + const validSets = editSets.filter((set) => set.forms.some((form) => form.length > 0)); 31 + if (validSets.length > 0) { 32 + data.pronounsRecord = { 33 + value: { 34 + sets: validSets, 35 + displayMode: editDisplayMode 36 + } 37 + }; 38 + } else { 39 + data.pronounsRecord = undefined; 40 + } 41 + data = { ...data }; 42 + modalOpen = false; 43 + } 44 + 45 + function addSet() { 46 + editSets = [...editSets, { forms: [''] }]; 47 + } 48 + 49 + function removeSet(index: number) { 50 + editSets = editSets.filter((_, i) => i !== index); 51 + } 52 + 53 + function updateSetInput(index: number, value: string) { 54 + editSets[index] = { forms: value.split('/').map((s) => s.trim()) }; 55 + } 56 + 57 + let allSets = $derived(data.pronounsRecord?.value?.sets ?? []); 58 + let sets = $derived( 59 + data.pronounsRecord?.value?.displayMode === 'firstOnly' ? allSets.slice(0, 1) : allSets 60 + ); 61 + </script> 62 + 63 + {#if sets.length} 64 + <div class="flex flex-wrap gap-1"> 65 + {#each sets as set, i (i)} 66 + <Badge>{set.forms.join('/')}</Badge> 67 + {/each} 68 + </div> 69 + {/if} 70 + 71 + {#if editing} 72 + <Button size="sm" onclick={openModal}> 73 + <svg 74 + xmlns="http://www.w3.org/2000/svg" 75 + fill="none" 76 + viewBox="0 0 24 24" 77 + stroke-width="1.5" 78 + stroke="currentColor" 79 + class="size-3" 80 + > 81 + <path 82 + stroke-linecap="round" 83 + stroke-linejoin="round" 84 + 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" 85 + /> 86 + </svg> 87 + {sets.length ? 'edit' : 'add'} pronouns 88 + </Button> 89 + 90 + <Modal open={modalOpen} onOpenChange={(v) => (modalOpen = v)} closeButton> 91 + <div class="flex flex-col gap-4"> 92 + <h3 class="text-base-900 dark:text-base-100 text-lg font-semibold">Edit pronouns</h3> 93 + 94 + <div class="flex flex-col gap-2"> 95 + {#each editSets as set, i (i)} 96 + <div class="flex items-center gap-2"> 97 + <Input 98 + value={set.forms.join('/')} 99 + oninput={(e) => updateSetInput(i, e.currentTarget.value)} 100 + placeholder="e.g. she/her" 101 + variant="secondary" 102 + sizeVariant="sm" 103 + class="grow" 104 + /> 105 + {#if editSets.length > 1} 106 + <Button size="iconSm" variant="ghost" onclick={() => removeSet(i)}>&times;</Button> 107 + {/if} 108 + </div> 109 + {/each} 110 + </div> 111 + 112 + {#if editSets.length < 10} 113 + <Button 114 + size="sm" 115 + variant="secondary" 116 + onclick={addSet} 117 + disabled={!editSets.at(-1)?.forms.some((f) => f.length > 0)} 118 + class="w-fit" 119 + > 120 + + add more pronouns 121 + </Button> 122 + {/if} 123 + 124 + {#if editSets.length > 1} 125 + <div class="flex items-center gap-1.5"> 126 + <Switch 127 + id="pronouns-display-mode" 128 + checked={editDisplayMode === 'firstOnly'} 129 + onCheckedChange={(checked) => { 130 + editDisplayMode = checked ? 'firstOnly' : 'all'; 131 + }} 132 + /> 133 + <Label for="pronouns-display-mode">show first only</Label> 134 + </div> 135 + {/if} 136 + 137 + <div class="flex justify-end gap-2"> 138 + <Button variant="secondary" onclick={() => (modalOpen = false)}>Cancel</Button> 139 + <Button onclick={save}>Save</Button> 140 + </div> 141 + </div> 142 + </Modal> 143 + {/if}
+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 };