tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
pulls
pipelines
commit
Florian
1 month ago
cb11c84b
73a1deda
+281
-11
7 changed files
expand all
collapse all
unified
split
src
lib
cards
FriendsCard
FriendsCard.svelte
FriendsCardSettings.svelte
index.ts
PhotoGalleryCard
PhotoGalleryCard.svelte
index.ts
website
EditableWebsite.svelte
layout-mirror.ts
+120
src/lib/cards/FriendsCard/FriendsCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import type { ContentComponentProps } from '../types';
4
4
+
import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context';
5
5
+
import { getBlentoOrBskyProfile } from '$lib/atproto/methods';
6
6
+
import type { FriendsProfile } from '.';
7
7
+
import type { Did } from '@atcute/lexicons';
8
8
+
import { Avatar } from '@foxui/core';
9
9
+
10
10
+
let { item }: ContentComponentProps = $props();
11
11
+
12
12
+
const isMobile = getIsMobile();
13
13
+
const canEdit = getCanEdit();
14
14
+
const additionalData = getAdditionalUserData();
15
15
+
16
16
+
let dids: string[] = $derived(item.cardData.friends ?? []);
17
17
+
18
18
+
let serverProfiles: FriendsProfile[] = $derived(
19
19
+
(additionalData[item.cardType] as FriendsProfile[]) ?? []
20
20
+
);
21
21
+
22
22
+
let clientProfiles: FriendsProfile[] = $state([]);
23
23
+
24
24
+
let profiles = $derived.by(() => {
25
25
+
if (serverProfiles.length > 0) {
26
26
+
return dids
27
27
+
.map((did) => serverProfiles.find((p) => p.did === did))
28
28
+
.filter((p): p is FriendsProfile => !!p);
29
29
+
}
30
30
+
return dids
31
31
+
.map((did) => clientProfiles.find((p) => p.did === did))
32
32
+
.filter((p): p is FriendsProfile => !!p);
33
33
+
});
34
34
+
35
35
+
onMount(() => {
36
36
+
if (serverProfiles.length === 0 && dids.length > 0) {
37
37
+
loadProfiles();
38
38
+
}
39
39
+
});
40
40
+
41
41
+
async function loadProfiles() {
42
42
+
const results = await Promise.all(
43
43
+
dids.map((did) => getBlentoOrBskyProfile({ did: did as Did }).catch(() => undefined))
44
44
+
);
45
45
+
clientProfiles = results.filter(
46
46
+
(p): p is FriendsProfile => !!p && p.handle !== 'handle.invalid'
47
47
+
);
48
48
+
}
49
49
+
50
50
+
// Reload when dids change in editing mode
51
51
+
$effect(() => {
52
52
+
if (canEdit() && dids.length > 0) {
53
53
+
loadProfiles();
54
54
+
}
55
55
+
});
56
56
+
57
57
+
let sizeClass = $derived.by(() => {
58
58
+
const w = isMobile() ? item.mobileW / 2 : item.w;
59
59
+
if (w < 3) return 'sm';
60
60
+
if (w < 5) return 'md';
61
61
+
return 'lg';
62
62
+
});
63
63
+
64
64
+
function getLink(profile: FriendsProfile): string {
65
65
+
if (profile.hasBlento && profile.handle && profile.handle !== 'handle.invalid') {
66
66
+
return `/${profile.handle}`;
67
67
+
}
68
68
+
if (profile.handle && profile.handle !== 'handle.invalid') {
69
69
+
return `https://bsky.app/profile/${profile.handle}`;
70
70
+
}
71
71
+
return `https://bsky.app/profile/${profile.did}`;
72
72
+
}
73
73
+
</script>
74
74
+
75
75
+
<div class="flex h-full w-full items-center justify-center overflow-hidden px-2">
76
76
+
{#if dids.length === 0}
77
77
+
{#if canEdit()}
78
78
+
<span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm">
79
79
+
Add friends in settings
80
80
+
</span>
81
81
+
{/if}
82
82
+
{:else}
83
83
+
<div class="flex items-center justify-center">
84
84
+
{#each visibleProfiles as profile, i (profile.did)}
85
85
+
<a
86
86
+
href={getLink(profile)}
87
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
88
+
class:-ml-3={i > 0 && sizeClass === 'sm'}
89
89
+
class:-ml-5={i > 0 && sizeClass === 'md'}
90
90
+
class:-ml-6={i > 0 && sizeClass === 'lg'}
91
91
+
>
92
92
+
<Avatar
93
93
+
src={profile.avatar}
94
94
+
alt={profile.handle}
95
95
+
class={sizeClass === 'sm' ? 'size-12' : sizeClass === 'md' ? 'size-16' : 'size-20'}
96
96
+
/>
97
97
+
</a>
98
98
+
{/each}
99
99
+
{#if overflowCount > 0}
100
100
+
<div
101
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
102
+
class:-ml-3={sizeClass === 'sm'}
103
103
+
class:-ml-5={sizeClass === 'md'}
104
104
+
class:-ml-6={sizeClass === 'lg'}
105
105
+
class:size-12={sizeClass === 'sm'}
106
106
+
class:size-16={sizeClass === 'md'}
107
107
+
class:size-20={sizeClass === 'lg'}
108
108
+
>
109
109
+
<span
110
110
+
class="text-base-600 dark:text-base-300 accent:text-accent-200 font-semibold"
111
111
+
class:text-sm={sizeClass === 'sm'}
112
112
+
class:text-base={sizeClass === 'md' || sizeClass === 'lg'}
113
113
+
>
114
114
+
+{overflowCount}
115
115
+
</span>
116
116
+
</div>
117
117
+
{/if}
118
118
+
</div>
119
119
+
{/if}
120
120
+
</div>
+104
src/lib/cards/FriendsCard/FriendsCardSettings.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import type { Item } from '$lib/types';
4
4
+
import type { SettingsComponentProps } from '../types';
5
5
+
import type { AppBskyActorDefs } from '@atcute/bluesky';
6
6
+
import type { Did } from '@atcute/lexicons';
7
7
+
import type { FriendsProfile } from '.';
8
8
+
import { getBlentoOrBskyProfile } from '$lib/atproto/methods';
9
9
+
import HandleInput from '$lib/atproto/UI/HandleInput.svelte';
10
10
+
import { Avatar, Button } from '@foxui/core';
11
11
+
12
12
+
let { item = $bindable<Item>() }: SettingsComponentProps = $props();
13
13
+
14
14
+
let handleValue = $state('');
15
15
+
let inputRef: HTMLInputElement | null = $state(null);
16
16
+
let profiles: FriendsProfile[] = $state([]);
17
17
+
18
18
+
let dids: string[] = $derived(item.cardData.friends ?? []);
19
19
+
20
20
+
onMount(() => {
21
21
+
loadProfiles();
22
22
+
});
23
23
+
24
24
+
async function loadProfiles() {
25
25
+
const results = await Promise.all(
26
26
+
dids.map((did) => getBlentoOrBskyProfile({ did: did as Did }).catch(() => undefined))
27
27
+
);
28
28
+
profiles = results.filter(
29
29
+
(p): p is FriendsProfile => !!p && p.handle !== 'handle.invalid'
30
30
+
);
31
31
+
}
32
32
+
33
33
+
function addFriend(actor: AppBskyActorDefs.ProfileViewBasic) {
34
34
+
if (!item.cardData.friends) item.cardData.friends = [];
35
35
+
if (item.cardData.friends.includes(actor.did)) return;
36
36
+
item.cardData.friends = [...item.cardData.friends, actor.did];
37
37
+
profiles = [
38
38
+
...profiles,
39
39
+
{
40
40
+
did: actor.did,
41
41
+
handle: actor.handle,
42
42
+
displayName: actor.displayName || actor.handle,
43
43
+
avatar: actor.avatar,
44
44
+
hasBlento: false
45
45
+
} as FriendsProfile
46
46
+
];
47
47
+
requestAnimationFrame(() => {
48
48
+
handleValue = '';
49
49
+
if (inputRef) inputRef.value = '';
50
50
+
});
51
51
+
}
52
52
+
53
53
+
function removeFriend(did: string) {
54
54
+
item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did);
55
55
+
profiles = profiles.filter((p) => p.did !== did);
56
56
+
}
57
57
+
58
58
+
function getProfile(did: string): FriendsProfile | undefined {
59
59
+
return profiles.find((p) => p.did === did);
60
60
+
}
61
61
+
</script>
62
62
+
63
63
+
<div class="flex flex-col gap-3">
64
64
+
<HandleInput bind:value={handleValue} onselected={addFriend} bind:ref={inputRef} />
65
65
+
66
66
+
{#if dids.length > 0}
67
67
+
<div class="flex flex-col gap-1.5">
68
68
+
{#each dids as did (did)}
69
69
+
{@const profile = getProfile(did)}
70
70
+
<div class="flex items-center gap-2">
71
71
+
<Avatar
72
72
+
src={profile?.avatar}
73
73
+
alt={profile?.handle ?? did}
74
74
+
class="size-6 rounded-full"
75
75
+
/>
76
76
+
<span class="min-w-0 flex-1 truncate text-sm">
77
77
+
{profile?.handle ?? did}
78
78
+
</span>
79
79
+
<Button
80
80
+
variant="ghost"
81
81
+
size="icon"
82
82
+
class="size-6 min-w-6"
83
83
+
onclick={() => removeFriend(did)}
84
84
+
>
85
85
+
<svg
86
86
+
xmlns="http://www.w3.org/2000/svg"
87
87
+
fill="none"
88
88
+
viewBox="0 0 24 24"
89
89
+
stroke-width="2"
90
90
+
stroke="currentColor"
91
91
+
class="size-3.5"
92
92
+
>
93
93
+
<path
94
94
+
stroke-linecap="round"
95
95
+
stroke-linejoin="round"
96
96
+
d="M6 18 18 6M6 6l12 12"
97
97
+
/>
98
98
+
</svg>
99
99
+
</Button>
100
100
+
</div>
101
101
+
{/each}
102
102
+
</div>
103
103
+
{/if}
104
104
+
</div>
+44
src/lib/cards/FriendsCard/index.ts
···
1
1
+
import type { CardDefinition } from '../types';
2
2
+
import type { Did } from '@atcute/lexicons';
3
3
+
import { getBlentoOrBskyProfile } from '$lib/atproto/methods';
4
4
+
import FriendsCard from './FriendsCard.svelte';
5
5
+
import FriendsCardSettings from './FriendsCardSettings.svelte';
6
6
+
7
7
+
export type FriendsProfile = Awaited<ReturnType<typeof getBlentoOrBskyProfile>>;
8
8
+
9
9
+
export const FriendsCardDefinition = {
10
10
+
type: 'friends',
11
11
+
contentComponent: FriendsCard,
12
12
+
settingsComponent: FriendsCardSettings,
13
13
+
createNew: (card) => {
14
14
+
card.w = 4;
15
15
+
card.h = 2;
16
16
+
card.mobileW = 8;
17
17
+
card.mobileH = 4;
18
18
+
card.cardData.friends = [];
19
19
+
},
20
20
+
loadData: async (items) => {
21
21
+
const allDids = new Set<Did>();
22
22
+
for (const item of items) {
23
23
+
for (const did of item.cardData.friends ?? []) {
24
24
+
allDids.add(did as Did);
25
25
+
}
26
26
+
}
27
27
+
if (allDids.size === 0) return [];
28
28
+
29
29
+
const profiles = await Promise.all(
30
30
+
Array.from(allDids).map((did) =>
31
31
+
getBlentoOrBskyProfile({ did }).catch(() => undefined)
32
32
+
)
33
33
+
);
34
34
+
return profiles.filter((p) => p && p.handle !== 'handle.invalid');
35
35
+
},
36
36
+
allowSetColor: true,
37
37
+
defaultColor: 'base',
38
38
+
minW: 2,
39
39
+
minH: 2,
40
40
+
name: 'Friends',
41
41
+
groups: ['Social'],
42
42
+
keywords: ['friends', 'avatars', 'people', 'community', 'blentos'],
43
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
44
+
} as CardDefinition & { type: 'friends' };
+8
-1
src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte
···
49
49
});
50
50
51
51
let images = $derived(
52
52
-
feed
52
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
66
+
.filter((i) => i.src !== undefined) || []) as {
67
67
+
src: string;
68
68
+
name: string;
69
69
+
width: number;
70
70
+
height: number;
71
71
+
position: number;
72
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
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
76
-
SpotifyCardDefinition
77
77
+
SpotifyCardDefinition,
77
78
// Model3DCardDefinition
79
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
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
54
-
const sorted = items.toSorted(
55
55
-
(a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX
56
56
-
);
54
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
69
-
item.mobileX = clamp(
70
70
-
Math.floor((item.x * 2) / 2) * 2,
71
71
-
0,
72
72
-
COLUMNS - item.mobileW
73
73
-
);
67
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);