Read-it-later social network

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

Changed files
+257 -73
src
lib
components
server
routes
+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: {