your personal website on atproto - mirror blento.app

add github profile card

Florian f0cea4f6 732a8aa2

+345 -2
+2 -1
.claude/settings.local.json
··· 3 "allow": [ 4 "Bash(pnpm check:*)", 5 "mcp__ide__getDiagnostics", 6 - "mcp__plugin_svelte_svelte__svelte-autofixer" 7 ] 8 } 9 }
··· 3 "allow": [ 4 "Bash(pnpm check:*)", 5 "mcp__ide__getDiagnostics", 6 + "mcp__plugin_svelte_svelte__svelte-autofixer", 7 + "mcp__plugin_svelte_svelte__list-sections" 8 ] 9 } 10 }
+100
src/lib/cards/GitHubProfileCard/GitHubProfileCard.svelte
···
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { siGithub } from 'simple-icons'; 4 + import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 5 + import type { ContentComponentProps } from '../types'; 6 + import type { GithubProfileLoadedData } from '.'; 7 + import GithubContributionsGraph from './GithubContributionsGraph.svelte'; 8 + import { Button } from '@foxui/core'; 9 + 10 + let { item }: ContentComponentProps = $props(); 11 + 12 + const data = getAdditionalUserData(); 13 + 14 + let isLoaded = $state(false); 15 + // svelte-ignore state_referenced_locally 16 + let contributionsData = $state( 17 + (data[item.cardType] as GithubProfileLoadedData)?.[item.cardData.user] 18 + ); 19 + 20 + onMount(async () => { 21 + console.log(contributionsData); 22 + if (!contributionsData && item.cardData?.user) { 23 + try { 24 + const response = await fetch(`/api/github?user=${encodeURIComponent(item.cardData.user)}`); 25 + if (response.ok) { 26 + contributionsData = await response.json(); 27 + data[item.cardType] ??= {}; 28 + data[item.cardType][item.cardData.user] = contributionsData; 29 + } 30 + } catch (error) { 31 + console.error('Failed to fetch GitHub contributions:', error); 32 + } 33 + } 34 + isLoaded = true; 35 + }); 36 + 37 + let isMobile = getIsMobile(); 38 + </script> 39 + 40 + <div class="h-full overflow-hidden p-4"> 41 + {#if contributionsData} 42 + <div class="flex h-full flex-col justify-between"> 43 + <!-- Header --> 44 + <div class="flex justify-between"> 45 + <div class="flex items-center gap-3"> 46 + <div class="fill-base-950 size-6 shrink-0 dark:fill-white [&_svg]:size-full"> 47 + {@html siGithub.svg} 48 + </div> 49 + <a 50 + href="https://github.com/{item.cardData.user}" 51 + target="_blank" 52 + rel="noopener noreferrer" 53 + class=" flex truncate text-2xl font-bold transition-colors" 54 + > 55 + {item.cardData.user} 56 + </a> 57 + </div> 58 + 59 + {#if isMobile() ? item.mobileW > 4 : item.w > 2} 60 + <Button 61 + href="https://github.com/{item.cardData.user}" 62 + target="_blank" 63 + rel="noopener noreferrer" 64 + class="z-50">Follow</Button 65 + > 66 + {/if} 67 + </div> 68 + 69 + <div class="flex"> 70 + <GithubContributionsGraph 71 + data={contributionsData} 72 + isBig={isMobile() ? item.mobileH > 5 : item.h > 2} 73 + /> 74 + </div> 75 + </div> 76 + {:else if isLoaded} 77 + <div 78 + class="text-base-600 dark:text-base-400 accent:text-base-800 flex h-full w-full items-center justify-center text-sm" 79 + > 80 + Could not load GitHub contributions 81 + </div> 82 + {:else} 83 + <div 84 + class="text-base-600 dark:text-base-400 accent:text-base-800 flex h-full w-full items-center justify-center text-sm" 85 + > 86 + Loading contributions... 87 + </div> 88 + {/if} 89 + </div> 90 + 91 + {#if item.cardData.href} 92 + <a 93 + href={item.cardData.href} 94 + class="absolute inset-0 h-full w-full" 95 + target="_blank" 96 + rel="noopener noreferrer" 97 + > 98 + <span class="sr-only"> Show on github </span> 99 + </a> 100 + {/if}
+29
src/lib/cards/GitHubProfileCard/GithubContributionsGraph.svelte
···
··· 1 + <script lang="ts"> 2 + import { cn } from '@foxui/core'; 3 + import type { GitHubContributionsData } from './types'; 4 + 5 + let { data, isBig = false }: { data: GitHubContributionsData; isBig: boolean } = $props(); 6 + 7 + let colors: Record<string, string> = { 8 + '#ebedf0': 'bg-accent-200/50 dark:bg-accent-950/30 accent:bg-accent-800/20', 9 + '#9be9a8': 'bg-accent-300/50 dark:bg-accent-800/70 accent:bg-accent-800/40', 10 + '#40c463': 'bg-accent-300 dark:bg-accent-700 accent:bg-accent-800/60', 11 + '#30a14e': 'bg-accent-400 dark:bg-accent-600 accent:bg-accent-800/80', 12 + '#216e39': 'bg-accent-500 accent:bg-accent-800' 13 + }; 14 + </script> 15 + 16 + <div class={cn('flex h-full w-full justify-end gap-0.5', isBig && 'gap-1')}> 17 + {#each data.contributionsCollection.contributionCalendar.weeks as week (week.contributionDays)} 18 + <div class={cn('flex w-full flex-col gap-0.5', isBig && 'gap-1')}> 19 + {#if week.contributionDays.length === 7} 20 + {#each week.contributionDays as day (day.date)} 21 + <div 22 + class={cn('size-2.5 rounded-sm', colors[day.color], isBig && 'size-3')} 23 + title="Contributions: {day.contributionCount} on {day.date}" 24 + ></div> 25 + {/each} 26 + {/if} 27 + </div> 28 + {/each} 29 + </div>
+90
src/lib/cards/GitHubProfileCard/index.ts
···
··· 1 + import type { CardDefinition } from '../types'; 2 + import type GithubContributionsGraph from './GithubContributionsGraph.svelte'; 3 + import GitHubProfileCard from './GitHubProfileCard.svelte'; 4 + import type { GitHubContributionsData } from './types'; 5 + 6 + export type GithubProfileLoadedData = Record<string, GitHubContributionsData | undefined>; 7 + 8 + export const GithubProfileCardDefitition = { 9 + type: 'githubProfile', 10 + contentComponent: GitHubProfileCard, 11 + 12 + loadData: async (items) => { 13 + const githubData: Record<string, GithubContributionsGraph> = {}; 14 + for (const item of items) { 15 + try { 16 + const response = await fetch( 17 + `https://blento.app/api/github?user=${encodeURIComponent(item.cardData.user)}` 18 + ); 19 + if (response.ok) { 20 + githubData[item.cardData.user] = await response.json(); 21 + } 22 + } catch (error) { 23 + console.error('Failed to fetch GitHub contributions:', error); 24 + } 25 + } 26 + return githubData; 27 + }, 28 + onUrlHandler: (url, item) => { 29 + const username = getGitHubUsername(url); 30 + 31 + console.log(username); 32 + if (!username) return; 33 + 34 + item.cardData.href = url; 35 + item.cardData.user = username; 36 + 37 + item.w = 6; 38 + item.mobileW = 8; 39 + item.h = 3; 40 + item.mobileH = 6; 41 + return item; 42 + }, 43 + urlHandlerPriority: 5, 44 + minH: 2, 45 + minW: 2, 46 + 47 + canChange: (item) => Boolean(getGitHubUsername(item.cardData.href)), 48 + change: (item) => { 49 + item.cardData.user = getGitHubUsername(item.cardData.href); 50 + 51 + return item; 52 + }, 53 + name: 'Github Profile' 54 + } as CardDefinition & { type: 'githubProfile' }; 55 + 56 + function getGitHubUsername(url: string | undefined): string | undefined { 57 + if (!url) return; 58 + 59 + try { 60 + const parsed = new URL(url); 61 + 62 + // Must be github.com (optionally with www.) 63 + if (!/^(www\.)?github\.com$/.test(parsed.hostname)) { 64 + return undefined; 65 + } 66 + 67 + // Remove empty segments 68 + const segments = parsed.pathname.split('/').filter(Boolean); 69 + 70 + // Profile URLs have exactly one path segment: /username 71 + if (segments.length !== 1) { 72 + return undefined; 73 + } 74 + 75 + const username = segments[0]; 76 + 77 + // GitHub username rules (simplified but accurate) 78 + // - Alphanumeric or hyphens 79 + // - Cannot start or end with a hyphen 80 + // - Max length 39 81 + if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(username)) { 82 + return undefined; 83 + } 84 + 85 + return username; 86 + } catch { 87 + // Invalid URL 88 + return undefined; 89 + } 90 + }
+25
src/lib/cards/GitHubProfileCard/types.ts
···
··· 1 + export type GitHubContributionDay = { 2 + date: string; 3 + contributionCount: number; 4 + color: string; 5 + }; 6 + 7 + export type GitHubContributionWeek = { 8 + contributionDays: GitHubContributionDay[]; 9 + }; 10 + 11 + export type GitHubContributionsData = { 12 + login: string; 13 + avatarUrl: string; 14 + contributionsCollection: { 15 + contributionCalendar: { 16 + totalContributions: number; 17 + weeks: GitHubContributionWeek[]; 18 + }; 19 + }; 20 + followers: { 21 + totalCount: number; 22 + }; 23 + 24 + updatedAt: number; 25 + };
+3 -1
src/lib/cards/index.ts
··· 16 import { VideoCardDefinition } from './VideoCard'; 17 import { YoutubeCardDefinition } from './YoutubeVideoCard'; 18 import { BlueskyProfileCardDefinition } from './BlueskyProfileCard'; 19 20 export const AllCardDefinitions = [ 21 ImageCardDefinition, ··· 34 SectionCardDefinition, 35 BlueskyMediaCardDefinition, 36 DinoGameCardDefinition, 37 - BlueskyProfileCardDefinition 38 ] as const; 39 40 export const CardDefinitionsByType = AllCardDefinitions.reduce(
··· 16 import { VideoCardDefinition } from './VideoCard'; 17 import { YoutubeCardDefinition } from './YoutubeVideoCard'; 18 import { BlueskyProfileCardDefinition } from './BlueskyProfileCard'; 19 + import { GithubProfileCardDefitition } from './GitHubProfileCard'; 20 21 export const AllCardDefinitions = [ 22 ImageCardDefinition, ··· 35 SectionCardDefinition, 36 BlueskyMediaCardDefinition, 37 DinoGameCardDefinition, 38 + BlueskyProfileCardDefinition, 39 + GithubProfileCardDefitition 40 ] as const; 41 42 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+1
src/lib/cards/types.ts
··· 40 41 // optionally load some extra data 42 loadData?: ( 43 items: Item[], 44 { did, handle, cache }: { did: string; handle: string; cache?: UserCache } 45 ) => Promise<unknown>;
··· 40 41 // optionally load some extra data 42 loadData?: ( 43 + // all cards of that type 44 items: Item[], 45 { did, handle, cache }: { did: string; handle: string; cache?: UserCache } 46 ) => Promise<unknown>;
+95
src/routes/api/github/+server.ts
···
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { env } from '$env/dynamic/private'; 4 + import type { GitHubContributionsData } from '$lib/cards/GitHubProfileCard/types'; 5 + 6 + const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql'; 7 + 8 + const CONTRIBUTIONS_QUERY = ` 9 + query($login: String!) { 10 + user(login: $login) { 11 + login 12 + avatarUrl 13 + contributionsCollection { 14 + contributionCalendar { 15 + totalContributions 16 + weeks { 17 + contributionDays { 18 + date 19 + contributionCount 20 + color 21 + } 22 + } 23 + } 24 + } 25 + followers { 26 + totalCount 27 + } 28 + } 29 + } 30 + `; 31 + 32 + export const GET: RequestHandler = async ({ url, platform }) => { 33 + const user = url.searchParams.get('user'); 34 + 35 + if (!user) { 36 + return json({ error: 'No user provided' }, { status: 400 }); 37 + } 38 + 39 + const cachedData = await platform?.env?.USER_DATA_CACHE?.get('#github:' + user); 40 + 41 + if (cachedData) { 42 + const parsedCache = JSON.parse(cachedData); 43 + 44 + const TWELVE_HOURS = 12 * 60 * 60 * 1000; 45 + const now = Date.now(); 46 + 47 + if (now - (parsedCache.updatedAt || 0) < TWELVE_HOURS) { 48 + return json(parsedCache); 49 + } 50 + } 51 + 52 + const token = env.GITHUB_TOKEN; 53 + 54 + if (!token) { 55 + return json({ error: 'GitHub token not configured' }, { status: 500 }); 56 + } 57 + 58 + try { 59 + const response = await fetch(GITHUB_GRAPHQL_URL, { 60 + method: 'POST', 61 + headers: { 62 + 'Content-Type': 'application/json', 63 + Authorization: `Bearer ${token}` 64 + }, 65 + body: JSON.stringify({ 66 + query: CONTRIBUTIONS_QUERY, 67 + variables: { login: user } 68 + }) 69 + }); 70 + 71 + if (!response.ok) { 72 + return json({ error: 'Failed to fetch GitHub data' }, { status: response.status }); 73 + } 74 + 75 + const data = await response.json(); 76 + 77 + if (data.errors) { 78 + return json({ error: data.errors[0]?.message || 'GraphQL error' }, { status: 400 }); 79 + } 80 + 81 + if (!data.data?.user) { 82 + return json({ error: 'User not found' }, { status: 404 }); 83 + } 84 + 85 + const result = data.data.user as GitHubContributionsData; 86 + result.updatedAt = Date.now(); 87 + 88 + await platform?.env?.USER_DATA_CACHE?.put('#github:' + user, JSON.stringify(result)); 89 + 90 + return json(result); 91 + } catch (error) { 92 + console.error('Error fetching GitHub contributions:', error); 93 + return json({ error: 'Failed to fetch GitHub data' }, { status: 500 }); 94 + } 95 + };