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
1
pulls
pipelines
fixes
Florian
1 month ago
7febe723
e052efde
+162
-128
12 changed files
expand all
collapse all
unified
split
src
lib
cards
FriendsCard
FriendsCard.svelte
FriendsCardSettings.svelte
index.ts
GameCards
DinoGameCard
index.ts
TetrisCard
index.ts
PopfeedReviews
PopfeedReviewsCard.svelte
StandardSiteDocumentListCard
StandardSiteDocumentListCard.svelte
StatusphereCard
index.ts
components
card-command
CardCommand.svelte
website
Account.svelte
Context.svelte
EditableWebsite.svelte
+38
-11
src/lib/cards/FriendsCard/FriendsCard.svelte
···
61
61
return 'lg';
62
62
});
63
63
64
64
+
function removeFriend(did: string) {
65
65
+
item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did);
66
66
+
}
67
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
88
-
<a
89
89
-
href={getLink(profile)}
90
90
-
class="accent:ring-accent-500 relative block rounded-full ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900"
91
91
-
style="margin: -{olY}px 0 0 -{olX}px;"
92
92
-
>
93
93
-
<Avatar
94
94
-
src={profile.avatar}
95
95
-
alt={profile.handle}
96
96
-
class={sizeClass === 'sm' ? 'size-12' : sizeClass === 'md' ? 'size-16' : 'size-20'}
97
97
-
/>
98
98
-
</a>
92
92
+
<div class="group relative" style="margin: -{olY}px 0 0 -{olX}px;">
93
93
+
<a
94
94
+
href={getLink(profile)}
95
95
+
class="accent:ring-accent-500 relative block rounded-full ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900"
96
96
+
>
97
97
+
<Avatar
98
98
+
src={profile.avatar}
99
99
+
alt={profile.handle}
100
100
+
class={sizeClass === 'sm' ? 'size-12' : sizeClass === 'md' ? 'size-16' : 'size-20'}
101
101
+
/>
102
102
+
</a>
103
103
+
{#if canEdit()}
104
104
+
<button
105
105
+
aria-label="Remove friend"
106
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
107
+
onclick={(e) => {
108
108
+
e.preventDefault();
109
109
+
e.stopPropagation();
110
110
+
removeFriend(profile.did);
111
111
+
}}
112
112
+
>
113
113
+
<svg
114
114
+
xmlns="http://www.w3.org/2000/svg"
115
115
+
fill="none"
116
116
+
viewBox="0 0 24 24"
117
117
+
stroke-width="2.5"
118
118
+
stroke="currentColor"
119
119
+
class="size-4"
120
120
+
>
121
121
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
122
122
+
</svg>
123
123
+
</button>
124
124
+
{/if}
125
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
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
6
-
import type { Did } from '@atcute/lexicons';
7
7
-
import type { FriendsProfile } from '.';
8
8
-
import { getBlentoOrBskyProfile } from '$lib/atproto/methods';
9
5
import HandleInput from '$lib/atproto/UI/HandleInput.svelte';
10
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
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((p): p is FriendsProfile => !!p && p.handle !== 'handle.invalid');
29
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
35
-
profiles = [
36
36
-
...profiles,
37
37
-
{
38
38
-
did: actor.did,
39
39
-
handle: actor.handle,
40
40
-
displayName: actor.displayName || actor.handle,
41
41
-
avatar: actor.avatar,
42
42
-
hasBlento: false
43
43
-
} as FriendsProfile
44
44
-
];
45
16
requestAnimationFrame(() => {
46
17
handleValue = '';
47
18
if (inputRef) inputRef.value = '';
48
19
});
49
20
}
50
50
-
51
51
-
function removeFriend(did: string) {
52
52
-
item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did);
53
53
-
profiles = profiles.filter((p) => p.did !== did);
54
54
-
}
55
55
-
56
56
-
function getProfile(did: string): FriendsProfile | undefined {
57
57
-
return profiles.find((p) => p.did === did);
58
58
-
}
59
21
</script>
60
22
61
61
-
<div class="flex flex-col gap-3">
62
62
-
<HandleInput bind:value={handleValue} onselected={addFriend} bind:ref={inputRef} />
63
63
-
64
64
-
{#if dids.length > 0}
65
65
-
<div class="flex flex-col gap-1.5">
66
66
-
{#each dids as did (did)}
67
67
-
{@const profile = getProfile(did)}
68
68
-
<div class="flex items-center gap-2">
69
69
-
<Avatar src={profile?.avatar} alt={profile?.handle ?? did} class="size-6 rounded-full" />
70
70
-
<span class="min-w-0 flex-1 truncate text-sm">
71
71
-
{profile?.handle ?? did}
72
72
-
</span>
73
73
-
<Button
74
74
-
variant="ghost"
75
75
-
size="icon"
76
76
-
class="size-6 min-w-6"
77
77
-
onclick={() => removeFriend(did)}
78
78
-
>
79
79
-
<svg
80
80
-
xmlns="http://www.w3.org/2000/svg"
81
81
-
fill="none"
82
82
-
viewBox="0 0 24 24"
83
83
-
stroke-width="2"
84
84
-
stroke="currentColor"
85
85
-
class="size-3.5"
86
86
-
>
87
87
-
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
88
88
-
</svg>
89
89
-
</Button>
90
90
-
</div>
91
91
-
{/each}
92
92
-
</div>
93
93
-
{/if}
94
94
-
</div>
23
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
39
-
// groups: ['Social'],
39
39
+
groups: ['Social'],
40
40
keywords: ['friends', 'avatars', 'people', 'community', 'blentos'],
41
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
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
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
11
-
card.h = 4;
11
11
+
card.h = 2;
12
12
card.mobileW = 8;
13
13
-
card.mobileH = 6;
13
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
12
-
defaultColor: 'accent',
13
12
createNew: (card) => {
14
14
-
card.w = 4;
15
15
-
card.h = 6;
13
13
+
card.w = 2;
14
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
4
-
import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context';
4
4
+
import {
5
5
+
getAdditionalUserData,
6
6
+
getCanEdit,
7
7
+
getDidContext,
8
8
+
getHandleContext
9
9
+
} from '$lib/website/context';
5
10
import { CardDefinitionsByType } from '..';
6
11
import Rating from './Rating.svelte';
12
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
36
+
37
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
39
-
class="flex"
47
47
+
class="flex h-full shrink-0"
40
48
href="https://popfeed.social/review/{review.uri}"
41
49
>
42
50
<div
43
43
-
class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1"
51
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
52
-
class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent"
60
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
61
-
<div
62
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
63
-
>
69
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
71
+
{#if canEdit()}
72
72
+
<Button href="https://popfeed.social/" target="_blank" rel="noopener noreferrer">
73
73
+
Review something on Popfeed
74
74
+
</Button>
75
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
2
-
import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context';
2
2
+
import {
3
3
+
getAdditionalUserData,
4
4
+
getCanEdit,
5
5
+
getDidContext,
6
6
+
getHandleContext
7
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
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
22
+
23
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
40
-
<div
41
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
42
-
>
43
43
-
<span>No blog posts found.</span>
44
44
-
<span>
45
45
-
Create some on <a
46
46
-
href="https://leaflet.pub"
47
47
-
target="_blank"
48
48
-
rel="noopener noreferrer"
49
49
-
class="underline">Leaflet</a
50
50
-
>
51
51
-
or
52
52
-
<a href="https://pckt.pub" target="_blank" rel="noopener noreferrer" class="underline"
53
53
-
>Pckt</a
54
54
-
>
55
55
-
</span>
48
48
+
<div class="z-50 flex h-full flex-col items-center justify-center gap-4 text-center text-sm">
49
49
+
<span class="text-lg font-semibold">No blog posts found.</span>
50
50
+
51
51
+
{#if canEdit()}
52
52
+
<span>
53
53
+
Create some for example on <Button
54
54
+
href="https://leaflet.pub"
55
55
+
target="_blank"
56
56
+
rel="noopener noreferrer"
57
57
+
class="">Leaflet</Button
58
58
+
>
59
59
+
or
60
60
+
<Button href="https://pckt.pub" target="_blank" rel="noopener noreferrer" class=""
61
61
+
>Pckt</Button
62
62
+
>
63
63
+
</span>
64
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
16
-
createNew: (item) => {
17
17
-
item.h = 3;
18
18
-
item.mobileH = 5;
19
19
-
},
16
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
78
-
open = true;
78
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
2
+
import { goto } from '$app/navigation';
2
3
import { user, login, logout } from '$lib/atproto';
4
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
17
-
<div class="fixed right-4 bottom-4 z-20">
19
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
25
-
<Button variant="ghost" onclick={logout}>Logout</Button>
27
27
+
<div class="flex flex-col">
28
28
+
{#if user.profile}
29
29
+
<Button
30
30
+
variant="ghost"
31
31
+
onclick={() => {
32
32
+
goto('/' + getHandleOrDid(user.profile), {});
33
33
+
}}>Leave edit mode</Button
34
34
+
>
35
35
+
{/if}
36
36
+
37
37
+
<Button variant="ghost" onclick={logout}>Logout</Button>
38
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
11
-
children
11
11
+
children,
12
12
+
isEditing
12
13
}: {
13
14
data: WebsiteData;
14
15
children: Snippet<[]>;
16
16
+
isEditing?: boolean;
15
17
} = $props();
16
18
17
19
// svelte-ignore state_referenced_locally
18
20
setAdditionalUserData(data.additionalData);
19
21
20
20
-
setCanEdit(() => dev || (user.isLoggedIn && user.profile?.did === data.did));
22
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
2
-
import { Button, toast, Toaster, Sidebar } from '@foxui/core';
2
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
126
+
let showMobileWarning = $state((innerWidth.current ?? 1000) < 1024);
126
127
127
128
setIsMobile(() => isMobile);
128
129
···
191
192
}
192
193
}
193
194
195
195
+
function cleanupDialogArtifacts() {
196
196
+
// bits-ui's body scroll lock and portal may not clean up fully when the
197
197
+
// modal is unmounted instead of closed via the open prop.
198
198
+
const restore = () => {
199
199
+
document.body.style.removeProperty('overflow');
200
200
+
document.body.style.removeProperty('pointer-events');
201
201
+
document.body.style.removeProperty('padding-right');
202
202
+
document.body.style.removeProperty('margin-right');
203
203
+
// Remove any orphaned dialog overlay/content elements left by the portal
204
204
+
for (const el of document.querySelectorAll(
205
205
+
'[data-dialog-overlay], [data-dialog-content]'
206
206
+
)) {
207
207
+
el.remove();
208
208
+
}
209
209
+
};
210
210
+
// Run immediately and again after bits-ui's 24ms scheduled cleanup
211
211
+
restore();
212
212
+
setTimeout(restore, 50);
213
213
+
}
214
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
235
+
cleanupDialogArtifacts();
214
236
215
237
scrollToItem(item, isMobile, container);
216
238
}
···
258
280
isSaving = false;
259
281
}
260
282
}
261
261
-
262
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
1136
-
<Context {data}>
1156
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
1175
-
oncancel={() => {
1195
1195
+
oncancel={async () => {
1176
1196
newItem = {};
1197
1197
+
await tick();
1198
1198
+
cleanupDialogArtifacts();
1177
1199
}}
1178
1200
/>
1179
1201
{/if}
···
1184
1206
handle={data.handle}
1185
1207
page={data.page}
1186
1208
/>
1209
1209
+
1210
1210
+
<Modal open={showMobileWarning} closeButton={false}>
1211
1211
+
<div class="flex flex-col items-center gap-4 text-center">
1212
1212
+
<svg
1213
1213
+
xmlns="http://www.w3.org/2000/svg"
1214
1214
+
fill="none"
1215
1215
+
viewBox="0 0 24 24"
1216
1216
+
stroke-width="1.5"
1217
1217
+
stroke="currentColor"
1218
1218
+
class="text-accent-500 size-10"
1219
1219
+
>
1220
1220
+
<path
1221
1221
+
stroke-linecap="round"
1222
1222
+
stroke-linejoin="round"
1223
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
1224
+
/>
1225
1225
+
</svg>
1226
1226
+
<p class="text-base-700 dark:text-base-300 text-xl font-bold">Mobile Editing</p>
1227
1227
+
<p class="text-base-500 dark:text-base-400 text-sm">
1228
1228
+
Mobile editing is currently experimental. For the best experience, use a desktop browser.
1229
1229
+
</p>
1230
1230
+
<Button class="mt-2 w-full" onclick={() => (showMobileWarning = false)}>Continue</Button>
1231
1231
+
</div>
1232
1232
+
</Modal>
1187
1233
1188
1234
<div
1189
1235
class={[