ActivityUpdated 24 days ago
42,000
3,600
1,600
310
TypeScript
npm install clsx tailwind-merge
/lib/utils.ts
Create a utils.ts file with the cn utility function
1import { clsx, type ClassValue } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs));
6}
1"use client";
2import { useState, useEffect, useCallback, useMemo } from "react";
3import Link from "next/link";
4import { BookOpen, Code, Eye, Github, History, Star, GitFork, AlertCircle, Check } from "lucide-react";
5import { cn } from "@/lib/utils";
6import Image from "next/image";
7
8const LANGUAGE_COLORS = {
9 JavaScript: "#f1e05a",
10 TypeScript: "#3178c6",
11 Python: "#3572A5",
12 Java: "#b07219",
13 Go: "#00ADD8",
14 Rust: "#dea584",
15 C: "#555555",
16 "C++": "#f34b7d",
17 "C#": "#178600",
18 PHP: "#4F5D95",
19 Ruby: "#701516",
20 Swift: "#F05138",
21 Kotlin: "#A97BFF",
22 Dart: "#00B4AB",
23 HTML: "#e34c26",
24 CSS: "#563d7c",
25 Shell: "#89e051",
26};
27
28export type ThemeOption = {
29 id: string;
30 name: string;
31 description: string;
32 cardBg: string;
33 cardBorder: string;
34 cardHoverShadow: string;
35 accentColor: string;
36 accentColorLight: string;
37 graphColor: string;
38 graphBgColor: string;
39 badgeBg: string;
40 badgeText: string;
41 textMuted: string;
42 textNormal: string;
43};
44
45export const themes: ThemeOption[] = [
46 {
47 id: "modern-dark",
48 name: "Modern Dark",
49 description: "Sleek dark theme with blue accents",
50 cardBg: "bg-gradient-to-br from-slate-900 to-slate-800",
51 cardBorder: "border border-slate-700/50",
52 cardHoverShadow: "hover:shadow-lg hover:shadow-blue-500/10 transition-all duration-300",
53 accentColor: "text-blue-400",
54 accentColorLight: "text-blue-400/10",
55 graphColor: "text-blue-400",
56 graphBgColor: "text-blue-400/10",
57 badgeBg: "bg-slate-800/80 backdrop-blur-sm",
58 badgeText: "text-blue-300",
59 textMuted: "text-slate-400",
60 textNormal: "text-slate-200",
61 },
62 {
63 id: "modern-light",
64 name: "Modern Light",
65 description: "Clean light theme with subtle shadows",
66 cardBg: "bg-gradient-to-br from-white to-slate-50",
67 cardBorder: "border border-slate-200",
68 cardHoverShadow: "hover:shadow-lg hover:shadow-slate-200/50 transition-all duration-300",
69 accentColor: "text-indigo-600",
70 accentColorLight: "text-indigo-600/10",
71 graphColor: "text-indigo-600",
72 graphBgColor: "text-indigo-600/10",
73 badgeBg: "bg-indigo-50",
74 badgeText: "text-indigo-700",
75 textMuted: "text-slate-600",
76 textNormal: "text-slate-900",
77 },
78 {
79 id: "retro",
80 name: "Neo Brutalist",
81 description: "Bold contrasting theme with box shadows",
82 cardBg: "bg-amber-50",
83 cardBorder: "border-2 border-black",
84 cardHoverShadow: "hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] transition-all duration-300",
85 accentColor: "text-rose-600",
86 accentColorLight: "text-rose-600/10",
87 graphColor: "text-rose-600",
88 graphBgColor: "text-rose-600/10",
89 badgeBg: "bg-white border border-black",
90 badgeText: "text-black font-bold",
91 textMuted: "text-slate-700",
92 textNormal: "text-black",
93 },
94 {
95 id: "midnight",
96 name: "Midnight",
97 description: "Deep dark theme with vibrant purples",
98 cardBg: "bg-gradient-to-br from-slate-950 to-slate-900",
99 cardBorder: "border border-purple-900/30",
100 cardHoverShadow: "hover:shadow-lg hover:shadow-purple-500/20 transition-all duration-300",
101 accentColor: "text-purple-400",
102 accentColorLight: "text-purple-400/10",
103 graphColor: "text-purple-400",
104 graphBgColor: "text-purple-400/10",
105 badgeBg: "bg-purple-950/80 backdrop-blur-sm",
106 badgeText: "text-purple-300",
107 textMuted: "text-slate-400",
108 textNormal: "text-slate-200",
109 }
110];
111
112export type RepoData = {
113 name: string;
114 fullName: string;
115 description?: string;
116 owner: {
117 login: string;
118 avatarUrl: string;
119 };
120 stars: number;
121 forks: number;
122 watchers: number;
123 issues: number;
124 language?: string;
125 languageColor?: string;
126 updatedAt: string;
127 topics: string[];
128 activityData?: number[];
129 isPrivate: boolean;
130};
131const CACHE_TTL = 15 * 60 * 1000;
132
133interface GitHubRepoCardProps {
134 repoOwner?: string;
135 repoName?: string;
136 githubToken?: string;
137 manualMode?: boolean;
138 repoData?: RepoData;
139 themeId?: string;
140}
141
142const getLanguageColor = (language: string) => {
143 return LANGUAGE_COLORS[language as keyof typeof LANGUAGE_COLORS] || "#858585";
144};
145
146const formatRelativeTime = (dateString: string) => {
147 const date = new Date(dateString);
148 const now = new Date();
149 const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
150 const intervals = {
151 year: 31536000,
152 month: 2592000,
153 day: 86400,
154 hour: 3600,
155 minute: 60,
156 second: 1
157 };
158 for (const [unit, seconds] of Object.entries(intervals)) {
159 const count = Math.floor(diffInSeconds / seconds);
160 if (count >= 1) {
161 return `${count} ${unit}${count !== 1 ? 's' : ''} ago`;
162 }
163 }
164 return 'just now';
165};
166
167const getCacheKey = (repoOwner: string, repoName: string) => {
168 return `github_repo_${repoOwner}_${repoName}`;
169};
170
171export function GitHubRepoCard({
172 repoOwner,
173 repoName,
174 githubToken,
175 manualMode = false,
176 repoData,
177 themeId = "modern-light",
178}: GitHubRepoCardProps) {
179 const [copied, setCopied] = useState(false);
180 const [loading, setLoading] = useState(!manualMode);
181 const [error, setError] = useState<string | null>(null);
182 const [repo, setRepo] = useState<RepoData | null>(manualMode ? repoData || null : null);
183 const [rateLimit, setRateLimit] = useState<{ remaining: number; limit: number } | null>(null);
184
185 const currentTheme = useMemo(() =>
186 themes.find((theme) => theme.id === themeId) || themes[0],
187 [themeId]);
188
189 const repoUrl = useMemo(() => {
190 if (!repo) return '';
191 return `https://github.com/${repo.fullName}`;
192 }, [repo]);
193
194 const cloneCommand = useMemo(() => {
195 if (!repo) return '';
196 return `git clone https://github.com/${repo.fullName}.git`;
197 }, [repo]);
198
199 const getCachedData = useCallback(() => {
200 if (!repoOwner || !repoName || typeof window === 'undefined') return null;
201
202 try {
203 const cacheKey = getCacheKey(repoOwner, repoName);
204 const cachedData = localStorage.getItem(cacheKey);
205
206 if (cachedData) {
207 const { data, timestamp } = JSON.parse(cachedData);
208
209 if (Date.now() - timestamp < CACHE_TTL) {
210 return data;
211 } else {
212 localStorage.removeItem(cacheKey);
213 }
214 }
215 } catch (err) {
216 console.error('Error reading from cache:', err);
217 }
218
219 return null;
220 }, [repoOwner, repoName]);
221
222 const setCachedData = useCallback((data: RepoData) => {
223 if (!repoOwner || !repoName || typeof window === 'undefined') return;
224
225 try {
226 const cacheKey = getCacheKey(repoOwner, repoName);
227 const cacheData = JSON.stringify({
228 data,
229 timestamp: Date.now(),
230 });
231
232 localStorage.setItem(cacheKey, cacheData);
233 } catch (err) {
234 console.error('Error writing to cache:', err);
235 }
236 }, [repoOwner, repoName]);
237
238 const fetchRepoData = useCallback(async () => {
239 if (!repoOwner || !repoName) return;
240
241 const cachedData = getCachedData();
242 if (cachedData) {
243 setRepo(cachedData);
244 setLoading(false);
245 return;
246 }
247 setLoading(true);
248 setError(null);
249 try {
250 const headers: HeadersInit = {};
251 if (githubToken) {
252 headers.Authorization = `token ${githubToken}`;
253 }
254 const repoResponse = await fetch(
255 `https://api.github.com/repos/${repoOwner}/${repoName}`,
256 { headers }
257 );
258 const rateLimitRemaining = repoResponse.headers.get("x-ratelimit-remaining");
259 const rateLimitLimit = repoResponse.headers.get("x-ratelimit-limit");
260
261 if (rateLimitRemaining && rateLimitLimit) {
262 setRateLimit({
263 remaining: parseInt(rateLimitRemaining, 10),
264 limit: parseInt(rateLimitLimit, 10),
265 });
266 }
267 if (!repoResponse.ok) {
268 if (repoResponse.status === 403 && rateLimitRemaining === "0") {
269 throw new Error("GitHub API rate limit exceeded. Please provide a GitHub token.");
270 } else {
271 throw new Error(`Failed to fetch repository data: ${repoResponse.status}`);
272 }
273 }
274 const repoData = await repoResponse.json();
275 const commitsResponse = await fetch(
276 `https://api.github.com/repos/${repoOwner}/${repoName}/stats/commit_activity`,
277 { headers }
278 );
279 let activityData: number[] = [];
280 if (commitsResponse.ok) {
281 const commitsData = await commitsResponse.json();
282 const recentCommits = commitsData.slice(-12).map((week: { total: number }) => week.total);
283 const maxCommit = Math.max(...recentCommits, 1);
284 activityData = recentCommits.map((count: number) => count / maxCommit);
285 }
286 const transformedRepo: RepoData = {
287 name: repoData.name,
288 fullName: repoData.full_name,
289 description: repoData.description || "",
290 owner: {
291 login: repoData.owner.login,
292 avatarUrl: repoData.owner.avatar_url,
293 },
294 stars: repoData.stargazers_count,
295 forks: repoData.forks_count,
296 watchers: repoData.watchers_count,
297 issues: repoData.open_issues_count,
298 language: repoData.language,
299 languageColor: repoData.language ? getLanguageColor(repoData.language) : undefined,
300 updatedAt: repoData.updated_at,
301 topics: repoData.topics || [],
302 activityData,
303 isPrivate: repoData.private,
304 };
305
306 setRepo(transformedRepo);
307 setCachedData(transformedRepo);
308 setLoading(false);
309 } catch (err) {
310 setError(err instanceof Error ? err.message : "An unknown error occurred");
311 setLoading(false);
312 }
313 }, [repoOwner, repoName, githubToken, getCachedData, setCachedData]);
314
315 useEffect(() => {
316 if (manualMode && repoData) {
317 setRepo(repoData);
318 setLoading(false);
319 return;
320 }
321
322 if (!manualMode && repoOwner && repoName) {
323 fetchRepoData();
324 }
325 }, [manualMode, repoData, repoOwner, repoName, fetchRepoData]);
326
327 const copyToClipboard = () => {
328 if (repo) {
329 navigator.clipboard
330 .writeText(cloneCommand)
331 .then(() => {
332 setCopied(true);
333 setTimeout(() => setCopied(false), 2000);
334 })
335 .catch(console.error);
336 }
337 };
338
339 const renderLoadingState = () => (
340 <div
341 className={cn(
342 "w-full max-w-full overflow-hidden transition-all duration-300 p-4 sm:p-6 rounded-md",
343 currentTheme.cardBg,
344 currentTheme.cardBorder,
345 currentTheme.cardHoverShadow
346 )}
347 aria-busy="true"
348 aria-live="polite"
349 >
350 <div className="pb-2">
351 <div className="mb-2">
352 <div className="flex items-center gap-2">
353 <div className="h-4 w-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
354 <div className="flex items-center">
355 <div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
356 <div className="mx-1 h-4 w-2 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
357 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
358 </div>
359 <div className="ml-auto h-4 w-28 bg-gray-200 dark:bg-gray-700 rounded animate-pulse hidden sm:block" />
360 </div>
361 </div>
362
363 <div className="flex items-center gap-2 mb-3">
364 <div className="h-6 w-6 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse" />
365 <div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
366 <div className="ml-auto h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse" />
367 </div>
368
369 <div className="space-y-2 mb-4">
370 <div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
371 <div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
372 </div>
373 </div>
374 <div className="pb-2">
375 <div className="mb-4 sm:mb-6 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
376 <div className="mb-1 sm:mb-2 flex items-center justify-between p-1.5 sm:p-2">
377 <div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
378 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
379 </div>
380 <div className="h-[40px] sm:h-[60px] w-full rounded-b-lg bg-gray-100 dark:bg-gray-800 p-1 sm:p-2 animate-pulse">
381 <div className="h-full w-full bg-gray-200 dark:bg-gray-700 rounded opacity-30 animate-pulse" />
382 </div>
383 </div>
384 <div className="grid grid-cols-2 xs:grid-cols-4 gap-1 sm:gap-2">
385 {[0, 1, 2, 3].map((index) => (
386 <div key={index} className="flex items-center gap-1">
387 <div className="h-4 w-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
388 <div className="h-4 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
389 </div>
390 ))}
391 </div>
392 <div className="mt-3 sm:mt-4">
393 <div className="mb-1.5 sm:mb-2 flex items-center gap-2">
394 <div className="flex items-center gap-1.5">
395 <div className="h-3 w-3 rounded-full bg-gray-200 dark:bg-gray-700 animate-pulse" />
396 <div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
397 </div>
398 </div>
399
400 <div className="flex flex-col xs:flex-row items-start xs:items-center justify-between gap-2">
401 <div className="flex flex-wrap gap-1.5">
402 {[0, 1, 2].map((index) => (
403 <div
404 key={index}
405 className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-md animate-pulse"
406 />
407 ))}
408 </div>
409 <div className="w-full xs:w-auto flex justify-end">
410 <div className="h-6 w-16 bg-gray-200 dark:bg-gray-700 rounded-md animate-pulse" />
411 </div>
412 </div>
413 </div>
414 </div>
415 </div>
416 );
417 const renderErrorState = () => (
418 <div
419 className={cn(
420 "w-full max-w-full overflow-hidden transition-all duration-300 p-4 sm:p-6 rounded-md",
421 currentTheme.cardBg,
422 currentTheme.cardBorder,
423 currentTheme.cardHoverShadow
424 )}
425 aria-live="assertive"
426 >
427 <div className="flex flex-col items-center justify-center py-6 sm:py-8">
428 <div className={cn(
429 "flex items-center justify-center w-16 h-16 mb-4 rounded-full",
430 currentTheme.badgeBg
431 )}>
432 <AlertCircle className={cn("h-8 w-8", currentTheme.accentColor)} aria-hidden="true" />
433 </div>
434
435 <h3 className={cn("text-lg font-semibold mb-2 text-center", currentTheme.textNormal)}>
436 Repository Not Found
437 </h3>
438
439 <p className={cn("text-sm text-center max-w-xs mb-4", currentTheme.textMuted)}>
440 {error?.includes("404")
441 ? "This repository doesn't exist or you might not have access to it."
442 : error?.includes("rate limit")
443 ? "GitHub API rate limit exceeded. Please try again later or use a GitHub token."
444 : error}
445 </p>
446
447 <div className="flex flex-wrap gap-3 justify-center">
448 {error?.includes("rate limit") && (
449 <button
450 onClick={() => {
451 if (typeof window !== 'undefined') {
452 try {
453 const cacheKey = getCacheKey(repoOwner || '', repoName || '');
454 localStorage.removeItem(cacheKey);
455 } catch (e) {
456 console.error('Error clearing cache:', e);
457 }
458 }
459 fetchRepoData();
460 }}
461 className={cn(
462 "flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md",
463 currentTheme.badgeBg,
464 currentTheme.badgeText,
465 "hover:opacity-90 transition-all duration-200"
466 )}
467 >
468 <svg
469 className="h-4 w-4"
470 fill="none"
471 viewBox="0 0 24 24"
472 stroke="currentColor"
473 >
474 <path
475 strokeLinecap="round"
476 strokeLinejoin="round"
477 strokeWidth={2}
478 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"
479 />
480 </svg>
481 Try Again
482 </button>
483 )}
484
485 <Link
486 href={`https://github.com/${repoOwner || ''}/${repoName || ''}`}
487 target="_blank"
488 rel="noopener noreferrer"
489 className={cn(
490 "flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md",
491 currentTheme.badgeBg,
492 currentTheme.badgeText,
493 "hover:opacity-90 transition-all duration-200"
494 )}
495 >
496 <Github className="h-4 w-4" aria-hidden="true" />
497 Visit GitHub
498 </Link>
499 </div>
500 </div>
501 </div>
502 );
503
504 if (loading) return renderLoadingState();
505 if (error) return renderErrorState();
506 if (!repo) return null;
507
508 return (
509 <div
510 className={cn(
511 "w-full max-w-full overflow-hidden transition-all duration-300 p-4 sm:p-6 rounded-md",
512 currentTheme.cardBg,
513 currentTheme.cardBorder,
514 currentTheme.cardHoverShadow
515 )}
516 role="article"
517 aria-label={`GitHub repository: ${repo?.name}`}
518 >
519 <div className="pb-2">
520 <div>
521 <div className="mb-2">
522 <div className="flex flex-wrap items-center gap-1 sm:gap-2">
523 <Github className={cn("h-3 w-3 sm:h-4 sm:w-4", currentTheme.accentColor)} aria-hidden="true" />
524 <div className="flex items-center text-xs sm:text-sm">
525 <Link
526 href={`https://github.com/${repo.owner.login}`}
527 className={cn("hover:underline font-medium", currentTheme.textMuted)}
528 aria-label={`View ${repo.owner.login}'s GitHub profile`}
529 >
530 {repo.owner.login}
531 </Link>
532 <span className={cn("mx-1", currentTheme.textMuted)} aria-hidden="true">/</span>
533 <Link
534 href={repoUrl}
535 className={cn("font-medium hover:underline", currentTheme.accentColor)}
536 aria-label={`View ${repo.name} repository on GitHub`}
537 >
538 {repo.name}
539 </Link>
540 </div>
541 {rateLimit && (
542 <div className={cn("ml-auto text-xs font-medium hidden sm:flex items-center", currentTheme.textMuted)} aria-label={`GitHub API rate limit: ${rateLimit.remaining} of ${rateLimit.limit} requests remaining`}>
543 <span>{rateLimit.remaining}/{rateLimit.limit}</span>
544 <span className="ml-1 text-xs">API requests</span>
545 </div>
546 )}
547 </div>
548 </div>
549 </div>
550 <div className="flex items-center gap-2">
551 <div className="relative inline-flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center overflow-hidden rounded-full">
552 {repo.owner.avatarUrl ? (
553 <Image
554 src={repo.owner.avatarUrl}
555 alt={`${repo.owner.login}'s avatar`}
556 className="h-full w-full object-cover"
557 width={24}
558 height={24}
559 loading="lazy"
560 />
561 ) : (
562 <div className="flex h-full w-full items-center justify-center bg-gray-200 text-xs font-medium">
563 {repo.owner.login.substring(0, 2).toUpperCase()}
564 </div>
565 )}
566 </div>
567 <h1 className={cn("text-sm sm:text-lg font-semibold truncate max-w-[150px] sm:max-w-full", currentTheme.textNormal)}>
568 {repo.name}
569 </h1>
570 <h1
571 className={cn(
572 "ml-auto text-[10px] sm:text-xs font-medium px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-lg border",
573 currentTheme.badgeBg,
574 currentTheme.badgeText
575 )}
576 >
577 {repo.isPrivate ? "Private" : "Public"}
578 </h1>
579 </div>
580 <div className={cn(
581 "line-clamp-2 text-xs sm:text-sm font-normal leading-snug tracking-wide my-2 sm:my-3",
582 currentTheme.textMuted
583 )}>
584 {repo.description || "No description provided"}
585 </div>
586 </div>
587 <div className="pb-2">
588 <div className="mb-4 sm:mb-6 rounded-md border border-border shadow-sm" aria-label="Repository activity graph">
589 <div className="mb-1 sm:mb-2 flex items-center justify-between text-xs p-1.5 sm:p-2">
590 <span className={cn("flex items-center gap-0.5 sm:gap-1 font-semibold text-[10px] sm:text-xs", currentTheme.textMuted)}>
591 <History className="h-3 w-3 sm:h-4 sm:w-4" aria-hidden="true" />
592 Activity
593 </span>
594 <span className={cn("text-[10px] sm:text-xs font-medium", currentTheme.textMuted)}>
595 Updated {formatRelativeTime(repo.updatedAt)}
596 </span>
597 </div>
598 <div className="h-[40px] sm:h-[60px] w-full overflow-hidden rounded-b-lg bg-muted/50 dark:bg-gray-800/50 p-1 sm:p-2" role="img" aria-label="Repository commit activity visualization">
599 {repo.activityData && repo.activityData.length > 0 ? (
600 <svg
601 className="h-full w-full"
602 viewBox="0 0 100 20"
603 preserveAspectRatio="none"
604 aria-hidden="true"
605 >
606 <line x1="0" y1="5" x2="100" y2="5" stroke="currentColor" strokeWidth="0.2" strokeDasharray="1" className="text-muted/30" />
607 <line x1="0" y1="10" x2="100" y2="10" stroke="currentColor" strokeWidth="0.2" strokeDasharray="1" className="text-muted/30" />
608 <line x1="0" y1="15" x2="100" y2="15" stroke="currentColor" strokeWidth="0.2" strokeDasharray="1" className="text-muted/30" />
609 {repo.activityData.map((value, index) => (
610 <circle
611 key={index}
612 cx={`${index * (100 / (repo.activityData?.length || 1))}`}
613 cy={`${20 - value * 20}`}
614 r="0.8"
615 className={currentTheme.graphColor}
616 />
617 ))}
618 <polyline
619 points={repo.activityData
620 .map((value, index) =>
621 `${index * (100 / (repo.activityData?.length || 1))},${20 - value * 20}`
622 )
623 .join(" ")}
624 fill="none"
625 stroke="currentColor"
626 strokeWidth="1.5"
627 strokeLinecap="round"
628 strokeLinejoin="round"
629 className={currentTheme.graphColor}
630 />
631 <path
632 d={`M0,20 ${repo.activityData
633 .map((value, index) =>
634 `L${index * (100 / (repo.activityData?.length || 1))},${20 - value * 20}`
635 )
636 .join(" ")} L100,20 Z`}
637 fill="currentColor"
638 className={currentTheme.graphBgColor}
639 />
640 </svg>
641 ) : (
642 <div className={cn("flex h-full items-center justify-center rounded-md bg-background/50 text-xs font-medium", currentTheme.textMuted)} aria-label="No activity data available">
643 No activity data available
644 </div>
645 )}
646 </div>
647 </div>
648 <div className="grid grid-cols-2 xs:grid-cols-4 gap-1 sm:gap-2 text-xs sm:text-sm" role="group" aria-label="Repository statistics">
649 <div>
650 <div className={cn("flex items-center gap-0.5 sm:gap-1 font-normal", currentTheme.textMuted)} aria-label={`${repo.stars.toLocaleString()} stars`}>
651 <Star className="h-3 w-3 sm:h-4 sm:w-4" aria-hidden="true" />
652 <span>{repo.stars.toLocaleString()}</span>
653 </div>
654 </div>
655 <div>
656 <div className={cn("flex items-center gap-0.5 sm:gap-1 font-normal", currentTheme.textMuted)} aria-label={`${repo.forks.toLocaleString()} forks`}>
657 <GitFork className="h-3 w-3 sm:h-4 sm:w-4" aria-hidden="true" />
658 <span>{repo.forks.toLocaleString()}</span>
659 </div>
660 </div>
661 <div>
662 <div className={cn("flex items-center gap-0.5 sm:gap-1 font-normal", currentTheme.textMuted)} aria-label={`${repo.watchers.toLocaleString()} watchers`}>
663 <Eye className="h-3 w-3 sm:h-4 sm:w-4" aria-hidden="true" />
664 <span>{repo.watchers.toLocaleString()}</span>
665 </div>
666 </div>
667 <div>
668 <div className={cn("flex items-center gap-0.5 sm:gap-1 font-normal", currentTheme.textMuted)} aria-label={`${repo.issues.toLocaleString()} issues`}>
669 <BookOpen className="h-3 w-3 sm:h-4 sm:w-4" aria-hidden="true" />
670 <span>{repo.issues.toLocaleString()}</span>
671 </div>
672 </div>
673 </div>
674 <div className="mt-3 sm:mt-4">
675 {repo.language && (
676 <div className="mb-1.5 sm:mb-2 flex items-center gap-2" aria-label={`Primary language: ${repo.language}`}>
677 <div className="flex items-center gap-1 sm:gap-1.5">
678 <div
679 className="h-2 w-2 sm:h-3 sm:w-3 rounded-full"
680 style={{ backgroundColor: repo.languageColor }}
681 aria-hidden="true"
682 />
683 <span className={cn("text-xs sm:text-sm font-medium", currentTheme.textNormal)}>
684 {repo.language}
685 </span>
686 </div>
687 </div>
688 )}
689 <div className="flex flex-col xs:flex-row items-start xs:items-center justify-between gap-2">
690 <div className="w-full xs:w-auto">
691 {repo.topics && repo.topics.length > 0 && (
692 <div className="flex flex-wrap gap-1 sm:gap-1.5" role="group" aria-label="Repository topics">
693 {repo.topics.slice(0, 2).map((topic) => (
694 <h1
695 key={topic}
696 className={cn(
697 "text-[10px] sm:text-xs font-medium px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-md border leading-snug tracking-wide",
698 currentTheme.badgeBg,
699 currentTheme.badgeText
700 )}
701 >
702 {topic}
703 </h1>
704 ))}
705 {repo.topics.length > 2 && (
706 <h1
707 className={cn("text-[10px] sm:text-xs font-medium px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-md border leading-snug tracking-wide", currentTheme.badgeBg, currentTheme.badgeText)}
708 >
709 +{repo.topics.length - 2} more
710 </h1>
711 )}
712 </div>
713 )}
714 </div>
715 <div className="w-full xs:w-auto flex justify-end">
716 <button
717 className={`flex items-center gap-1 border border-${currentTheme.accentColor.split(' ')[0]} text-[15px] sm:text-xs font-medium px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-md transition-colors duration-200 ${currentTheme.accentColor} hover:bg-slate-100/20 dark:hover:bg-slate-800/20 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 ${currentTheme.textNormal}`}
718 onClick={copyToClipboard}
719 type="button"
720 aria-label={copied ? "Clone command copied to clipboard" : "Copy clone command to clipboard"}
721 >
722 {copied ? (
723 <>
724 <Check className="h-3 w-3 sm:h-3.5 sm:w-3.5" aria-hidden="true" />
725 <span>Copied</span>
726 </>
727 ) : (
728 <>
729 <Code className="h-3 w-3 sm:h-3.5 sm:w-3.5" aria-hidden="true" />
730 <span>Clone</span>
731 </>
732 )}
733 </button>
734 </div>
735 </div>
736 </div>
737 </div>
738 </div>
739 );
740}
Make sure to update the import paths in the component code to match your project structure. For example, change @/components/ui/button
to match your UI components location.
Name | Type | Default | Description |
---|---|---|---|
repoOwner | string | undefined | GitHub username or organization name that owns the repository. Required when not using manualMode. |
repoName | string | undefined | Name of the GitHub repository. Required when not using manualMode. |
githubToken | string | undefined | Optional GitHub API token for increased rate limits (unauthenticated: 60/hr, authenticated: 5,000/hr). Store securely using environment variables. |
manualMode | boolean | false | When true, uses provided repoData instead of fetching from GitHub API. Useful for avoiding rate limits or displaying custom repository data. |
repoData | ManualRepoData | undefined | Repository data object for manual mode. Required when manualMode is true. Includes fields for repository name, stars, forks, language, etc. |
themeId | string | modern-light | Visual theme for the card. Options: modern-dark, modern-light, retro, midnight. Some themes support automatic light/dark mode switching. |