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