mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: editor add github contribution
This commit is contained in:
@@ -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;
|
||||
@@ -102,6 +102,9 @@ const initialResumeState: Omit<ResumeData, "id" | "createdAt" | "updatedAt"> = {
|
||||
},
|
||||
],
|
||||
photo: "/avatar.svg",
|
||||
githubKey: "",
|
||||
githubUseName: "",
|
||||
githubContributionsVisible: false,
|
||||
},
|
||||
education: [
|
||||
{
|
||||
|
||||
@@ -78,6 +78,9 @@ export interface BasicInfo {
|
||||
icon?: string;
|
||||
visible?: boolean;
|
||||
}>;
|
||||
githubKey: string;
|
||||
githubUseName: string;
|
||||
githubContributionsVisible: boolean;
|
||||
}
|
||||
|
||||
export interface Education {
|
||||
|
||||
Reference in New Issue
Block a user