feat: editor add github contribution

This commit is contained in:
JOYCEQL
2024-12-10 13:48:55 +08:00
committed by qingchen
parent 527a354acf
commit 8ad46e900e
6 changed files with 319 additions and 10 deletions
@@ -3,14 +3,15 @@ import React, { useState } from "react";
import { PlusCircle, GripVertical, Trash2, Eye, EyeOff } from "lucide-react";
import { Reorder, AnimatePresence, motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import PhotoUpload from "@/components/shared/PhotoSelector";
import IconSelector from "../IconSelector";
import Field from "../Field";
import { cn } from "@/lib/utils";
import { DEFAULT_FIELD_ORDER } from "@/config";
import { useResumeStore } from "@/store/useResumeStore";
import { BasicFieldType, CustomFieldType } from "@/types/resume";
import { DEFAULT_FIELD_ORDER } from "@/config";
import IconSelector from "../IconSelector";
interface CustomFieldProps {
field: CustomFieldType;
onUpdate: (field: CustomFieldType) => void;
@@ -347,7 +348,6 @@ const BasicPanel: React.FC = () => {
))}
</Reorder.Group>
</AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -359,6 +359,60 @@ const BasicPanel: React.FC = () => {
</Button>
</motion.div>
</motion.div>
<motion.div className="space-y-3">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center justify-between">
<motion.h3 className="font-medium text-neutral-900 dark:text-neutral-200 px-1">
Github
</motion.h3>
<Switch
checked={basic?.githubContributionsVisible}
onCheckedChange={(checked) =>
updateBasicInfo({
...basic,
githubContributionsVisible: checked,
})
}
/>
</div>
<div className="mt-4">
<div className="flex items-center ml-3 space-x-2">
<div className=" w-[110px]">Access Token</div>
<Input
placeholder="请输入github access token"
className="flex-1"
value={basic?.githubKey}
onChange={(e) =>
updateBasicInfo({
...basic,
githubKey: e.target.value,
})
}
/>
</div>
<div className="flex items-center ml-3 mt-4 space-x-2">
<div className="w-[110px]">UseName</div>
<Input
className="flex-1"
placeholder="请输入github username"
value={basic?.githubUseName}
onChange={(e) =>
updateBasicInfo({
...basic,
githubUseName: e.target.value,
})
}
/>
</div>
</div>
</motion.div>
</motion.div>
</motion.div>
</motion.div>
);
@@ -137,7 +137,6 @@ export function BaseInfo({
</motion.h1>
)}
{/* 职位 - 仅在可见时显示 */}
{titleField && basic[titleField.key] && (
<motion.h2
layout="position"
@@ -153,7 +152,7 @@ export function BaseInfo({
{/* 其他字段 */}
<motion.div
layout="position"
className="flex justify-center items-center flex-wrap gap-3"
className="flex justify-start items-center flex-wrap gap-3"
style={{
fontSize: `${globalSettings?.baseFontSize || 14}px`,
color: "rgb(75, 85, 99)",
@@ -1,10 +1,8 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion, LayoutGroup } from "framer-motion";
import { useResumeStore } from "@/store/useResumeStore";
import { cn } from "@/lib/utils";
import { throttle } from "lodash";
import { ResumeData, THEME_COLORS } from "@/types/resume";
import GithubContribution from "@/components/shared/GithubContribution";
import { BaseInfo } from "./BaseInfo";
import { SectionTitle } from "./SectionTitle";
import ProjectItem from "./ProjectItem";
@@ -13,6 +11,9 @@ import { EducationSection } from "./EducationSection";
import { CustomSection } from "./CustomSection";
import { SkillSection } from "./SkillPanel";
import { cn } from "@/lib/utils";
import { useResumeStore } from "@/store/useResumeStore";
import { THEME_COLORS } from "@/types/resume";
const getFontFamilyClass = (fontFamily: string) => {
switch (fontFamily) {
case "serif":
@@ -280,6 +281,15 @@ export function PreviewPanel() {
<LayoutGroup>
<motion.div layout ref={resumeContentRef}>
<BaseInfo basic={basic} globalSettings={globalSettings} />
{basic?.githubContributionsVisible && (
<div className="mt-2">
<GithubContribution
githubKey={basic?.githubKey}
username={basic?.githubUseName}
/>
</div>
)}
{menuSections
.filter((section) => section.enabled)
.sort((a, b) => a.order - b.order)
@@ -0,0 +1,240 @@
"use client";
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface ContributionDay {
date: string;
count: number;
}
interface GithubContributionsProps {
username?: string;
githubKey?: string;
className?: string;
year?: number;
}
const colorLevels = [
"bg-[#ebedf0]", // 0 contributions
"bg-[#9be9a8]", // 1-3 contributions
"bg-[#40c463]", // 4-7 contributions
"bg-[#30a14e]", // 8-12 contributions
"bg-[#216e39]", // 13+ contributions
];
const getColorLevel = (count: number): string => {
if (count === 0) return colorLevels[0];
if (count <= 3) return colorLevels[1];
if (count <= 7) return colorLevels[2];
if (count <= 12) return colorLevels[3];
return colorLevels[4];
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
});
};
async function fetchGithubContributions(
username?: string,
githubKey?: string
): Promise<ContributionDay[]> {
const token = githubKey;
if (!token) {
throw new Error("GitHub token is required");
}
const query = `
query($username: String!) {
user(login: $username) {
contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays {
contributionCount
date
}
}
}
}
}
}
`;
try {
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
variables: { username },
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("GitHub API Error:", errorData);
throw new Error(errorData.message || "Failed to fetch GitHub data");
}
const data = await response.json();
if (data.errors) {
console.error("GitHub API Error:", data.errors);
throw new Error(data.errors[0]?.message || "GitHub API Error");
}
const calendar =
data.data?.user?.contributionsCollection?.contributionCalendar;
if (!calendar) {
throw new Error("No contribution data found");
}
const contributions: ContributionDay[] = [];
calendar.weeks.forEach((week: any) => {
week.contributionDays.forEach((day: any) => {
contributions.push({
date: day.date,
count: day.contributionCount,
});
});
});
return contributions;
} catch (error) {
console.error("Error fetching GitHub contributions:", error);
throw error;
}
}
const GithubContributions: React.FC<GithubContributionsProps> = ({
username,
githubKey,
className,
year = 2024,
}) => {
const [weeks, setWeeks] = useState<ContributionDay[][]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadContributions() {
try {
setLoading(true);
const contributions = await fetchGithubContributions(
username,
githubKey
);
const yearContributions = contributions.filter((day) => {
const date = new Date(day.date);
return date.getFullYear() === year;
});
const groupedWeeks: ContributionDay[][] = [];
let currentWeek: ContributionDay[] = [];
yearContributions.forEach((day, index) => {
currentWeek.push(day);
if (
currentWeek.length === 7 ||
index === yearContributions.length - 1
) {
// 如果是最后一周且不满7天,用空数据填充
if (currentWeek.length < 7) {
const emptyDays = 7 - currentWeek.length;
for (let i = 0; i < emptyDays; i++) {
currentWeek.push({
date: "",
count: 0,
});
}
}
groupedWeeks.push([...currentWeek]);
currentWeek = [];
}
});
setWeeks(groupedWeeks);
setError(null);
} catch (err) {
setError("Failed to load GitHub contributions");
} finally {
setLoading(false);
}
}
if (username) {
loadContributions();
}
}, [username, year]);
if (loading) {
return <div className="animate-pulse bg-gray-200 h-32 rounded-md"></div>;
}
if (error) {
return <div className="text-red-500">{error}</div>;
}
const goGithub = () => {
window.open(`https://github.com/${username}`, "_blank");
};
return (
<div className="w-full flex flex-col gap-2">
<div
className={cn(
"w-full flex justify-start gap-[1px] overflow-hidden",
className
)}
>
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-[3px]">
{week.map((day, dayIndex) => (
<div
key={`${weekIndex}-${dayIndex}`}
className={cn(
"w-[13px] h-[13px] rounded-[3px] relative group cursor-pointer",
getColorLevel(day.count),
"transition-colors duration-200"
)}
title={String(day.date)}
onClick={goGithub}
>
{day.date && (
<div
className="pointer-events-none absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 whitespace-nowrap z-10"
style={{
visibility: "hidden",
transitionProperty: "opacity, visibility",
transitionDuration: "150ms",
}}
>
{formatDate(day.date)}: {day.count} contributions
<div className="absolute top-full left-1/2 transform -translate-x-1/2 -mt-1 border-4 border-transparent border-t-gray-900"></div>
</div>
)}
</div>
))}
</div>
))}
</div>
</div>
);
};
export default GithubContributions;
+3
View File
@@ -102,6 +102,9 @@ const initialResumeState: Omit<ResumeData, "id" | "createdAt" | "updatedAt"> = {
},
],
photo: "/avatar.svg",
githubKey: "",
githubUseName: "",
githubContributionsVisible: false,
},
education: [
{
+3
View File
@@ -78,6 +78,9 @@ export interface BasicInfo {
icon?: string;
visible?: boolean;
}>;
githubKey: string;
githubUseName: string;
githubContributionsVisible: boolean;
}
export interface Education {