bun.lockb
bun.lockb
This is a binary file and will not be displayed.
+1
package.json
+1
package.json
-37
src/hooks.server.ts
-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
-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
-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
-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
-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
-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
-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
-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
-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
+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
-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
+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
+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
-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
+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
-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
-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
-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
-6
src/routes/client-metadata.json/+server.ts
-34
src/routes/oauth/callback/+server.ts
-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
+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
+