your personal website on atproto - mirror blento.app

Merge pull request #126 from flo-bit/github-contribs

GitHub contributors

authored by Florian and committed by GitHub 8643d6b2 9059b09f

+657 -117
-1
src/lib/cards/BlueskyMediaCard/CreateBlueskyMediaCardModal.svelte
··· 62 {#each mediaList as media (media.thumbnail || media.playlist)} 63 <button 64 onclick={() => { 65 - console.log(media); 66 selected = media; 67 if (media.isVideo) { 68 item.cardData = {
··· 62 {#each mediaList as media (media.thumbnail || media.playlist)} 63 <button 64 onclick={() => { 65 selected = media; 66 if (media.isVideo) { 67 item.cardData = {
-1
src/lib/cards/FluidTextCard/FluidTextCard.svelte
··· 203 204 const computedColor = getHexOfCardColor(item); 205 const hue = colorToHue(computedColor) / 360; 206 - console.log(computedColor, hue); 207 208 // Wait for a frame to ensure dimensions are set 209 requestAnimationFrame(() => {
··· 203 204 const computedColor = getHexOfCardColor(item); 205 const hue = colorToHue(computedColor) / 360; 206 207 // Wait for a frame to ensure dimensions are set 208 requestAnimationFrame(() => {
+76
src/lib/cards/GitHubContributorsCard/CreateGitHubContributorsCardModal.svelte
···
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + let inputValue = $state(''); 9 + </script> 10 + 11 + <Modal open={true} closeButton={false}> 12 + <form 13 + onsubmit={() => { 14 + let input = inputValue.trim(); 15 + if (!input) { 16 + errorMessage = 'Please enter a repository in owner/repo format or a GitHub URL'; 17 + return; 18 + } 19 + 20 + let owner: string | undefined; 21 + let repo: string | undefined; 22 + 23 + // Try parsing as URL first 24 + try { 25 + const parsed = new URL(input); 26 + if (/^(www\.)?github\.com$/.test(parsed.hostname)) { 27 + const segments = parsed.pathname.split('/').filter(Boolean); 28 + if (segments.length >= 2) { 29 + owner = segments[0]; 30 + repo = segments[1]; 31 + } 32 + } 33 + } catch { 34 + // Not a URL, try as owner/repo format 35 + const parts = input.split('/'); 36 + if (parts.length === 2) { 37 + owner = parts[0].trim(); 38 + repo = parts[1].trim(); 39 + } 40 + } 41 + 42 + if (!owner || !repo) { 43 + errorMessage = 'Please enter a valid owner/repo or GitHub repository URL'; 44 + return; 45 + } 46 + 47 + item.cardData.owner = owner; 48 + item.cardData.repo = repo; 49 + item.cardData.href = `https://github.com/${owner}/${repo}`; 50 + 51 + item.w = 4; 52 + item.mobileW = 8; 53 + item.h = 2; 54 + item.mobileH = 4; 55 + 56 + oncreate?.(); 57 + }} 58 + class="flex flex-col gap-2" 59 + > 60 + <Subheading>Enter a GitHub repository</Subheading> 61 + <Input 62 + bind:value={inputValue} 63 + placeholder="owner/repo or https://github.com/owner/repo" 64 + class="mt-4" 65 + /> 66 + 67 + {#if errorMessage} 68 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 69 + {/if} 70 + 71 + <div class="mt-4 flex justify-end gap-2"> 72 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 73 + <Button type="submit">Create</Button> 74 + </div> 75 + </form> 76 + </Modal>
+173
src/lib/cards/GitHubContributorsCard/GitHubContributorsCard.svelte
···
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context'; 5 + import type { GitHubContributor, GitHubContributorsLoadedData } from '.'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + const isMobile = getIsMobile(); 10 + const canEdit = getCanEdit(); 11 + const additionalData = getAdditionalUserData(); 12 + 13 + let owner: string = $derived(item.cardData.owner ?? ''); 14 + let repo: string = $derived(item.cardData.repo ?? ''); 15 + let repoKey: string = $derived(owner && repo ? `${owner}/${repo}` : ''); 16 + 17 + let serverContributors: GitHubContributor[] = $derived.by(() => { 18 + if (!repoKey) return []; 19 + const data = additionalData[item.cardType] as GitHubContributorsLoadedData | undefined; 20 + return data?.[repoKey] ?? []; 21 + }); 22 + 23 + let clientContributors: GitHubContributor[] = $state([]); 24 + 25 + let allContributors: GitHubContributor[] = $derived( 26 + serverContributors.length > 0 ? serverContributors : clientContributors 27 + ); 28 + 29 + let namedContributors: GitHubContributor[] = $derived( 30 + allContributors.filter((c) => !c.anonymous) 31 + ); 32 + 33 + onMount(() => { 34 + if (serverContributors.length === 0 && repoKey) { 35 + loadContributors(); 36 + } 37 + }); 38 + 39 + async function loadContributors() { 40 + if (!owner || !repo) return; 41 + try { 42 + const response = await fetch( 43 + `/api/github/contributors?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}` 44 + ); 45 + if (response.ok) { 46 + const data = await response.json(); 47 + clientContributors = data; 48 + } 49 + } catch (error) { 50 + console.error('Failed to fetch GitHub contributors:', error); 51 + } 52 + } 53 + 54 + let containerWidth = $state(0); 55 + let containerHeight = $state(0); 56 + 57 + let totalItems = $derived(namedContributors.length); 58 + 59 + const GAP = 6; 60 + const MIN_SIZE = 16; 61 + const MAX_SIZE = 80; 62 + 63 + function hexCapacity(size: number, availW: number, availH: number): number { 64 + const colsWide = Math.floor((availW + GAP) / (size + GAP)); 65 + if (colsWide < 1) return 0; 66 + const colsNarrow = Math.max(1, colsWide - 1); 67 + const maxRows = Math.floor((availH + GAP) / (size + GAP)); 68 + let capacity = 0; 69 + for (let r = 0; r < maxRows; r++) { 70 + capacity += r % 2 === 0 ? colsWide : colsNarrow; 71 + } 72 + return capacity; 73 + } 74 + 75 + let computedSize = $derived.by(() => { 76 + if (!containerWidth || !containerHeight || totalItems === 0) return 40; 77 + 78 + let lo = MIN_SIZE; 79 + let hi = MAX_SIZE; 80 + 81 + while (lo <= hi) { 82 + const mid = Math.floor((lo + hi) / 2); 83 + const availW = containerWidth - mid; 84 + const availH = containerHeight - mid; 85 + if (availW <= 0 || availH <= 0) { 86 + hi = mid - 1; 87 + continue; 88 + } 89 + if (hexCapacity(mid, availW, availH) >= totalItems) { 90 + lo = mid + 1; 91 + } else { 92 + hi = mid - 1; 93 + } 94 + } 95 + 96 + return Math.max(MIN_SIZE, hi); 97 + }); 98 + 99 + let padding = $derived(computedSize / 2); 100 + 101 + let rows = $derived.by(() => { 102 + const availW = containerWidth - computedSize; 103 + if (availW <= 0) return [] as GitHubContributor[][]; 104 + const colsWide = Math.floor((availW + GAP) / (computedSize + GAP)); 105 + const colsNarrow = Math.max(1, colsWide - 1); 106 + 107 + const result: GitHubContributor[][] = []; 108 + let idx = 0; 109 + let rowNum = 0; 110 + while (idx < namedContributors.length) { 111 + const cols = rowNum % 2 === 0 ? colsWide : colsNarrow; 112 + result.push(namedContributors.slice(idx, idx + cols)); 113 + idx += cols; 114 + rowNum++; 115 + } 116 + return result; 117 + }); 118 + 119 + let textSize = $derived( 120 + computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm' 121 + ); 122 + </script> 123 + 124 + <div 125 + class="flex h-full w-full items-center justify-center overflow-hidden px-2" 126 + bind:clientWidth={containerWidth} 127 + bind:clientHeight={containerHeight} 128 + > 129 + {#if !owner || !repo} 130 + {#if canEdit()} 131 + <span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm"> 132 + Enter a repository 133 + </span> 134 + {/if} 135 + {:else if totalItems > 0} 136 + <div style="padding: {padding}px;"> 137 + <div class="flex flex-col items-center" style="gap: {GAP}px;"> 138 + {#each rows as row, rowIdx (rowIdx)} 139 + <div class="flex justify-center" style="gap: {GAP}px;"> 140 + {#each row as contributor (contributor.username)} 141 + <a 142 + href="https://github.com/{contributor.username}" 143 + target="_blank" 144 + rel="noopener noreferrer" 145 + class="accent:ring-accent-500 block rounded-full ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 146 + > 147 + {#if contributor.avatarUrl} 148 + <img 149 + src={contributor.avatarUrl} 150 + alt={contributor.username} 151 + class="rounded-full object-cover" 152 + style="width: {computedSize}px; height: {computedSize}px;" 153 + /> 154 + {:else} 155 + <div 156 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center rounded-full" 157 + style="width: {computedSize}px; height: {computedSize}px;" 158 + > 159 + <span 160 + class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium" 161 + > 162 + {contributor.username.charAt(0).toUpperCase()} 163 + </span> 164 + </div> 165 + {/if} 166 + </a> 167 + {/each} 168 + </div> 169 + {/each} 170 + </div> 171 + </div> 172 + {/if} 173 + </div>
+70
src/lib/cards/GitHubContributorsCard/index.ts
···
··· 1 + import type { CardDefinition } from '../types'; 2 + import GitHubContributorsCard from './GitHubContributorsCard.svelte'; 3 + import CreateGitHubContributorsCardModal from './CreateGitHubContributorsCardModal.svelte'; 4 + 5 + export type GitHubContributor = { 6 + username: string; 7 + avatarUrl: string | null; 8 + contributions: number; 9 + anonymous: boolean; 10 + }; 11 + 12 + export type GitHubContributorsLoadedData = Record<string, GitHubContributor[] | undefined>; 13 + 14 + export const GitHubContributorsCardDefinition = { 15 + type: 'githubContributors', 16 + contentComponent: GitHubContributorsCard, 17 + creationModalComponent: CreateGitHubContributorsCardModal, 18 + createNew: (card) => { 19 + card.w = 4; 20 + card.h = 2; 21 + card.mobileW = 8; 22 + card.mobileH = 4; 23 + card.cardData.owner = ''; 24 + card.cardData.repo = ''; 25 + }, 26 + loadData: async (items) => { 27 + const contributorsData: GitHubContributorsLoadedData = {}; 28 + for (const item of items) { 29 + const { owner, repo } = item.cardData; 30 + if (!owner || !repo) continue; 31 + const key = `${owner}/${repo}`; 32 + if (contributorsData[key]) continue; 33 + try { 34 + const response = await fetch( 35 + `https://blento.app/api/github/contributors?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}` 36 + ); 37 + if (response.ok) { 38 + contributorsData[key] = await response.json(); 39 + } 40 + } catch (error) { 41 + console.error('Failed to fetch GitHub contributors:', error); 42 + } 43 + } 44 + return contributorsData; 45 + }, 46 + onUrlHandler: (url, item) => { 47 + const match = url.match(/github\.com\/([^/]+)\/([^/]+)/); 48 + if (!match) return null; 49 + 50 + item.cardData.owner = match[1]; 51 + item.cardData.repo = match[2]; 52 + 53 + item.w = 4; 54 + item.h = 2; 55 + item.mobileW = 8; 56 + item.mobileH = 4; 57 + 58 + return item; 59 + }, 60 + urlHandlerPriority: 1, 61 + allowSetColor: true, 62 + defaultColor: 'base', 63 + minW: 2, 64 + minH: 2, 65 + name: 'GitHub Contributors', 66 + groups: ['Social'], 67 + keywords: ['github', 'contributors', 'open source', 'repository'], 68 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>`, 69 + canHaveLabel: true 70 + } as CardDefinition & { type: 'githubContributors' };
-1
src/lib/cards/GitHubProfileCard/GitHubProfileCard.svelte
··· 21 ); 22 23 onMount(async () => { 24 - console.log(contributionsData); 25 if (!contributionsData && item.cardData?.user) { 26 try { 27 const response = await fetch(`/api/github?user=${encodeURIComponent(item.cardData.user)}`);
··· 21 ); 22 23 onMount(async () => { 24 if (!contributionsData && item.cardData?.user) { 25 try { 26 const response = await fetch(`/api/github?user=${encodeURIComponent(item.cardData.user)}`);
-1
src/lib/cards/GitHubProfileCard/index.ts
··· 30 onUrlHandler: (url, item) => { 31 const username = getGitHubUsername(url); 32 33 - console.log(username); 34 if (!username) return; 35 36 item.cardData.href = url;
··· 30 onUrlHandler: (url, item) => { 31 const username = getGitHubUsername(url); 32 33 if (!username) return; 34 35 item.cardData.href = url;
-2
src/lib/cards/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte
··· 23 })) as any 24 ).feed; 25 26 - console.log(feed); 27 - 28 data[item.cardType] = feed; 29 } 30 });
··· 23 })) as any 24 ).feed; 25 26 data[item.cardType] = feed; 27 } 28 });
-1
src/lib/cards/LivestreamCard/index.ts
··· 65 }, 66 67 onUrlHandler: (url, item) => { 68 - console.log(url, 'https://stream.place/' + user.profile?.handle); 69 if (url === 'https://stream.place/' + user.profile?.handle) { 70 item.w = 4; 71 item.h = 4;
··· 65 }, 66 67 onUrlHandler: (url, item) => { 68 if (url === 'https://stream.place/' + user.profile?.handle) { 69 item.w = 4; 70 item.h = 4;
-2
src/lib/cards/MapCard/CreateMapCardModal.svelte
··· 20 if (response.ok) { 21 const data = await response.json(); 22 23 - console.log(data); 24 - 25 if (!data.lat || !data.lon) throw new Error('lat or lon not found'); 26 27 item.cardData.lat = data.lat;
··· 20 if (response.ok) { 21 const data = await response.json(); 22 23 if (!data.lat || !data.lon) throw new Error('lat or lon not found'); 24 25 item.cardData.lat = data.lat;
+1 -1
src/lib/cards/MapCard/Map.svelte
··· 15 16 onMount(() => { 17 if (!mapContainer || !env.PUBLIC_MAPBOX_TOKEN) { 18 - console.log('no map container or no mapbox token'); 19 return; 20 } 21
··· 15 16 onMount(() => { 17 if (!mapContainer || !env.PUBLIC_MAPBOX_TOKEN) { 18 + console.error('no map container or no mapbox token'); 19 return; 20 } 21
-3
src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte
··· 33 let handle = getHandleContext(); 34 35 onMount(async () => { 36 - console.log(feed); 37 if (!feed) { 38 feed = ( 39 (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { ··· 41 handle 42 })) as Record<string, PhotoItem[]> | undefined 43 )?.[item.cardData.galleryUri]; 44 - 45 - console.log(feed); 46 47 data[item.cardType] = feed; 48 }
··· 33 let handle = getHandleContext(); 34 35 onMount(async () => { 36 if (!feed) { 37 feed = ( 38 (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { ··· 40 handle 41 })) as Record<string, PhotoItem[]> | undefined 42 )?.[item.cardData.galleryUri]; 43 44 data[item.cardType] = feed; 45 }
-3
src/lib/cards/PopfeedReviews/PopfeedReviewsCard.svelte
··· 21 let handle = getHandleContext(); 22 23 onMount(async () => { 24 - console.log(feed); 25 if (!feed) { 26 feed = (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 27 did, 28 handle 29 })) as any; 30 - 31 - console.log(feed); 32 33 data[item.cardType] = feed; 34 }
··· 21 let handle = getHandleContext(); 22 23 onMount(async () => { 24 if (!feed) { 25 feed = (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 26 did, 27 handle 28 })) as any; 29 30 data[item.cardType] = feed; 31 }
+3 -1
src/lib/cards/index.ts
··· 36 import { ButtonCardDefinition } from './ButtonCard'; 37 import { GuestbookCardDefinition } from './GuestbookCard'; 38 import { FriendsCardDefinition } from './FriendsCard'; 39 // import { Model3DCardDefinition } from './Model3DCard'; 40 41 export const AllCardDefinitions = [ ··· 76 SpotifyCardDefinition, 77 AppleMusicCardDefinition, 78 // Model3DCardDefinition 79 - FriendsCardDefinition 80 ] as const; 81 82 export const CardDefinitionsByType = AllCardDefinitions.reduce(
··· 36 import { ButtonCardDefinition } from './ButtonCard'; 37 import { GuestbookCardDefinition } from './GuestbookCard'; 38 import { FriendsCardDefinition } from './FriendsCard'; 39 + import { GitHubContributorsCardDefinition } from './GitHubContributorsCard'; 40 // import { Model3DCardDefinition } from './Model3DCard'; 41 42 export const AllCardDefinitions = [ ··· 77 SpotifyCardDefinition, 78 AppleMusicCardDefinition, 79 // Model3DCardDefinition 80 + FriendsCardDefinition, 81 + GitHubContributorsCardDefinition 82 ] as const; 83 84 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+149 -92
src/lib/components/bluesky-post/index.ts
··· 1 - import type { PostData, PostEmbed } from '../post'; 2 import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 3 import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 4 ··· 11 .replace(/'/g, '&#39;'); 12 } 13 14 - function blueskyEmbedTypeToEmbedType(type: string) { 15 - switch (type) { 16 - case 'app.bsky.embed.external#view': 17 - case 'app.bsky.embed.external': 18 - return 'external'; 19 - case 'app.bsky.embed.images#view': 20 - case 'app.bsky.embed.images': 21 - return 'images'; 22 - case 'app.bsky.embed.video#view': 23 - case 'app.bsky.embed.video': 24 - return 'video'; 25 - default: 26 - return 'unknown'; 27 - } 28 - } 29 - 30 - export function blueskyPostToPostData( 31 - data: PostView, 32 - baseUrl: string = 'https://bsky.app' 33 - ): PostData { 34 - const post = data; 35 - // const reason = data.reason; 36 - // const reply = data.reply?.parent; 37 - // const replyId = reply?.uri?.split('/').pop(); 38 - console.log(JSON.parse(JSON.stringify(data))); 39 - 40 - const id = post.uri.split('/').pop(); 41 - 42 - return { 43 - id, 44 - href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 45 - // reposted: 46 - // reason && reason.$type === 'app.bsky.feed.defs#reasonRepost' 47 - // ? { 48 - // handle: reason.by.handle, 49 - // href: `${baseUrl}/profile/${reason.by.handle}` 50 - // } 51 - // : undefined, 52 - 53 - // replyTo: 54 - // reply && replyId 55 - // ? { 56 - // handle: reply.author.handle, 57 - // href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}` 58 - // } 59 - // : undefined, 60 - author: { 61 - displayName: post.author.displayName || '', 62 - handle: post.author.handle, 63 - avatar: post.author.avatar, 64 - href: `${baseUrl}/profile/${post.author.did}` 65 - }, 66 - replyCount: post.replyCount ?? 0, 67 - repostCount: post.repostCount ?? 0, 68 - likeCount: post.likeCount ?? 0, 69 - createdAt: post.record.createdAt as string, 70 - 71 - embed: post.embed 72 - ? ({ 73 - type: blueskyEmbedTypeToEmbedType(post.embed?.$type), 74 - // Cast to any to handle union type - properties are conditionally accessed 75 - images: (post.embed as any)?.images?.map((image: any) => ({ 76 - alt: image.alt, 77 - thumb: image.thumb, 78 - aspectRatio: image.aspectRatio, 79 - fullsize: image.fullsize 80 - })), 81 - external: (post.embed as any)?.external 82 - ? { 83 - href: (post.embed as any).external.uri, 84 - title: (post.embed as any).external.title, 85 - description: (post.embed as any).external.description, 86 - thumb: (post.embed as any).external.thumb 87 - } 88 - : undefined, 89 - video: (post.embed as any)?.playlist 90 - ? { 91 - playlist: (post.embed as any).playlist, 92 - thumb: (post.embed as any).thumbnail, 93 - alt: (post.embed as any).alt, 94 - aspectRatio: (post.embed as any).aspectRatio 95 - } 96 - : undefined 97 - } as PostEmbed) 98 - : undefined, 99 - 100 - htmlContent: blueskyPostToHTML(post, baseUrl), 101 - labels: post.labels ? post.labels.map((label) => label.val) : undefined 102 - }; 103 - } 104 - 105 interface MentionFeature { 106 $type: 'app.bsky.richtext.facet#mention'; 107 did: string; ··· 150 const segments = segmentize(text, facets); 151 return segments.map((v) => renderSegment(v, baseUrl)).join(''); 152 }; 153 154 export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') { 155 if (!post?.record) {
··· 1 + import type { PostData, PostEmbed, QuotedPostData } from '../post'; 2 import type { PostView } from '@atcute/bluesky/types/app/feed/defs'; 3 import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 4 ··· 11 .replace(/'/g, '&#39;'); 12 } 13 14 interface MentionFeature { 15 $type: 'app.bsky.richtext.facet#mention'; 16 did: string; ··· 59 const segments = segmentize(text, facets); 60 return segments.map((v) => renderSegment(v, baseUrl)).join(''); 61 }; 62 + 63 + function blueskyEmbedTypeToEmbedType(type: string) { 64 + switch (type) { 65 + case 'app.bsky.embed.external#view': 66 + case 'app.bsky.embed.external': 67 + return 'external'; 68 + case 'app.bsky.embed.images#view': 69 + case 'app.bsky.embed.images': 70 + return 'images'; 71 + case 'app.bsky.embed.video#view': 72 + case 'app.bsky.embed.video': 73 + return 'video'; 74 + case 'app.bsky.embed.record#view': 75 + case 'app.bsky.embed.record': 76 + return 'record'; 77 + case 'app.bsky.embed.recordWithMedia#view': 78 + case 'app.bsky.embed.recordWithMedia': 79 + return 'recordWithMedia'; 80 + default: 81 + return 'unknown'; 82 + } 83 + } 84 + 85 + function extractQuotedPost(recordView: any, baseUrl: string): QuotedPostData | null { 86 + if (!recordView?.author) return null; 87 + 88 + const id = recordView.uri?.split('/').pop(); 89 + const author = recordView.author; 90 + const value = recordView.value as any; 91 + 92 + let htmlContent = ''; 93 + if (value?.text) { 94 + htmlContent = RichText({ text: value.text, facets: value.facets }, baseUrl).replace( 95 + /\n/g, 96 + '<br>' 97 + ); 98 + } 99 + 100 + // Convert nested media embeds (skip record embeds to avoid recursion) 101 + let embed: PostEmbed | undefined; 102 + const firstEmbed = recordView.embeds?.[0] as any; 103 + if (firstEmbed) { 104 + const embedType = blueskyEmbedTypeToEmbedType(firstEmbed.$type); 105 + if (embedType !== 'record' && embedType !== 'recordWithMedia' && embedType !== 'unknown') { 106 + embed = convertEmbed(firstEmbed, baseUrl); 107 + } 108 + } 109 + 110 + return { 111 + author: { 112 + displayName: author.displayName || '', 113 + handle: author.handle, 114 + avatar: author.avatar, 115 + href: `${baseUrl}/profile/${author.did}` 116 + }, 117 + href: `${baseUrl}/profile/${author.handle}/post/${id}`, 118 + htmlContent, 119 + createdAt: value?.createdAt, 120 + embed 121 + }; 122 + } 123 + 124 + function convertEmbed(embedView: any, baseUrl: string): PostEmbed { 125 + const type = blueskyEmbedTypeToEmbedType(embedView?.$type); 126 + 127 + switch (type) { 128 + case 'images': 129 + return { 130 + type: 'images', 131 + images: embedView.images?.map((image: any) => ({ 132 + alt: image.alt, 133 + thumb: image.thumb, 134 + aspectRatio: image.aspectRatio, 135 + fullsize: image.fullsize 136 + })) 137 + }; 138 + case 'external': 139 + return embedView.external 140 + ? { 141 + type: 'external', 142 + external: { 143 + href: embedView.external.uri, 144 + title: embedView.external.title, 145 + description: embedView.external.description, 146 + thumb: embedView.external.thumb 147 + } 148 + } 149 + : { type: 'unknown' }; 150 + case 'video': 151 + return embedView.playlist 152 + ? { 153 + type: 'video', 154 + video: { 155 + playlist: embedView.playlist, 156 + thumb: embedView.thumbnail, 157 + alt: embedView.alt, 158 + aspectRatio: embedView.aspectRatio 159 + } 160 + } 161 + : { type: 'unknown' }; 162 + case 'record': { 163 + const record = extractQuotedPost(embedView.record, baseUrl); 164 + return record ? { type: 'record', record } : { type: 'unknown' }; 165 + } 166 + case 'recordWithMedia': { 167 + const record = extractQuotedPost(embedView.record?.record, baseUrl); 168 + const media = embedView.media ? convertEmbed(embedView.media, baseUrl) : undefined; 169 + if (record) { 170 + return { 171 + type: 'recordWithMedia', 172 + record, 173 + media: media ?? { type: 'unknown' } 174 + }; 175 + } 176 + return media ?? { type: 'unknown' }; 177 + } 178 + default: 179 + return { type: 'unknown' }; 180 + } 181 + } 182 + 183 + export function blueskyPostToPostData( 184 + data: PostView, 185 + baseUrl: string = 'https://bsky.app' 186 + ): PostData { 187 + const post = data; 188 + const id = post.uri.split('/').pop(); 189 + 190 + return { 191 + id, 192 + href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 193 + author: { 194 + displayName: post.author.displayName || '', 195 + handle: post.author.handle, 196 + avatar: post.author.avatar, 197 + href: `${baseUrl}/profile/${post.author.did}` 198 + }, 199 + replyCount: post.replyCount ?? 0, 200 + repostCount: post.repostCount ?? 0, 201 + likeCount: post.likeCount ?? 0, 202 + createdAt: post.record.createdAt as string, 203 + 204 + embed: post.embed ? convertEmbed(post.embed, baseUrl) : undefined, 205 + 206 + htmlContent: blueskyPostToHTML(post, baseUrl), 207 + labels: post.labels ? post.labels.map((label) => label.val) : undefined 208 + }; 209 + } 210 211 export function blueskyPostToHTML(post: PostView, baseUrl: string = 'https://bsky.app') { 212 if (!post?.record) {
+14
src/lib/components/post/embeds/Embed.svelte
··· 3 import External from './External.svelte'; 4 import Images from './Images.svelte'; 5 import Video from './Video.svelte'; 6 7 const { embed }: { embed: PostEmbed } = $props(); 8 </script> ··· 14 <External data={embed} /> 15 {:else if embed.type === 'video' && embed.video} 16 <Video data={embed} /> 17 {:else if embed.type === 'unknown'} 18 <div 19 class="text-base-700 dark:text-base-300 bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-600/30 rounded-2xl border p-4 text-sm"
··· 3 import External from './External.svelte'; 4 import Images from './Images.svelte'; 5 import Video from './Video.svelte'; 6 + import QuotedPost from './QuotedPost.svelte'; 7 8 const { embed }: { embed: PostEmbed } = $props(); 9 </script> ··· 15 <External data={embed} /> 16 {:else if embed.type === 'video' && embed.video} 17 <Video data={embed} /> 18 + {:else if embed.type === 'record' && embed.record} 19 + <QuotedPost record={embed.record} /> 20 + {:else if embed.type === 'recordWithMedia' && embed.record} 21 + {#if embed.media} 22 + {#if embed.media.type === 'images'} 23 + <Images data={embed.media} /> 24 + {:else if embed.media.type === 'external' && embed.media.external} 25 + <External data={embed.media} /> 26 + {:else if embed.media.type === 'video' && embed.media.video} 27 + <Video data={embed.media} /> 28 + {/if} 29 + {/if} 30 + <QuotedPost record={embed.record} /> 31 {:else if embed.type === 'unknown'} 32 <div 33 class="text-base-700 dark:text-base-300 bg-base-200/50 dark:bg-base-900/50 border-base-300 dark:border-base-600/30 rounded-2xl border p-4 text-sm"
+47
src/lib/components/post/embeds/QuotedPost.svelte
···
··· 1 + <script lang="ts"> 2 + import type { QuotedPostData } from '..'; 3 + import { sanitize } from '$lib/sanitize'; 4 + import Images from './Images.svelte'; 5 + import External from './External.svelte'; 6 + import Video from './Video.svelte'; 7 + 8 + const { record }: { record: QuotedPostData } = $props(); 9 + </script> 10 + 11 + <div 12 + class="border-base-300 dark:border-base-600/30 bg-base-950/5 dark:bg-base-950/20 overflow-hidden rounded-2xl border text-sm" 13 + > 14 + <div class="p-3"> 15 + <div class="flex items-center gap-2"> 16 + {#if record.author.avatar} 17 + <img src={record.author.avatar} alt="" class="size-5 rounded-full object-cover" /> 18 + {/if} 19 + <div class="flex items-baseline gap-1.5 overflow-hidden text-xs"> 20 + {#if record.author.displayName} 21 + <span class="text-base-900 dark:text-base-50 truncate font-semibold"> 22 + {record.author.displayName} 23 + </span> 24 + {/if} 25 + <span class="text-base-500 dark:text-base-400 truncate"> 26 + @{record.author.handle} 27 + </span> 28 + </div> 29 + </div> 30 + {#if record.htmlContent} 31 + <div class="text-base-800 dark:text-base-200 accent:text-base-900 mt-1.5 line-clamp-3"> 32 + {@html sanitize(record.htmlContent, { ADD_ATTR: ['target'] })} 33 + </div> 34 + {/if} 35 + </div> 36 + {#if record.embed} 37 + <div class="px-3 pb-3"> 38 + {#if record.embed.type === 'images'} 39 + <Images data={record.embed} /> 40 + {:else if record.embed.type === 'external' && record.embed.external} 41 + <External data={record.embed} /> 42 + {:else if record.embed.type === 'video' && record.embed.video} 43 + <Video data={record.embed} /> 44 + {/if} 45 + </div> 46 + {/if} 47 + </div>
+31 -1
src/lib/components/post/index.ts
··· 39 }; 40 }; 41 42 export type UnknownEmbed = { 43 type: 'unknown'; 44 } & Record<string, unknown>; 45 46 - export type PostEmbed = PostEmbedImage | PostEmbedExternal | PostEmbedVideo | UnknownEmbed; 47 48 export type PostData = { 49 href?: string;
··· 39 }; 40 }; 41 42 + export type QuotedPostData = { 43 + author: { 44 + displayName: string; 45 + handle: string; 46 + avatar?: string; 47 + href?: string; 48 + }; 49 + href?: string; 50 + htmlContent?: string; 51 + createdAt?: string; 52 + embed?: PostEmbed; 53 + }; 54 + 55 + export type PostEmbedRecord = { 56 + type: 'record'; 57 + record: QuotedPostData; 58 + }; 59 + 60 + export type PostEmbedRecordWithMedia = { 61 + type: 'recordWithMedia'; 62 + record: QuotedPostData; 63 + media: PostEmbed; 64 + }; 65 + 66 export type UnknownEmbed = { 67 type: 'unknown'; 68 } & Record<string, unknown>; 69 70 + export type PostEmbed = 71 + | PostEmbedImage 72 + | PostEmbedExternal 73 + | PostEmbedVideo 74 + | PostEmbedRecord 75 + | PostEmbedRecordWithMedia 76 + | UnknownEmbed; 77 78 export type PostData = { 79 href?: string;
+1 -1
src/lib/website/EditableWebsite.svelte
··· 271 // Refresh cached data 272 await fetch('/' + data.handle + '/api/refresh'); 273 } catch (error) { 274 - console.log(error); 275 showSaveModal = false; 276 toast.error('Error saving page!'); 277 } finally {
··· 271 // Refresh cached data 272 await fetch('/' + data.handle + '/api/refresh'); 273 } catch (error) { 274 + console.error(error); 275 showSaveModal = false; 276 toast.error('Error saving page!'); 277 } finally {
+38
src/lib/website/EmptyState.svelte
··· 2 import BaseCard from '$lib/cards/BaseCard/BaseCard.svelte'; 3 import Card from '$lib/cards/Card/Card.svelte'; 4 import type { Item, WebsiteData } from '$lib/types'; 5 6 let { data }: { data: WebsiteData } = $props(); 7 ··· 44 platform: 'bluesky', 45 href: `https://bsky.app/profile/${data.handle}`, 46 color: '0285FF' 47 } 48 }); 49
··· 2 import BaseCard from '$lib/cards/BaseCard/BaseCard.svelte'; 3 import Card from '$lib/cards/Card/Card.svelte'; 4 import type { Item, WebsiteData } from '$lib/types'; 5 + import { text } from '@sveltejs/kit'; 6 7 let { data }: { data: WebsiteData } = $props(); 8 ··· 45 platform: 'bluesky', 46 href: `https://bsky.app/profile/${data.handle}`, 47 color: '0285FF' 48 + } 49 + }); 50 + 51 + items.push({ 52 + id: 'empty-instruction', 53 + x: 0, 54 + y: 3, 55 + w: 8, 56 + h: 1, 57 + mobileX: 0, 58 + mobileY: 6, 59 + mobileW: 8, 60 + mobileH: 2, 61 + cardType: 'text', 62 + color: 'transparent', 63 + cardData: { 64 + text: `Is this your account? Login to start creating your blento!`, 65 + textAlign: 'center', 66 + verticalAlign: 'bottom' 67 + } 68 + }); 69 + 70 + items.push({ 71 + id: 'empty-login-button', 72 + x: 0, 73 + y: 4, 74 + w: 8, 75 + h: 1, 76 + mobileX: 0, 77 + mobileY: 8, 78 + mobileW: 8, 79 + mobileH: 2, 80 + cardType: 'button', 81 + color: 'transparent', 82 + cardData: { 83 + href: '#login', 84 + text: `Login` 85 } 86 }); 87
-1
src/routes/(auth)/oauth/callback/+page.svelte
··· 4 import { getHandleOrDid } from '$lib/atproto/methods'; 5 6 $effect(() => { 7 - console.log('hello', user); 8 if (user.profile) { 9 goto('/' + getHandleOrDid(user.profile) + '/edit', {}); 10 }
··· 4 import { getHandleOrDid } from '$lib/atproto/methods'; 5 6 $effect(() => { 7 if (user.profile) { 8 goto('/' + getHandleOrDid(user.profile) + '/edit', {}); 9 }
+1 -3
src/routes/api/github/+server.ts
··· 26 27 try { 28 const response = await fetch(GithubAPIURL + user); 29 - console.log('hello', user); 30 31 if (!response.ok) { 32 - console.log('error', response.statusText); 33 return json( 34 { error: 'Failed to fetch GitHub data ' + response.statusText }, 35 { status: response.status } ··· 39 const data = await response.json(); 40 41 if (!data?.user) { 42 - console.log('user not found', response.statusText); 43 return json({ error: 'User not found' }, { status: 404 }); 44 } 45
··· 26 27 try { 28 const response = await fetch(GithubAPIURL + user); 29 30 if (!response.ok) { 31 + console.error('error', response.statusText); 32 return json( 33 { error: 'Failed to fetch GitHub data ' + response.statusText }, 34 { status: response.status } ··· 38 const data = await response.json(); 39 40 if (!data?.user) { 41 return json({ error: 'User not found' }, { status: 404 }); 42 } 43
+53
src/routes/api/github/contributors/+server.ts
···
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + 4 + const GithubContributorsAPIURL = 5 + 'https://edge-function-github-contribution.vercel.app/api/github-contributors'; 6 + 7 + export const GET: RequestHandler = async ({ url, platform }) => { 8 + const owner = url.searchParams.get('owner'); 9 + const repo = url.searchParams.get('repo'); 10 + 11 + if (!owner || !repo) { 12 + return json({ error: 'Missing owner or repo parameter' }, { status: 400 }); 13 + } 14 + 15 + const cacheKey = `#github-contributors:${owner}/${repo}`; 16 + const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey); 17 + 18 + if (cachedData) { 19 + const parsedCache = JSON.parse(cachedData); 20 + 21 + const TWELVE_HOURS = 12 * 60 * 60 * 1000; 22 + const now = Date.now(); 23 + 24 + if (now - (parsedCache.updatedAt || 0) < TWELVE_HOURS) { 25 + return json(parsedCache.data); 26 + } 27 + } 28 + 29 + try { 30 + const response = await fetch( 31 + `${GithubContributorsAPIURL}?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}` 32 + ); 33 + 34 + if (!response.ok) { 35 + return json( 36 + { error: 'Failed to fetch GitHub contributors ' + response.statusText }, 37 + { status: response.status } 38 + ); 39 + } 40 + 41 + const data = await response.json(); 42 + 43 + await platform?.env?.USER_DATA_CACHE?.put( 44 + cacheKey, 45 + JSON.stringify({ data, updatedAt: Date.now() }) 46 + ); 47 + 48 + return json(data); 49 + } catch (error) { 50 + console.error('Error fetching GitHub contributors:', error); 51 + return json({ error: 'Failed to fetch GitHub contributors' }, { status: 500 }); 52 + } 53 + };
-2
src/routes/api/update/+server.ts
··· 18 for (const handle of existingUsersHandle) { 19 if (!handle) continue; 20 21 - console.log('updating', handle); 22 try { 23 const cached = await getCache(handle, 'self', cache as UserCache); 24 if (!cached) await loadData(handle, cache as UserCache, true); ··· 26 console.error(error); 27 return json('error'); 28 } 29 - console.log('updated', handle); 30 } 31 32 return json('ok');
··· 18 for (const handle of existingUsersHandle) { 19 if (!handle) continue; 20 21 try { 22 const cached = await getCache(handle, 'self', cache as UserCache); 23 if (!cached) await loadData(handle, cache as UserCache, true); ··· 25 console.error(error); 26 return json('error'); 27 } 28 } 29 30 return json('ok');