diff --git a/README.md b/README.md index 1d9e376..3956449 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ svelte bsky client, wip - make everything faster, faster, faster - add post creator -- allow setting default feed +- add keyboard bindings (e.g. j/k to navigate, enter to open post, r to reply, etc) ## posts @@ -39,4 +39,9 @@ svelte bsky client, wip ## settings -- allow setting theme (auto/dark/light) and theme colors (base and accent color) \ No newline at end of file +- allow setting theme (auto/dark/light) and theme colors (base and accent color) +- allow setting default feed + +## bugs + +- fix feed flashing sometimes \ No newline at end of file diff --git a/src/lib/atproto/server/feed.remote.ts b/src/lib/atproto/server/feed.remote.ts index 0b9e8df..2e68784 100644 --- a/src/lib/atproto/server/feed.remote.ts +++ b/src/lib/atproto/server/feed.remote.ts @@ -57,6 +57,57 @@ export const unlikePost = command( } ); +export const followUser = command( + v.object({ + did: v.string() + }), + async (input) => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + const rkey = TID.now(); + const res = await locals.client.post('com.atproto.repo.createRecord', { + input: { + repo: locals.did, + collection: 'app.bsky.graph.follow', + rkey, + record: { + $type: 'app.bsky.graph.follow', + subject: input.did, + createdAt: new Date().toISOString() + } + } + }); + + if (!res.ok) error(res.status, 'Failed to follow'); + return { uri: res.data.uri }; + } +); + +export const unfollowUser = command( + v.object({ + followUri: v.string() + }), + async (input) => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + const parts = input.followUri.split('/'); + const rkey = parts[parts.length - 1]; + + const res = await locals.client.post('com.atproto.repo.deleteRecord', { + input: { + repo: locals.did, + collection: 'app.bsky.graph.follow', + rkey + } + }); + + if (!res.ok) error(res.status, 'Failed to unfollow'); + return { ok: true }; + } +); + export const getPostThread = command( v.object({ uri: v.string(), @@ -106,6 +157,30 @@ export const getAuthorFeed = command( } ); +export const searchPosts = command( + v.object({ + q: v.string(), + cursor: v.optional(v.string()) + }), + async (input) => { + const { locals } = getRequestEvent(); + + const client = locals.client ?? new Client({ + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) + }); + + const res = await client.get('app.bsky.feed.searchPosts', { + params: { + q: input.q, + limit: 25, + ...(input.cursor ? { cursor: input.cursor } : {}) + } + }); + if (!res.ok) error(res.status, 'Failed to search posts'); + return { posts: res.data.posts, cursor: res.data.cursor ?? null }; + } +); + export const loadFeed = command( v.object({ feedUri: v.string(), @@ -129,3 +204,67 @@ export const loadFeed = command( return { posts: res.data.feed, cursor: res.data.cursor ?? null }; } ); + +export const createBookmark = command( + v.object({ + uri: v.string(), + cid: v.string() + }), + async (input) => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const opts: any = { as: null, input: { uri: input.uri, cid: input.cid } }; + const res = await locals.client.post('app.bsky.bookmark.createBookmark' as any, opts); // eslint-disable-line @typescript-eslint/no-explicit-any + if (!res.ok) error(res.status, 'Failed to bookmark'); + return { ok: true }; + } +); + +export const deleteBookmark = command( + v.object({ + uri: v.string() + }), + async (input) => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const opts: any = { as: null, input: { uri: input.uri } }; + await locals.client.post('app.bsky.bookmark.deleteBookmark' as any, opts); // eslint-disable-line @typescript-eslint/no-explicit-any + } catch (e: any) { // eslint-disable-line @typescript-eslint/no-explicit-any + console.error('[deleteBookmark] error:', e?.status, e?.body ?? e?.message ?? e); + error(e?.status ?? 500, 'Failed to remove bookmark'); + } + return { ok: true }; + } +); + +export const getBookmarks = command( + v.object({ + cursor: v.optional(v.string()), + limit: v.optional(v.number()) + }), + async (input) => { + const { locals } = getRequestEvent(); + if (!locals.client || !locals.did) error(401, 'Not authenticated'); + + const res = await locals.client.get('app.bsky.bookmark.getBookmarks' as any, { // eslint-disable-line @typescript-eslint/no-explicit-any + params: { + limit: input.limit ?? 30, + ...(input.cursor ? { cursor: input.cursor } : {}) + } + }); + + if (!res.ok) error(res.status, 'Failed to load bookmarks'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = res.data as any; + // API returns [{ createdAt, subject, item: PostView }] + const raw = data.items ?? data.bookmarks ?? []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const posts = raw.map((entry: any) => entry.item ?? entry.post ?? entry); + return { posts, cursor: data.cursor ?? null }; + } +); diff --git a/src/lib/bookmarks.svelte.ts b/src/lib/bookmarks.svelte.ts new file mode 100644 index 0000000..b2796f7 --- /dev/null +++ b/src/lib/bookmarks.svelte.ts @@ -0,0 +1,27 @@ +import { createBookmark, deleteBookmark } from '$lib/atproto/server/feed.remote'; + +// Track bookmark state: postUri -> bookmarked +let _bookmarkState = $state>({}); + +export const bookmarks = { + isBookmarked(postUri: string, viewerBookmark?: boolean): boolean { + if (postUri in _bookmarkState) return _bookmarkState[postUri]; + return !!viewerBookmark; + }, + + async toggle(postUri: string, postCid: string, viewerBookmark?: boolean) { + const currentlyBookmarked = this.isBookmarked(postUri, viewerBookmark); + // Optimistic update + _bookmarkState[postUri] = !currentlyBookmarked; + try { + if (currentlyBookmarked) { + await deleteBookmark({ uri: postUri }); + } else { + await createBookmark({ uri: postUri, cid: postCid }); + } + } catch { + // Revert on failure + _bookmarkState[postUri] = currentlyBookmarked; + } + } +}; diff --git a/src/lib/components/ScrollToTop.svelte b/src/lib/components/ScrollToTop.svelte new file mode 100644 index 0000000..95cec04 --- /dev/null +++ b/src/lib/components/ScrollToTop.svelte @@ -0,0 +1,24 @@ + + + + +{#if visible} + +{/if} diff --git a/src/lib/components/action-buttons/BookmarkButton.svelte b/src/lib/components/action-buttons/BookmarkButton.svelte index 455f445..7b1bc2f 100644 --- a/src/lib/components/action-buttons/BookmarkButton.svelte +++ b/src/lib/components/action-buttons/BookmarkButton.svelte @@ -11,7 +11,7 @@ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" - 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" + class="group-hover/post-action:bg-accent-500/10 text-accent-700 dark:text-accent-400 -m-1 size-6 rounded-full p-1 transition-all duration-100" > {@render icon()} {:else} - + {@render icon()} {/if} diff --git a/src/lib/components/action-buttons/ReplyButton.svelte b/src/lib/components/action-buttons/ReplyButton.svelte index c3021ed..1fc9452 100644 --- a/src/lib/components/action-buttons/ReplyButton.svelte +++ b/src/lib/components/action-buttons/ReplyButton.svelte @@ -36,7 +36,6 @@ {@render icon()} diff --git a/src/lib/components/bluesky-post/index.ts b/src/lib/components/bluesky-post/index.ts index d8e6336..bb1e6b0 100644 --- a/src/lib/components/bluesky-post/index.ts +++ b/src/lib/components/bluesky-post/index.ts @@ -9,11 +9,11 @@ export type BlueskyHrefs = { hashtag?: (tag: string) => string; }; -function defaultHrefs(baseUrl: string): Required { +function defaultHrefs(_baseUrl: string): Required { return { - profile: (handle, did) => `${baseUrl}/profile/${did ?? handle}`, - post: (handle, postId) => `${baseUrl}/profile/${handle}/post/${postId}`, - hashtag: (tag) => `${baseUrl}/hashtag/${tag}` + profile: (handle) => `/profile/${handle}`, + post: (handle, postId) => `/profile/${handle}/post/${postId}`, + hashtag: (tag) => `/hashtag/${tag}` }; } diff --git a/src/lib/components/embed/ImageLightbox.svelte b/src/lib/components/embed/ImageLightbox.svelte index 3241cd5..110ab98 100644 --- a/src/lib/components/embed/ImageLightbox.svelte +++ b/src/lib/components/embed/ImageLightbox.svelte @@ -15,6 +15,24 @@ lightboxState.open = false; } + async function download() { + const img = lightboxState.images[lightboxState.index]; + if (!img?.fullsize) return; + try { + const res = await fetch(img.fullsize); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `image-${lightboxState.index + 1}.jpg`; + a.click(); + URL.revokeObjectURL(url); + } catch { + // Fallback: open in new tab + window.open(img.fullsize, '_blank'); + } + } + function onkeydown(event: KeyboardEvent) { if (!lightboxState.open) return; if (event.key === 'Escape') { @@ -40,6 +58,29 @@ aria-modal="true" aria-label={image.alt} > + + +
e.stopPropagation()}> + + +
+ {#if lightboxState.images.length > 1 && lightboxState.index > 0}
e.stopPropagation()}> @@ -54,12 +95,10 @@
{/if} - {image.alt} e.stopPropagation()} + class="h-full w-full object-contain" /> {#if lightboxState.images.length > 1 && lightboxState.index < lightboxState.images.length - 1} diff --git a/src/lib/components/embed/index.ts b/src/lib/components/embed/index.ts index 7027a26..5bc6352 100644 --- a/src/lib/components/embed/index.ts +++ b/src/lib/components/embed/index.ts @@ -39,7 +39,7 @@ export function wireEmbedClicks( } }; embed.record.onclickhandle = (handle) => navigateToProfile(handle); - embed.record.handleHref = (handle) => `/p/${handle}`; + embed.record.handleHref = (handle) => `/profile/${handle}`; } } return embeds; diff --git a/src/lib/components/nested-comments/Comment.svelte b/src/lib/components/nested-comments/Comment.svelte index 6cd4ee5..44f7f6a 100644 --- a/src/lib/components/nested-comments/Comment.svelte +++ b/src/lib/components/nested-comments/Comment.svelte @@ -39,7 +39,7 @@
+ + {#if user.did} + + {/if} {#if user.did} - {@const profileHref = `/p/${user.profile?.handle ?? user.did}`} + {@const profileHref = `/profile/${user.profile?.handle ?? user.did}`} @@ -66,6 +75,7 @@ + goto(`/p/${handle}/post/${rkey}`), (handle) => goto(`/p/${handle}`))} + embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/profile/${handle}/post/${rkey}`), (handle) => goto(`/profile/${handle}`))} href={postHref} - onclickhandle={(handle) => goto(`/p/${handle}`)} - handleHref={(handle) => `/p/${handle}`} + onclickhandle={(handle) => goto(`/profile/${handle}`)} + handleHref={(handle) => `/profile/${handle}`} actions={user.did ? { reply: { - count: postData.replyCount + count: postData.replyCount, + href: postHref + '#replies' }, repost: { count: postData.repostCount @@ -185,11 +187,16 @@ count: getLikeCount(feedPost.post.uri, postData.likeCount ?? 0), active: isLiked(feedPost.post.uri, feedPost.post.viewer?.like), onclick: () => handleLike(feedPost.post.uri, feedPost.post.cid, feedPost.post.viewer?.like) + }, + bookmark: { + active: bookmarks.isBookmarked(feedPost.post.uri, feedPost.post.viewer?.bookmarked), + onclick: () => bookmarks.toggle(feedPost.post.uri, feedPost.post.cid, feedPost.post.viewer?.bookmarked) } } : { reply: { - count: postData.replyCount + count: postData.replyCount, + href: postHref + '#replies' }, repost: { count: postData.repostCount diff --git a/src/routes/bookmarks/+page.svelte b/src/routes/bookmarks/+page.svelte new file mode 100644 index 0000000..d507e50 --- /dev/null +++ b/src/routes/bookmarks/+page.svelte @@ -0,0 +1,194 @@ + + +
+
+
+ +

Bookmarks

+
+ + {#if loading} +
+ +
+ {:else if posts.length === 0} +
+

No bookmarks yet

+
+ {:else} +
+ {#each posts as item, i (getPostView(item)?.uri ? `${getPostView(item).uri}-${i}` : i)} + {@const postView = getPostView(item)} + {#if postView?.uri && postView?.author} + {@const { postData, embeds } = blueskyPostToPostData(postView, 'https://bsky.app')} + {@const postHref = `/profile/${postView.author.handle}/post/${postView.uri.split('/').pop()}`} +
+ goto(`/profile/${handle}/post/${rkey}`), (handle) => goto(`/profile/${handle}`))} + href={postHref} + onclickhandle={(handle) => goto(`/profile/${handle}`)} + handleHref={(handle) => `/profile/${handle}`} + actions={user.did + ? { + reply: { count: postData.replyCount }, + repost: { count: postData.repostCount }, + like: { + count: getLikeCount(postView.uri, postData.likeCount ?? 0), + active: isLiked(postView.uri, postView.viewer?.like), + onclick: () => handleLike(postView.uri, postView.cid, postView.viewer?.like) + }, + bookmark: { + active: bookmarks.isBookmarked(postView.uri, postView.viewer?.bookmarked), + onclick: () => bookmarks.toggle(postView.uri, postView.cid, postView.viewer?.bookmarked) + } + } + : { + reply: { count: postData.replyCount }, + repost: { count: postData.repostCount }, + like: { count: postData.likeCount } + }} + /> +
+ {#if i < posts.length - 1} +
+ {/if} + {/if} + {/each} +
+ + {#if loadingMore} +
+ +
+ {/if} + + {#if !cursor && posts.length > 0} +

You've reached the end

+ {/if} + {/if} + +
+
+
diff --git a/src/routes/chat/[convoId]/+page.svelte b/src/routes/chat/[convoId]/+page.svelte index df9c38e..3ddc822 100644 --- a/src/routes/chat/[convoId]/+page.svelte +++ b/src/routes/chat/[convoId]/+page.svelte @@ -182,11 +182,11 @@ - - {:else} @@ -225,7 +225,7 @@
{#if showHeader}
- diff --git a/src/routes/hashtag/[tag]/+page.svelte b/src/routes/hashtag/[tag]/+page.svelte new file mode 100644 index 0000000..74dde55 --- /dev/null +++ b/src/routes/hashtag/[tag]/+page.svelte @@ -0,0 +1,181 @@ + + +
+
+ +
+ +

{tag}

+
+ + {#if loading} +
+ +
+ {:else if posts.length === 0} +
+ +

No posts with #{tag}

+
+ {:else} +
+ {#each posts as post, i (post.uri ? `${post.uri}-${i}` : i)} + {@const { postData, embeds } = blueskyPostToPostData(post)} + {@const rkey = post.uri.split('/').pop()} + {@const postHref = `/profile/${post.author.handle}/post/${rkey}`} +
+ goto(`/profile/${handle}/post/${rk}`), (handle) => goto(`/profile/${handle}`))} + href={postHref} + onclickhandle={(handle) => goto(`/profile/${handle}`)} + handleHref={(handle) => `/profile/${handle}`} + actions={user.did + ? { + reply: { count: postData.replyCount, href: postHref + '#replies' }, + repost: { count: postData.repostCount }, + like: { + count: getLikeCount(post.uri, postData.likeCount ?? 0), + active: isLiked(post.uri, post.viewer?.like), + onclick: () => handleLike(post.uri, post.cid, post.viewer?.like) + } + } + : { + reply: { count: postData.replyCount, href: postHref + '#replies' }, + repost: { count: postData.repostCount }, + like: { count: postData.likeCount } + }} + /> +
+ {#if i < posts.length - 1} +
+ {/if} + {/each} +
+ + {#if loadingMore} +
+ +
+ {/if} + + {#if !cursor && posts.length > 0} +

No more results

+ {/if} + +
+ {/if} +
+
diff --git a/src/routes/notifications/+page.svelte b/src/routes/notifications/+page.svelte index 1bbb482..8701271 100644 --- a/src/routes/notifications/+page.svelte +++ b/src/routes/notifications/+page.svelte @@ -166,7 +166,7 @@ function navigateToNotification(notif: Notification) { if (notif.reason === 'follow') { - goto(`/p/${notif.author.handle}`); + goto(`/profile/${notif.author.handle}`); return; } @@ -174,7 +174,7 @@ if (['reply', 'quote', 'mention'].includes(notif.reason)) { const parts = notif.uri.split('/'); const rkey = parts[parts.length - 1]; - goto(`/p/${notif.author.handle}/post/${rkey}`); + goto(`/profile/${notif.author.handle}/post/${rkey}`); return; } @@ -184,7 +184,7 @@ const rkey = parts[parts.length - 1]; const did = parts[2]; // Use the profile handle from user since it's our own post - goto(`/p/${user.profile?.handle ?? did}/post/${rkey}`); + goto(`/profile/${user.profile?.handle ?? did}/post/${rkey}`); return; } } @@ -253,7 +253,7 @@ onmousedown={(e) => { e.stopPropagation(); e.preventDefault(); - goto(`/p/${notif.author.handle}`); + goto(`/profile/${notif.author.handle}`); }} > @@ -267,7 +267,7 @@ onmousedown={(e) => { e.stopPropagation(); e.preventDefault(); - goto(`/p/${notif.author.handle}`); + goto(`/profile/${notif.author.handle}`); }} > @{notif.author.handle} diff --git a/src/routes/p/[actor]/+page.svelte b/src/routes/profile/[handle]/+page.svelte similarity index 72% rename from src/routes/p/[actor]/+page.svelte rename to src/routes/profile/[handle]/+page.svelte index bc101a8..a91adac 100644 --- a/src/routes/p/[actor]/+page.svelte +++ b/src/routes/profile/[handle]/+page.svelte @@ -9,11 +9,17 @@ import { user, logout } from '$lib/atproto/auth.svelte'; import { actorToDid, getDetailedProfile } from '$lib/atproto/methods'; import { getCachedProfile, cacheProfile, cachePosts, prefetchThread } from '$lib/cache.svelte'; - import { getAuthorFeed, likePost, unlikePost } from '$lib/atproto/server/feed.remote'; + import { getAuthorFeed, likePost, unlikePost, followUser, unfollowUser } from '$lib/atproto/server/feed.remote'; import { wireEmbedClicks } from '$lib/components/embed'; + import { bookmarks } from '$lib/bookmarks.svelte'; import { Client, simpleFetchHandler } from '@atcute/client'; + import { UserPlus, UserCheck } from '@lucide/svelte'; + let isOwnProfile = $derived(user.did && profile?.did === user.did); + let followUri = $state(null); + let isFollowing = $derived(followUri !== null); + let followLoading = $state(false); let loading = $state(true); let error = $state(null); @@ -66,6 +72,30 @@ } } + async function toggleFollow() { + if (!profile?.did || followLoading) return; + followLoading = true; + try { + if (isFollowing) { + await unfollowUser({ followUri: followUri! }); + followUri = null; + } else { + const result = await followUser({ did: profile.did }); + followUri = result.uri; + } + } catch (e) { + console.error('Failed to toggle follow:', e); + } finally { + followLoading = false; + } + } + + function numberToHuman(n: number): string { + if (n < 1000) return String(n); + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(1)}m`; + } + async function loadProfile(actor: string) { loading = true; error = null; @@ -75,6 +105,7 @@ postsLoading = true; likeState = {}; likeCountDelta = {}; + followUri = null; // Show cached profile instantly const cached = await getCachedProfile(actor); @@ -93,6 +124,7 @@ if (fresh) { profile = fresh; cacheProfile(fresh); + followUri = fresh.viewer?.following ?? null; } else if (!cached) { error = 'Profile not found'; } @@ -120,7 +152,7 @@ } $effect(() => { - const actor = page.params.actor; + const actor = page.params.handle; if (actor) untrack(() => loadProfile(actor)); }); @@ -129,7 +161,7 @@ loadingMore = true; try { const result = await getAuthorFeed({ - actor: page.params.actor, + actor: page.params.handle, cursor: postsCursor }); const newPosts = JSON.parse(JSON.stringify(result.posts)); @@ -180,15 +212,42 @@ description: profile.description }} class="" - /> - {#if isOwnProfile} -
- + > +
+
+ + +
+ {#if isOwnProfile} + + {:else if user.did} + + {/if}
- {/if} + {#if postsLoading} @@ -202,7 +261,7 @@ {@const { postData, embeds } = blueskyPostToPostData(feedPost.post, 'https://bsky.app', feedPost.reason)} {@const postHref = (() => { const rkey = feedPost.post.uri.split('/').pop(); - return `/p/${feedPost.post.author.handle}/post/${rkey}`; + return `/profile/${feedPost.post.author.handle}/post/${rkey}`; })()}
goto(`/p/${handle}/post/${rkey}`), (handle) => goto(`/p/${handle}`))} + embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/profile/${handle}/post/${rkey}`), (handle) => goto(`/profile/${handle}`))} href={postHref} - onclickhandle={(handle) => goto(`/p/${handle}`)} - handleHref={(handle) => `/p/${handle}`} + onclickhandle={(handle) => goto(`/profile/${handle}`)} + handleHref={(handle) => `/profile/${handle}`} actions={user.did ? { reply: { count: postData.replyCount }, @@ -222,6 +281,10 @@ count: getLikeCount(feedPost.post.uri, postData.likeCount ?? 0), active: isLiked(feedPost.post.uri, feedPost.post.viewer?.like), onclick: () => handleLike(feedPost.post.uri, feedPost.post.cid, feedPost.post.viewer?.like) + }, + bookmark: { + active: bookmarks.isBookmarked(feedPost.post.uri, feedPost.post.viewer?.bookmarked), + onclick: () => bookmarks.toggle(feedPost.post.uri, feedPost.post.cid, feedPost.post.viewer?.bookmarked) } } : { diff --git a/src/routes/p/[actor]/post/[rkey]/+page.svelte b/src/routes/profile/[handle]/post/[rkey]/+page.svelte similarity index 87% rename from src/routes/p/[actor]/post/[rkey]/+page.svelte rename to src/routes/profile/[handle]/post/[rkey]/+page.svelte index 5120ad7..4ae34f4 100644 --- a/src/routes/p/[actor]/post/[rkey]/+page.svelte +++ b/src/routes/profile/[handle]/post/[rkey]/+page.svelte @@ -12,6 +12,7 @@ import { getCachedPost, getCachedThread, getThreadAge } from '$lib/cache.svelte'; import { threadStore } from '$lib/db.svelte'; import { wireEmbedClicks } from '$lib/components/embed'; + import { bookmarks } from '$lib/bookmarks.svelte'; let loading = $state(true); let loadingComments = $state(true); @@ -99,7 +100,7 @@ onMount(async () => { try { - const did = await actorToDid(page.params.actor); + const did = await actorToDid(page.params.handle); const uri = `at://${did}/app.bsky.feed.post/${page.params.rkey}`; // Show cached data instantly @@ -152,7 +153,7 @@ }); function handleClickHandle(handle: string) { - goto(`/p/${handle}`); + goto(`/profile/${handle}`); } @@ -177,9 +178,9 @@
goto(`/p/${handle}/post/${rkey}`), (handle) => goto(`/p/${handle}`))} + embeds={wireEmbedClicks(embeds, (handle, rkey) => goto(`/profile/${handle}/post/${rkey}`), (handle) => goto(`/profile/${handle}`))} onclickhandle={handleClickHandle} - handleHref={(handle) => `/p/${handle}`} + handleHref={(handle) => `/profile/${handle}`} actions={user.did ? { reply: { count: postData.replyCount }, @@ -188,6 +189,10 @@ count: getLikeCount(postView.uri, postData.likeCount ?? 0), active: isLiked(postView.uri, postView.viewer?.like), onclick: () => handleLike(postView.uri, postView.cid, postView.viewer?.like) + }, + bookmark: { + active: bookmarks.isBookmarked(postView.uri, postView.viewer?.bookmarked), + onclick: () => bookmarks.toggle(postView.uri, postView.cid, postView.viewer?.bookmarked) } } : { @@ -198,15 +203,17 @@ />
- {#if loadingComments} -
- -
- {:else if comments.length > 0} -
- -
- {/if} +
+ {#if loadingComments} +
+ +
+ {:else if comments.length > 0} +
+ +
+ {/if} +
{/if}
diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte new file mode 100644 index 0000000..90394ac --- /dev/null +++ b/src/routes/search/+page.svelte @@ -0,0 +1,207 @@ + + +
+
+ +
+
+ + +
+
+ + {#if loading} +
+ +
+ {:else if searched && posts.length === 0} +
+ +

No results for "{query}"

+
+ {:else if posts.length > 0} +
+ {#each posts as post, i (post.uri ? `${post.uri}-${i}` : i)} + {@const { postData, embeds } = blueskyPostToPostData(post)} + {@const rkey = post.uri.split('/').pop()} + {@const postHref = `/profile/${post.author.handle}/post/${rkey}`} +
+ goto(`/profile/${handle}/post/${rk}`), (handle) => goto(`/profile/${handle}`))} + href={postHref} + onclickhandle={(handle) => goto(`/profile/${handle}`)} + handleHref={(handle) => `/profile/${handle}`} + actions={user.did + ? { + reply: { count: postData.replyCount, href: postHref + '#replies' }, + repost: { count: postData.repostCount }, + like: { + count: getLikeCount(post.uri, postData.likeCount ?? 0), + active: isLiked(post.uri, post.viewer?.like), + onclick: () => handleLike(post.uri, post.cid, post.viewer?.like) + } + } + : { + reply: { count: postData.replyCount, href: postHref + '#replies' }, + repost: { count: postData.repostCount }, + like: { count: postData.likeCount } + }} + /> +
+ {#if i < posts.length - 1} +
+ {/if} + {/each} +
+ + {#if loadingMore} +
+ +
+ {/if} + + {#if !cursor && posts.length > 0} +

No more results

+ {/if} + +
+ {:else if !searched} +
+ +

Search for posts on Bluesky

+
+ {/if} +
+