tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
small improvements
Florian
1 month ago
3c22fd5b
5a13fdd8
+726
-2
13 changed files
expand all
collapse all
unified
split
package.json
pnpm-lock.yaml
src
lib
cards
BlueskyPostCard
BlueskyPostCard.svelte
components
bluesky-post
BlueskyPost.svelte
index.ts
index.ts
post
Post.svelte
PostAction.svelte
embeds
Embed.svelte
External.svelte
Images.svelte
Video.svelte
index.ts
+1
package.json
···
61
61
"bits-ui": "^2.14.4",
62
62
"clsx": "^2.1.1",
63
63
"gsap": "^3.14.2",
64
64
+
"hls.js": "^1.6.15",
64
65
"leaflet": "^1.9.4",
65
66
"link-preview-js": "^4.0.0",
66
67
"marked": "^15.0.11",
+3
pnpm-lock.yaml
···
68
68
gsap:
69
69
specifier: ^3.14.2
70
70
version: 3.14.2
71
71
+
hls.js:
72
72
+
specifier: ^1.6.15
73
73
+
version: 1.6.15
71
74
leaflet:
72
75
specifier: ^1.9.4
73
76
version: 1.9.4
+3
-2
src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte
···
1
1
<script lang="ts">
2
2
import { getAdditionalUserData } from '$lib/helper';
3
3
-
import { BlueskyPost } from '@foxui/social';
3
3
+
import { BlueskyPost } from '../../components/bluesky-post';
4
4
5
5
const feed = getAdditionalUserData().recentPosts?.feed;
6
6
7
7
$inspect(feed);
8
8
</script>
9
9
10
10
-
<div class="flex max-h-full overflow-y-scroll p-4">
10
10
+
<div class="flex h-full flex-col justify-center-safe overflow-y-scroll p-4">
11
11
{#if feed?.[0].post}
12
12
<BlueskyPost showLogo showBookmark={false} feedViewPost={feed?.[0].post}></BlueskyPost>
13
13
+
<div class="h-4 w-full"></div>
13
14
{:else}
14
15
Your latest bluesky post will appear here.
15
16
{/if}
+36
src/lib/components/bluesky-post/BlueskyPost.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { FeedViewPost } from '@atproto/api/dist/client/types/app/bsky/feed/defs';
3
3
+
import { Post } from '../post';
4
4
+
import { blueskyPostToPostData } from '.';
5
5
+
import type { Snippet } from 'svelte';
6
6
+
7
7
+
let {
8
8
+
feedViewPost,
9
9
+
children,
10
10
+
showLogo = false,
11
11
+
...restProps
12
12
+
}: { feedViewPost?: FeedViewPost; children?: Snippet; showLogo?: boolean } = $props();
13
13
+
14
14
+
const postData = $derived(feedViewPost ? blueskyPostToPostData(feedViewPost) : undefined);
15
15
+
</script>
16
16
+
17
17
+
{#snippet logo()}
18
18
+
<a
19
19
+
class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500"
20
20
+
href={postData?.href}
21
21
+
>
22
22
+
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" class="size-4" viewBox="0 0 600 530">
23
23
+
<path
24
24
+
d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"
25
25
+
fill="currentColor"
26
26
+
/>
27
27
+
</svg>
28
28
+
<span class="sr-only">Bluesky</span>
29
29
+
</a>
30
30
+
{/snippet}
31
31
+
32
32
+
{#if postData}
33
33
+
<Post data={postData} logo={showLogo ? logo : undefined} {...restProps}>
34
34
+
{@render children?.()}
35
35
+
</Post>
36
36
+
{/if}
+124
src/lib/components/bluesky-post/index.ts
···
1
1
+
import { RichText } from '@atproto/api';
2
2
+
import type { PostData, PostEmbed } from '../post';
3
3
+
import type { PostView } from '@atproto/api/dist/client/types/app/bsky/feed/defs';
4
4
+
5
5
+
function blueskyEmbedTypeToEmbedType(type: string) {
6
6
+
console.log(type);
7
7
+
switch (type) {
8
8
+
case 'app.bsky.embed.external#view':
9
9
+
case 'app.bsky.embed.external':
10
10
+
return 'external';
11
11
+
case 'app.bsky.embed.images#view':
12
12
+
case 'app.bsky.embed.images':
13
13
+
return 'images';
14
14
+
case 'app.bsky.embed.video#view':
15
15
+
case 'app.bsky.embed.video':
16
16
+
return 'video';
17
17
+
default:
18
18
+
return 'unknown';
19
19
+
}
20
20
+
}
21
21
+
22
22
+
export function blueskyPostToPostData(
23
23
+
data: PostView,
24
24
+
baseUrl: string = 'https://bsky.app'
25
25
+
): PostData {
26
26
+
console.log(data);
27
27
+
const post = data;
28
28
+
// const reason = data.reason;
29
29
+
// const reply = data.reply?.parent;
30
30
+
// const replyId = reply?.uri?.split('/').pop();
31
31
+
32
32
+
const id = post.uri.split('/').pop();
33
33
+
34
34
+
return {
35
35
+
id,
36
36
+
href: `${baseUrl}/profile/${post.author.handle}/post/${id}`,
37
37
+
// reposted:
38
38
+
// reason && reason.$type === 'app.bsky.feed.defs#reasonRepost'
39
39
+
// ? {
40
40
+
// handle: reason.by.handle,
41
41
+
// href: `${baseUrl}/profile/${reason.by.handle}`
42
42
+
// }
43
43
+
// : undefined,
44
44
+
45
45
+
// replyTo:
46
46
+
// reply && replyId
47
47
+
// ? {
48
48
+
// handle: reply.author.handle,
49
49
+
// href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}`
50
50
+
// }
51
51
+
// : undefined,
52
52
+
author: {
53
53
+
displayName: post.author.displayName,
54
54
+
handle: post.author.handle,
55
55
+
avatar: post.author.avatar,
56
56
+
href: `${baseUrl}/profile/${post.author.did}`
57
57
+
},
58
58
+
replyCount: post.replyCount ?? 0,
59
59
+
repostCount: post.repostCount ?? 0,
60
60
+
likeCount: post.likeCount ?? 0,
61
61
+
createdAt: post.record.createdAt ?? 0,
62
62
+
63
63
+
embed: post.embed
64
64
+
? ({
65
65
+
type: blueskyEmbedTypeToEmbedType(post.embed?.$type),
66
66
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
67
67
+
images: post.embed?.images?.map((image: any) => ({
68
68
+
alt: image.alt,
69
69
+
thumb: image.thumb,
70
70
+
aspectRatio: image.aspectRatio,
71
71
+
fullsize: image.fullsize
72
72
+
})),
73
73
+
external: post.embed?.external
74
74
+
? {
75
75
+
href: post.embed.external.uri,
76
76
+
title: post.embed.external.title,
77
77
+
description: post.embed.external.description,
78
78
+
thumb: post.embed.external.thumb
79
79
+
}
80
80
+
: undefined,
81
81
+
video: post.embed
82
82
+
? {
83
83
+
playlist: post.embed.playlist,
84
84
+
thumb: post.embed.thumbnail,
85
85
+
alt: post.embed.alt,
86
86
+
aspectRatio: post.embed.aspectRatio
87
87
+
}
88
88
+
: undefined
89
89
+
} as PostEmbed)
90
90
+
: undefined,
91
91
+
92
92
+
htmlContent: blueskyPostToHTML(post, baseUrl)
93
93
+
};
94
94
+
}
95
95
+
96
96
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
97
97
+
export function blueskyPostToHTML(post: any, baseUrl: string = 'https://bsky.app') {
98
98
+
if (!post?.record) {
99
99
+
return '';
100
100
+
}
101
101
+
const rt = new RichText(post.record);
102
102
+
let html = '';
103
103
+
104
104
+
const createLink = (href: string, text: string) => {
105
105
+
return `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}">${encodeURI(text)}</a>`;
106
106
+
};
107
107
+
108
108
+
for (const segment of rt.segments()) {
109
109
+
if (!segment) continue;
110
110
+
if (segment.isLink() && segment.link?.uri) {
111
111
+
html += createLink(segment.link?.uri, segment.text);
112
112
+
} else if (segment.isMention() && segment.mention?.did) {
113
113
+
html += createLink(`${baseUrl}/profile/${segment.mention?.did}`, segment.text);
114
114
+
} else if (segment.isTag() && segment.tag?.tag) {
115
115
+
html += createLink(`${baseUrl}/hashtag/${segment.tag?.tag}`, segment.text);
116
116
+
} else {
117
117
+
html += segment.text;
118
118
+
}
119
119
+
}
120
120
+
121
121
+
return html.replace(/\n/g, '<br>');
122
122
+
}
123
123
+
124
124
+
export { default as BlueskyPost } from './BlueskyPost.svelte';
+12
src/lib/components/index.ts
···
1
1
+
2
2
+
export function numberToHumanReadable(number: number) {
3
3
+
if (number < 1000) {
4
4
+
return number;
5
5
+
}
6
6
+
7
7
+
if (number < 1000000) {
8
8
+
return `${(number / 1000).toFixed(1)}k`;
9
9
+
}
10
10
+
11
11
+
return `${(number / 1000000).toFixed(1)}m`;
12
12
+
}
+299
src/lib/components/post/Post.svelte
···
1
1
+
<script lang="ts">
2
2
+
import Embed from './embeds/Embed.svelte';
3
3
+
import { cn, Avatar, Prose } from '@foxui/core';
4
4
+
import type { WithChildren, WithElementRef } from 'bits-ui';
5
5
+
import type { HTMLAttributes } from 'svelte/elements';
6
6
+
import type { PostData } from '.';
7
7
+
import PostAction from './PostAction.svelte';
8
8
+
import type { Snippet } from 'svelte';
9
9
+
import { numberToHumanReadable } from '..';
10
10
+
import { RelativeTime } from '@foxui/time';
11
11
+
12
12
+
let {
13
13
+
ref = $bindable(),
14
14
+
data,
15
15
+
class: className,
16
16
+
bookmarked = $bindable(false),
17
17
+
liked = $bindable(false),
18
18
+
19
19
+
showReply = $bindable(true),
20
20
+
showRepost = $bindable(true),
21
21
+
showLike = $bindable(true),
22
22
+
showBookmark = $bindable(true),
23
23
+
24
24
+
onReplyClick,
25
25
+
onRepostClick,
26
26
+
onLikeClick,
27
27
+
onBookmarkClick,
28
28
+
29
29
+
customActions,
30
30
+
31
31
+
children,
32
32
+
33
33
+
logo
34
34
+
}: WithElementRef<WithChildren<HTMLAttributes<HTMLDivElement>>> & {
35
35
+
data: PostData;
36
36
+
class?: string;
37
37
+
38
38
+
bookmarked?: boolean;
39
39
+
liked?: boolean;
40
40
+
41
41
+
showReply?: boolean;
42
42
+
showRepost?: boolean;
43
43
+
showLike?: boolean;
44
44
+
showBookmark?: boolean;
45
45
+
46
46
+
onReplyClick?: () => void;
47
47
+
onRepostClick?: () => void;
48
48
+
onLikeClick?: () => void;
49
49
+
onBookmarkClick?: () => void;
50
50
+
51
51
+
customActions?: Snippet;
52
52
+
53
53
+
logo?: Snippet;
54
54
+
} = $props();
55
55
+
</script>
56
56
+
57
57
+
<div
58
58
+
bind:this={ref}
59
59
+
class={cn('text-base-950 dark:text-base-50 transition-colors duration-200', className)}
60
60
+
>
61
61
+
{#if data.reposted}
62
62
+
<div class="mb-3 inline-flex items-center gap-2 text-xs">
63
63
+
<svg
64
64
+
xmlns="http://www.w3.org/2000/svg"
65
65
+
viewBox="0 0 24 24"
66
66
+
fill="currentColor"
67
67
+
class="size-3"
68
68
+
>
69
69
+
<path
70
70
+
fill-rule="evenodd"
71
71
+
d="M4.755 10.059a7.5 7.5 0 0 1 12.548-3.364l1.903 1.903h-3.183a.75.75 0 1 0 0 1.5h4.992a.75.75 0 0 0 .75-.75V4.356a.75.75 0 0 0-1.5 0v3.18l-1.9-1.9A9 9 0 0 0 3.306 9.67a.75.75 0 1 0 1.45.388Zm15.408 3.352a.75.75 0 0 0-.919.53 7.5 7.5 0 0 1-12.548 3.364l-1.902-1.903h3.183a.75.75 0 0 0 0-1.5H2.984a.75.75 0 0 0-.75.75v4.992a.75.75 0 0 0 1.5 0v-3.18l1.9 1.9a9 9 0 0 0 15.059-4.035.75.75 0 0 0-.53-.918Z"
72
72
+
clip-rule="evenodd"
73
73
+
/>
74
74
+
</svg>
75
75
+
76
76
+
<div class="inline-flex gap-1">
77
77
+
reposted by
78
78
+
<a
79
79
+
href={data.reposted.href}
80
80
+
class="hover:text-accent-600 dark:hover:text-accent-400 font-bold"
81
81
+
>
82
82
+
@{data.reposted.handle}
83
83
+
</a>
84
84
+
</div>
85
85
+
</div>
86
86
+
{/if}
87
87
+
{#if data.replyTo}
88
88
+
<div class="mb-3 inline-flex items-center gap-2 text-xs">
89
89
+
<svg
90
90
+
xmlns="http://www.w3.org/2000/svg"
91
91
+
viewBox="0 0 24 24"
92
92
+
fill="currentColor"
93
93
+
class="size-3"
94
94
+
>
95
95
+
<path
96
96
+
fill-rule="evenodd"
97
97
+
d="M14.47 2.47a.75.75 0 0 1 1.06 0l6 6a.75.75 0 0 1 0 1.06l-6 6a.75.75 0 1 1-1.06-1.06l4.72-4.72H9a5.25 5.25 0 1 0 0 10.5h3a.75.75 0 0 1 0 1.5H9a6.75 6.75 0 0 1 0-13.5h10.19l-4.72-4.72a.75.75 0 0 1 0-1.06Z"
98
98
+
clip-rule="evenodd"
99
99
+
/>
100
100
+
</svg>
101
101
+
102
102
+
<div class="inline-flex gap-1">
103
103
+
replying to
104
104
+
<a
105
105
+
href={data.replyTo.href}
106
106
+
class="hover:text-accent-600 dark:hover:text-accent-400 font-bold"
107
107
+
>
108
108
+
@{data.replyTo.handle}
109
109
+
</a>
110
110
+
</div>
111
111
+
</div>
112
112
+
{/if}
113
113
+
<div class="flex gap-4">
114
114
+
<div class="w-full">
115
115
+
<div class="mb-1 flex items-start justify-between gap-2">
116
116
+
<div class="flex items-start gap-4">
117
117
+
{#if data.author.href}
118
118
+
<a
119
119
+
class="hover:bg-accent-900/5 group/post-author -mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row"
120
120
+
href={data.author.href}
121
121
+
>
122
122
+
{#if data.author.displayName}
123
123
+
<div
124
124
+
class="text-base-900 group-hover/post-author:text-accent-600 dark:text-base-50 dark:group-hover/post-author:text-accent-300 line-clamp-1 text-sm leading-tight font-semibold"
125
125
+
>
126
126
+
{data.author.displayName}
127
127
+
</div>
128
128
+
{/if}
129
129
+
<div
130
130
+
class={cn(
131
131
+
'group-hover/post-author:text-accent-600 dark:group-hover/post-author:text-accent-400 text-sm',
132
132
+
!data.author.displayName
133
133
+
? 'text-base-900 dark:text-base-50 font-semibold'
134
134
+
: 'text-base-600 dark:text-base-400'
135
135
+
)}
136
136
+
>
137
137
+
@{data.author.handle}
138
138
+
</div>
139
139
+
</a>
140
140
+
{:else}
141
141
+
<div
142
142
+
class="-mx-2 -my-0.5 flex flex-col items-baseline gap-x-2 gap-y-0.5 rounded-xl px-2 py-0.5 sm:flex-row"
143
143
+
>
144
144
+
<div class="text-base-900 dark:text-base-50 text-sm leading-tight font-semibold">
145
145
+
{data.author.displayName}
146
146
+
</div>
147
147
+
<div class="text-base-600 dark:text-base-400 text-sm">
148
148
+
@{data.author.handle}
149
149
+
</div>
150
150
+
</div>
151
151
+
{/if}
152
152
+
153
153
+
<div class="text-base-600 dark:text-base-400 block text-sm no-underline">
154
154
+
<RelativeTime date={new Date(data.createdAt)} locale="en" />
155
155
+
</div>
156
156
+
</div>
157
157
+
158
158
+
{#if logo}
159
159
+
{@render logo?.()}
160
160
+
{/if}
161
161
+
</div>
162
162
+
163
163
+
<Prose size="md">
164
164
+
{#if data.htmlContent}
165
165
+
{@html data.htmlContent}
166
166
+
{:else}
167
167
+
{@render children?.()}
168
168
+
{/if}
169
169
+
</Prose>
170
170
+
171
171
+
{#if data.embed}
172
172
+
<Embed embed={data.embed} />
173
173
+
{/if}
174
174
+
175
175
+
{#if showReply || showRepost || showLike || showBookmark || customActions}
176
176
+
<div class="text-base-500 dark:text-base-400 mt-4 flex justify-between gap-2">
177
177
+
{#if showReply}
178
178
+
<PostAction onclick={onReplyClick}>
179
179
+
<svg
180
180
+
xmlns="http://www.w3.org/2000/svg"
181
181
+
fill="none"
182
182
+
viewBox="0 0 24 24"
183
183
+
stroke-width="1.5"
184
184
+
stroke="currentColor"
185
185
+
class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100"
186
186
+
>
187
187
+
<path
188
188
+
stroke-linecap="round"
189
189
+
stroke-linejoin="round"
190
190
+
d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z"
191
191
+
/>
192
192
+
</svg>
193
193
+
{#if data.replyCount}
194
194
+
{numberToHumanReadable(data.replyCount)}
195
195
+
{/if}
196
196
+
</PostAction>
197
197
+
{/if}
198
198
+
199
199
+
{#if showRepost}
200
200
+
<PostAction onclick={onRepostClick}>
201
201
+
<svg
202
202
+
xmlns="http://www.w3.org/2000/svg"
203
203
+
fill="none"
204
204
+
viewBox="0 0 24 24"
205
205
+
stroke-width="1.5"
206
206
+
stroke="currentColor"
207
207
+
class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100"
208
208
+
>
209
209
+
<path
210
210
+
stroke-linecap="round"
211
211
+
stroke-linejoin="round"
212
212
+
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
213
213
+
/>
214
214
+
</svg>
215
215
+
{#if data.repostCount}
216
216
+
{numberToHumanReadable(data.repostCount)}
217
217
+
{/if}
218
218
+
</PostAction>
219
219
+
{/if}
220
220
+
{#if showLike}
221
221
+
<PostAction
222
222
+
class={liked ? 'text-accent-700 dark:text-accent-500 font-semibold' : ''}
223
223
+
onclick={onLikeClick}
224
224
+
>
225
225
+
{#if liked}
226
226
+
<svg
227
227
+
xmlns="http://www.w3.org/2000/svg"
228
228
+
viewBox="0 0 24 24"
229
229
+
fill="currentColor"
230
230
+
class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-500 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100"
231
231
+
>
232
232
+
<path
233
233
+
d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z"
234
234
+
/>
235
235
+
</svg>
236
236
+
{:else}
237
237
+
<svg
238
238
+
xmlns="http://www.w3.org/2000/svg"
239
239
+
fill="none"
240
240
+
viewBox="0 0 24 24"
241
241
+
stroke-width="1.5"
242
242
+
stroke="currentColor"
243
243
+
class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100"
244
244
+
>
245
245
+
<path
246
246
+
stroke-linecap="round"
247
247
+
stroke-linejoin="round"
248
248
+
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"
249
249
+
/>
250
250
+
</svg>
251
251
+
{/if}
252
252
+
{#if data.likeCount}
253
253
+
{numberToHumanReadable(data.likeCount)}
254
254
+
{/if}
255
255
+
</PostAction>
256
256
+
{/if}
257
257
+
258
258
+
{#if showBookmark}
259
259
+
<PostAction onclick={onBookmarkClick}>
260
260
+
<span class="sr-only">Bookmark</span>
261
261
+
262
262
+
{#if bookmarked}
263
263
+
<svg
264
264
+
xmlns="http://www.w3.org/2000/svg"
265
265
+
viewBox="0 0 24 24"
266
266
+
fill="currentColor"
267
267
+
class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100"
268
268
+
>
269
269
+
<path
270
270
+
fill-rule="evenodd"
271
271
+
d="M6.32 2.577a49.255 49.255 0 0 1 11.36 0c1.497.174 2.57 1.46 2.57 2.93V21a.75.75 0 0 1-1.085.67L12 18.089l-7.165 3.583A.75.75 0 0 1 3.75 21V5.507c0-1.47 1.073-2.756 2.57-2.93Z"
272
272
+
clip-rule="evenodd"
273
273
+
/>
274
274
+
</svg>
275
275
+
{:else}
276
276
+
<svg
277
277
+
xmlns="http://www.w3.org/2000/svg"
278
278
+
fill="none"
279
279
+
viewBox="0 0 24 24"
280
280
+
stroke-width="1.5"
281
281
+
stroke="currentColor"
282
282
+
class="group-hover/post-action:bg-accent-500/10 group-hover/post-action:text-accent-700 dark:group-hover/post-action:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100"
283
283
+
>
284
284
+
<path
285
285
+
stroke-linecap="round"
286
286
+
stroke-linejoin="round"
287
287
+
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
288
288
+
/>
289
289
+
</svg>
290
290
+
{/if}
291
291
+
</PostAction>
292
292
+
{/if}
293
293
+
294
294
+
{@render customActions?.()}
295
295
+
</div>
296
296
+
{/if}
297
297
+
</div>
298
298
+
</div>
299
299
+
</div>
+27
src/lib/components/post/PostAction.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { cn } from '@foxui/core';
3
3
+
import type { Snippet } from 'svelte';
4
4
+
5
5
+
let {
6
6
+
onclick,
7
7
+
children,
8
8
+
class: className
9
9
+
}: {
10
10
+
onclick?: () => void;
11
11
+
children: Snippet;
12
12
+
class?: string;
13
13
+
} = $props();
14
14
+
</script>
15
15
+
16
16
+
{#if onclick}
17
17
+
<button
18
18
+
class={cn('group/post-action inline-flex cursor-pointer items-center gap-2 text-sm', className)}
19
19
+
{onclick}
20
20
+
>
21
21
+
{@render children?.()}
22
22
+
</button>
23
23
+
{:else}
24
24
+
<div class={cn('inline-flex items-center gap-2 text-sm', className)}>
25
25
+
{@render children?.()}
26
26
+
</div>
27
27
+
{/if}
+24
src/lib/components/post/embeds/Embed.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { PostEmbed } from '../';
3
3
+
import External from './External.svelte';
4
4
+
import Images from './Images.svelte';
5
5
+
import Video from './Video.svelte';
6
6
+
7
7
+
const { embed }: { embed: PostEmbed } = $props();
8
8
+
</script>
9
9
+
10
10
+
<div class="flex flex-col gap-2 pt-3 text-sm">
11
11
+
{#if embed.type === 'images'}
12
12
+
<Images data={embed} />
13
13
+
{:else if embed.type === 'external' && embed.external}
14
14
+
<External data={embed} />
15
15
+
{:else if embed.type === 'video' && embed.video}
16
16
+
<Video data={embed} />
17
17
+
{:else if embed.type === 'unknown'}
18
18
+
<div
19
19
+
class="text-base-700 dark:text-base-300 bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-600/30 rounded-2xl border p-4 text-sm"
20
20
+
>
21
21
+
Unknown embed type
22
22
+
</div>
23
23
+
{/if}
24
24
+
</div>
+39
src/lib/components/post/embeds/External.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { PostEmbedExternal } from '..';
3
3
+
4
4
+
const { data }: { data: PostEmbedExternal } = $props();
5
5
+
6
6
+
const domain = new URL(data.external.href).hostname.replace('www.', '');
7
7
+
</script>
8
8
+
9
9
+
<article
10
10
+
class={[
11
11
+
'group dark:bg-base-900 bg-base-200 border-base-300 dark:border-base-600/30 max-w-md relative isolate flex flex-col justify-end overflow-hidden rounded-2xl border p-4',
12
12
+
data.external.thumb ? 'aspect-[16/9]' : ''
13
13
+
]}
14
14
+
>
15
15
+
{#if data.external.thumb}
16
16
+
<img
17
17
+
src={data.external.thumb}
18
18
+
alt={data.external.description}
19
19
+
class="absolute inset-0 -z-10 size-full object-cover transition-transform duration-500 ease-in-out group-hover:scale-102"
20
20
+
/>
21
21
+
{/if}
22
22
+
<div
23
23
+
class="dark:from-base-950/90 dark:via-base-950/40 from-base-50/90 via-base-50/40 absolute inset-0 -z-10 bg-gradient-to-t"
24
24
+
></div>
25
25
+
26
26
+
<div
27
27
+
class="text-base-700 dark:text-base-300 flex flex-wrap items-center gap-y-1 overflow-hidden text-sm"
28
28
+
>
29
29
+
<div>{domain}</div>
30
30
+
</div>
31
31
+
<h3
32
32
+
class="dark:text-base-50 text-base-900 group-hover:text-accent-600 dark:group-hover:text-accent-400 mt-1 text-lg/6 font-semibold transition-colors duration-200"
33
33
+
>
34
34
+
<a href={data.external.href} target="_blank" rel="noopener noreferrer nofollow">
35
35
+
<span class="absolute inset-0"></span>
36
36
+
{data.external.title}
37
37
+
</a>
38
38
+
</h3>
39
39
+
</article>
+38
src/lib/components/post/embeds/Images.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { PostEmbedImage } from '..';
3
3
+
4
4
+
const { data }: { data: PostEmbedImage } = $props();
5
5
+
</script>
6
6
+
7
7
+
{#snippet imageSnippet(
8
8
+
image: {
9
9
+
alt: string;
10
10
+
thumb: string;
11
11
+
fullsize: string;
12
12
+
aspectRatio?: { width: number; height: number };
13
13
+
},
14
14
+
className?: string
15
15
+
)}
16
16
+
<img
17
17
+
loading="lazy"
18
18
+
src={image.thumb}
19
19
+
alt={image.alt}
20
20
+
style={image.aspectRatio
21
21
+
? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}`
22
22
+
: 'aspect-ratio: 1 / 1'}
23
23
+
class={[
24
24
+
'border-base-500/20 dark:border-base-400/20 w-fit max-w-full rounded-2xl border max-h-[40rem] object-contain',
25
25
+
className
26
26
+
]}
27
27
+
/>
28
28
+
{/snippet}
29
29
+
30
30
+
{#if data.images.length === 1}
31
31
+
{@render imageSnippet(data.images[0])}
32
32
+
{:else}
33
33
+
<div class="columns-2 gap-4">
34
34
+
{#each data.images as image}
35
35
+
{@render imageSnippet(image, 'mb-4')}
36
36
+
{/each}
37
37
+
</div>
38
38
+
{/if}
+45
src/lib/components/post/embeds/Video.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { onMount } from 'svelte';
3
3
+
import Hls from 'hls.js';
4
4
+
import type { PostEmbedVideo } from '..';
5
5
+
6
6
+
const { data }: { data: PostEmbedVideo } = $props();
7
7
+
8
8
+
onMount(async () => {
9
9
+
const Plyr = (await import('plyr')).default;
10
10
+
if (Hls.isSupported()) {
11
11
+
var hls = new Hls();
12
12
+
hls.loadSource(data.video.playlist);
13
13
+
hls.attachMedia(element);
14
14
+
}
15
15
+
16
16
+
new Plyr(element, {
17
17
+
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'],
18
18
+
ratio: data.video.aspectRatio
19
19
+
? `${data.video.aspectRatio.width}:${data.video.aspectRatio.height}`
20
20
+
: '16:9'
21
21
+
});
22
22
+
});
23
23
+
24
24
+
let element: HTMLMediaElement;
25
25
+
</script>
26
26
+
27
27
+
<svelte:head>
28
28
+
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
29
29
+
</svelte:head>
30
30
+
31
31
+
<div
32
32
+
style={data.video.aspectRatio
33
33
+
? `aspect-ratio: ${data.video.aspectRatio.width} / ${data.video.aspectRatio.height}`
34
34
+
: 'aspect-ratio: 16 / 9'}
35
35
+
class="border-base-300 dark:border-base-400/40 w-full max-w-full overflow-hidden rounded-2xl border"
36
36
+
>
37
37
+
<!-- svelte-ignore a11y_media_has_caption -->
38
38
+
<video bind:this={element} class="h-full w-full" aria-label={data.video.alt}></video>
39
39
+
</div>
40
40
+
41
41
+
<style>
42
42
+
* {
43
43
+
--plyr-color-main: var(--color-accent-500);
44
44
+
}
45
45
+
</style>
+75
src/lib/components/post/index.ts
···
1
1
+
export type PostEmbedImage = {
2
2
+
type: 'images';
3
3
+
4
4
+
images: {
5
5
+
alt: string;
6
6
+
thumb: string;
7
7
+
fullsize: string;
8
8
+
aspectRatio?: {
9
9
+
width: number;
10
10
+
height: number;
11
11
+
};
12
12
+
}[];
13
13
+
};
14
14
+
15
15
+
export type PostEmbedExternal = {
16
16
+
type: 'external';
17
17
+
18
18
+
external: {
19
19
+
href: string;
20
20
+
thumb: string;
21
21
+
title: string;
22
22
+
description: string;
23
23
+
};
24
24
+
};
25
25
+
26
26
+
export type PostEmbedVideo = {
27
27
+
type: 'video';
28
28
+
29
29
+
video: {
30
30
+
playlist: string;
31
31
+
32
32
+
thumb: string;
33
33
+
alt: string;
34
34
+
35
35
+
aspectRatio?: {
36
36
+
width: number;
37
37
+
height: number;
38
38
+
};
39
39
+
};
40
40
+
};
41
41
+
42
42
+
export type UnknownEmbed = {
43
43
+
type: 'unknown';
44
44
+
} & Record<string, unknown>;
45
45
+
46
46
+
export type PostEmbed = PostEmbedImage | PostEmbedExternal | PostEmbedVideo | UnknownEmbed;
47
47
+
48
48
+
export type PostData = {
49
49
+
href?: string;
50
50
+
id?: string;
51
51
+
52
52
+
reposted?: { handle: string; href: string };
53
53
+
replyTo?: { handle: string; href: string };
54
54
+
55
55
+
author: {
56
56
+
displayName: string;
57
57
+
handle: string;
58
58
+
avatar?: string;
59
59
+
href?: string;
60
60
+
};
61
61
+
62
62
+
replyCount: number;
63
63
+
repostCount: number;
64
64
+
likeCount: number;
65
65
+
66
66
+
createdAt: string;
67
67
+
68
68
+
embed?: PostEmbed;
69
69
+
70
70
+
htmlContent?: string;
71
71
+
72
72
+
replies?: PostData[];
73
73
+
};
74
74
+
75
75
+
export { default as Post } from './Post.svelte';