tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
add github profile card
Florian
3 weeks ago
f0cea4f6
732a8aa2
+345
-2
8 changed files
expand all
collapse all
unified
split
.claude
settings.local.json
src
lib
cards
GitHubProfileCard
GitHubProfileCard.svelte
GithubContributionsGraph.svelte
index.ts
types.ts
index.ts
types.ts
routes
api
github
+server.ts
+2
-1
.claude/settings.local.json
···
3
"allow": [
4
"Bash(pnpm check:*)",
5
"mcp__ide__getDiagnostics",
6
-
"mcp__plugin_svelte_svelte__svelte-autofixer"
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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';
0
19
20
export const AllCardDefinitions = [
21
ImageCardDefinition,
···
34
SectionCardDefinition,
35
BlueskyMediaCardDefinition,
36
DinoGameCardDefinition,
37
-
BlueskyProfileCardDefinition
0
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?: (
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
+
};