your personal website on atproto - mirror blento.app

commit

Florian cb11c84b 73a1deda

+281 -11
+120
src/lib/cards/FriendsCard/FriendsCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context'; 5 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 6 + import type { FriendsProfile } from '.'; 7 + import type { Did } from '@atcute/lexicons'; 8 + import { Avatar } from '@foxui/core'; 9 + 10 + let { item }: ContentComponentProps = $props(); 11 + 12 + const isMobile = getIsMobile(); 13 + const canEdit = getCanEdit(); 14 + const additionalData = getAdditionalUserData(); 15 + 16 + let dids: string[] = $derived(item.cardData.friends ?? []); 17 + 18 + let serverProfiles: FriendsProfile[] = $derived( 19 + (additionalData[item.cardType] as FriendsProfile[]) ?? [] 20 + ); 21 + 22 + let clientProfiles: FriendsProfile[] = $state([]); 23 + 24 + let profiles = $derived.by(() => { 25 + if (serverProfiles.length > 0) { 26 + return dids 27 + .map((did) => serverProfiles.find((p) => p.did === did)) 28 + .filter((p): p is FriendsProfile => !!p); 29 + } 30 + return dids 31 + .map((did) => clientProfiles.find((p) => p.did === did)) 32 + .filter((p): p is FriendsProfile => !!p); 33 + }); 34 + 35 + onMount(() => { 36 + if (serverProfiles.length === 0 && dids.length > 0) { 37 + loadProfiles(); 38 + } 39 + }); 40 + 41 + async function loadProfiles() { 42 + const results = await Promise.all( 43 + dids.map((did) => getBlentoOrBskyProfile({ did: did as Did }).catch(() => undefined)) 44 + ); 45 + clientProfiles = results.filter( 46 + (p): p is FriendsProfile => !!p && p.handle !== 'handle.invalid' 47 + ); 48 + } 49 + 50 + // Reload when dids change in editing mode 51 + $effect(() => { 52 + if (canEdit() && dids.length > 0) { 53 + loadProfiles(); 54 + } 55 + }); 56 + 57 + let sizeClass = $derived.by(() => { 58 + const w = isMobile() ? item.mobileW / 2 : item.w; 59 + if (w < 3) return 'sm'; 60 + if (w < 5) return 'md'; 61 + return 'lg'; 62 + }); 63 + 64 + function getLink(profile: FriendsProfile): string { 65 + if (profile.hasBlento && profile.handle && profile.handle !== 'handle.invalid') { 66 + return `/${profile.handle}`; 67 + } 68 + if (profile.handle && profile.handle !== 'handle.invalid') { 69 + return `https://bsky.app/profile/${profile.handle}`; 70 + } 71 + return `https://bsky.app/profile/${profile.did}`; 72 + } 73 + </script> 74 + 75 + <div class="flex h-full w-full items-center justify-center overflow-hidden px-2"> 76 + {#if dids.length === 0} 77 + {#if canEdit()} 78 + <span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm"> 79 + Add friends in settings 80 + </span> 81 + {/if} 82 + {:else} 83 + <div class="flex items-center justify-center"> 84 + {#each visibleProfiles as profile, i (profile.did)} 85 + <a 86 + href={getLink(profile)} 87 + class="accent:ring-accent-500/30 relative rounded-full ring-2 ring-white transition-transform hover:z-10 hover:scale-110 dark:ring-neutral-900" 88 + class:-ml-3={i > 0 && sizeClass === 'sm'} 89 + class:-ml-5={i > 0 && sizeClass === 'md'} 90 + class:-ml-6={i > 0 && sizeClass === 'lg'} 91 + > 92 + <Avatar 93 + src={profile.avatar} 94 + alt={profile.handle} 95 + class={sizeClass === 'sm' ? 'size-12' : sizeClass === 'md' ? 'size-16' : 'size-20'} 96 + /> 97 + </a> 98 + {/each} 99 + {#if overflowCount > 0} 100 + <div 101 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-400/30 accent:ring-accent-500/30 relative flex items-center justify-center rounded-full ring-2 ring-white dark:ring-neutral-900" 102 + class:-ml-3={sizeClass === 'sm'} 103 + class:-ml-5={sizeClass === 'md'} 104 + class:-ml-6={sizeClass === 'lg'} 105 + class:size-12={sizeClass === 'sm'} 106 + class:size-16={sizeClass === 'md'} 107 + class:size-20={sizeClass === 'lg'} 108 + > 109 + <span 110 + class="text-base-600 dark:text-base-300 accent:text-accent-200 font-semibold" 111 + class:text-sm={sizeClass === 'sm'} 112 + class:text-base={sizeClass === 'md' || sizeClass === 'lg'} 113 + > 114 + +{overflowCount} 115 + </span> 116 + </div> 117 + {/if} 118 + </div> 119 + {/if} 120 + </div>
+104
src/lib/cards/FriendsCard/FriendsCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type { Item } from '$lib/types'; 4 + import type { SettingsComponentProps } from '../types'; 5 + 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 + import HandleInput from '$lib/atproto/UI/HandleInput.svelte'; 10 + import { Avatar, Button } from '@foxui/core'; 11 + 12 + let { item = $bindable<Item>() }: SettingsComponentProps = $props(); 13 + 14 + let handleValue = $state(''); 15 + 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( 29 + (p): p is FriendsProfile => !!p && p.handle !== 'handle.invalid' 30 + ); 31 + } 32 + 33 + function addFriend(actor: AppBskyActorDefs.ProfileViewBasic) { 34 + if (!item.cardData.friends) item.cardData.friends = []; 35 + if (item.cardData.friends.includes(actor.did)) return; 36 + item.cardData.friends = [...item.cardData.friends, actor.did]; 37 + profiles = [ 38 + ...profiles, 39 + { 40 + did: actor.did, 41 + handle: actor.handle, 42 + displayName: actor.displayName || actor.handle, 43 + avatar: actor.avatar, 44 + hasBlento: false 45 + } as FriendsProfile 46 + ]; 47 + requestAnimationFrame(() => { 48 + handleValue = ''; 49 + if (inputRef) inputRef.value = ''; 50 + }); 51 + } 52 + 53 + function removeFriend(did: string) { 54 + item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did); 55 + profiles = profiles.filter((p) => p.did !== did); 56 + } 57 + 58 + function getProfile(did: string): FriendsProfile | undefined { 59 + return profiles.find((p) => p.did === did); 60 + } 61 + </script> 62 + 63 + <div class="flex flex-col gap-3"> 64 + <HandleInput bind:value={handleValue} onselected={addFriend} bind:ref={inputRef} /> 65 + 66 + {#if dids.length > 0} 67 + <div class="flex flex-col gap-1.5"> 68 + {#each dids as did (did)} 69 + {@const profile = getProfile(did)} 70 + <div class="flex items-center gap-2"> 71 + <Avatar 72 + src={profile?.avatar} 73 + alt={profile?.handle ?? did} 74 + class="size-6 rounded-full" 75 + /> 76 + <span class="min-w-0 flex-1 truncate text-sm"> 77 + {profile?.handle ?? did} 78 + </span> 79 + <Button 80 + variant="ghost" 81 + size="icon" 82 + class="size-6 min-w-6" 83 + onclick={() => removeFriend(did)} 84 + > 85 + <svg 86 + xmlns="http://www.w3.org/2000/svg" 87 + fill="none" 88 + viewBox="0 0 24 24" 89 + stroke-width="2" 90 + stroke="currentColor" 91 + class="size-3.5" 92 + > 93 + <path 94 + stroke-linecap="round" 95 + stroke-linejoin="round" 96 + d="M6 18 18 6M6 6l12 12" 97 + /> 98 + </svg> 99 + </Button> 100 + </div> 101 + {/each} 102 + </div> 103 + {/if} 104 + </div>
+44
src/lib/cards/FriendsCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 4 + import FriendsCard from './FriendsCard.svelte'; 5 + import FriendsCardSettings from './FriendsCardSettings.svelte'; 6 + 7 + export type FriendsProfile = Awaited<ReturnType<typeof getBlentoOrBskyProfile>>; 8 + 9 + export const FriendsCardDefinition = { 10 + type: 'friends', 11 + contentComponent: FriendsCard, 12 + settingsComponent: FriendsCardSettings, 13 + createNew: (card) => { 14 + card.w = 4; 15 + card.h = 2; 16 + card.mobileW = 8; 17 + card.mobileH = 4; 18 + card.cardData.friends = []; 19 + }, 20 + loadData: async (items) => { 21 + const allDids = new Set<Did>(); 22 + for (const item of items) { 23 + for (const did of item.cardData.friends ?? []) { 24 + allDids.add(did as Did); 25 + } 26 + } 27 + if (allDids.size === 0) return []; 28 + 29 + const profiles = await Promise.all( 30 + Array.from(allDids).map((did) => 31 + getBlentoOrBskyProfile({ did }).catch(() => undefined) 32 + ) 33 + ); 34 + return profiles.filter((p) => p && p.handle !== 'handle.invalid'); 35 + }, 36 + allowSetColor: true, 37 + defaultColor: 'base', 38 + minW: 2, 39 + minH: 2, 40 + name: 'Friends', 41 + groups: ['Social'], 42 + keywords: ['friends', 'avatars', 'people', 'community', 'blentos'], 43 + 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>` 44 + } as CardDefinition & { type: 'friends' };
+8 -1
src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte
··· 49 49 }); 50 50 51 51 let images = $derived( 52 - feed 52 + (feed 53 53 ?.toSorted((a: PhotoItem, b: PhotoItem) => { 54 54 return (a.value.position ?? 0) - (b.value.position ?? 0); 55 55 }) ··· 63 63 position: i.value.position ?? 0 64 64 }; 65 65 }) 66 + .filter((i) => i.src !== undefined) || []) as { 67 + src: string; 68 + name: string; 69 + width: number; 70 + height: number; 71 + position: number; 72 + }[] 66 73 ); 67 74 68 75 let isMobile = getIsMobile();
+3 -1
src/lib/cards/index.ts
··· 35 35 import { SpotifyCardDefinition } from './SpotifyCard'; 36 36 import { ButtonCardDefinition } from './ButtonCard'; 37 37 import { GuestbookCardDefinition } from './GuestbookCard'; 38 + import { FriendsCardDefinition } from './FriendsCard'; 38 39 // import { Model3DCardDefinition } from './Model3DCard'; 39 40 40 41 export const AllCardDefinitions = [ ··· 73 74 TimerCardDefinition, 74 75 ClockCardDefinition, 75 76 CountdownCardDefinition, 76 - SpotifyCardDefinition 77 + SpotifyCardDefinition, 77 78 // Model3DCardDefinition 79 + FriendsCardDefinition 78 80 ] as const; 79 81 80 82 export const CardDefinitionsByType = AllCardDefinitions.reduce(
-1
src/lib/website/EditableWebsite.svelte
··· 931 931 > 932 932 <div class="pointer-events-none"></div> 933 933 <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 934 - <!-- svelte-ignore a11y_click_events_have_key_events --> 935 934 <div 936 935 bind:this={container} 937 936 onclick={(e) => {
+2 -8
src/lib/website/layout-mirror.ts
··· 51 51 if (fromMobile) { 52 52 // Mobile → Desktop: reflow items to use the full grid width. 53 53 // Sort by mobile position so items are placed in reading order. 54 - const sorted = items.toSorted( 55 - (a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX 56 - ); 54 + const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 57 55 58 56 // Place each item into the first available spot on the desktop grid 59 57 const placed: Item[] = []; ··· 66 64 } else { 67 65 // Desktop → Mobile: proportional positions 68 66 for (const item of items) { 69 - item.mobileX = clamp( 70 - Math.floor((item.x * 2) / 2) * 2, 71 - 0, 72 - COLUMNS - item.mobileW 73 - ); 67 + item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 74 68 item.mobileY = Math.max(0, Math.round(item.y * 2)); 75 69 } 76 70 fixAllCollisions(items, true);