Read-it-later social network

Compare changes

Choose any two refs to compare.

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 -
+29 -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 } 17 + 18 + export type Node = { 19 + uri: string; 20 + cid: string; 21 + did: string; 22 + indexedAt: string; 23 + actorHandle: string; 24 + } 25 + 26 + export type PublicationNode = Node & { value: { 27 + url: string; 28 + name: string; 29 + description: string; 30 + }} 31 + 32 + export type DocumentNode = Node & { value: { 33 + title: string; 34 + site: string; 35 + publishedAt: string; 36 + path?: string; 37 + content?: string; 38 + bskyPostRef?: string; 39 + description?: string; 40 + textContent?: string; 41 + tags?: string[]; 42 + updatedAt?: string; 43 + }}
-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 - };
+44 -85
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"; 2 + import type { PublicationNode } from '$lib/utils'; 3 + import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query'; 6 4 7 5 let { data } = $props(); 8 - let query = $state(""); 9 - let filterTags = $state<string[]>([]); 10 - 11 - let bookmarkPage = $state(0); 12 - const exploreBookmarksQuery = createInfiniteQuery(() => ({ 13 - queryKey: ["explore"], 14 - queryFn: ({ pageParam }) => getAllBookmarks({ cursor: pageParam }), 6 + let { atclient, user } = data; 7 + 8 + const publicationsQuery = createInfiniteQuery(() => ({ 9 + queryKey: ["publications"], 10 + queryFn: async ({ pageParam }) => { 11 + const query = ` 12 + query GetPublications { 13 + siteStandardPublication(first: 20, after: "${pageParam}") { 14 + edges {} 15 + pageInfo { 16 + hasNextPage 17 + endCursor 18 + } 19 + } 20 + } 21 + `; 22 + const data = await atclient.publicQuery(query); 23 + return data as { 24 + siteStandardPublication: { 25 + edges: { node: PublicationNode, cursor: string }[], 26 + pageInfo: { 27 + hasNextPage: boolean; 28 + endCursor: string; 29 + } 30 + } 31 + } 32 + }, 33 + staleTime: 1000000, 15 34 initialPageParam: "", 16 - getNextPageParam: (lastPage) => lastPage.cursor, 17 - select: (data) => data.pages.map((page) => page.list).flat(), 18 - staleTime: 600 35 + getNextPageParam: (lastPage) => lastPage.siteStandardPublication.pageInfo.endCursor 19 36 })); 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 37 </script> 35 38 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> 39 + {#if publicationsQuery.isFetching} 40 + <p>Fetching...</p> 41 + {:else if publicationsQuery.isError} 42 + <p>Error</p> 43 + {:else if publicationsQuery.isSuccess} 44 + {@const publications = publicationsQuery.data.pages.map((p) => p.siteStandardPublication.edges.map((edge) => edge.node)).flat()} 45 + {#each publications as publication} 46 + <a href={`/pub?uri=${publication.uri}`}>{publication.uri}</a> 47 + <p>{publication.value.url}</p> 48 + {/each} 49 + {#if publicationsQuery.hasNextPage} 50 + <button onclick={() => publicationsQuery.fetchNextPage()}>Next Page</button> 68 51 {/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 52 {/if} 53 +
-96
src/routes/[handle]/bookmarks/+page.svelte
··· 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 - </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 - }
+61
src/routes/pub/+page.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + import type { DocumentNode, PublicationNode } from '$lib/utils'; 4 + import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query'; 5 + 6 + let { data } = $props(); 7 + let { atclient, user } = data; 8 + 9 + let uri = $derived(page.url.searchParams.get("uri")); 10 + $inspect(uri); 11 + 12 + const documentsQuery = createQuery(() => ({ 13 + queryKey: ["documents", uri], 14 + queryFn: async ({ pageParam }) => { 15 + const query = ` 16 + query GetDocuments { 17 + siteStandardDocument(where: { 18 + site: { 19 + eq: "${uri}" 20 + } 21 + }) { 22 + edges {} 23 + pageInfo { 24 + hasNextPage 25 + endCursor 26 + } 27 + } 28 + } 29 + `; 30 + const data = await atclient.publicQuery(query); 31 + console.log(data); 32 + return data as { 33 + siteStandardDocument: { 34 + edges: { node: DocumentNode, cursor: string }[], 35 + pageInfo: { 36 + hasNextPage: boolean; 37 + endCursor: string; 38 + } 39 + } 40 + } 41 + }, 42 + // @ts-ignore 43 + select: (data) => data.siteStandardDocument.edges.map((edge) => edge.node) 44 + })); 45 + </script> 46 + 47 + {#if documentsQuery.isFetching} 48 + <p>Fetching...</p> 49 + {:else if documentsQuery.isError} 50 + <p>Error</p> 51 + {:else if documentsQuery.isSuccess} 52 + {@const documents = documentsQuery.data} 53 + {#if documents.length === 0} 54 + <p>No documents...</p> 55 + {:else} 56 + {#each documents as document} 57 + <p>{document.value.title}</p> 58 + {/each} 59 + {/if} 60 + {/if} 61 +