Compare changes

Choose any two refs to compare.

+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
··· 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
··· 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
··· 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
··· 56 56 <li> 57 57 <a href="/">Home</a> 58 58 </li> 59 - 59 + <li> 60 + <a href="/feed">Feed</a> 61 + </li> 60 62 <li> 61 63 <a href="/demo">Demo</a> 62 64 </li>
+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
··· 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>