+ {#if embed.type === 'images'}
+
+ {:else if embed.type === 'external' && embed.external}
+
+ {:else if embed.type === 'video' && embed.video}
+
+ {:else if embed.type === 'record' && embed.record}
+
+ {:else if embed.type === 'unknown'}
+
+ Unsupported embed type
+
+ {/if}
+
diff --git a/src/lib/components/embed/External.svelte b/src/lib/components/embed/External.svelte
new file mode 100644
index 0000000..0f29dfe
--- /dev/null
+++ b/src/lib/components/embed/External.svelte
@@ -0,0 +1,40 @@
+
+
+;
+
+export type Embed =
+ | EmbedImageData
+ | EmbedExternalData
+ | EmbedVideoData
+ | EmbedRecord
+ | UnknownEmbed;
diff --git a/src/lib/components/emoji-picker/EmojiPicker.svelte b/src/lib/components/emoji-picker/EmojiPicker.svelte
new file mode 100644
index 0000000..26827d9
--- /dev/null
+++ b/src/lib/components/emoji-picker/EmojiPicker.svelte
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+ {#await emojiDatabase.db?.getEmojiByGroup(currentGroup) then emojis}
+ {#if emojis}
+ {#each emojis as emoji}
+ {#if isEmojiSupported(emoji.unicode)}
+ {
+ onpicked?.(emoji);
+ }}
+ class="hover:bg-accent-300/20 dark:hover:bg-accent-700/20 size-10 cursor-pointer rounded-full text-center text-xl transition-transform duration-150 hover:scale-110"
+ >{emoji.unicode}
+ {/if}
+ {/each}
+ {/if}
+ {/await}
+
+
+ {#each allGroups as group}
+ (currentGroup = group.id)}
+ class={cn(
+ '[&>svg]:size-4.5 relative cursor-pointer py-2 [&>svg]:transition-all [&>svg]:duration-100 [&>svg]:hover:scale-105',
+ group.id === currentGroup
+ ? 'text-accent-600 dark:text-accent-400'
+ : 'hover:text-accent-700 dark:hover:text-accent-300'
+ )}
+ >
+ {@html group.svg}
+ {group.name}
+
+ {#if group.id === currentGroup}
+
+ {/if}
+
+ {/each}
+
+
diff --git a/src/lib/components/emoji-picker/PopoverEmojiPicker.svelte b/src/lib/components/emoji-picker/PopoverEmojiPicker.svelte
new file mode 100644
index 0000000..f24d8e0
--- /dev/null
+++ b/src/lib/components/emoji-picker/PopoverEmojiPicker.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/lib/components/emoji-picker/emoji.ts b/src/lib/components/emoji-picker/emoji.ts
new file mode 100644
index 0000000..cd37523
--- /dev/null
+++ b/src/lib/components/emoji-picker/emoji.ts
@@ -0,0 +1,63 @@
+export const allGroups = [
+ [
+ 0,
+ '😀',
+ 'smileys-emotion',
+ ` `
+ ],
+ [
+ 1,
+ '👋',
+ 'people-body',
+ ` `
+ ],
+ [
+ 3,
+ '🐱',
+ 'animals-nature',
+ ` `
+ ],
+ [
+ 4,
+ '🍎',
+ 'food-drink',
+ ` `
+ ],
+ [
+ 5,
+ '🏠️',
+ 'travel-places',
+ ` `
+ ],
+ [
+ 6,
+ '⚽',
+ 'activities',
+ ` `
+ ],
+ [
+ 7,
+ '📝',
+ 'objects',
+ ` `
+ ],
+ [
+ 8,
+ '⛔️',
+ 'symbols',
+ ` `
+ ],
+ [
+ 9,
+ '🏁',
+ 'flags',
+ ` `
+ ]
+].map(([id, emoji, name, svg]) => ({ id, emoji, name, svg })) as {
+ id: number;
+ emoji: string;
+ name: string;
+ svg: string;
+}[];
+
+export type { NativeEmoji } from 'emoji-picker-element/shared';
diff --git a/src/lib/components/emoji-picker/index.ts b/src/lib/components/emoji-picker/index.ts
new file mode 100644
index 0000000..8edfb97
--- /dev/null
+++ b/src/lib/components/emoji-picker/index.ts
@@ -0,0 +1,4 @@
+export { default as EmojiPicker } from './EmojiPicker.svelte';
+export { default as PopoverEmojiPicker } from './PopoverEmojiPicker.svelte';
+
+export { loadEmojis } from './EmojiPicker.svelte';
diff --git a/src/lib/components/github-corner/GithubCorner.svelte b/src/lib/components/github-corner/GithubCorner.svelte
new file mode 100644
index 0000000..a86602b
--- /dev/null
+++ b/src/lib/components/github-corner/GithubCorner.svelte
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+ View source on GitHub
+
+
+
diff --git a/src/lib/components/github-corner/index.ts b/src/lib/components/github-corner/index.ts
new file mode 100644
index 0000000..62f6365
--- /dev/null
+++ b/src/lib/components/github-corner/index.ts
@@ -0,0 +1 @@
+export { default as GithubCorner } from './GithubCorner.svelte';
diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts
new file mode 100644
index 0000000..d8b785a
--- /dev/null
+++ b/src/lib/components/index.ts
@@ -0,0 +1,29 @@
+export * from './action-buttons';
+export * from './atproto-login';
+export * from './embed';
+export * from './post';
+export * from './star-rating';
+export * from './social-icons';
+export * from './swiper-cards';
+export * from './user-profile';
+export * from './github-corner';
+export * from './chat';
+export * from './emoji-picker';
+export * from './bluesky-post';
+export * from './nested-comments';
+export * from './link-card';
+export * from './animated-emoji-picker';
+export * from './atproto-handle-popup';
+export * from './microblogging-post-creator';
+
+export function numberToHumanReadable(number: number) {
+ if (number < 1000) {
+ return number;
+ }
+
+ if (number < 1000000) {
+ return `${(number / 1000).toFixed(1)}k`;
+ }
+
+ return `${(number / 1000000).toFixed(1)}m`;
+}
diff --git a/src/lib/components/link-card/LinkCard.svelte b/src/lib/components/link-card/LinkCard.svelte
new file mode 100644
index 0000000..3d3319e
--- /dev/null
+++ b/src/lib/components/link-card/LinkCard.svelte
@@ -0,0 +1,104 @@
+
+
+
+
+
+ {#if showMedia}
+ {#if meta.video && meta.videoType}
+
+
+
+ {:else if meta.image}
+
+ {/if}
+ {/if}
+
+ {#if showGradient}
+
+ {/if}
+
+ {#if showDomain}
+
+ {href ? new URL(href).hostname.replace('www.', '') : ''}
+
+ {/if}
+
+
+ {#if showDescription}
+
+ {meta?.description ?? ''}
+
+ {/if}
+
diff --git a/src/lib/components/link-card/index.ts b/src/lib/components/link-card/index.ts
new file mode 100644
index 0000000..8c1c5ee
--- /dev/null
+++ b/src/lib/components/link-card/index.ts
@@ -0,0 +1 @@
+export { default as LinkCard } from './LinkCard.svelte';
diff --git a/src/lib/components/microblogging-post-creator/MicrobloggingPostCreator.svelte b/src/lib/components/microblogging-post-creator/MicrobloggingPostCreator.svelte
new file mode 100644
index 0000000..8167d69
--- /dev/null
+++ b/src/lib/components/microblogging-post-creator/MicrobloggingPostCreator.svelte
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+ {#if dragging}
+
+
+ Drop {onimageadded && onvideoadded ? 'image or video' : onimageadded ? 'image' : 'video'} here
+
+
+ {/if}
+
{
+ const json = c as JSONContent;
+ content = { text: extractText(json), json };
+ onupdate?.(content);
+ }}
+ class={textEditorClass}
+ >
+ {#if searchMentions && $editor}
+
+ {/if}
+
+
+ {#if children}
+ {@render children()}
+ {/if}
+
+ {#if maxLength}
+
+ {remaining}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/microblogging-post-creator/bluesky.ts b/src/lib/components/microblogging-post-creator/bluesky.ts
new file mode 100644
index 0000000..6a480b6
--- /dev/null
+++ b/src/lib/components/microblogging-post-creator/bluesky.ts
@@ -0,0 +1,26 @@
+import type { MentionItem } from '@foxui/text';
+import { searchActorsTypeahead } from '../atproto-handle-popup/helper';
+
+export { editorJsonToBlueskyPost } from './facets';
+export type { BlueskyPostContent, BlueskyFacet } from './facets';
+
+/**
+ * Creates a mention search function for Bluesky actors.
+ * Use this with the `searchMentions` prop of MicrobloggingPostCreator.
+ *
+ * @example
+ * ```svelte
+ *
+ * ```
+ */
+export function createBlueskyMentionSearch(host?: string) {
+ return async (query: string): Promise => {
+ const result = await searchActorsTypeahead(query, 8, host);
+ return result.actors.map((actor) => ({
+ id: actor.did,
+ label: actor.handle,
+ avatar: actor.avatar,
+ data: { displayName: actor.displayName }
+ }));
+ };
+}
diff --git a/src/lib/components/microblogging-post-creator/facets.ts b/src/lib/components/microblogging-post-creator/facets.ts
new file mode 100644
index 0000000..996ac42
--- /dev/null
+++ b/src/lib/components/microblogging-post-creator/facets.ts
@@ -0,0 +1,104 @@
+import type { JSONContent } from '@foxui/text';
+
+interface MentionFeature {
+ $type: 'app.bsky.richtext.facet#mention';
+ did: string;
+}
+
+interface LinkFeature {
+ $type: 'app.bsky.richtext.facet#link';
+ uri: string;
+}
+
+interface TagFeature {
+ $type: 'app.bsky.richtext.facet#tag';
+ tag: string;
+}
+
+type Feature = MentionFeature | LinkFeature | TagFeature;
+
+interface FacetIndex {
+ byteStart: number;
+ byteEnd: number;
+}
+
+export interface BlueskyFacet {
+ index: FacetIndex;
+ features: Feature[];
+}
+
+export interface BlueskyPostContent {
+ text: string;
+ facets: BlueskyFacet[];
+}
+
+const encoder = new TextEncoder();
+
+function byteLength(str: string): number {
+ return encoder.encode(str).byteLength;
+}
+
+export function editorJsonToBlueskyPost(json: JSONContent): BlueskyPostContent {
+ let text = '';
+ const facets: BlueskyFacet[] = [];
+
+ function processNode(node: JSONContent) {
+ if (node.type === 'doc') {
+ for (const child of node.content ?? []) {
+ processNode(child);
+ }
+ } else if (node.type === 'paragraph') {
+ if (text.length > 0) text += '\n';
+ for (const child of node.content ?? []) {
+ processInline(child);
+ }
+ }
+ }
+
+ function processInline(node: JSONContent) {
+ if (node.type === 'mention') {
+ const label = `@${node.attrs?.label ?? node.attrs?.id}`;
+ const byteStart = byteLength(text);
+ text += label;
+ const byteEnd = byteLength(text);
+ facets.push({
+ index: { byteStart, byteEnd },
+ features: [{ $type: 'app.bsky.richtext.facet#mention', did: node.attrs?.id }]
+ });
+ } else if (node.type === 'text') {
+ const nodeText = node.text ?? '';
+ const linkMark = node.marks?.find((m) => m.type === 'link');
+ if (linkMark) {
+ const byteStart = byteLength(text);
+ text += nodeText;
+ const byteEnd = byteLength(text);
+ facets.push({
+ index: { byteStart, byteEnd },
+ features: [{ $type: 'app.bsky.richtext.facet#link', uri: linkMark.attrs?.href }]
+ });
+ } else {
+ text += nodeText;
+ }
+ }
+ }
+
+ processNode(json);
+
+ // Detect hashtags in the final text
+ const hashtagRegex =
+ /(?<=^|\s)#([a-zA-Z\u00C0-\u024F\u1E00-\u1EFF][\w\u00C0-\u024F\u1E00-\u1EFF]*)/g;
+ let match;
+ while ((match = hashtagRegex.exec(text)) !== null) {
+ const fullMatch = match[0];
+ const tag = match[1];
+ const charStart = match.index;
+ const byteStart = byteLength(text.slice(0, charStart));
+ const byteEnd = byteStart + byteLength(fullMatch);
+ facets.push({
+ index: { byteStart, byteEnd },
+ features: [{ $type: 'app.bsky.richtext.facet#tag', tag }]
+ });
+ }
+
+ return { text, facets };
+}
diff --git a/src/lib/components/microblogging-post-creator/index.ts b/src/lib/components/microblogging-post-creator/index.ts
new file mode 100644
index 0000000..b8939ee
--- /dev/null
+++ b/src/lib/components/microblogging-post-creator/index.ts
@@ -0,0 +1,8 @@
+export { default as MicrobloggingPostCreator } from './MicrobloggingPostCreator.svelte';
+export type { MicrobloggingPostContent } from './MicrobloggingPostCreator.svelte';
+export {
+ createBlueskyMentionSearch,
+ editorJsonToBlueskyPost,
+ type BlueskyPostContent,
+ type BlueskyFacet
+} from './bluesky';
diff --git a/src/lib/components/nested-comments/Comment.svelte b/src/lib/components/nested-comments/Comment.svelte
new file mode 100644
index 0000000..6cd4ee5
--- /dev/null
+++ b/src/lib/components/nested-comments/Comment.svelte
@@ -0,0 +1,98 @@
+
+
+
+
(expanded = !expanded)}
+ >
+
+ {expanded ? 'collapse' : 'expand'} comment
+
+
+
+ {#if expanded}
+
(expanded = false)}
+ />
+ {:else}
+ (expanded = true)}
+ showAvatar={true}
+ compact
+ {logo}
+ {target}
+ class="opacity-60 grayscale hover:opacity-100 hover:grayscale-0 transition-all duration-200"
+ />
+ {/if}
+
+
+ {#if comment.replies?.length && depth <= maxDepth && expanded}
+
+ {#each comment.replies.toSorted(sort) as reply (reply.id ?? reply.href ?? reply.createdAt + reply.author.handle)}
+
+ {/each}
+
+ {/if}
+
diff --git a/src/lib/components/nested-comments/NestedComments.svelte b/src/lib/components/nested-comments/NestedComments.svelte
new file mode 100644
index 0000000..10adf72
--- /dev/null
+++ b/src/lib/components/nested-comments/NestedComments.svelte
@@ -0,0 +1,40 @@
+
+
+{#if comments.length > 0}
+
+ {#each sorted as comment (comment.id ?? comment.href ?? comment.createdAt + comment.author.handle)}
+
+ {/each}
+
+{/if}
diff --git a/src/lib/components/nested-comments/index.ts b/src/lib/components/nested-comments/index.ts
new file mode 100644
index 0000000..c7e694b
--- /dev/null
+++ b/src/lib/components/nested-comments/index.ts
@@ -0,0 +1,16 @@
+import type { PostData } from '../post';
+
+export { default as NestedComments } from './NestedComments.svelte';
+
+export type CommentSortFn = (a: PostData, b: PostData) => number;
+
+export const commentSort = {
+ oldestFirst: ((a, b) =>
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) satisfies CommentSortFn,
+ newestFirst: ((a, b) =>
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) satisfies CommentSortFn,
+ mostLiked: ((a, b) => (b.likeCount ?? 0) - (a.likeCount ?? 0)) satisfies CommentSortFn,
+ leastLiked: ((a, b) => (a.likeCount ?? 0) - (b.likeCount ?? 0)) satisfies CommentSortFn,
+ mostReplies: ((a, b) => (b.replyCount ?? 0) - (a.replyCount ?? 0)) satisfies CommentSortFn,
+ leastReplies: ((a, b) => (a.replyCount ?? 0) - (b.replyCount ?? 0)) satisfies CommentSortFn
+};
diff --git a/src/lib/components/post/Post.svelte b/src/lib/components/post/Post.svelte
new file mode 100644
index 0000000..a7a3e17
--- /dev/null
+++ b/src/lib/components/post/Post.svelte
@@ -0,0 +1,181 @@
+
+
+
+ {#if data.reposted}
+
+
+
+
+
+
+ reposted by
+ {#if onclickhandle}
+
onclickhandle(data.reposted!.handle, data.reposted!.href)}
+ >
+ @{data.reposted.handle}
+
+ {:else}
+
+ @{data.reposted.handle}
+
+ {/if}
+
+
+ {/if}
+ {#if data.replyTo}
+
+
+
+
+
+
+ replying to
+ {#if onclickhandle}
+
onclickhandle(data.replyTo!.handle, data.replyTo!.href)}
+ >
+ @{data.replyTo.handle}
+
+ {:else}
+
+ @{data.replyTo.handle}
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if showAvatar}
+ {@const avatarClass = compact ? 'size-7' : 'size-10'}
+ {#if onclickavatar}
+
+
+
+ {:else if onclickhandle}
+
onclickhandle(data.author.handle, data.author.href)}>
+
+
+ {:else}
+
+
+
+ {/if}
+ {/if}
+
+
+
+
+
+ {#if data.htmlContent}
+ {@html sanitize(data.htmlContent, { ADD_ATTR: ['target'] })}
+ {:else}
+ {@render children?.()}
+ {/if}
+
+
+ {#if embeds?.length}
+ {#each embeds as embed}
+
+ {/each}
+ {/if}
+
+ {#if extraEmbeds}
+
+ {@render extraEmbeds()}
+
+ {/if}
+
+ {#if actions}
+
+ {/if}
+
+
+
+
+
diff --git a/src/lib/components/post/PostHeader.svelte b/src/lib/components/post/PostHeader.svelte
new file mode 100644
index 0000000..dec1e37
--- /dev/null
+++ b/src/lib/components/post/PostHeader.svelte
@@ -0,0 +1,142 @@
+
+
+
+ {#if showAvatar}
+ {@const avatarClass = compact ? 'size-7' : 'size-10'}
+ {#if onclickavatar}
+
+
+
+ {:else if onclickhandle}
+
onclickhandle(author.handle, author.href)}>
+
+
+ {:else}
+
+
+
+ {/if}
+ {/if}
+
+
+
+
+ {#if onclickhandle}
+
onclickhandle(author.handle, author.href)}
+ >
+ {#if author.displayName}
+
+ {author.displayName}
+
+ {/if}
+
+ @{author.handle}
+
+
+ {:else if author.href}
+
+ {#if author.displayName}
+
+ {author.displayName}
+
+ {/if}
+
+ @{author.handle}
+
+
+ {:else}
+
+
+ {author.displayName}
+
+
+ @{author.handle}
+
+
+ {/if}
+
+
·
+
+ {#if timestamp?.href}
+
+
+
+ {:else if timestamp?.onclick}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
+ {#if logo}
+ {@render logo?.()}
+ {/if}
+
+
+
+
diff --git a/src/lib/components/post/index.ts b/src/lib/components/post/index.ts
new file mode 100644
index 0000000..a07d36e
--- /dev/null
+++ b/src/lib/components/post/index.ts
@@ -0,0 +1,8 @@
+export type {
+ PostData,
+ PostHeaderProps,
+ PostProps
+} from './types';
+
+export { default as Post } from './Post.svelte';
+export { default as PostHeader } from './PostHeader.svelte';
diff --git a/src/lib/components/post/types.ts b/src/lib/components/post/types.ts
new file mode 100644
index 0000000..0fd7641
--- /dev/null
+++ b/src/lib/components/post/types.ts
@@ -0,0 +1,68 @@
+import type { Snippet } from 'svelte';
+import type { Embed } from '../embed';
+import type { ActionButtonsProps } from '../action-buttons';
+
+export type PostData = {
+ href?: string;
+ id?: string;
+
+ reposted?: { handle: string; href: string };
+ replyTo?: { handle: string; href: string };
+
+ author: {
+ displayName: string;
+ handle: string;
+ avatar?: string;
+ href?: string;
+ };
+
+ replyCount?: number;
+ repostCount?: number;
+ likeCount?: number;
+
+ createdAt: string;
+
+ embeds?: Embed[];
+
+ htmlContent?: string;
+
+ replies?: PostData[];
+
+ labels?: string[];
+};
+
+export type PostHeaderProps = {
+ author: PostData['author'];
+ createdAt: string;
+ timestamp?: { href?: string; onclick?: () => void };
+ onclickhandle?: (handle: string, href?: string) => void;
+ onclickavatar?: () => void;
+ showAvatar?: boolean;
+ compact?: boolean;
+ logo?: Snippet;
+ target?: string;
+ class?: string;
+};
+
+export type PostProps = {
+ data: PostData;
+ class?: string;
+ children?: Snippet;
+
+ embeds?: Embed[];
+ showSensitive?: boolean;
+ extraEmbeds?: Snippet;
+
+ actions?: ActionButtonsProps;
+
+ onclickhandle?: (handle: string, href?: string) => void;
+ onclickavatar?: () => void;
+
+ timestamp?: { href?: string; onclick?: () => void };
+
+ logo?: Snippet;
+
+ showAvatar?: boolean;
+ compact?: boolean;
+ target?: string;
+};
diff --git a/src/lib/components/social-icons/All.svelte b/src/lib/components/social-icons/All.svelte
new file mode 100644
index 0000000..29464c3
--- /dev/null
+++ b/src/lib/components/social-icons/All.svelte
@@ -0,0 +1,47 @@
+
+
+
+ {#if discord}
+
+ {/if}
+ {#if github}
+
+ {/if}
+ {#if twitter}
+
+ {/if}
+ {#if youtube}
+
+ {/if}
+ {#if bluesky}
+
+ {/if}
+ {#if facebook}
+
+ {/if}
+
diff --git a/src/lib/components/social-icons/Bluesky.svelte b/src/lib/components/social-icons/Bluesky.svelte
new file mode 100644
index 0000000..44b5857
--- /dev/null
+++ b/src/lib/components/social-icons/Bluesky.svelte
@@ -0,0 +1,37 @@
+
+
+
+ Bluesky
+
+
+
diff --git a/src/lib/components/social-icons/Discord.svelte b/src/lib/components/social-icons/Discord.svelte
new file mode 100644
index 0000000..fad935f
--- /dev/null
+++ b/src/lib/components/social-icons/Discord.svelte
@@ -0,0 +1,37 @@
+
+
+
+ Discord
+
+
+
diff --git a/src/lib/components/social-icons/Facebook.svelte b/src/lib/components/social-icons/Facebook.svelte
new file mode 100644
index 0000000..9adbd47
--- /dev/null
+++ b/src/lib/components/social-icons/Facebook.svelte
@@ -0,0 +1,37 @@
+
+
+
+ Facebook
+
+
+
diff --git a/src/lib/components/social-icons/Github.svelte b/src/lib/components/social-icons/Github.svelte
new file mode 100644
index 0000000..1f4568a
--- /dev/null
+++ b/src/lib/components/social-icons/Github.svelte
@@ -0,0 +1,38 @@
+
+
+
+ GitHub
+
+
+
diff --git a/src/lib/components/social-icons/Twitter.svelte b/src/lib/components/social-icons/Twitter.svelte
new file mode 100644
index 0000000..8e5f437
--- /dev/null
+++ b/src/lib/components/social-icons/Twitter.svelte
@@ -0,0 +1,37 @@
+
+
+
+ X
+
+ X
+
diff --git a/src/lib/components/social-icons/Youtube.svelte b/src/lib/components/social-icons/Youtube.svelte
new file mode 100644
index 0000000..a1c9d3e
--- /dev/null
+++ b/src/lib/components/social-icons/Youtube.svelte
@@ -0,0 +1,36 @@
+
+
+
+ YouTube
+
+
diff --git a/src/lib/components/social-icons/index.ts b/src/lib/components/social-icons/index.ts
new file mode 100644
index 0000000..d004b3b
--- /dev/null
+++ b/src/lib/components/social-icons/index.ts
@@ -0,0 +1,9 @@
+// all icons from https://simpleicons.org/
+
+export { default as SocialIcons } from './All.svelte';
+export { default as Discord } from './Discord.svelte';
+export { default as Github } from './Github.svelte';
+export { default as Twitter } from './Twitter.svelte';
+export { default as Youtube } from './Youtube.svelte';
+export { default as Bluesky } from './Bluesky.svelte';
+export { default as Facebook } from './Facebook.svelte';
diff --git a/src/lib/components/star-rating/StarRating.svelte b/src/lib/components/star-rating/StarRating.svelte
new file mode 100644
index 0000000..e1b86e3
--- /dev/null
+++ b/src/lib/components/star-rating/StarRating.svelte
@@ -0,0 +1,104 @@
+
+
+
+ {#if changeable}
+ {#each Array.from({ length: 5 }).map((_, i) => i + 1) as i}
+
(rating = i)}
+ onfocus={() => (hoverRating = i)}
+ onblur={() => (hoverRating = rating)}
+ onmouseenter={() => (hoverRating = i)}
+ onmouseleave={() => (hoverRating = rating)}
+ >
+ rating &&
+ i > hoverRating &&
+ 'stroke-base-400 text-base-100 dark:stroke-base-700/70 dark:text-base-900',
+ i <= rating &&
+ i <= hoverRating &&
+ 'stroke-accent-600 text-accent-400 dark:stroke-accent-500/80 dark:text-accent-500/40',
+
+ ((i > rating && i <= hoverRating) || (i > hoverRating && i <= rating)) &&
+ 'stroke-accent-600/50 text-accent-400/50 dark:stroke-accent-500/40 dark:text-accent-500/20',
+ svgClasses
+ )}
+ viewBox="0 0 24 24"
+ fill="currentColor"
+ aria-hidden="true"
+ stroke-width={strokeWidth}
+ data-slot="icon"
+ >
+
+
+
+ rate {i} stars
+
+ {/each}
+ {:else}
+ {#each Array.from({ length: 5 }).map((_, i) => i + 1) as i}
+
+
+
+ {/each}
+ {/if}
+
diff --git a/src/lib/components/star-rating/index.ts b/src/lib/components/star-rating/index.ts
new file mode 100644
index 0000000..e735a84
--- /dev/null
+++ b/src/lib/components/star-rating/index.ts
@@ -0,0 +1 @@
+export { default as StarRating } from './StarRating.svelte';
diff --git a/src/lib/components/swiper-cards/CardSwiper.svelte b/src/lib/components/swiper-cards/CardSwiper.svelte
new file mode 100644
index 0000000..43e2031
--- /dev/null
+++ b/src/lib/components/swiper-cards/CardSwiper.svelte
@@ -0,0 +1,235 @@
+
+
+ {
+ if (!arrowKeys) return;
+ if (e.key === 'ArrowLeft') {
+ swipe('left');
+ } else if (e.key === 'ArrowRight') {
+ swipe('right');
+ }
+ }}
+/>
+
+{#snippet DefaultCard({ image, title, description }: CardData)}
+
+ {#key image}
+ {#if image}
+
+ {/if}
+ {/key}
+
+
+
+
{title}
+
{description}
+
+
+
+{/snippet}
+
+
+
+ {#each cards as _, i}
+
+ {#if cardsData[i]}
+ {@render card({
+ image: cardsData[i].image,
+ title: cardsData[i].title,
+ description: cardsData[i].description
+ })}
+ {/if}
+
+ {/each}
+
+
diff --git a/src/lib/components/swiper-cards/index.ts b/src/lib/components/swiper-cards/index.ts
new file mode 100644
index 0000000..f178620
--- /dev/null
+++ b/src/lib/components/swiper-cards/index.ts
@@ -0,0 +1,18 @@
+export type CardData = {
+ title?: string;
+ description?: string;
+ image?: string;
+ // anything else
+ [key: string]: unknown;
+};
+
+export type SwipeEventData = {
+ direction: Direction;
+ data: CardData;
+ index: number;
+ element: HTMLElement;
+};
+
+export type Direction = 'left' | 'right';
+
+export { default as CardSwiper } from './CardSwiper.svelte';
diff --git a/src/lib/components/user-profile/UserProfile.svelte b/src/lib/components/user-profile/UserProfile.svelte
new file mode 100644
index 0000000..971f942
--- /dev/null
+++ b/src/lib/components/user-profile/UserProfile.svelte
@@ -0,0 +1,60 @@
+
+
+{#if profile}
+
+
+ {#if profile.banner}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+ {profile.displayName || profile.handle}
+
+
+ {profile.handle}
+
+
+
+
+
+
+
+
+ {@html sanitize(profile.description?.replaceAll('\n', ' ') ?? '')}
+
+
+{/if}
diff --git a/src/lib/components/user-profile/index.ts b/src/lib/components/user-profile/index.ts
new file mode 100644
index 0000000..aee956e
--- /dev/null
+++ b/src/lib/components/user-profile/index.ts
@@ -0,0 +1 @@
+export { default as UserProfile } from './UserProfile.svelte';
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 9ab48ff..d06ea94 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -2,8 +2,8 @@
import { tick, onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/atproto/auth.svelte';
- import { blueskyPostToPostData } from '@foxui/social';
- import { Post } from '@foxui/social';
+ import { blueskyPostToPostData } from '$lib/components';
+ import { Post } from '$lib/components';
import { Loader2 } from '@lucide/svelte';
import { loadFeed, likePost, unlikePost } from '$lib/atproto/server/feed.remote';
import { cachePost, prefetchThread, feedCache, prefetchNotifications, setFeedUri } from '$lib/cache.svelte';
diff --git a/src/routes/p/[actor]/+page.svelte b/src/routes/p/[actor]/+page.svelte
index dea3a76..b18146e 100644
--- a/src/routes/p/[actor]/+page.svelte
+++ b/src/routes/p/[actor]/+page.svelte
@@ -1,7 +1,7 @@