your personal website on atproto - mirror blento.app

Merge pull request #124 from flo-bit/hide-friends

fixes

authored by

Florian and committed by
GitHub
c14c3c15 7a4891df

+162 -128
+38 -11
src/lib/cards/FriendsCard/FriendsCard.svelte
··· 61 61 return 'lg'; 62 62 }); 63 63 64 + function removeFriend(did: string) { 65 + item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did); 66 + } 67 + 64 68 function getLink(profile: FriendsProfile): string { 65 69 if (profile.hasBlento && profile.handle && profile.handle !== 'handle.invalid') { 66 70 return `/${profile.handle}`; ··· 85 89 <div class=""> 86 90 <div class="flex flex-wrap items-center justify-center" style="padding: {olY}px 0 0 {olX}px;"> 87 91 {#each profiles as profile (profile.did)} 88 - <a 89 - href={getLink(profile)} 90 - class="accent:ring-accent-500 relative block rounded-full ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 91 - style="margin: -{olY}px 0 0 -{olX}px;" 92 - > 93 - <Avatar 94 - src={profile.avatar} 95 - alt={profile.handle} 96 - class={sizeClass === 'sm' ? 'size-12' : sizeClass === 'md' ? 'size-16' : 'size-20'} 97 - /> 98 - </a> 92 + <div class="group relative" style="margin: -{olY}px 0 0 -{olX}px;"> 93 + <a 94 + href={getLink(profile)} 95 + class="accent:ring-accent-500 relative block rounded-full ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 96 + > 97 + <Avatar 98 + src={profile.avatar} 99 + alt={profile.handle} 100 + class={sizeClass === 'sm' ? 'size-12' : sizeClass === 'md' ? 'size-16' : 'size-20'} 101 + /> 102 + </a> 103 + {#if canEdit()} 104 + <button 105 + aria-label="Remove friend" 106 + class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 transition-opacity group-hover:opacity-100" 107 + onclick={(e) => { 108 + e.preventDefault(); 109 + e.stopPropagation(); 110 + removeFriend(profile.did); 111 + }} 112 + > 113 + <svg 114 + xmlns="http://www.w3.org/2000/svg" 115 + fill="none" 116 + viewBox="0 0 24 24" 117 + stroke-width="2.5" 118 + stroke="currentColor" 119 + class="size-4" 120 + > 121 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 122 + </svg> 123 + </button> 124 + {/if} 125 + </div> 99 126 {/each} 100 127 </div> 101 128 </div>
+1 -72
src/lib/cards/FriendsCard/FriendsCardSettings.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 2 import type { Item } from '$lib/types'; 4 3 import type { SettingsComponentProps } from '../types'; 5 4 import type { AppBskyActorDefs } from '@atcute/bluesky'; 6 - import type { Did } from '@atcute/lexicons'; 7 - import type { FriendsProfile } from '.'; 8 - import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 9 5 import HandleInput from '$lib/atproto/UI/HandleInput.svelte'; 10 - import { Avatar, Button } from '@foxui/core'; 11 6 12 7 let { item = $bindable<Item>() }: SettingsComponentProps = $props(); 13 8 14 9 let handleValue = $state(''); 15 10 let inputRef: HTMLInputElement | null = $state(null); 16 - let profiles: FriendsProfile[] = $state([]); 17 - 18 - let dids: string[] = $derived(item.cardData.friends ?? []); 19 - 20 - onMount(() => { 21 - loadProfiles(); 22 - }); 23 - 24 - async function loadProfiles() { 25 - const results = await Promise.all( 26 - dids.map((did) => getBlentoOrBskyProfile({ did: did as Did }).catch(() => undefined)) 27 - ); 28 - profiles = results.filter((p): p is FriendsProfile => !!p && p.handle !== 'handle.invalid'); 29 - } 30 11 31 12 function addFriend(actor: AppBskyActorDefs.ProfileViewBasic) { 32 13 if (!item.cardData.friends) item.cardData.friends = []; 33 14 if (item.cardData.friends.includes(actor.did)) return; 34 15 item.cardData.friends = [...item.cardData.friends, actor.did]; 35 - profiles = [ 36 - ...profiles, 37 - { 38 - did: actor.did, 39 - handle: actor.handle, 40 - displayName: actor.displayName || actor.handle, 41 - avatar: actor.avatar, 42 - hasBlento: false 43 - } as FriendsProfile 44 - ]; 45 16 requestAnimationFrame(() => { 46 17 handleValue = ''; 47 18 if (inputRef) inputRef.value = ''; 48 19 }); 49 20 } 50 - 51 - function removeFriend(did: string) { 52 - item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did); 53 - profiles = profiles.filter((p) => p.did !== did); 54 - } 55 - 56 - function getProfile(did: string): FriendsProfile | undefined { 57 - return profiles.find((p) => p.did === did); 58 - } 59 21 </script> 60 22 61 - <div class="flex flex-col gap-3"> 62 - <HandleInput bind:value={handleValue} onselected={addFriend} bind:ref={inputRef} /> 63 - 64 - {#if dids.length > 0} 65 - <div class="flex flex-col gap-1.5"> 66 - {#each dids as did (did)} 67 - {@const profile = getProfile(did)} 68 - <div class="flex items-center gap-2"> 69 - <Avatar src={profile?.avatar} alt={profile?.handle ?? did} class="size-6 rounded-full" /> 70 - <span class="min-w-0 flex-1 truncate text-sm"> 71 - {profile?.handle ?? did} 72 - </span> 73 - <Button 74 - variant="ghost" 75 - size="icon" 76 - class="size-6 min-w-6" 77 - onclick={() => removeFriend(did)} 78 - > 79 - <svg 80 - xmlns="http://www.w3.org/2000/svg" 81 - fill="none" 82 - viewBox="0 0 24 24" 83 - stroke-width="2" 84 - stroke="currentColor" 85 - class="size-3.5" 86 - > 87 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 88 - </svg> 89 - </Button> 90 - </div> 91 - {/each} 92 - </div> 93 - {/if} 94 - </div> 23 + <HandleInput bind:value={handleValue} onselected={addFriend} bind:ref={inputRef} />
+3 -2
src/lib/cards/FriendsCard/index.ts
··· 36 36 minW: 2, 37 37 minH: 2, 38 38 name: 'Friends', 39 - // groups: ['Social'], 39 + groups: ['Social'], 40 40 keywords: ['friends', 'avatars', 'people', 'community', 'blentos'], 41 - icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /></svg>` 41 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /></svg>`, 42 + canHaveLabel: true 42 43 } as CardDefinition & { type: 'friends' };
+2 -2
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 8 8 allowSetColor: true, 9 9 createNew: (card) => { 10 10 card.w = 4; 11 - card.h = 4; 11 + card.h = 2; 12 12 card.mobileW = 8; 13 - card.mobileH = 6; 13 + card.mobileH = 4; 14 14 card.cardData = {}; 15 15 }, 16 16 canHaveLabel: true,
+2 -3
src/lib/cards/GameCards/TetrisCard/index.ts
··· 9 9 type: 'tetris', 10 10 contentComponent: TetrisCard as unknown as Component<ContentComponentProps>, 11 11 allowSetColor: true, 12 - defaultColor: 'accent', 13 12 createNew: (card) => { 14 - card.w = 4; 15 - card.h = 6; 13 + card.w = 2; 14 + card.h = 4; 16 15 card.mobileW = 8; 17 16 card.mobileH = 12; 18 17 card.cardData = {};
+18 -7
src/lib/cards/PopfeedReviews/PopfeedReviewsCard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Item } from '$lib/types'; 3 3 import { onMount } from 'svelte'; 4 - import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 4 + import { 5 + getAdditionalUserData, 6 + getCanEdit, 7 + getDidContext, 8 + getHandleContext 9 + } from '$lib/website/context'; 5 10 import { CardDefinitionsByType } from '..'; 6 11 import Rating from './Rating.svelte'; 12 + import { Button } from '@foxui/core'; 7 13 8 14 let { item }: { item: Item } = $props(); 9 15 ··· 27 33 data[item.cardType] = feed; 28 34 } 29 35 }); 36 + 37 + let canEdit = getCanEdit(); 30 38 </script> 31 39 32 40 <div class="z-10 flex h-full gap-4 overflow-x-scroll p-4"> ··· 36 44 <a 37 45 rel="noopener noreferrer" 38 46 target="_blank" 39 - class="flex" 47 + class="flex h-full shrink-0" 40 48 href="https://popfeed.social/review/{review.uri}" 41 49 > 42 50 <div 43 - class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1" 51 + class="relative flex aspect-2/3 h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1" 44 52 > 45 53 <img 46 54 src={review.value.posterUrl} ··· 49 57 /> 50 58 51 59 <div 52 - class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent" 60 + class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-linear-to-t via-transparent" 53 61 ></div> 54 62 55 63 <Rating class="z-10 text-lg" rating={review.value.rating} /> ··· 58 66 {/if} 59 67 {/each} 60 68 {:else if feed} 61 - <div 62 - class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full w-full items-center justify-center text-center text-sm" 63 - > 69 + <div class="flex h-full w-full flex-col items-center justify-center gap-4 text-center text-sm"> 64 70 No reviews yet. 71 + {#if canEdit()} 72 + <Button href="https://popfeed.social/" target="_blank" rel="noopener noreferrer"> 73 + Review something on Popfeed 74 + </Button> 75 + {/if} 65 76 </div> 66 77 {:else} 67 78 <div
+26 -17
src/lib/cards/StandardSiteDocumentListCard/StandardSiteDocumentListCard.svelte
··· 1 1 <script lang="ts"> 2 - import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 2 + import { 3 + getAdditionalUserData, 4 + getCanEdit, 5 + getDidContext, 6 + getHandleContext 7 + } from '$lib/website/context'; 3 8 import { onMount } from 'svelte'; 4 9 import { CardDefinitionsByType } from '..'; 5 10 import type { ContentComponentProps } from '../types'; 6 11 import BlogEntry from './BlogEntry.svelte'; 12 + import { Button } from '@foxui/core'; 7 13 8 14 let { item }: ContentComponentProps = $props(); 9 15 ··· 13 19 14 20 let did = getDidContext(); 15 21 let handle = getHandleContext(); 22 + 23 + let canEdit = getCanEdit(); 16 24 17 25 onMount(async () => { 18 26 if (!feed) { ··· 37 45 /> 38 46 {/each} 39 47 {:else if feed} 40 - <div 41 - class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full flex-col items-center justify-center gap-2 text-center text-sm" 42 - > 43 - <span>No blog posts found.</span> 44 - <span> 45 - Create some on <a 46 - href="https://leaflet.pub" 47 - target="_blank" 48 - rel="noopener noreferrer" 49 - class="underline">Leaflet</a 50 - > 51 - or 52 - <a href="https://pckt.pub" target="_blank" rel="noopener noreferrer" class="underline" 53 - >Pckt</a 54 - > 55 - </span> 48 + <div class="z-50 flex h-full flex-col items-center justify-center gap-4 text-center text-sm"> 49 + <span class="text-lg font-semibold">No blog posts found.</span> 50 + 51 + {#if canEdit()} 52 + <span> 53 + Create some for example on <Button 54 + href="https://leaflet.pub" 55 + target="_blank" 56 + rel="noopener noreferrer" 57 + class="">Leaflet</Button 58 + > 59 + or 60 + <Button href="https://pckt.pub" target="_blank" rel="noopener noreferrer" class="" 61 + >Pckt</Button 62 + > 63 + </span> 64 + {/if} 56 65 </div> 57 66 {:else} 58 67 <div
+1 -4
src/lib/cards/StatusphereCard/index.ts
··· 13 13 contentComponent: StatusphereCard, 14 14 editingContentComponent: EditStatusphereCard, 15 15 16 - createNew: (item) => { 17 - item.h = 3; 18 - item.mobileH = 5; 19 - }, 16 + createNew: (item) => {}, 20 17 21 18 loadData: async (items, { did }) => { 22 19 const data = await listRecords({ did, collection: 'xyz.statusphere.status', limit: 1 });
+1 -1
src/lib/components/card-command/CardCommand.svelte
··· 75 75 function handleKeydown(e: KeyboardEvent) { 76 76 if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { 77 77 e.preventDefault(); 78 - open = true; 78 + open = !open; 79 79 } 80 80 if (e.key === '+' && !isTyping()) { 81 81 e.preventDefault();
+15 -2
src/lib/website/Account.svelte
··· 1 1 <script lang="ts"> 2 + import { goto } from '$app/navigation'; 2 3 import { user, login, logout } from '$lib/atproto'; 4 + import { getHandleOrDid } from '$lib/atproto/methods'; 3 5 import type { WebsiteData } from '$lib/types'; 4 6 import type { ActorIdentifier } from '@atcute/lexicons'; 5 7 import { Avatar, Button, Popover } from '@foxui/core'; ··· 14 16 </script> 15 17 16 18 {#if user.isLoggedIn && user.profile} 17 - <div class="fixed right-4 bottom-4 z-20"> 19 + <div class="fixed top-4 right-4 z-20"> 18 20 <Popover sideOffset={8} bind:open={settingsPopoverOpen} class="bg-base-100 dark:bg-base-900"> 19 21 {#snippet child({ props })} 20 22 <button {...props}> ··· 22 24 </button> 23 25 {/snippet} 24 26 25 - <Button variant="ghost" onclick={logout}>Logout</Button> 27 + <div class="flex flex-col"> 28 + {#if user.profile} 29 + <Button 30 + variant="ghost" 31 + onclick={() => { 32 + goto('/' + getHandleOrDid(user.profile), {}); 33 + }}>Leave edit mode</Button 34 + > 35 + {/if} 36 + 37 + <Button variant="ghost" onclick={logout}>Logout</Button> 38 + </div> 26 39 </Popover> 27 40 </div> 28 41 {/if}
+4 -2
src/lib/website/Context.svelte
··· 8 8 9 9 let { 10 10 data, 11 - children 11 + children, 12 + isEditing 12 13 }: { 13 14 data: WebsiteData; 14 15 children: Snippet<[]>; 16 + isEditing?: boolean; 15 17 } = $props(); 16 18 17 19 // svelte-ignore state_referenced_locally 18 20 setAdditionalUserData(data.additionalData); 19 21 20 - setCanEdit(() => dev || (user.isLoggedIn && user.profile?.did === data.did)); 22 + setCanEdit(() => dev || (user.isLoggedIn && user.profile?.did === data.did && isEditing === true)); 21 23 22 24 // svelte-ignore state_referenced_locally 23 25 setDidContext(data.did as Did);
+51 -5
src/lib/website/EditableWebsite.svelte
··· 1 1 <script lang="ts"> 2 - import { Button, toast, Toaster, Sidebar } from '@foxui/core'; 2 + import { Button, Modal, toast, Toaster, Sidebar } from '@foxui/core'; 3 3 import { COLUMNS, margin, mobileMargin } from '$lib'; 4 4 import { 5 5 checkAndUploadImage, ··· 123 123 124 124 let showingMobileView = $state(false); 125 125 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 126 + let showMobileWarning = $state((innerWidth.current ?? 1000) < 1024); 126 127 127 128 setIsMobile(() => isMobile); 128 129 ··· 191 192 } 192 193 } 193 194 195 + function cleanupDialogArtifacts() { 196 + // bits-ui's body scroll lock and portal may not clean up fully when the 197 + // modal is unmounted instead of closed via the open prop. 198 + const restore = () => { 199 + document.body.style.removeProperty('overflow'); 200 + document.body.style.removeProperty('pointer-events'); 201 + document.body.style.removeProperty('padding-right'); 202 + document.body.style.removeProperty('margin-right'); 203 + // Remove any orphaned dialog overlay/content elements left by the portal 204 + for (const el of document.querySelectorAll( 205 + '[data-dialog-overlay], [data-dialog-content]' 206 + )) { 207 + el.remove(); 208 + } 209 + }; 210 + // Run immediately and again after bits-ui's 24ms scheduled cleanup 211 + restore(); 212 + setTimeout(restore, 50); 213 + } 214 + 194 215 async function saveNewItem() { 195 216 if (!newItem.item) return; 196 217 const item = newItem.item; ··· 211 232 newItem = {}; 212 233 213 234 await tick(); 235 + cleanupDialogArtifacts(); 214 236 215 237 scrollToItem(item, isMobile, container); 216 238 } ··· 258 280 isSaving = false; 259 281 } 260 282 } 261 - 262 - const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.name); 263 283 264 284 function addAllCardTypes() { 265 285 const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games']; ··· 1133 1153 1134 1154 <Account {data} /> 1135 1155 1136 - <Context {data}> 1156 + <Context {data} isEditing={true}> 1137 1157 <CardCommand 1138 1158 bind:open={showCardCommand} 1139 1159 onselect={(cardDef: CardDefinition) => { ··· 1172 1192 saveNewItem(); 1173 1193 }} 1174 1194 bind:item={newItem.item} 1175 - oncancel={() => { 1195 + oncancel={async () => { 1176 1196 newItem = {}; 1197 + await tick(); 1198 + cleanupDialogArtifacts(); 1177 1199 }} 1178 1200 /> 1179 1201 {/if} ··· 1184 1206 handle={data.handle} 1185 1207 page={data.page} 1186 1208 /> 1209 + 1210 + <Modal open={showMobileWarning} closeButton={false}> 1211 + <div class="flex flex-col items-center gap-4 text-center"> 1212 + <svg 1213 + xmlns="http://www.w3.org/2000/svg" 1214 + fill="none" 1215 + viewBox="0 0 24 24" 1216 + stroke-width="1.5" 1217 + stroke="currentColor" 1218 + class="text-accent-500 size-10" 1219 + > 1220 + <path 1221 + stroke-linecap="round" 1222 + stroke-linejoin="round" 1223 + d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3" 1224 + /> 1225 + </svg> 1226 + <p class="text-base-700 dark:text-base-300 text-xl font-bold">Mobile Editing</p> 1227 + <p class="text-base-500 dark:text-base-400 text-sm"> 1228 + Mobile editing is currently experimental. For the best experience, use a desktop browser. 1229 + </p> 1230 + <Button class="mt-2 w-full" onclick={() => (showMobileWarning = false)}>Continue</Button> 1231 + </div> 1232 + </Modal> 1187 1233 1188 1234 <div 1189 1235 class={[