Read-it-later social network
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

implement slices via remote functions, see bookmarks per user, filter via term, init tag filter

+257 -73
+13
README.md
··· 2 2 3 3 Get started at [potatonet.app](https://potatonet.app) 🥔 4 4 5 + - [ ] `/<handle>/home`: bookmarks per user 6 + - [x] fetch bookmarks 7 + - [x] filter by query term 8 + - [x] refresh bookmarks 9 + - [ ] filter by tags 10 + - [ ] atproto auth 11 + - [x] login/logout 12 + - [ ] bookmark CRUD 13 + - [ ] explore 14 + - [ ] query and paginate all bookmarks 15 + - [ ] filter explore 16 + - [ ] search bar `/search?q=<term>` 17 + 5 18 > Special thanks to [pilcrowonpaper](https://pilcrowonpaper.com) for `@oslojs/encoding` library and the 6 19 [encryption gist](https://gist.github.com/pilcrowonpaper/353318556029221c8e25f451b91e5f76) that the `encryption.ts` file is based on.
bun.lockb

This is a binary file and will not be displayed.

+2 -1
package.json
··· 32 32 "@tailwindcss/vite": "^4.1.13", 33 33 "@tanstack/svelte-query": "^5.90.2", 34 34 "drizzle-orm": "^0.44.5", 35 - "postgres": "^3.4.7" 35 + "postgres": "^3.4.7", 36 + "valibot": "^1.1.0" 36 37 } 37 38 }
+40
src/lib/components/BookmarkCard.svelte
··· 1 + <script lang="ts"> 2 + import type { LexiconCommunityBookmark } from "$lib/utils"; 3 + 4 + type BookmarkCardProps = { 5 + isOwner: boolean; 6 + bookmark: LexiconCommunityBookmark; 7 + onTagClick: (tag: string) => void; 8 + onTagDeleteClick?: (tag: string) => void; 9 + }; 10 + 11 + let { isOwner, bookmark, onTagClick, onTagDeleteClick }: BookmarkCardProps = $props(); 12 + </script> 13 + 14 + <article class="flex flex-col gap-4 border border-dashed hover:border-solid px-4 py-3 w-fit"> 15 + <a href={bookmark.subject} class="hover:cursor-pointer text-sm">{bookmark.subject}</a> 16 + {#if bookmark.tags && bookmark.tags.length > 0} 17 + <div class="flex gap-5"> 18 + {#each bookmark.tags as tag} 19 + <div class="relative group"> 20 + {#if isOwner} 21 + <button 22 + onclick={() => onTagDeleteClick?.(tag)} 23 + class="absolute -right-3 -top-3 group-hover:block hover:cursor-pointer hidden border bg-red-500 text-white text-xs px-1" 24 + > 25 + 🗑️ 26 + </button> 27 + {/if} 28 + <button 29 + onclick={() => onTagClick(tag)} 30 + class="bg-gray-200 w-fit px-2 py-1 hover:cursor-pointer" 31 + > 32 + {tag} 33 + </button> 34 + </div> 35 + {/each} 36 + </div> 37 + {:else} 38 + <p class="text-sm italic">No tags</p> 39 + {/if} 40 + </article>
+52
src/lib/server/api.ts
··· 1 + import { dev } from "$app/environment"; 2 + import { SLICES_NETWORK_ACCESS_TOKEN } from "$env/static/private"; 3 + import type { LexiconCommunityBookmark, SliceItem, SliceList } from "$lib/utils"; 4 + 5 + const SLICES_NETWORK_SLICE_URI = "at://did:plc:gotnvwkr56ibs33l4hwgfoet/network.slices.slice/3m26tswgbi42i" 6 + 7 + const baseUrl = "https://api.slices.network/xrpc/"; 8 + 9 + type GetListProps = { 10 + limit?: number; // default: 50, max: 100 11 + cursor?: string; 12 + where?: { 13 + [key: string]: { eq?: string, contains?: string, in?: string[] } 14 + }; 15 + sortBy?: { field: string, direction: "desc" | "asc" }[] 16 + }; 17 + 18 + export class SlicesAPI<T> { 19 + 20 + collection: string; 21 + sliceUri: string; 22 + 23 + constructor({ collection, sliceUri }: { collection: string, sliceUri : string }) { 24 + this.collection = collection; 25 + this.sliceUri = sliceUri; 26 + } 27 + 28 + async getRecord({ uri }: { uri: string }) { 29 + const searchParams = new URLSearchParams({ slice: SLICES_NETWORK_SLICE_URI, uri }); 30 + const response = await fetch(`${baseUrl}${this.collection}.getRecord?${searchParams.toString()}`); 31 + return await response.json() as SliceItem<T>; 32 + } 33 + 34 + async getList(body: GetListProps) { 35 + const response = await fetch(`${baseUrl}${this.collection}.getRecords`, { 36 + method: "POST", 37 + headers: { 38 + "Content-Type": "application/json", 39 + "Authorization": SLICES_NETWORK_ACCESS_TOKEN 40 + }, 41 + body: JSON.stringify({ ...body, slice: SLICES_NETWORK_SLICE_URI }) 42 + }); 43 + const data = await response.json() as SliceList<T>; 44 + console.log({ data }); 45 + return data; 46 + } 47 + } 48 + 49 + export const LexiconBookmarkSlicesAPI = new SlicesAPI<LexiconCommunityBookmark>({ 50 + collection: "community.lexicon.bookmarks.bookmark", 51 + sliceUri: SLICES_NETWORK_SLICE_URI 52 + });
+15
src/lib/utils.ts
··· 1 + import { atclient } from "./atproto"; 2 + 1 3 // --- UTILITIES --- 4 + export type CommonSliceFields = { 5 + indexedAt: string; 6 + cid: string; 7 + uri: string; 8 + collection: string; 9 + } 2 10 3 11 export type LexiconCommunityBookmark = { 4 12 $type: "community.lexicon.bookmarks.bookmark"; ··· 11 19 $type: "community.lexicon.interaction.like"; 12 20 subject: string; 13 21 createdAt: string; 22 + } 23 + 24 + export type SliceItem<T> = CommonSliceFields & { value: T }; 25 + 26 + export type SliceList<T> = CommonSliceFields & { 27 + cursor: string; 28 + records: (CommonSliceFields & { did: string, value: T })[]; 14 29 } 15 30 16 31 export function parseAtUri(uri: string) {
+4 -1
src/routes/+layout.server.ts
··· 2 2 3 3 export async function load({ locals }: ServerLoadEvent) { 4 4 // have user available throughout the app via LayoutData 5 - return { user: locals.user, authedAgent: locals.authedAgent }; 5 + return !locals.user ? undefined : { user: { 6 + did: locals.user.did, 7 + handle: locals.user.handle 8 + }}; 6 9 }
+45 -45
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import '../app.css'; 3 - import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; 2 + import { page } from '$app/state'; 3 + import '../app.css'; 4 4 5 5 let { data, children } = $props(); 6 6 const user = $derived(data.user); 7 - const queryClient = new QueryClient(); 8 7 </script> 9 8 10 - <QueryClientProvider client={queryClient}> 11 - <div class="flex flex-col gap-8 w-screen h-full min-h-screen font-neco"> 12 - <header class="flex items-center w-full gap-4 px-8 py-4 justify-between"> 13 - <nav class="text-lg flex gap-4 items-center"> 14 - <a href="/" class="font-comico text-2xl hover:text-shadow-md">potatonet.app</a> 15 - <a href="https://tangled.sh/@zeu.dev/potatonet-app" class="hover:text-shadow-lg">🧶</a> 16 - <a href="https://bsky.app/profile/zeu.dev" class="hover:text-shadow-lg">🦋</a> 17 - </nav> 9 + <div class="flex flex-col gap-8 w-screen h-full min-h-screen font-neco"> 10 + <header class="flex items-center w-full gap-4 px-8 py-4 justify-between"> 11 + <nav class="text-lg flex gap-4 items-center"> 12 + <a href="/" class="font-comico text-2xl hover:text-shadow-md">potatonet.app</a> 13 + <a href="https://tangled.sh/@zeu.dev/potatonet-app" class="hover:text-shadow-lg">🧶</a> 14 + <a href="https://bsky.app/profile/zeu.dev" class="hover:text-shadow-lg">🦋</a> 15 + </nav> 18 16 19 - <div class="flex gap-4 items-center text-lg"> 20 - {#if user} 21 - <a href={`/${user.handle}/home`} class="hover:text-shadow-lg">🏡</a> 22 - <form action="/?/logout" method="POST"> 23 - <button type="submit" class="hover:text-shadow-lg hover:cursor-pointer font-comico"> 24 - Logout 25 - </button> 26 - </form> 27 - {:else} 28 - <form action="/?/login" method="POST"> 29 - <input 30 - name="handle" 31 - type="text" 32 - placeholder="Handle (eg: zeu.dev)" 33 - class="border border-black border-dashed px-3 py-2 hover:shadow-lg focus:shadow-lg" 34 - /> 35 - <button type="submit" class="hover:text-shadow-lg hover:cursor-pointer font-comico"> 36 - Login 37 - </button> 38 - </form> 39 - {/if} 40 - </div> 41 - </header> 17 + <div class="flex gap-4 items-center text-lg"> 18 + {#if user} 19 + <a href={`/${user.handle}/home`} class="hover:text-shadow-lg">🏡</a> 20 + <form action="/?/logout" method="POST"> 21 + <button type="submit" class="hover:text-shadow-lg hover:cursor-pointer font-comico"> 22 + Logout 23 + </button> 24 + </form> 25 + {:else} 26 + <form action="/?/login" method="POST" class="flex gap-4"> 27 + <input 28 + name="handle" 29 + type="text" 30 + placeholder="Handle (eg: zeu.dev)" 31 + class="border border-black border-dashed px-3 py-2 hover:shadow-lg focus:shadow-lg" 32 + /> 33 + <button type="submit" class="hover:text-shadow-lg hover:cursor-pointer font-comico"> 34 + Login 35 + </button> 36 + </form> 37 + {/if} 38 + </div> 39 + </header> 40 + 41 + {#key page.url.pathname} 42 + <main class="flex flex-col gap-4 p-8"> 43 + <svelte:boundary> 44 + {@render children()} 45 + 46 + {#snippet pending()} 47 + <p>Page loading...</p> 48 + {/snippet} 49 + </svelte:boundary> 50 + </main> 51 + {/key} 52 + </div> 42 53 43 - <main class="flex flex-col gap-4 p-8"> 44 - <svelte:boundary> 45 - {@render children()} 46 - 47 - {#snippet pending()} 48 - <p>Page loading...</p> 49 - {/snippet} 50 - </svelte:boundary> 51 - </main> 52 - </div> 53 - </QueryClientProvider>
+57 -25
src/routes/[handle]/home/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { page } from "$app/state"; 3 - import { Agent } from "@atproto/api"; 4 - import { createQuery } from "@tanstack/svelte-query"; 5 - import type { LexiconCommunityBookmark } from "$lib/utils"; 3 + import BookmarkCard from "$lib/components/BookmarkCard.svelte"; 4 + import { getUserBookmarks } from "../../api/bookmarks/data.remote"; 6 5 6 + let { data } = $props(); 7 7 const { handle } = page.params; 8 - const agent = new Agent({ service: "https://selfhosted.social" }); 8 + let isOwner = $derived(data.user?.handle === handle); 9 + let cursor = $state(""); 10 + const userBookmarksQuery = $derived(getUserBookmarks({ handle: handle as string, cursor })); 11 + 12 + let query = $state(""); 13 + let filterTags = $state<string[]>([]); 14 + 15 + function onTagClick(tag: string) { 16 + const index = filterTags.findIndex((t) => t === tag); 17 + if (index >= 0) { filterTags.splice(index, 1); } 18 + else { 19 + filterTags.push(tag); 20 + } 21 + } 9 22 10 - const bookmarksQuery = createQuery({ 11 - queryKey: ["bookmarks", handle], 12 - queryFn: async () => { 13 - if (!handle) { throw Error } 14 - const result = await agent.com.atproto.repo.listRecords({ 15 - repo: handle, 16 - collection: "community.lexicon.bookmarks.bookmark" 17 - }); 18 - if (!result.success) { throw Error } 19 - console.log({ result }); 20 - return result.data as unknown as { cursor: string, records: { uri: string, cid: string, value: LexiconCommunityBookmark }[] }; 21 - }, 22 - staleTime: 3000 23 - }); 23 + function onTagDeleteClick(tag: string) { 24 + console.log("DELETE", tag); 25 + } 24 26 </script> 25 27 26 - {#if $bookmarksQuery.isLoading} 28 + <h1 class="text-3xl font-comico">Bookmarks by @{handle}</h1> 29 + 30 + {#if userBookmarksQuery.loading} 27 31 <p>Loading...</p> 28 - {:else if $bookmarksQuery.isError} 32 + {:else if userBookmarksQuery.error} 29 33 <p>Error</p> 30 - {:else if $bookmarksQuery.isSuccess} 31 - {@const bookmarks = $bookmarksQuery.data.records} 32 - {#each bookmarks as { uri, cid, value: bookmark }} 33 - <p>{bookmark.subject}</p> 34 - {/each} 34 + {:else} 35 + {@const { cursor: returnedCursor, bookmarks } = userBookmarksQuery.current || { cursor: "", bookmarks: []}} 36 + <menu class="flex justify-between"> 37 + <div class="flex gap-4"> 38 + <label class="flex items-center gap-2"> 39 + Search term: 40 + <input type="text" bind:value={query} class="border px-2 py-1" placeholder="recipe" /> 41 + </label> 42 + 43 + <label class="flex items-center gap-2"> 44 + Tags: 45 + {#each filterTags as filtered} 46 + <button onclick={() => onTagClick(filtered)}>{filtered}</button> 47 + {/each} 48 + </label> 49 + <button onclick={() => userBookmarksQuery.refresh()}>Refresh</button> 50 + </div> 51 + 52 + {#if isOwner} 53 + <button class="font-comico bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 54 + 🔖 New Bookmark 55 + </button> 56 + {/if} 57 + </menu> 58 + <hr /> 59 + 60 + <div class="flex flex-wrap gap-4"> 61 + {#each bookmarks as bookmark} 62 + {#if bookmark.subject.includes(query) && (bookmark.tags?.some(t => filterTags.length > 0 ? filterTags.includes(t) : true))} 63 + <BookmarkCard {isOwner} {bookmark} {onTagClick} {onTagDeleteClick} /> 64 + {/if} 65 + {/each} 66 + </div> 35 67 {/if}
+25
src/routes/api/bookmarks/data.remote.ts
··· 1 + import * as v from "valibot"; 2 + import { getRequestEvent, query } from "$app/server" 3 + import { LexiconBookmarkSlicesAPI } from "$lib/server/api" 4 + import { Agent } from "@atproto/api"; 5 + 6 + const GetUserBookmarksValidator = v.object({ 7 + handle: v.string(), 8 + cursor: v.optional(v.string()) 9 + }); 10 + 11 + export const getUserBookmarks = query(GetUserBookmarksValidator, async ({ handle, cursor }) => { 12 + const { locals } = getRequestEvent(); 13 + const agent = locals.authedAgent ?? new Agent({ service: "https://api.bsky.app" }); 14 + const result = await agent.resolveHandle({ handle }); 15 + if (!result.success) { throw Error() }; 16 + 17 + const data = await LexiconBookmarkSlicesAPI.getList({ 18 + cursor, 19 + where: { 20 + did: { eq: result.data.did } 21 + } 22 + }); 23 + 24 + return { cursor: data.cursor, bookmarks: data.records.map((r) => r.value )}; 25 + });
+4 -1
svelte.config.js
··· 8 8 preprocess: vitePreprocess(), 9 9 10 10 kit: { 11 - adapter: adapter() 11 + adapter: adapter(), 12 + experimental: { 13 + remoteFunctions: true 14 + } 12 15 }, 13 16 14 17 compilerOptions: {