tangled
alpha
login
or
join now
baileytownsend.dev
/
at-poke
forked from
baileytownsend.dev/atproto-sveltekit-template
1
fork
atom
WIP: Another at:// production from me
1
fork
atom
overview
issues
pulls
pipelines
wip
baileytownsend.dev
2 months ago
48ff829e
72e257fe
+500
-1
7 changed files
expand all
collapse all
unified
split
src
lib
components
InteractionBar.svelte
LeafletDocumentCard.svelte
MusicPlayCard.svelte
TangledRepoCard.svelte
routes
+layout.svelte
feed
+page.server.ts
+page.svelte
+91
src/lib/components/InteractionBar.svelte
···
1
1
+
<script lang="ts">
2
2
+
let {
3
3
+
likeCount = 0,
4
4
+
repostCount = 0,
5
5
+
commentCount = 0,
6
6
+
onlike,
7
7
+
onrepost,
8
8
+
oncomment
9
9
+
}: {
10
10
+
likeCount?: number;
11
11
+
repostCount?: number;
12
12
+
commentCount?: number;
13
13
+
onlike?: () => void;
14
14
+
onrepost?: () => void;
15
15
+
oncomment?: () => void;
16
16
+
} = $props();
17
17
+
</script>
18
18
+
19
19
+
<div class="flex gap-4 mt-4">
20
20
+
<button
21
21
+
class="btn btn-ghost btn-sm gap-2"
22
22
+
onclick={onlike}
23
23
+
aria-label="Like"
24
24
+
>
25
25
+
<svg
26
26
+
xmlns="http://www.w3.org/2000/svg"
27
27
+
class="h-5 w-5"
28
28
+
fill="none"
29
29
+
viewBox="0 0 24 24"
30
30
+
stroke="currentColor"
31
31
+
>
32
32
+
<path
33
33
+
stroke-linecap="round"
34
34
+
stroke-linejoin="round"
35
35
+
stroke-width="2"
36
36
+
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
37
37
+
/>
38
38
+
</svg>
39
39
+
{#if likeCount > 0}
40
40
+
<span class="text-sm">{likeCount}</span>
41
41
+
{/if}
42
42
+
</button>
43
43
+
44
44
+
<button
45
45
+
class="btn btn-ghost btn-sm gap-2"
46
46
+
onclick={onrepost}
47
47
+
aria-label="Repost"
48
48
+
>
49
49
+
<svg
50
50
+
xmlns="http://www.w3.org/2000/svg"
51
51
+
class="h-5 w-5"
52
52
+
fill="none"
53
53
+
viewBox="0 0 24 24"
54
54
+
stroke="currentColor"
55
55
+
>
56
56
+
<path
57
57
+
stroke-linecap="round"
58
58
+
stroke-linejoin="round"
59
59
+
stroke-width="2"
60
60
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
61
61
+
/>
62
62
+
</svg>
63
63
+
{#if repostCount > 0}
64
64
+
<span class="text-sm">{repostCount}</span>
65
65
+
{/if}
66
66
+
</button>
67
67
+
68
68
+
<button
69
69
+
class="btn btn-ghost btn-sm gap-2"
70
70
+
onclick={oncomment}
71
71
+
aria-label="Comment"
72
72
+
>
73
73
+
<svg
74
74
+
xmlns="http://www.w3.org/2000/svg"
75
75
+
class="h-5 w-5"
76
76
+
fill="none"
77
77
+
viewBox="0 0 24 24"
78
78
+
stroke="currentColor"
79
79
+
>
80
80
+
<path
81
81
+
stroke-linecap="round"
82
82
+
stroke-linejoin="round"
83
83
+
stroke-width="2"
84
84
+
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
85
85
+
/>
86
86
+
</svg>
87
87
+
{#if commentCount > 0}
88
88
+
<span class="text-sm">{commentCount}</span>
89
89
+
{/if}
90
90
+
</button>
91
91
+
</div>
+109
src/lib/components/LeafletDocumentCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import InteractionBar from './InteractionBar.svelte';
3
3
+
4
4
+
let { record } = $props();
5
5
+
6
6
+
const data = $derived(record.data as {
7
7
+
title?: string;
8
8
+
description?: string;
9
9
+
tags?: string[];
10
10
+
author?: string;
11
11
+
publishedAt?: string;
12
12
+
pages?: Array<{
13
13
+
blocks: Array<{
14
14
+
block: {
15
15
+
$type: string;
16
16
+
plaintext?: string;
17
17
+
};
18
18
+
}>;
19
19
+
}>;
20
20
+
});
21
21
+
22
22
+
function formatDate(dateString: string): string {
23
23
+
const date = new Date(dateString);
24
24
+
return new Intl.DateTimeFormat('en-US', {
25
25
+
month: 'short',
26
26
+
day: 'numeric',
27
27
+
year: 'numeric'
28
28
+
}).format(date);
29
29
+
}
30
30
+
31
31
+
function getPreviewText(): string {
32
32
+
if (!data.pages || data.pages.length === 0) return '';
33
33
+
34
34
+
for (const page of data.pages) {
35
35
+
for (const blockItem of page.blocks) {
36
36
+
if (
37
37
+
blockItem.block.$type === 'pub.leaflet.blocks.text' &&
38
38
+
blockItem.block.plaintext &&
39
39
+
blockItem.block.plaintext.trim().length > 0
40
40
+
) {
41
41
+
const text = blockItem.block.plaintext;
42
42
+
return text.length > 200 ? text.slice(0, 200) + '...' : text;
43
43
+
}
44
44
+
}
45
45
+
}
46
46
+
return '';
47
47
+
}
48
48
+
49
49
+
const previewText = getPreviewText();
50
50
+
</script>
51
51
+
52
52
+
<div class="card bg-base-100 shadow-xl">
53
53
+
<div class="card-body">
54
54
+
<div class="flex items-start gap-4">
55
55
+
<div class="flex-shrink-0">
56
56
+
<div class="avatar placeholder">
57
57
+
<div class="bg-accent text-accent-content rounded-lg w-16 h-16">
58
58
+
<svg
59
59
+
xmlns="http://www.w3.org/2000/svg"
60
60
+
class="h-8 w-8"
61
61
+
fill="none"
62
62
+
viewBox="0 0 24 24"
63
63
+
stroke="currentColor"
64
64
+
>
65
65
+
<path
66
66
+
stroke-linecap="round"
67
67
+
stroke-linejoin="round"
68
68
+
stroke-width="2"
69
69
+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
70
70
+
/>
71
71
+
</svg>
72
72
+
</div>
73
73
+
</div>
74
74
+
</div>
75
75
+
76
76
+
<div class="flex-grow">
77
77
+
<h3 class="card-title text-lg mb-2">{data.title || 'Untitled Document'}</h3>
78
78
+
79
79
+
{#if data.description}
80
80
+
<p class="text-base-content/80 mb-2">{data.description}</p>
81
81
+
{/if}
82
82
+
83
83
+
{#if previewText}
84
84
+
<p class="text-sm text-base-content/70 mb-3 line-clamp-3">{previewText}</p>
85
85
+
{/if}
86
86
+
87
87
+
{#if data.tags && data.tags.length > 0}
88
88
+
<div class="flex flex-wrap gap-2 mb-3">
89
89
+
{#each data.tags as tag}
90
90
+
<div class="badge badge-primary badge-sm">#{tag}</div>
91
91
+
{/each}
92
92
+
</div>
93
93
+
{/if}
94
94
+
95
95
+
{#if data.publishedAt}
96
96
+
<div class="flex items-center gap-2 text-sm text-base-content/60">
97
97
+
<span>Published {formatDate(data.publishedAt)}</span>
98
98
+
</div>
99
99
+
{/if}
100
100
+
101
101
+
<InteractionBar
102
102
+
onlike={() => console.log('Liked document')}
103
103
+
onrepost={() => console.log('Reposted document')}
104
104
+
oncomment={() => console.log('Commented on document')}
105
105
+
/>
106
106
+
</div>
107
107
+
</div>
108
108
+
</div>
109
109
+
</div>
+101
src/lib/components/MusicPlayCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import InteractionBar from './InteractionBar.svelte';
3
3
+
4
4
+
let { record } = $props();
5
5
+
6
6
+
const data = $derived(record.data as {
7
7
+
trackName?: string;
8
8
+
artists?: Array<{ artistName: string; artistMbId: string }>;
9
9
+
releaseName?: string;
10
10
+
playedTime?: string;
11
11
+
duration?: number;
12
12
+
originUrl?: string;
13
13
+
musicServiceBaseDomain?: string;
14
14
+
});
15
15
+
16
16
+
function formatDuration(seconds: number): string {
17
17
+
const mins = Math.floor(seconds / 60);
18
18
+
const secs = seconds % 60;
19
19
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
20
20
+
}
21
21
+
22
22
+
function formatDate(dateString: string): string {
23
23
+
const date = new Date(dateString);
24
24
+
return new Intl.DateTimeFormat('en-US', {
25
25
+
month: 'short',
26
26
+
day: 'numeric',
27
27
+
hour: 'numeric',
28
28
+
minute: '2-digit'
29
29
+
}).format(date);
30
30
+
}
31
31
+
</script>
32
32
+
33
33
+
<div class="card bg-base-100 shadow-xl">
34
34
+
<div class="card-body">
35
35
+
<div class="flex items-start gap-4">
36
36
+
<div class="flex-shrink-0">
37
37
+
<div class="avatar placeholder">
38
38
+
<div class="bg-primary text-primary-content rounded-lg w-16 h-16">
39
39
+
<svg
40
40
+
xmlns="http://www.w3.org/2000/svg"
41
41
+
class="h-8 w-8"
42
42
+
fill="none"
43
43
+
viewBox="0 0 24 24"
44
44
+
stroke="currentColor"
45
45
+
>
46
46
+
<path
47
47
+
stroke-linecap="round"
48
48
+
stroke-linejoin="round"
49
49
+
stroke-width="2"
50
50
+
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
51
51
+
/>
52
52
+
</svg>
53
53
+
</div>
54
54
+
</div>
55
55
+
</div>
56
56
+
57
57
+
<div class="flex-grow">
58
58
+
<h3 class="card-title text-lg">{data.trackName || 'Unknown Track'}</h3>
59
59
+
{#if data.artists && data.artists.length > 0}
60
60
+
<p class="text-base-content/70">
61
61
+
{data.artists.map((a) => a.artistName).join(', ')}
62
62
+
</p>
63
63
+
{/if}
64
64
+
{#if data.releaseName}
65
65
+
<p class="text-sm text-base-content/60 mt-1">{data.releaseName}</p>
66
66
+
{/if}
67
67
+
68
68
+
<div class="flex items-center gap-4 mt-2 text-sm text-base-content/60">
69
69
+
{#if data.duration}
70
70
+
<span>{formatDuration(data.duration)}</span>
71
71
+
<span>•</span>
72
72
+
{/if}
73
73
+
{#if data.playedTime}
74
74
+
<span>{formatDate(data.playedTime)}</span>
75
75
+
{/if}
76
76
+
{#if data.musicServiceBaseDomain}
77
77
+
<span>•</span>
78
78
+
<span class="capitalize">{data.musicServiceBaseDomain.split('.')[0]}</span>
79
79
+
{/if}
80
80
+
</div>
81
81
+
82
82
+
{#if data.originUrl}
83
83
+
<a
84
84
+
href={data.originUrl}
85
85
+
target="_blank"
86
86
+
rel="noopener noreferrer"
87
87
+
class="link link-primary text-sm mt-2 inline-block"
88
88
+
>
89
89
+
Listen on {data.musicServiceBaseDomain?.split('.')[0] || 'platform'}
90
90
+
</a>
91
91
+
{/if}
92
92
+
93
93
+
<InteractionBar
94
94
+
onlike={() => console.log('Liked music play')}
95
95
+
onrepost={() => console.log('Reposted music play')}
96
96
+
oncomment={() => console.log('Commented on music play')}
97
97
+
/>
98
98
+
</div>
99
99
+
</div>
100
100
+
</div>
101
101
+
</div>
+101
src/lib/components/TangledRepoCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import InteractionBar from './InteractionBar.svelte';
3
3
+
4
4
+
let { record } = $props();
5
5
+
6
6
+
const data = $derived(record.data as {
7
7
+
name?: string;
8
8
+
description?: string;
9
9
+
knot?: string;
10
10
+
labels?: string[];
11
11
+
source?: string;
12
12
+
createdAt?: string;
13
13
+
});
14
14
+
15
15
+
function formatDate(dateString: string): string {
16
16
+
const date = new Date(dateString);
17
17
+
return new Intl.DateTimeFormat('en-US', {
18
18
+
month: 'short',
19
19
+
day: 'numeric',
20
20
+
year: 'numeric'
21
21
+
}).format(date);
22
22
+
}
23
23
+
24
24
+
function extractLabelName(atUri: string): string {
25
25
+
const parts = atUri.split('/');
26
26
+
return parts[parts.length - 1] || atUri;
27
27
+
}
28
28
+
</script>
29
29
+
30
30
+
<div class="card bg-base-100 shadow-xl">
31
31
+
<div class="card-body">
32
32
+
<div class="flex items-start gap-4">
33
33
+
<div class="flex-shrink-0">
34
34
+
<div class="avatar placeholder">
35
35
+
<div class="bg-secondary text-secondary-content rounded-lg w-16 h-16">
36
36
+
<svg
37
37
+
xmlns="http://www.w3.org/2000/svg"
38
38
+
class="h-8 w-8"
39
39
+
fill="none"
40
40
+
viewBox="0 0 24 24"
41
41
+
stroke="currentColor"
42
42
+
>
43
43
+
<path
44
44
+
stroke-linecap="round"
45
45
+
stroke-linejoin="round"
46
46
+
stroke-width="2"
47
47
+
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
48
48
+
/>
49
49
+
</svg>
50
50
+
</div>
51
51
+
</div>
52
52
+
</div>
53
53
+
54
54
+
<div class="flex-grow">
55
55
+
<div class="flex items-center gap-2">
56
56
+
<h3 class="card-title text-lg">{data.name || 'Unknown Repository'}</h3>
57
57
+
{#if data.knot}
58
58
+
<div class="badge badge-outline badge-sm">{data.knot}</div>
59
59
+
{/if}
60
60
+
</div>
61
61
+
62
62
+
{#if data.description}
63
63
+
<p class="text-base-content/80 mt-2">{data.description}</p>
64
64
+
{/if}
65
65
+
66
66
+
{#if data.labels && data.labels.length > 0}
67
67
+
<div class="flex flex-wrap gap-2 mt-3">
68
68
+
{#each data.labels as label}
69
69
+
<div class="badge badge-ghost badge-sm">
70
70
+
{extractLabelName(label)}
71
71
+
</div>
72
72
+
{/each}
73
73
+
</div>
74
74
+
{/if}
75
75
+
76
76
+
{#if data.createdAt}
77
77
+
<div class="flex items-center gap-2 mt-3 text-sm text-base-content/60">
78
78
+
<span>Created {formatDate(data.createdAt)}</span>
79
79
+
</div>
80
80
+
{/if}
81
81
+
82
82
+
{#if data.source}
83
83
+
<a
84
84
+
href={data.source}
85
85
+
target="_blank"
86
86
+
rel="noopener noreferrer"
87
87
+
class="link link-primary text-sm mt-2 inline-block"
88
88
+
>
89
89
+
View source
90
90
+
</a>
91
91
+
{/if}
92
92
+
93
93
+
<InteractionBar
94
94
+
onlike={() => console.log('Liked repo')}
95
95
+
onrepost={() => console.log('Reposted repo')}
96
96
+
oncomment={() => console.log('Commented on repo')}
97
97
+
/>
98
98
+
</div>
99
99
+
</div>
100
100
+
</div>
101
101
+
</div>
+3
-1
src/routes/+layout.svelte
···
56
56
<li>
57
57
<a href="/">Home</a>
58
58
</li>
59
59
-
59
59
+
<li>
60
60
+
<a href="/feed">Feed</a>
61
61
+
</li>
60
62
<li>
61
63
<a href="/demo">Demo</a>
62
64
</li>
+37
src/routes/feed/+page.server.ts
···
1
1
+
import type { PageServerLoad } from './$types';
2
2
+
import { redirect } from '@sveltejs/kit';
3
3
+
import { db } from '$lib/server/db';
4
4
+
import { recordsTable } from '$lib/server/db/schema';
5
5
+
import { desc, eq, ne } from 'drizzle-orm';
6
6
+
7
7
+
export const load: PageServerLoad = async (event) => {
8
8
+
if (!event.locals.session) {
9
9
+
return redirect(302, '/login');
10
10
+
}
11
11
+
12
12
+
// Fetch one music play record
13
13
+
const musicRecords = await db
14
14
+
.select()
15
15
+
.from(recordsTable)
16
16
+
.where(eq(recordsTable.collection, 'fm.teal.alpha.feed.play'))
17
17
+
.orderBy(desc(recordsTable.indexedAt))
18
18
+
.limit(1);
19
19
+
20
20
+
// Fetch other records (non-music)
21
21
+
const otherRecords = await db
22
22
+
.select()
23
23
+
.from(recordsTable)
24
24
+
.where(ne(recordsTable.collection, 'fm.teal.alpha.feed.play'))
25
25
+
.orderBy(desc(recordsTable.indexedAt))
26
26
+
.limit(49);
27
27
+
28
28
+
// Combine and sort by indexedAt
29
29
+
const records = [...musicRecords, ...otherRecords].sort(
30
30
+
(a, b) => b.indexedAt.getTime() - a.indexedAt.getTime()
31
31
+
);
32
32
+
33
33
+
return {
34
34
+
records,
35
35
+
usersDid: event.locals.session.did
36
36
+
};
37
37
+
};
+58
src/routes/feed/+page.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { PageData } from './$types';
3
3
+
import MusicPlayCard from '$lib/components/MusicPlayCard.svelte';
4
4
+
import TangledRepoCard from '$lib/components/TangledRepoCard.svelte';
5
5
+
import LeafletDocumentCard from '$lib/components/LeafletDocumentCard.svelte';
6
6
+
7
7
+
let { data }: { data: PageData } = $props();
8
8
+
</script>
9
9
+
10
10
+
<svelte:head>
11
11
+
<title>Feed - atpoke.xyz</title>
12
12
+
</svelte:head>
13
13
+
14
14
+
<div class="container mx-auto px-4 py-8 max-w-3xl">
15
15
+
<div class="mb-8">
16
16
+
<h1 class="text-4xl font-bold mb-2">Feed</h1>
17
17
+
<p class="text-base-content/70">Discover the latest from the ATProto ecosystem</p>
18
18
+
</div>
19
19
+
20
20
+
{#if data.records.length === 0}
21
21
+
<div class="alert">
22
22
+
<svg
23
23
+
xmlns="http://www.w3.org/2000/svg"
24
24
+
fill="none"
25
25
+
viewBox="0 0 24 24"
26
26
+
class="stroke-info shrink-0 w-6 h-6"
27
27
+
>
28
28
+
<path
29
29
+
stroke-linecap="round"
30
30
+
stroke-linejoin="round"
31
31
+
stroke-width="2"
32
32
+
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
33
33
+
></path>
34
34
+
</svg>
35
35
+
<span>No records found. The feed is empty.</span>
36
36
+
</div>
37
37
+
{:else}
38
38
+
<div class="space-y-4">
39
39
+
{#each data.records as record (record.id)}
40
40
+
{#if record.collection === 'fm.teal.alpha.feed.play'}
41
41
+
<MusicPlayCard {record} />
42
42
+
{:else if record.collection === 'sh.tangled.repo'}
43
43
+
<TangledRepoCard {record} />
44
44
+
{:else if record.collection === 'pub.leaflet.document'}
45
45
+
<LeafletDocumentCard {record} />
46
46
+
{:else}
47
47
+
<div class="card bg-base-100 shadow-xl">
48
48
+
<div class="card-body">
49
49
+
<h3 class="card-title text-sm opacity-60">Unknown Collection Type</h3>
50
50
+
<p class="text-sm">{record.collection}</p>
51
51
+
<pre class="text-xs overflow-auto">{JSON.stringify(record.data, null, 2)}</pre>
52
52
+
</div>
53
53
+
</div>
54
54
+
{/if}
55
55
+
{/each}
56
56
+
</div>
57
57
+
{/if}
58
58
+
</div>