+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>
+109
src/lib/components/LeafletDocumentCard.svelte
+109
src/lib/components/LeafletDocumentCard.svelte
···
1
+
<script lang="ts">
2
+
import InteractionBar from './InteractionBar.svelte';
3
+
4
+
let { record } = $props();
5
+
6
+
const data = $derived(record.data as {
7
+
title?: string;
8
+
description?: string;
9
+
tags?: string[];
10
+
author?: string;
11
+
publishedAt?: string;
12
+
pages?: Array<{
13
+
blocks: Array<{
14
+
block: {
15
+
$type: string;
16
+
plaintext?: string;
17
+
};
18
+
}>;
19
+
}>;
20
+
});
21
+
22
+
function formatDate(dateString: string): string {
23
+
const date = new Date(dateString);
24
+
return new Intl.DateTimeFormat('en-US', {
25
+
month: 'short',
26
+
day: 'numeric',
27
+
year: 'numeric'
28
+
}).format(date);
29
+
}
30
+
31
+
function getPreviewText(): string {
32
+
if (!data.pages || data.pages.length === 0) return '';
33
+
34
+
for (const page of data.pages) {
35
+
for (const blockItem of page.blocks) {
36
+
if (
37
+
blockItem.block.$type === 'pub.leaflet.blocks.text' &&
38
+
blockItem.block.plaintext &&
39
+
blockItem.block.plaintext.trim().length > 0
40
+
) {
41
+
const text = blockItem.block.plaintext;
42
+
return text.length > 200 ? text.slice(0, 200) + '...' : text;
43
+
}
44
+
}
45
+
}
46
+
return '';
47
+
}
48
+
49
+
const previewText = getPreviewText();
50
+
</script>
51
+
52
+
<div class="card bg-base-100 shadow-xl">
53
+
<div class="card-body">
54
+
<div class="flex items-start gap-4">
55
+
<div class="flex-shrink-0">
56
+
<div class="avatar placeholder">
57
+
<div class="bg-accent text-accent-content rounded-lg w-16 h-16">
58
+
<svg
59
+
xmlns="http://www.w3.org/2000/svg"
60
+
class="h-8 w-8"
61
+
fill="none"
62
+
viewBox="0 0 24 24"
63
+
stroke="currentColor"
64
+
>
65
+
<path
66
+
stroke-linecap="round"
67
+
stroke-linejoin="round"
68
+
stroke-width="2"
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
+
/>
71
+
</svg>
72
+
</div>
73
+
</div>
74
+
</div>
75
+
76
+
<div class="flex-grow">
77
+
<h3 class="card-title text-lg mb-2">{data.title || 'Untitled Document'}</h3>
78
+
79
+
{#if data.description}
80
+
<p class="text-base-content/80 mb-2">{data.description}</p>
81
+
{/if}
82
+
83
+
{#if previewText}
84
+
<p class="text-sm text-base-content/70 mb-3 line-clamp-3">{previewText}</p>
85
+
{/if}
86
+
87
+
{#if data.tags && data.tags.length > 0}
88
+
<div class="flex flex-wrap gap-2 mb-3">
89
+
{#each data.tags as tag}
90
+
<div class="badge badge-primary badge-sm">#{tag}</div>
91
+
{/each}
92
+
</div>
93
+
{/if}
94
+
95
+
{#if data.publishedAt}
96
+
<div class="flex items-center gap-2 text-sm text-base-content/60">
97
+
<span>Published {formatDate(data.publishedAt)}</span>
98
+
</div>
99
+
{/if}
100
+
101
+
<InteractionBar
102
+
onlike={() => console.log('Liked document')}
103
+
onrepost={() => console.log('Reposted document')}
104
+
oncomment={() => console.log('Commented on document')}
105
+
/>
106
+
</div>
107
+
</div>
108
+
</div>
109
+
</div>
+101
src/lib/components/MusicPlayCard.svelte
+101
src/lib/components/MusicPlayCard.svelte
···
1
+
<script lang="ts">
2
+
import InteractionBar from './InteractionBar.svelte';
3
+
4
+
let { record } = $props();
5
+
6
+
const data = $derived(record.data as {
7
+
trackName?: string;
8
+
artists?: Array<{ artistName: string; artistMbId: string }>;
9
+
releaseName?: string;
10
+
playedTime?: string;
11
+
duration?: number;
12
+
originUrl?: string;
13
+
musicServiceBaseDomain?: string;
14
+
});
15
+
16
+
function formatDuration(seconds: number): string {
17
+
const mins = Math.floor(seconds / 60);
18
+
const secs = seconds % 60;
19
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
20
+
}
21
+
22
+
function formatDate(dateString: string): string {
23
+
const date = new Date(dateString);
24
+
return new Intl.DateTimeFormat('en-US', {
25
+
month: 'short',
26
+
day: 'numeric',
27
+
hour: 'numeric',
28
+
minute: '2-digit'
29
+
}).format(date);
30
+
}
31
+
</script>
32
+
33
+
<div class="card bg-base-100 shadow-xl">
34
+
<div class="card-body">
35
+
<div class="flex items-start gap-4">
36
+
<div class="flex-shrink-0">
37
+
<div class="avatar placeholder">
38
+
<div class="bg-primary text-primary-content rounded-lg w-16 h-16">
39
+
<svg
40
+
xmlns="http://www.w3.org/2000/svg"
41
+
class="h-8 w-8"
42
+
fill="none"
43
+
viewBox="0 0 24 24"
44
+
stroke="currentColor"
45
+
>
46
+
<path
47
+
stroke-linecap="round"
48
+
stroke-linejoin="round"
49
+
stroke-width="2"
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
+
/>
52
+
</svg>
53
+
</div>
54
+
</div>
55
+
</div>
56
+
57
+
<div class="flex-grow">
58
+
<h3 class="card-title text-lg">{data.trackName || 'Unknown Track'}</h3>
59
+
{#if data.artists && data.artists.length > 0}
60
+
<p class="text-base-content/70">
61
+
{data.artists.map((a) => a.artistName).join(', ')}
62
+
</p>
63
+
{/if}
64
+
{#if data.releaseName}
65
+
<p class="text-sm text-base-content/60 mt-1">{data.releaseName}</p>
66
+
{/if}
67
+
68
+
<div class="flex items-center gap-4 mt-2 text-sm text-base-content/60">
69
+
{#if data.duration}
70
+
<span>{formatDuration(data.duration)}</span>
71
+
<span>•</span>
72
+
{/if}
73
+
{#if data.playedTime}
74
+
<span>{formatDate(data.playedTime)}</span>
75
+
{/if}
76
+
{#if data.musicServiceBaseDomain}
77
+
<span>•</span>
78
+
<span class="capitalize">{data.musicServiceBaseDomain.split('.')[0]}</span>
79
+
{/if}
80
+
</div>
81
+
82
+
{#if data.originUrl}
83
+
<a
84
+
href={data.originUrl}
85
+
target="_blank"
86
+
rel="noopener noreferrer"
87
+
class="link link-primary text-sm mt-2 inline-block"
88
+
>
89
+
Listen on {data.musicServiceBaseDomain?.split('.')[0] || 'platform'}
90
+
</a>
91
+
{/if}
92
+
93
+
<InteractionBar
94
+
onlike={() => console.log('Liked music play')}
95
+
onrepost={() => console.log('Reposted music play')}
96
+
oncomment={() => console.log('Commented on music play')}
97
+
/>
98
+
</div>
99
+
</div>
100
+
</div>
101
+
</div>
+101
src/lib/components/TangledRepoCard.svelte
+101
src/lib/components/TangledRepoCard.svelte
···
1
+
<script lang="ts">
2
+
import InteractionBar from './InteractionBar.svelte';
3
+
4
+
let { record } = $props();
5
+
6
+
const data = $derived(record.data as {
7
+
name?: string;
8
+
description?: string;
9
+
knot?: string;
10
+
labels?: string[];
11
+
source?: string;
12
+
createdAt?: string;
13
+
});
14
+
15
+
function formatDate(dateString: string): string {
16
+
const date = new Date(dateString);
17
+
return new Intl.DateTimeFormat('en-US', {
18
+
month: 'short',
19
+
day: 'numeric',
20
+
year: 'numeric'
21
+
}).format(date);
22
+
}
23
+
24
+
function extractLabelName(atUri: string): string {
25
+
const parts = atUri.split('/');
26
+
return parts[parts.length - 1] || atUri;
27
+
}
28
+
</script>
29
+
30
+
<div class="card bg-base-100 shadow-xl">
31
+
<div class="card-body">
32
+
<div class="flex items-start gap-4">
33
+
<div class="flex-shrink-0">
34
+
<div class="avatar placeholder">
35
+
<div class="bg-secondary text-secondary-content rounded-lg w-16 h-16">
36
+
<svg
37
+
xmlns="http://www.w3.org/2000/svg"
38
+
class="h-8 w-8"
39
+
fill="none"
40
+
viewBox="0 0 24 24"
41
+
stroke="currentColor"
42
+
>
43
+
<path
44
+
stroke-linecap="round"
45
+
stroke-linejoin="round"
46
+
stroke-width="2"
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
+
/>
49
+
</svg>
50
+
</div>
51
+
</div>
52
+
</div>
53
+
54
+
<div class="flex-grow">
55
+
<div class="flex items-center gap-2">
56
+
<h3 class="card-title text-lg">{data.name || 'Unknown Repository'}</h3>
57
+
{#if data.knot}
58
+
<div class="badge badge-outline badge-sm">{data.knot}</div>
59
+
{/if}
60
+
</div>
61
+
62
+
{#if data.description}
63
+
<p class="text-base-content/80 mt-2">{data.description}</p>
64
+
{/if}
65
+
66
+
{#if data.labels && data.labels.length > 0}
67
+
<div class="flex flex-wrap gap-2 mt-3">
68
+
{#each data.labels as label}
69
+
<div class="badge badge-ghost badge-sm">
70
+
{extractLabelName(label)}
71
+
</div>
72
+
{/each}
73
+
</div>
74
+
{/if}
75
+
76
+
{#if data.createdAt}
77
+
<div class="flex items-center gap-2 mt-3 text-sm text-base-content/60">
78
+
<span>Created {formatDate(data.createdAt)}</span>
79
+
</div>
80
+
{/if}
81
+
82
+
{#if data.source}
83
+
<a
84
+
href={data.source}
85
+
target="_blank"
86
+
rel="noopener noreferrer"
87
+
class="link link-primary text-sm mt-2 inline-block"
88
+
>
89
+
View source
90
+
</a>
91
+
{/if}
92
+
93
+
<InteractionBar
94
+
onlike={() => console.log('Liked repo')}
95
+
onrepost={() => console.log('Reposted repo')}
96
+
oncomment={() => console.log('Commented on repo')}
97
+
/>
98
+
</div>
99
+
</div>
100
+
</div>
101
+
</div>
+3
-1
src/routes/+layout.svelte
+3
-1
src/routes/+layout.svelte
+37
src/routes/feed/+page.server.ts
+37
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
+
export const load: PageServerLoad = async (event) => {
8
+
if (!event.locals.session) {
9
+
return redirect(302, '/login');
10
+
}
11
+
12
+
// Fetch one music play record
13
+
const musicRecords = await db
14
+
.select()
15
+
.from(recordsTable)
16
+
.where(eq(recordsTable.collection, 'fm.teal.alpha.feed.play'))
17
+
.orderBy(desc(recordsTable.indexedAt))
18
+
.limit(1);
19
+
20
+
// Fetch other records (non-music)
21
+
const otherRecords = await db
22
+
.select()
23
+
.from(recordsTable)
24
+
.where(ne(recordsTable.collection, 'fm.teal.alpha.feed.play'))
25
+
.orderBy(desc(recordsTable.indexedAt))
26
+
.limit(49);
27
+
28
+
// Combine and sort by indexedAt
29
+
const records = [...musicRecords, ...otherRecords].sort(
30
+
(a, b) => b.indexedAt.getTime() - a.indexedAt.getTime()
31
+
);
32
+
33
+
return {
34
+
records,
35
+
usersDid: event.locals.session.did
36
+
};
37
+
};
+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 class="space-y-4">
39
+
{#each data.records as record (record.id)}
40
+
{#if record.collection === 'fm.teal.alpha.feed.play'}
41
+
<MusicPlayCard {record} />
42
+
{:else if record.collection === 'sh.tangled.repo'}
43
+
<TangledRepoCard {record} />
44
+
{:else if record.collection === 'pub.leaflet.document'}
45
+
<LeafletDocumentCard {record} />
46
+
{:else}
47
+
<div class="card bg-base-100 shadow-xl">
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>