Read-it-later social network
12
fork

Configure Feed

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

refactor to quickslice oauth

authored by

zeudev and committed by tangled.org e43ad6f0 b0140987

+61 -714
bun.lockb

This is a binary file and will not be displayed.

+1
package.json
··· 34 34 "@tanstack/svelte-query": "^6.0.9", 35 35 "drizzle-orm": "^0.44.5", 36 36 "postgres": "^3.4.7", 37 + "quickslice-client-js": "^0.3.0", 37 38 "valibot": "^1.1.0" 38 39 } 39 40 }
-37
src/hooks.server.ts
··· 1 - import { Agent } from "@atproto/api"; 2 - import { atclient } from "$lib/atproto"; 3 - 4 - import { decryptToString } from "$lib/server/encryption"; 5 - import { decodeBase64, decodeBase64urlIgnorePadding } from "@oslojs/encoding"; 6 - 7 - import type { Handle } from "@sveltejs/kit"; 8 - import { ENCRYPTION_PASSWORD } from "$env/static/private"; 9 - 10 - // runs everytime there's a new request 11 - export const handle: Handle = async ({ event, resolve }) => { 12 - const sid = event.cookies.get("sid"); 13 - 14 - // if there is a session cookie 15 - if (sid) { 16 - // if a user is already authed, skip reauthing 17 - if (event.locals.user) { return resolve(event); } 18 - 19 - // decrypt session cookie 20 - const decoded = decodeBase64urlIgnorePadding(sid); 21 - const key = decodeBase64(ENCRYPTION_PASSWORD); 22 - const decrypted = await decryptToString(key, decoded); 23 - 24 - // get oauth session from client using decrypted cookie 25 - const oauthSession = await atclient.restore(decrypted); 26 - 27 - // set the authed agent 28 - const authedAgent = new Agent(oauthSession); 29 - event.locals.authedAgent = authedAgent; 30 - 31 - // set the authed user with decrypted session DID 32 - const user = await authedAgent.getProfile({ actor: decrypted }); 33 - event.locals.user = user.data; 34 - } 35 - 36 - return resolve(event); 37 - }
-30
src/lib/atproto.ts
··· 1 - import { db } from "./server/db"; 2 - import { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 - import { AuthSessionStore, AuthStateStore } from "./stores"; 4 - 5 - import { dev } from "$app/environment"; 6 - 7 - const publicUrl = "https://potatonet.app" 8 - // localhost resolves to either 127.0.0.1 or [::1] (if ipv6) 9 - const url = dev ? "http://[::1]:5173" : publicUrl; 10 - 11 - export const atclient = new NodeOAuthClient({ 12 - stateStore: new AuthStateStore(db), 13 - sessionStore: new AuthSessionStore(db), 14 - clientMetadata: { 15 - client_name: "potatonet-app", 16 - client_id: !dev ? `${publicUrl}/client-metadata.json` 17 - : `http://localhost?redirect_uri=${ 18 - encodeURIComponent(`${url}/oauth/callback`) 19 - }&scope=${ 20 - encodeURIComponent(`atproto transition:generic`) 21 - }`, 22 - client_uri: url, 23 - redirect_uris: [`${url}/oauth/callback`], 24 - scope: "atproto transition:generic", 25 - grant_types: ["authorization_code", "refresh_token"], 26 - application_type: "web", 27 - token_endpoint_auth_method: "none", 28 - dpop_bound_access_tokens: true 29 - } 30 - });
-50
src/lib/components/BookmarkCard.svelte
··· 1 - <script lang="ts"> 2 - import TagPill from "./TagPill.svelte"; 3 - import type { LexiconCommunityBookmark } from "$lib/utils"; 4 - 5 - type BookmarkCardProps = { 6 - isOwner?: boolean; 7 - bookmark: LexiconCommunityBookmark; 8 - onTagClick: (tag: string) => void; 9 - onTagDeleteClick?: (tag: string) => void; 10 - }; 11 - 12 - let { isOwner = false, bookmark, onTagClick, onTagDeleteClick }: BookmarkCardProps = $props(); 13 - </script> 14 - 15 - <span class="flex border-3 border-double w-full rounded hover:shadow-lg"> 16 - <article class="flex flex-col gap-4 px-4 py-3 w-full h-fit"> 17 - <div class="flex gap-4 items-center"> 18 - {#if bookmark.$enriched?.favicon} 19 - <img src={bookmark.$enriched.favicon} alt={bookmark.$enriched.title} class="size-8 bg-neutral-300 rounded p-1" /> 20 - {/if} 21 - <h1 class="font-semibold">{bookmark.$enriched?.title}</h1> 22 - </div> 23 - 24 - <a href={bookmark.subject} class="break-all hover:underline underline-offset-4 hover:cursor-pointer text-xl visited:text-violet-600"> 25 - {bookmark.subject} 26 - </a> 27 - {#if bookmark.$enriched?.description} 28 - <p>{bookmark.$enriched.description}</p> 29 - {/if} 30 - {#if bookmark.tags && bookmark.tags.length > 0} 31 - <div class="flex gap-5 flex-wrap"> 32 - {#each bookmark.tags as tag} 33 - <TagPill {tag} showDeleteButton={isOwner} {onTagClick} {onTagDeleteClick} /> 34 - {/each} 35 - </div> 36 - {:else} 37 - <p class="text-sm italic">No tags</p> 38 - {/if} 39 - </article> 40 - 41 - <nav class="w-fit border-l grid grid-rows-3 divide-y-1"> 42 - <button class="px-4">💛</button> 43 - <button class="px-4">💬</button> 44 - {#if isOwner} 45 - <button class="px-4">🗑️</button> 46 - {:else} 47 - <button class="px-4">🔖</button> 48 - {/if} 49 - </nav> 50 - </span>
-39
src/lib/components/TagPill.svelte
··· 1 - <script lang="ts"> 2 - type TagPillProps = { 3 - tag: string; 4 - variant?: "menu"; 5 - showDeleteButton?: boolean; 6 - onTagClick?: (tag: string) => void; 7 - onTagDeleteClick?: (tag: string) => void; 8 - } 9 - 10 - let { tag, variant, showDeleteButton, onTagClick, onTagDeleteClick }: TagPillProps = $props(); 11 - </script> 12 - 13 - <div class="relative group flex w-fit"> 14 - {#if showDeleteButton && variant !== "menu"} 15 - <button 16 - onclick={() => onTagDeleteClick?.(tag)} 17 - class="absolute -right-3 -top-3 lg:group-hover:block hover:cursor-pointer hidden bg-white hover:bg-red-500/20 text-white text-xs px-1 py-0.5" 18 - > 19 - 20 - </button> 21 - {/if} 22 - <button 23 - onclick={() => onTagClick?.(tag)} 24 - class={[ 25 - variant === "menu" && "hover:bg-red-300", 26 - "bg-gray-200 w-fit px-2 py-1 hover:cursor-pointer text-sm" 27 - ]} 28 - > 29 - {tag} 30 - </button> 31 - {#if showDeleteButton} 32 - <button 33 - onclick={() => onTagDeleteClick?.(tag)} 34 - class="lg:hidden text-xs px-1.5 py-0.5 border-2 border-gray-200" 35 - > 36 - 37 - </button> 38 - {/if} 39 - </div>
-56
src/lib/server/api.ts
··· 1 - import { SLICES_BEARER_TOKEN, SLICES_NETWORK_ACCESS_TOKEN } from "$env/static/private"; 2 - import type { LexiconCommunityBookmark, SliceItem, SliceList } from "$lib/utils"; 3 - 4 - const SLICES_NETWORK_SLICE_URI = "at://did:plc:gotnvwkr56ibs33l4hwgfoet/network.slices.slice/3m26tswgbi42i" 5 - 6 - const baseUrl = "https://slices-api.fly.dev/xrpc/"; 7 - 8 - type GetListProps = { 9 - limit?: number; // default: 50, max: 100 10 - cursor?: string | null; 11 - where?: { 12 - [key: string]: { eq?: string, contains?: string, in?: string[] } 13 - }; 14 - sortBy?: { field: string, direction: "desc" | "asc" }[] 15 - }; 16 - 17 - export class SlicesAPI<T> { 18 - 19 - collection: string; 20 - sliceUri: string; 21 - 22 - constructor({ collection, sliceUri }: { collection: string, sliceUri : string }) { 23 - this.collection = collection; 24 - this.sliceUri = sliceUri; 25 - } 26 - 27 - /** 28 - async getRecord({ uri }: { uri: string }) { 29 - const response = await fetch(`${baseUrl}${this.collection}.getRecord?${searchParams.toString()}`); 30 - return await response.json() as SliceItem<T>; 31 - } 32 - **/ 33 - 34 - async getList(body: GetListProps) { 35 - const response = await fetch(`${baseUrl}${this.collection}.getRecords`, { 36 - method: "POST", 37 - headers: { 38 - // "Accept": "*/*", 39 - "Content-Type": "application/json", 40 - // "Authorization": `Bearer ${SLICES_BEARER_TOKEN}` 41 - }, 42 - body: JSON.stringify({ ...body, slice: SLICES_NETWORK_SLICE_URI }) 43 - }); 44 - const data = await response.json() as SliceList<T>; 45 - for (const d of data.records) { 46 - console.log(d); 47 - } 48 - console.log(data.cursor); 49 - return data; 50 - } 51 - } 52 - 53 - export const LexiconBookmarkSlicesAPI = new SlicesAPI<LexiconCommunityBookmark>({ 54 - collection: "community.lexicon.bookmarks.bookmark", 55 - sliceUri: SLICES_NETWORK_SLICE_URI 56 - });
-10
src/lib/server/db/index.ts
··· 1 - import { drizzle } from 'drizzle-orm/postgres-js'; 2 - import postgres from 'postgres'; 3 - import { env } from '$env/dynamic/private'; 4 - import * as schema from "./schema"; 5 - 6 - if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); 7 - const client = postgres(env.DATABASE_URL); 8 - 9 - // add schema 10 - export const db = drizzle(client, { schema });
-11
src/lib/server/db/schema.ts
··· 1 - import { pgTable, text, json } from 'drizzle-orm/pg-core'; 2 - 3 - export const AuthState = pgTable('auth_state', { 4 - key: text('key').primaryKey().unique(), 5 - state: json('state').notNull() 6 - }); 7 - 8 - export const AuthSession = pgTable('auth_session', { 9 - key: text('key').primaryKey().unique(), 10 - session: json('session').notNull() 11 - });
-49
src/lib/server/encryption.ts
··· 1 - // Code by @pilcrowonpaper on GitHub: https://gist.github.com/pilcrowonpaper/353318556029221c8e25f451b91e5f76 2 - // AES128 with the Web Crypto API. 3 - async function encrypt(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> { 4 - const iv = new Uint8Array(16); 5 - crypto.getRandomValues(iv); 6 - const cryptoKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt"]); 7 - const cipher = await crypto.subtle.encrypt( 8 - { 9 - name: "AES-GCM", 10 - iv, 11 - tagLength: 128 12 - }, 13 - cryptoKey, 14 - data 15 - ); 16 - const encrypted = new Uint8Array(iv.byteLength + cipher.byteLength); 17 - encrypted.set(iv); 18 - encrypted.set(new Uint8Array(cipher), iv.byteLength); 19 - return encrypted; 20 - } 21 - 22 - export async function encryptString(key: Uint8Array, data: string): Promise<Uint8Array> { 23 - const encoded = new TextEncoder().encode(data); 24 - const encrypted = await encrypt(key, encoded); 25 - return encrypted; 26 - } 27 - 28 - async function decrypt(key: Uint8Array, encrypted: Uint8Array): Promise<Uint8Array> { 29 - if (encrypted.length < 16) { 30 - throw new Error("Invalid data"); 31 - } 32 - const cryptoKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["decrypt"]); 33 - const decrypted = await crypto.subtle.decrypt( 34 - { 35 - name: "AES-GCM", 36 - iv: encrypted.slice(0, 16), 37 - tagLength: 128 38 - }, 39 - cryptoKey, 40 - encrypted.slice(16) 41 - ); 42 - return new Uint8Array(decrypted); 43 - } 44 - 45 - export async function decryptToString(key: Uint8Array, data: Uint8Array): Promise<string> { 46 - const decrypted = await decrypt(key, data); 47 - const decoded = new TextDecoder().decode(decrypted); 48 - return decoded; 49 - }
-63
src/lib/stores.ts
··· 1 - import { eq } from "drizzle-orm"; 2 - import { db as database } from "./server/db"; 3 - import * as schema from "./server/db/schema"; 4 - import type { NodeSavedSession, NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore } from "@atproto/oauth-client-node"; 5 - 6 - // can be implemented with your preferred DB and ORM 7 - // both stores are the same, only different is 'state' and 'session' 8 - 9 - export class AuthStateStore implements NodeSavedStateStore { 10 - constructor(private db: typeof database) {} 11 - 12 - async get(key: string): Promise<NodeSavedState | undefined> { 13 - const result = await this.db.query.AuthState.findFirst({ 14 - where: eq(schema.AuthState.key, key) 15 - }); 16 - 17 - if (!result) return; 18 - 19 - return result.state as NodeSavedState; 20 - } 21 - 22 - async set(key: string, val: NodeSavedState) { 23 - await this.db.insert(schema.AuthState) 24 - .values({ key, state: val }) 25 - .onConflictDoUpdate({ 26 - target: schema.AuthState.key, 27 - set: { state: val } 28 - }); 29 - } 30 - 31 - async del(key: string) { 32 - await this.db.delete(schema.AuthState) 33 - .where(eq(schema.AuthState.key, key)); 34 - } 35 - } 36 - 37 - export class AuthSessionStore implements NodeSavedSessionStore { 38 - constructor(private db: typeof database) {} 39 - 40 - async get(key: string): Promise<NodeSavedSession | undefined> { 41 - const result = await this.db.query.AuthSession.findFirst({ 42 - where: eq(schema.AuthSession.key, key) 43 - }); 44 - 45 - if (!result) return; 46 - return result.session as NodeSavedSession; 47 - } 48 - 49 - async set(key: string, val: NodeSavedSession) { 50 - await this.db.insert(schema.AuthSession) 51 - .values({ key, session: val }) 52 - .onConflictDoUpdate({ 53 - target: schema.AuthSession.key, 54 - set: { session: val } 55 - }); 56 - } 57 - 58 - async del(key: string) { 59 - await this.db.delete(schema.AuthSession) 60 - .where(eq(schema.AuthSession.key, key)); 61 - } 62 - } 63 -
+2 -34
src/lib/utils.ts
··· 1 1 // --- UTILITIES --- 2 - export type CommonSliceFields = { 3 - indexedAt: string; 4 - cid: string; 5 - uri: string; 6 - collection: string; 7 - } 8 - 9 - export type LexiconCommunityBookmark = { 10 - $type: "community.lexicon.bookmarks.bookmark"; 11 - subject: string; 12 - createdAt: string; 13 - tags?: string[]; 14 - $enriched?: { 15 - description: string; 16 - favicon: string; 17 - title: string; 18 - } 19 - }; 20 - 21 - export type LexiconCommunityLike = { 22 - $type: "community.lexicon.interaction.like"; 23 - subject: string; 24 - createdAt: string; 25 - } 26 - 27 - export type SliceItem<T> = CommonSliceFields & { value: T }; 28 - 29 - export type SliceList<T> = { 30 - cursor: string; 31 - records: (CommonSliceFields & { did: string, value: T })[]; 32 - } 33 - 34 2 export function parseAtUri(uri: string) { 35 3 const regex = /at:\/\/(?<did>did.*)\/(?<lexi>.*)\/(?<rkey>.*)/; 36 4 const groups = regex.exec(uri)?.groups; ··· 42 10 } 43 11 44 12 export async function resolveHandle(handle: string) { 45 - const result = await fetch(`https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`) 13 + const result = await fetch(`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}`) 46 14 const info = await result.json(); 47 - return info.did; 15 + return info; 48 16 }
-9
src/routes/+layout.server.ts
··· 1 - import type { ServerLoadEvent } from "@sveltejs/kit"; 2 - 3 - export async function load({ locals }: ServerLoadEvent) { 4 - // have user available throughout the app via LayoutData 5 - return !locals.user ? undefined : { user: { 6 - did: locals.user.did, 7 - handle: locals.user.handle 8 - }}; 9 - }
+29 -19
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import { page } from '$app/state'; 3 2 import '../app.css'; 4 - import { browser } from '$app/environment'; 3 + import { page } from '$app/state'; 5 4 import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query"; 6 5 7 6 let { data, children } = $props(); 8 - const user = $derived(data.user); 7 + const { atclient, user } = data; 8 + 9 + let handleInput = $state(""); 10 + 11 + async function login() { 12 + if (handleInput) { 13 + await atclient.loginWithRedirect({ handle: handleInput }); 14 + } 15 + } 16 + 17 + async function logout() { 18 + await atclient.logout(); 19 + } 20 + 21 + 9 22 const queryClient = new QueryClient({ 10 23 defaultOptions: { 11 24 queries: { ··· 26 39 <a href="https://tangled.sh/@zeu.dev/potatonet-app" class="hover:text-shadow-lg" title="source code" aria-label="source code">🧶 source code</a> 27 40 {#if user} 28 41 <a href={`/${user.handle}/bookmarks`} class="hover:text-shadow-lg" aria-label="logged in user's bookmarks">🔖 your bookmarks</a> 42 + <p>{user.handle}</p> 29 43 {/if} 30 44 </nav> 31 45 {#if user} 32 - <form action="/?/logout" method="POST"> 33 - <button type="submit" class="hover:text-shadow-lg hover:cursor-pointer"> 34 - Logout 35 - </button> 36 - </form> 46 + <button onclick={logout} class="hover:text-shadow-lg hover:cursor-pointer"> 47 + Logout 48 + </button> 37 49 {:else} 38 - <form action="/?/login" method="POST" class="flex gap-4 lg:basis-0"> 39 - <input 40 - name="handle" 41 - type="text" 42 - placeholder="Handle (eg: zeu.dev)" 43 - class="border border-black border-dashed text-sm px-3 py-2 hover:shadow-lg focus:shadow-lg" 44 - /> 45 - <button type="submit" class="bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 46 - Login 47 - </button> 48 - </form> 50 + <input 51 + type="text" 52 + bind:value={handleInput} 53 + placeholder="Handle (eg: zeu.dev)" 54 + class="border border-black border-dashed text-sm px-3 py-2 hover:shadow-lg focus:shadow-lg" 55 + /> 56 + <button onclick={login} class="bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 57 + Login 58 + </button> 49 59 {/if} 50 60 </div> 51 61 </header>
+29
src/routes/+layout.ts
··· 1 + import { redirect } from "@sveltejs/kit"; 2 + import { createQuicksliceClient, QuicksliceClient } from "quickslice-client-js"; 3 + import type { LayoutLoadEvent } from "./$types"; 4 + import { resolveHandle } from "$lib/utils"; 5 + 6 + export const ssr = false; 7 + 8 + export const load = async ({ url }: LayoutLoadEvent) => { 9 + const atclient = await createQuicksliceClient({ 10 + server: "https://admin.potatonet.app", 11 + clientId: "client_HYu7ocYtdMWtlOrEhgjpBA" 12 + }); 13 + 14 + if (url.searchParams.has("code")) { 15 + await atclient.handleRedirectCallback(); 16 + redirect(302, "/"); 17 + } 18 + 19 + const isAuthed = await atclient.isAuthenticated(); 20 + if (isAuthed) { 21 + const user = await atclient.getUser(); 22 + if (user) { 23 + const info = await resolveHandle(user.did); 24 + return { atclient, user: info } as { atclient: QuicksliceClient, user: Record<string, string> | undefined } 25 + } 26 + } 27 + 28 + return { atclient, user: undefined } as { atclient: QuicksliceClient, user: Record<string, string> | undefined } 29 + }
-32
src/routes/+page.server.ts
··· 1 - import { atclient } from "$lib/atproto"; 2 - import { isValidHandle } from "@atproto/syntax"; 3 - import { error, redirect, type Actions } from "@sveltejs/kit"; 4 - 5 - export const actions: Actions = { 6 - login: async ({ request }) => { 7 - // get handle from form 8 - const formData = await request.formData(); 9 - const handle = formData.get("handle") as string; 10 - 11 - // validate handle using ATProto SDK 12 - if (!isValidHandle(handle)) { 13 - error(400, { message: "Invalid handle" }); 14 - } 15 - 16 - // get oauth authorizing url to redirect to 17 - const redirectUrl = await atclient.authorize(handle, { 18 - scope: "atproto transition:generic" 19 - }); 20 - 21 - if (!redirectUrl) { 22 - error(500, { message: "Unable to authorize" }); 23 - } 24 - 25 - // redirect for user to authorize 26 - redirect(301, redirectUrl.toString()); 27 - }, 28 - logout: async ({ cookies }) => { 29 - cookies.delete("sid", { path: "/" }); 30 - redirect(301, "/"); 31 - } 32 - };
-89
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import BookmarkCard from "$lib/components/BookmarkCard.svelte"; 3 - import TagPill from "$lib/components/TagPill.svelte"; 4 - import { createInfiniteQuery } from "@tanstack/svelte-query"; 5 - import { getAllBookmarks } from "./api/bookmarks/data.remote"; 6 - 7 2 let { data } = $props(); 8 3 let query = $state(""); 9 4 let filterTags = $state<string[]>([]); 10 - 11 - let bookmarkPage = $state(0); 12 - const exploreBookmarksQuery = createInfiniteQuery(() => ({ 13 - queryKey: ["explore"], 14 - queryFn: ({ pageParam }) => getAllBookmarks({ cursor: pageParam }), 15 - initialPageParam: "", 16 - getNextPageParam: (lastPage) => lastPage.cursor, 17 - select: (data) => data.pages.map((page) => page.list).flat(), 18 - staleTime: 600 19 - })); 20 - let bookmarks = $derived(exploreBookmarksQuery.data ?? []); 21 - 22 - function onTagClick(tag: string) { 23 - const index = filterTags.findIndex((t) => t.toLowerCase() === tag.toLowerCase()); 24 - if (index >= 0) { filterTags.splice(index, 1); } 25 - else { filterTags.push(tag.toLowerCase()); 26 - } 27 - } 28 - 29 - function onTagDeleteClick(tag: string) { 30 - console.log("DELETE", tag); 31 - } 32 - 33 - $inspect(bookmarkPage, bookmarks.slice(bookmarkPage*50)); 34 5 </script> 35 - 36 - <div class="flex gap-4 items-center"> 37 - <h1 class="text-2xl lg:text-3xl">Explore</h1> 38 - </div> 39 - 40 - <menu class="flex flex-col lg:flex-row w-full gap-4"> 41 - <label class="flex items-center gap-2"> 42 - Search URLs: 43 - <input type="text" bind:value={query} class="border border-black border-dashed text-sm px-3 py-2 hover:shadow-lg focus:shadow-lg" placeholder="recipe" /> 44 - </label> 45 - 46 - <label class="flex items-center gap-2"> 47 - Tags: 48 - {#if filterTags.length === 0} 49 - <TagPill tag="all" /> 50 - {:else} 51 - {#each filterTags as filtered} 52 - <TagPill showDeleteButton tag={filtered} {onTagClick} onTagDeleteClick={onTagClick} variant="menu" /> 53 - {/each} 54 - {/if} 55 - </label> 56 - 57 - <button onclick={() => { exploreBookmarksQuery.fetchPreviousPage(); bookmarkPage--; }} disabled={!exploreBookmarksQuery.hasPreviousPage}> 58 - Prev Page 59 - </button> 60 - <button onclick={() => { exploreBookmarksQuery.fetchNextPage(); bookmarkPage++; }} disabled={!exploreBookmarksQuery.hasNextPage}> 61 - Next Page 62 - </button> 63 - 64 - {#if data.user} 65 - <button class="justify-self-end bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 66 - 🔖 New Bookmark 67 - </button> 68 - {/if} 69 - 70 - </menu> 71 - <hr /> 72 - 73 - {#if exploreBookmarksQuery.isPending} 74 - <p>Loading...</p> 75 - {:else if exploreBookmarksQuery.isError} 76 - <p>Error</p> 77 - {:else if exploreBookmarksQuery.isSuccess} 78 - <div class="flex flex-wrap gap-4"> 79 - {#if bookmarks} 80 - {@const pagedBookmarks = bookmarks.slice(bookmarkPage*50)} 81 - {#each pagedBookmarks as info} 82 - {@const bookmark = info.bookmark} 83 - {#if bookmark.subject.includes(query)} 84 - {#if (bookmark.tags && bookmark.tags.length > 0 85 - && bookmark.tags.some(t => filterTags.length > 0 ? filterTags.includes(t.toLowerCase()) : true) 86 - ) 87 - || (bookmark.tags && bookmark.tags.length === 0 && filterTags.length === 0)} 88 - <BookmarkCard {bookmark} {onTagClick} {onTagDeleteClick} /> 89 - {/if} 90 - {/if} 91 - {/each} 92 - {/if} 93 - </div> 94 - {/if}
-94
src/routes/[handle]/bookmarks/+page.svelte
··· 1 1 <script lang="ts"> 2 - import BookmarkCard from "$lib/components/BookmarkCard.svelte"; 3 - import TagPill from "$lib/components/TagPill.svelte"; 4 - import { createInfiniteQuery } from "@tanstack/svelte-query"; 5 - import { getUserBookmarks } from "../../api/bookmarks/data.remote.js"; 6 - import { page } from "$app/state"; 7 - 8 - 9 - let { data } = $props(); 10 - let query = $state(""); 11 - let filterTags = $state<string[]>([]); 12 - 13 - let bookmarkPage = $state(0); 14 - const userBookmarksQuery = createInfiniteQuery(() => ({ 15 - queryKey: ["user", page.params.handle], 16 - queryFn: ({ pageParam }) => getUserBookmarks({ handle: page.params.handle!, cursor: pageParam }), 17 - initialPageParam: "", 18 - getNextPageParam: (lastPage) => lastPage.cursor, 19 - select: (data) => data.pages.map((page) => page.list).flat(), 20 - staleTime: 600 21 - })); 22 - let bookmarks = $derived(userBookmarksQuery.data ?? []); 23 - 24 - function onTagClick(tag: string) { 25 - const index = filterTags.findIndex((t) => t.toLowerCase() === tag.toLowerCase()); 26 - if (index >= 0) { filterTags.splice(index, 1); } 27 - else { filterTags.push(tag.toLowerCase()); 28 - } 29 - } 30 - 31 - function onTagDeleteClick(tag: string) { 32 - console.log("DELETE", tag); 33 - } 34 - 35 - $inspect(bookmarkPage, bookmarks.slice(bookmarkPage*50)); 36 2 </script> 37 - 38 - <div class="flex gap-4 items-center"> 39 - <h1 class="text-2xl lg:text-3xl">Bookmarks by {page.params.handle}</h1> 40 - </div> 41 - 42 - <menu class="flex flex-col lg:flex-row w-full gap-4"> 43 - <label class="flex items-center gap-2"> 44 - Search URLs: 45 - <input type="text" bind:value={query} class="border border-black border-dashed text-sm px-3 py-2 hover:shadow-lg focus:shadow-lg" placeholder="recipe" /> 46 - </label> 47 - 48 - <label class="flex items-center gap-2"> 49 - Tags: 50 - {#if filterTags.length === 0} 51 - <TagPill tag="all" /> 52 - {:else} 53 - {#each filterTags as filtered} 54 - <TagPill showDeleteButton tag={filtered} {onTagClick} onTagDeleteClick={onTagClick} variant="menu" /> 55 - {/each} 56 - {/if} 57 - </label> 58 - 59 - <button onclick={() => { userBookmarksQuery.fetchPreviousPage(); bookmarkPage--; }} disabled={!userBookmarksQuery.hasPreviousPage}> 60 - Prev Page 61 - </button> 62 - <button onclick={() => { userBookmarksQuery.fetchNextPage(); bookmarkPage++; }} disabled={!userBookmarksQuery.hasNextPage}> 63 - Next Page 64 - </button> 65 - 66 - {#if data.user} 67 - <button class="justify-self-end bg-amber-400 text-black hover:cursor-pointer hover:bg-amber-500 hover:text-white px-4 py-2"> 68 - 🔖 New Bookmark 69 - </button> 70 - {/if} 71 - 72 - </menu> 73 - <hr /> 74 - 75 - {#if userBookmarksQuery.isPending} 76 - <p>Loading...</p> 77 - {:else if userBookmarksQuery.isError} 78 - <p>Error</p> 79 - {:else if userBookmarksQuery.isSuccess} 80 - <div class="flex flex-wrap gap-4"> 81 - {#if bookmarks} 82 - {@const pagedBookmarks = bookmarks.slice(bookmarkPage*50)} 83 - {#each pagedBookmarks as info} 84 - {@const bookmark = info.bookmark} 85 - {#if bookmark.subject.includes(query)} 86 - {#if (bookmark.tags && bookmark.tags.length > 0 87 - && bookmark.tags.some(t => filterTags.length > 0 ? filterTags.includes(t.toLowerCase()) : true) 88 - ) 89 - || (bookmark.tags && bookmark.tags.length === 0 && filterTags.length === 0)} 90 - <BookmarkCard {bookmark} {onTagClick} {onTagDeleteClick} /> 91 - {/if} 92 - {/if} 93 - {/each} 94 - {/if} 95 - </div> 96 - {/if}
-41
src/routes/api/bookmarks/data.remote.ts
··· 1 - import * as v from "valibot"; 2 - import { query } from "$app/server" 3 - import { LexiconBookmarkSlicesAPI } from "$lib/server/api" 4 - 5 - const GetUserBookmarksValidator = v.object({ 6 - handle: v.string(), 7 - cursor: v.optional(v.string()) 8 - }); 9 - 10 - export const getUserBookmarks = query(GetUserBookmarksValidator, async ({ handle, cursor }) => { 11 - const result = await fetch(`https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`) 12 - const info = await result.json(); 13 - 14 - if (!info) { throw Error(); } 15 - 16 - const data = await LexiconBookmarkSlicesAPI.getList({ 17 - cursor: !cursor ? null : cursor, 18 - where: { 19 - did: { eq: info.did } 20 - } 21 - }); 22 - 23 - console.log(info); 24 - 25 - return { cursor: data.cursor, list: data.records.map((r) => { 26 - return { did: r.did, bookmark: r.value } 27 - })}; 28 - }); 29 - 30 - 31 - const GetAllBookmarksValidator = v.object({ 32 - cursor: v.optional(v.string()) 33 - }); 34 - 35 - export const getAllBookmarks = query(GetAllBookmarksValidator, async ({ cursor }) => { 36 - const data = await LexiconBookmarkSlicesAPI.getList({ cursor }); 37 - 38 - return { cursor: data.cursor, list: data.records.map((r) => { 39 - return { did: r.did, bookmark: r.value } 40 - })}; 41 - });
-11
src/routes/api/metadata.remote.ts
··· 1 - import * as v from "valibot"; 2 - import ogs from "open-graph-scraper"; 3 - import { query } from "$app/server"; 4 - import { error } from "@sveltejs/kit"; 5 - 6 - export const getMetadata = query(v.string(), async (url) => { 7 - if (url === "/") { return error(401); } 8 - const response = await ogs({ url }); 9 - if (response.error) { return error(404); } 10 - return response.result; 11 - });
-6
src/routes/client-metadata.json/+server.ts
··· 1 - import { atclient } from "$lib/atproto"; 2 - import { json } from "@sveltejs/kit"; 3 - 4 - export async function GET() { 5 - return json(atclient.clientMetadata); 6 - }
-34
src/routes/oauth/callback/+server.ts
··· 1 - import { atclient } from "$lib/atproto"; 2 - import { encryptString } from "$lib/server/encryption"; 3 - import { decodeBase64, encodeBase64urlNoPadding } from "@oslojs/encoding"; 4 - 5 - import { error, redirect } from "@sveltejs/kit"; 6 - import type { RequestEvent } from "@sveltejs/kit"; 7 - import { ENCRYPTION_PASSWORD } from "$env/static/private"; 8 - 9 - // called on after authorizing OAuth 10 - export async function GET({ request, cookies }: RequestEvent) { 11 - // get parameters set by the callback 12 - const params = new URLSearchParams(request.url.split("?")[1]); 13 - 14 - try { 15 - const { session } = await atclient.callback(params); 16 - const key = decodeBase64(ENCRYPTION_PASSWORD); 17 - 18 - // encrypt the user DID 19 - const encrypted = await encryptString(key, session.did); 20 - const encoded = encodeBase64urlNoPadding(encrypted); 21 - 22 - // set encoded session DID as cookies for auth 23 - cookies.set("sid", encoded, { 24 - path: "/", 25 - maxAge: 60 * 60, 26 - httpOnly: true, 27 - sameSite: "lax" 28 - }); 29 - } catch (err) { 30 - error(500, { message: (err as Error).message }); 31 - } 32 - 33 - redirect(301, `/`); 34 - }