+91
src/lib/components/InteractionBar.svelte
+91
src/lib/components/InteractionBar.svelte
···
1
+
<script lang="ts">
2
+
let {
3
+
likeCount = 0,
4
+
repostCount = 0,
5
+
commentCount = 0,
6
+
onlike,
7
+
onrepost,
8
+
oncomment
9
+
}: {
10
+
likeCount?: number;
11
+
repostCount?: number;
12
+
commentCount?: number;
13
+
onlike?: () => void;
14
+
onrepost?: () => void;
15
+
oncomment?: () => void;
16
+
} = $props();
17
+
</script>
18
+
19
+
<div class="flex gap-4 mt-4">
20
+
<button
21
+
class="btn btn-ghost btn-sm gap-2"
22
+
onclick={onlike}
23
+
aria-label="Like"
24
+
>
25
+
<svg
26
+
xmlns="http://www.w3.org/2000/svg"
27
+
class="h-5 w-5"
28
+
fill="none"
29
+
viewBox="0 0 24 24"
30
+
stroke="currentColor"
31
+
>
32
+
<path
33
+
stroke-linecap="round"
34
+
stroke-linejoin="round"
35
+
stroke-width="2"
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
+
/>
38
+
</svg>
39
+
{#if likeCount > 0}
40
+
<span class="text-sm">{likeCount}</span>
41
+
{/if}
42
+
</button>
43
+
44
+
<button
45
+
class="btn btn-ghost btn-sm gap-2"
46
+
onclick={onrepost}
47
+
aria-label="Repost"
48
+
>
49
+
<svg
50
+
xmlns="http://www.w3.org/2000/svg"
51
+
class="h-5 w-5"
52
+
fill="none"
53
+
viewBox="0 0 24 24"
54
+
stroke="currentColor"
55
+
>
56
+
<path
57
+
stroke-linecap="round"
58
+
stroke-linejoin="round"
59
+
stroke-width="2"
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
+
/>
62
+
</svg>
63
+
{#if repostCount > 0}
64
+
<span class="text-sm">{repostCount}</span>
65
+
{/if}
66
+
</button>
67
+
68
+
<button
69
+
class="btn btn-ghost btn-sm gap-2"
70
+
onclick={oncomment}
71
+
aria-label="Comment"
72
+
>
73
+
<svg
74
+
xmlns="http://www.w3.org/2000/svg"
75
+
class="h-5 w-5"
76
+
fill="none"
77
+
viewBox="0 0 24 24"
78
+
stroke="currentColor"
79
+
>
80
+
<path
81
+
stroke-linecap="round"
82
+
stroke-linejoin="round"
83
+
stroke-width="2"
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
+
/>
86
+
</svg>
87
+
{#if commentCount > 0}
88
+
<span class="text-sm">{commentCount}</span>
89
+
{/if}
90
+
</button>
91
+
</div>
+118
src/lib/components/LeafletDocumentCard.svelte
+118
src/lib/components/LeafletDocumentCard.svelte
···
1
+
<script lang="ts">
2
+
import InteractionBar from './InteractionBar.svelte';
3
+
4
+
let {
5
+
record,
6
+
profile
7
+
}: {
8
+
record: any;
9
+
profile?: { handle: string; avatar?: string; displayName?: string };
10
+
} = $props();
11
+
12
+
const data = $derived(record.data as {
13
+
title?: string;
14
+
description?: string;
15
+
tags?: string[];
16
+
author?: string;
17
+
publishedAt?: string;
18
+
pages?: Array<{
19
+
blocks: Array<{
20
+
block: {
21
+
$type: string;
22
+
plaintext?: string;
23
+
};
24
+
}>;
25
+
}>;
26
+
});
27
+
28
+
function formatDate(dateString: string): string {
29
+
const date = new Date(dateString);
30
+
return new Intl.DateTimeFormat('en-US', {
31
+
month: 'short',
32
+
day: 'numeric',
33
+
year: 'numeric'
34
+
}).format(date);
35
+
}
36
+
37
+
function getPreviewText(): string {
38
+
if (!data.pages || data.pages.length === 0) return '';
39
+
40
+
for (const page of data.pages) {
41
+
for (const blockItem of page.blocks) {
42
+
if (
43
+
blockItem.block.$type === 'pub.leaflet.blocks.text' &&
44
+
blockItem.block.plaintext &&
45
+
blockItem.block.plaintext.trim().length > 0
46
+
) {
47
+
const text = blockItem.block.plaintext;
48
+
return text.length > 200 ? text.slice(0, 200) + '...' : text;
49
+
}
50
+
}
51
+
}
52
+
return '';
53
+
}
54
+
55
+
const previewText = getPreviewText();
56
+
</script>
57
+
58
+
<div class="card bg-base-100 border-b border-base-300">
59
+
<div class="card-body">
60
+
<div class="flex items-start gap-4">
61
+
<div class="flex-shrink-0">
62
+
<div class="avatar">
63
+
<div class="w-12 h-12 rounded-full">
64
+
{#if profile?.avatar}
65
+
<img src={profile.avatar} alt={profile.handle} />
66
+
{:else}
67
+
<div class="bg-accent text-accent-content rounded-full w-12 h-12 flex items-center justify-center">
68
+
<span class="text-xl">{profile?.handle?.[0]?.toUpperCase() || '?'}</span>
69
+
</div>
70
+
{/if}
71
+
</div>
72
+
</div>
73
+
</div>
74
+
75
+
<div class="flex-grow">
76
+
{#if profile}
77
+
<div class="text-sm text-base-content/60 mb-2">
78
+
<a href="https://bsky.app/profile/{profile.handle}" target="_blank" rel="noopener noreferrer" class="link link-hover">
79
+
@{profile.handle}
80
+
</a>
81
+
{#if profile.displayName}
82
+
<span class="ml-1">ยท {profile.displayName}</span>
83
+
{/if}
84
+
</div>
85
+
{/if}
86
+
<h3 class="card-title text-lg mb-2">{data.title || 'Untitled Document'}</h3>
87
+
88
+
{#if data.description}
89
+
<p class="text-base-content/80 mb-2">{data.description}</p>
90
+
{/if}
91
+
92
+
{#if previewText}
93
+
<p class="text-sm text-base-content/70 mb-3 line-clamp-3">{previewText}</p>
94
+
{/if}
95
+
96
+
{#if data.tags && data.tags.length > 0}
97
+
<div class="flex flex-wrap gap-2 mb-3">
98
+
{#each data.tags as tag}
99
+
<div class="badge badge-primary badge-sm">#{tag}</div>
100
+
{/each}
101
+
</div>
102
+
{/if}
103
+
104
+
{#if data.publishedAt}
105
+
<div class="flex items-center gap-2 text-sm text-base-content/60">
106
+
<span>Published {formatDate(data.publishedAt)}</span>
107
+
</div>
108
+
{/if}
109
+
110
+
<InteractionBar
111
+
onlike={() => console.log('Liked document')}
112
+
onrepost={() => console.log('Reposted document')}
113
+
oncomment={() => console.log('Commented on document')}
114
+
/>
115
+
</div>
116
+
</div>
117
+
</div>
118
+
</div>
+110
src/lib/components/MusicPlayCard.svelte
+110
src/lib/components/MusicPlayCard.svelte
···
1
+
<script lang="ts">
2
+
import InteractionBar from './InteractionBar.svelte';
3
+
4
+
let {
5
+
record,
6
+
profile
7
+
}: {
8
+
record: any;
9
+
profile?: { handle: string; avatar?: string; displayName?: string };
10
+
} = $props();
11
+
12
+
const data = $derived(record.data as {
13
+
trackName?: string;
14
+
artists?: Array<{ artistName: string; artistMbId: string }>;
15
+
releaseName?: string;
16
+
playedTime?: string;
17
+
duration?: number;
18
+
originUrl?: string;
19
+
musicServiceBaseDomain?: string;
20
+
});
21
+
22
+
function formatDuration(seconds: number): string {
23
+
const mins = Math.floor(seconds / 60);
24
+
const secs = seconds % 60;
25
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
26
+
}
27
+
28
+
function formatDate(dateString: string): string {
29
+
const date = new Date(dateString);
30
+
return new Intl.DateTimeFormat('en-US', {
31
+
month: 'short',
32
+
day: 'numeric',
33
+
hour: 'numeric',
34
+
minute: '2-digit'
35
+
}).format(date);
36
+
}
37
+
</script>
38
+
39
+
<div class="card bg-base-100 border-b border-base-300">
40
+
<div class="card-body">
41
+
<div class="flex items-start gap-4">
42
+
<div class="flex-shrink-0">
43
+
<div class="avatar">
44
+
<div class="w-12 h-12 rounded-full">
45
+
{#if profile?.avatar}
46
+
<img src={profile.avatar} alt={profile.handle} />
47
+
{:else}
48
+
<div class="bg-primary text-primary-content rounded-full w-12 h-12 flex items-center justify-center">
49
+
<span class="text-xl">{profile?.handle?.[0]?.toUpperCase() || '?'}</span>
50
+
</div>
51
+
{/if}
52
+
</div>
53
+
</div>
54
+
</div>
55
+
56
+
<div class="flex-grow">
57
+
{#if profile}
58
+
<div class="text-sm text-base-content/60 mb-2">
59
+
<a href="https://bsky.app/profile/{profile.handle}" target="_blank" rel="noopener noreferrer" class="link link-hover">
60
+
@{profile.handle}
61
+
</a>
62
+
{#if profile.displayName}
63
+
<span class="ml-1">ยท {profile.displayName}</span>
64
+
{/if}
65
+
</div>
66
+
{/if}
67
+
<h3 class="card-title text-lg">{data.trackName || 'Unknown Track'}</h3>
68
+
{#if data.artists && data.artists.length > 0}
69
+
<p class="text-base-content/70">
70
+
{data.artists.map((a) => a.artistName).join(', ')}
71
+
</p>
72
+
{/if}
73
+
{#if data.releaseName}
74
+
<p class="text-sm text-base-content/60 mt-1">{data.releaseName}</p>
75
+
{/if}
76
+
77
+
<div class="flex items-center gap-4 mt-2 text-sm text-base-content/60">
78
+
{#if data.duration}
79
+
<span>{formatDuration(data.duration)}</span>
80
+
<span>โข</span>
81
+
{/if}
82
+
{#if data.playedTime}
83
+
<span>{formatDate(data.playedTime)}</span>
84
+
{/if}
85
+
{#if data.musicServiceBaseDomain}
86
+
<span>โข</span>
87
+
<span class="capitalize">{data.musicServiceBaseDomain.split('.')[0]}</span>
88
+
{/if}
89
+
</div>
90
+
91
+
{#if data.originUrl}
92
+
<a
93
+
href={data.originUrl}
94
+
target="_blank"
95
+
rel="noopener noreferrer"
96
+
class="link link-primary text-sm mt-2 inline-block"
97
+
>
98
+
Listen on {data.musicServiceBaseDomain?.split('.')[0] || 'platform'}
99
+
</a>
100
+
{/if}
101
+
102
+
<InteractionBar
103
+
onlike={() => console.log('Liked music play')}
104
+
onrepost={() => console.log('Reposted music play')}
105
+
oncomment={() => console.log('Commented on music play')}
106
+
/>
107
+
</div>
108
+
</div>
109
+
</div>
110
+
</div>
+110
src/lib/components/TangledRepoCard.svelte
+110
src/lib/components/TangledRepoCard.svelte
···
1
+
<script lang="ts">
2
+
import InteractionBar from './InteractionBar.svelte';
3
+
4
+
let {
5
+
record,
6
+
profile
7
+
}: {
8
+
record: any;
9
+
profile?: { handle: string; avatar?: string; displayName?: string };
10
+
} = $props();
11
+
12
+
const data = $derived(record.data as {
13
+
name?: string;
14
+
description?: string;
15
+
knot?: string;
16
+
labels?: string[];
17
+
source?: string;
18
+
createdAt?: string;
19
+
});
20
+
21
+
function formatDate(dateString: string): string {
22
+
const date = new Date(dateString);
23
+
return new Intl.DateTimeFormat('en-US', {
24
+
month: 'short',
25
+
day: 'numeric',
26
+
year: 'numeric'
27
+
}).format(date);
28
+
}
29
+
30
+
function extractLabelName(atUri: string): string {
31
+
const parts = atUri.split('/');
32
+
return parts[parts.length - 1] || atUri;
33
+
}
34
+
</script>
35
+
36
+
<div class="card bg-base-100 border-b border-base-300">
37
+
<div class="card-body">
38
+
<div class="flex items-start gap-4">
39
+
<div class="flex-shrink-0">
40
+
<div class="avatar">
41
+
<div class="w-12 h-12 rounded-full">
42
+
{#if profile?.avatar}
43
+
<img src={profile.avatar} alt={profile.handle} />
44
+
{:else}
45
+
<div class="bg-secondary text-secondary-content rounded-full w-12 h-12 flex items-center justify-center">
46
+
<span class="text-xl">{profile?.handle?.[0]?.toUpperCase() || '?'}</span>
47
+
</div>
48
+
{/if}
49
+
</div>
50
+
</div>
51
+
</div>
52
+
53
+
<div class="flex-grow">
54
+
{#if profile}
55
+
<div class="text-sm text-base-content/60 mb-2">
56
+
<a href="https://bsky.app/profile/{profile.handle}" target="_blank" rel="noopener noreferrer" class="link link-hover">
57
+
@{profile.handle}
58
+
</a>
59
+
{#if profile.displayName}
60
+
<span class="ml-1">ยท {profile.displayName}</span>
61
+
{/if}
62
+
</div>
63
+
{/if}
64
+
<div class="flex items-center gap-2">
65
+
<h3 class="card-title text-lg">{data.name || 'Unknown Repository'}</h3>
66
+
{#if data.knot}
67
+
<div class="badge badge-outline badge-sm">{data.knot}</div>
68
+
{/if}
69
+
</div>
70
+
71
+
{#if data.description}
72
+
<p class="text-base-content/80 mt-2">{data.description}</p>
73
+
{/if}
74
+
75
+
{#if data.labels && data.labels.length > 0}
76
+
<div class="flex flex-wrap gap-2 mt-3">
77
+
{#each data.labels as label}
78
+
<div class="badge badge-ghost badge-sm">
79
+
{extractLabelName(label)}
80
+
</div>
81
+
{/each}
82
+
</div>
83
+
{/if}
84
+
85
+
{#if data.createdAt}
86
+
<div class="flex items-center gap-2 mt-3 text-sm text-base-content/60">
87
+
<span>Created {formatDate(data.createdAt)}</span>
88
+
</div>
89
+
{/if}
90
+
91
+
{#if data.source}
92
+
<a
93
+
href={data.source}
94
+
target="_blank"
95
+
rel="noopener noreferrer"
96
+
class="link link-primary text-sm mt-2 inline-block"
97
+
>
98
+
View source
99
+
</a>
100
+
{/if}
101
+
102
+
<InteractionBar
103
+
onlike={() => console.log('Liked repo')}
104
+
onrepost={() => console.log('Reposted repo')}
105
+
oncomment={() => console.log('Commented on repo')}
106
+
/>
107
+
</div>
108
+
</div>
109
+
</div>
110
+
</div>
+3
-1
src/routes/+layout.svelte
+3
-1
src/routes/+layout.svelte
+76
src/routes/feed/+page.server.ts
+76
src/routes/feed/+page.server.ts
···
1
+
import type { PageServerLoad } from './$types';
2
+
import { redirect } from '@sveltejs/kit';
3
+
import { db } from '$lib/server/db';
4
+
import { recordsTable } from '$lib/server/db/schema';
5
+
import { desc, eq, ne } from 'drizzle-orm';
6
+
7
+
interface ProfileData {
8
+
handle: string;
9
+
avatar?: string;
10
+
displayName?: string;
11
+
}
12
+
13
+
async function fetchProfile(repo: string): Promise<ProfileData | null> {
14
+
try {
15
+
const response = await fetch(
16
+
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${repo}`
17
+
);
18
+
if (!response.ok) return null;
19
+
const data = await response.json();
20
+
return {
21
+
handle: data.handle,
22
+
avatar: data.avatar,
23
+
displayName: data.displayName
24
+
};
25
+
} catch {
26
+
return null;
27
+
}
28
+
}
29
+
30
+
export const load: PageServerLoad = async (event) => {
31
+
if (!event.locals.session) {
32
+
return redirect(302, '/login');
33
+
}
34
+
35
+
// Fetch one music play record
36
+
const musicRecords = await db
37
+
.select()
38
+
.from(recordsTable)
39
+
.where(eq(recordsTable.collection, 'fm.teal.alpha.feed.play'))
40
+
.orderBy(desc(recordsTable.indexedAt))
41
+
.limit(1);
42
+
43
+
// Fetch other records (non-music)
44
+
const otherRecords = await db
45
+
.select()
46
+
.from(recordsTable)
47
+
.where(ne(recordsTable.collection, 'fm.teal.alpha.feed.play'))
48
+
.orderBy(desc(recordsTable.indexedAt))
49
+
.limit(49);
50
+
51
+
// Combine and sort by indexedAt
52
+
const records = [...musicRecords, ...otherRecords].sort(
53
+
(a, b) => b.indexedAt.getTime() - a.indexedAt.getTime()
54
+
);
55
+
56
+
// Fetch profile data for all unique repos
57
+
const uniqueRepos = [...new Set(records.map((r) => r.repo))];
58
+
const profilePromises = uniqueRepos.map(async (repo) => {
59
+
const profile = await fetchProfile(repo);
60
+
return { repo, profile };
61
+
});
62
+
63
+
const profileResults = await Promise.all(profilePromises);
64
+
const profiles: Record<string, ProfileData> = {};
65
+
for (const { repo, profile } of profileResults) {
66
+
if (profile) {
67
+
profiles[repo] = profile;
68
+
}
69
+
}
70
+
71
+
return {
72
+
records,
73
+
profiles,
74
+
usersDid: event.locals.session.did
75
+
};
76
+
};
+58
src/routes/feed/+page.svelte
+58
src/routes/feed/+page.svelte
···
1
+
<script lang="ts">
2
+
import type { PageData } from './$types';
3
+
import MusicPlayCard from '$lib/components/MusicPlayCard.svelte';
4
+
import TangledRepoCard from '$lib/components/TangledRepoCard.svelte';
5
+
import LeafletDocumentCard from '$lib/components/LeafletDocumentCard.svelte';
6
+
7
+
let { data }: { data: PageData } = $props();
8
+
</script>
9
+
10
+
<svelte:head>
11
+
<title>Feed - atpoke.xyz</title>
12
+
</svelte:head>
13
+
14
+
<div class="container mx-auto px-4 py-8 max-w-3xl">
15
+
<div class="mb-8">
16
+
<h1 class="text-4xl font-bold mb-2">Feed</h1>
17
+
<p class="text-base-content/70">Discover the latest from the ATProto ecosystem</p>
18
+
</div>
19
+
20
+
{#if data.records.length === 0}
21
+
<div class="alert">
22
+
<svg
23
+
xmlns="http://www.w3.org/2000/svg"
24
+
fill="none"
25
+
viewBox="0 0 24 24"
26
+
class="stroke-info shrink-0 w-6 h-6"
27
+
>
28
+
<path
29
+
stroke-linecap="round"
30
+
stroke-linejoin="round"
31
+
stroke-width="2"
32
+
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
33
+
></path>
34
+
</svg>
35
+
<span>No records found. The feed is empty.</span>
36
+
</div>
37
+
{:else}
38
+
<div>
39
+
{#each data.records as record (record.id)}
40
+
{#if record.collection === 'fm.teal.alpha.feed.play'}
41
+
<MusicPlayCard {record} profile={data.profiles[record.repo]} />
42
+
{:else if record.collection === 'sh.tangled.repo'}
43
+
<TangledRepoCard {record} profile={data.profiles[record.repo]} />
44
+
{:else if record.collection === 'pub.leaflet.document'}
45
+
<LeafletDocumentCard {record} profile={data.profiles[record.repo]} />
46
+
{:else}
47
+
<div class="card bg-base-100 border-b border-base-300">
48
+
<div class="card-body">
49
+
<h3 class="card-title text-sm opacity-60">Unknown Collection Type</h3>
50
+
<p class="text-sm">{record.collection}</p>
51
+
<pre class="text-xs overflow-auto">{JSON.stringify(record.data, null, 2)}</pre>
52
+
</div>
53
+
</div>
54
+
{/if}
55
+
{/each}
56
+
</div>
57
+
{/if}
58
+
</div>