wip

+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
··· 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
··· 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
··· 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
··· 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>
+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
··· 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>