Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 22 additions & 267 deletions src/components/BusinessCard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { forwardRef } from "react";
import type { CardBlockId, CardDisplayOptions, CardLayout, UserSummary } from "@/lib/types";
import { DEFAULT_CARD_LAYOUT } from "@/lib/types";
import {
BuildingIcon,
CalendarIcon,
LinkIcon,
MapPinIcon,
TwitterIcon,
} from "./Icons";
import { AvatarBlock } from "./business-card/AvatarBlock";
import { BioBlock } from "./business-card/BioBlock";
import { StatsBlock } from "./business-card/StatsBlock";
import { TopLanguagesBlock } from "./business-card/TopLanguagesBlock";
import { TopReposBlock } from "./business-card/TopReposBlock";
Comment on lines +4 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Relative imports in businesscard.tsx 📘 Rule violation ✧ Quality

src/components/BusinessCard.tsx and src/components/business-card/BioBlock.tsx import modules
that live under src/ using relative paths (e.g., ./business-card/AvatarBlock and ../Icons)
instead of the required @/ alias. This violates the repo’s standard import convention and can lead
to brittle paths during refactors.
Agent Prompt
## Issue description
Update imports in `src/components/BusinessCard.tsx` and `src/components/business-card/BioBlock.tsx` that reference modules under `src/` to use the required `@/` alias instead of relative paths.

## Issue Context
Compliance ID 226103 mandates `@/` alias usage for imports targeting code within `src/`. The current code uses relative paths such as `./business-card/...` (resolving under `src/components/...`) and `../Icons` from within `src/components/business-card/` (resolving under `src/components/`), which violates the convention and can become brittle during refactors.

## Fix Focus Areas
- src/components/BusinessCard.tsx[4-8]
- src/components/business-card/BioBlock.tsx[2-8]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


type Props = {
summary: UserSummary;
Expand All @@ -21,18 +19,7 @@ const BusinessCard = forwardRef<HTMLDivElement, Props>(({ summary, layout, optio
if (!profile) return null;

const activeLayout = layout ?? DEFAULT_CARD_LAYOUT;
const {
showCompany = false,
showLocation = false,
showWebsite = false,
showTwitter = false,
showJoinedDate = false,
showTopics = false,
showContributionBreakdown = false,
showStreaks = false,
showInterests = false,
showActivityBreakdown = false,
} = options || {};


const topLanguages = repositories?.languages.slice(0, 5) || [];
const topTopics = repositories?.topics.slice(0, 10) || [];
Expand All @@ -41,265 +28,33 @@ const BusinessCard = forwardRef<HTMLDivElement, Props>(({ summary, layout, optio
? profile.pinnedRepos.slice(0, 2)
: repositories?.topRepos.slice(0, 2) || [];

const renderAvatarBlock = () => (
<div className="mb-10 flex items-center gap-8">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={profile.avatar_url}
alt={profile.login}
className="h-40 w-40 rounded-full border-4 border-card-border shadow-xl"
crossOrigin="anonymous"
/>
<div className="min-w-0 flex-1">
<h1 className="mb-2 break-words text-5xl font-bold leading-tight tracking-tight text-white">
{profile.name || profile.login}
</h1>
<p className="break-all text-3xl font-medium text-gray-400">@{profile.login}</p>
</div>
</div>
);

const renderBioBlock = () => (
<>
<div className="mb-8">
<p className="line-clamp-3 max-w-2xl text-2xl leading-relaxed text-gray-300">
{profile.bio || "No bio available."}
</p>
</div>

{(showCompany || showLocation || showWebsite || showTwitter || showJoinedDate) && (
<div className="mb-10 flex flex-wrap gap-x-8 gap-y-3 text-lg text-gray-300">
{showCompany && profile.company && (
<div className="flex items-center gap-2">
<BuildingIcon className="text-accent" />
<span>{profile.company}</span>
</div>
)}
{showLocation && profile.location && (
<div className="flex items-center gap-2">
<MapPinIcon className="text-accent" />
<span>{profile.location}</span>
</div>
)}
{showWebsite && profile.blog && (
<div className="flex items-center gap-2">
<LinkIcon className="text-accent" />
<span className="max-w-[200px] truncate">
{profile.blog.replace(/^https?:\/\//, "")}
</span>
</div>
)}
{showTwitter && profile.twitter_username && (
<div className="flex items-center gap-2">
<TwitterIcon className="text-accent" />
<span>@{profile.twitter_username}</span>
</div>
)}
{showJoinedDate && (
<div className="flex items-center gap-2">
<CalendarIcon className="text-accent" />
<span>
Joined {new Date(profile.created_at).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
</span>
</div>
)}
</div>
)}
</>
);

const renderStatsBlock = () => (
<>
<div className="grid grid-cols-3 gap-8">
<div>
<div className="mb-1 text-4xl font-bold text-white">
{(contributions?.totalContributions ?? 0).toLocaleString()}
</div>
<div className="text-lg uppercase tracking-wide text-gray-400">Contributions</div>
</div>
<div>
<div className="mb-1 text-4xl font-bold text-white">
{profile.followers.toLocaleString()}
</div>
<div className="text-lg uppercase tracking-wide text-gray-400">Followers</div>
</div>
<div>
<div className="mb-1 text-4xl font-bold text-white">
{profile.public_repos.toLocaleString()}
</div>
<div className="text-lg uppercase tracking-wide text-gray-400">Repositories</div>
</div>
</div>

{showContributionBreakdown && contributions && (
<div className="mt-8 grid grid-cols-2 gap-x-8 gap-y-4">
<div className="flex items-center justify-between">
<span className="text-lg text-gray-400">Commits</span>
<span className="text-xl font-bold text-white">{contributions.totalCommits.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-lg text-gray-400">Pull Requests</span>
<span className="text-xl font-bold text-white">{contributions.totalPRs.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-lg text-gray-400">Issues</span>
<span className="text-xl font-bold text-white">{contributions.totalIssues.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-lg text-gray-400">Code Reviews</span>
<span className="text-xl font-bold text-white">{contributions.totalReviews.toLocaleString()}</span>
</div>
</div>
)}

{showStreaks && contributions && (
<div className="mt-8 grid grid-cols-2 gap-8">
<div>
<div className="mb-1 text-3xl font-bold text-white">
{contributions.longestStreak} days
</div>
<div className="text-base uppercase tracking-wide text-gray-400">Longest Streak</div>
</div>
<div>
<div className="mb-1 text-3xl font-bold text-white">
{contributions.currentStreak} days
</div>
<div className="text-base uppercase tracking-wide text-gray-400">Current Streak</div>
</div>
</div>
)}
</>
);

const renderTopLanguagesBlock = () => (
<div className="space-y-8">
{topLanguages.length > 0 && (
<div>
<h3 className="mb-4 flex items-center gap-2 text-2xl font-semibold text-accent">
Top Languages
</h3>
<div className="space-y-3">
{topLanguages.map((lang) => (
<div key={lang.name} className="flex items-center gap-4">
<span
className="h-4 w-4 rounded-full shadow-sm ring-2 ring-white/10"
style={{ backgroundColor: lang.color }}
/>
<span className="flex-1 text-xl font-medium text-gray-200">{lang.name}</span>
<span className="tabular-nums text-lg text-gray-500">{lang.percentage.toFixed(1)}%</span>
</div>
))}
</div>
</div>
)}

{showTopics && topTopics.length > 0 && (
<div>
<h3 className="mb-4 flex items-center gap-2 text-2xl font-semibold text-accent">
Top Topics
</h3>
<div className="flex flex-wrap gap-2">
{topTopics.map((topic) => (
<span
key={topic.name}
className="break-all rounded-full bg-white/10 px-3 py-1 text-sm font-medium text-gray-200"
>
#{topic.name}
</span>
))}
</div>
</div>
)}

{showInterests && interests && interests.topTopics.length > 0 && (
<div>
<h3 className="mb-4 flex items-center gap-2 text-2xl font-semibold text-accent">
Interests
</h3>
<div className="flex flex-wrap gap-2">
{interests.topTopics.slice(0, 8).map((topic) => (
<span
key={topic.name}
className="break-all rounded-full bg-accent/20 px-3 py-1 text-sm font-medium text-accent-light"
>
#{topic.name}
</span>
))}
</div>
</div>
)}

{showActivityBreakdown && activity && activity.eventBreakdown.length > 0 && (
<div>
<h3 className="mb-4 flex items-center gap-2 text-2xl font-semibold text-accent">
Recent Activity
</h3>
<div className="space-y-2">
{activity.eventBreakdown.slice(0, 5).map((event) => (
<div key={event.type} className="flex items-center justify-between text-gray-300">
<span>{event.type}</span>
<span className="font-bold">{event.count}</span>
</div>
))}
</div>
</div>
)}
</div>
);

const renderTopReposBlock = () => (
<div>
{reposToShow.length > 0 && (
<>
<h3 className="mb-4 flex items-center gap-2 text-2xl font-semibold text-accent">
Top Repositories
</h3>
<div className="space-y-3">
{reposToShow.map((repo) => (
<div
key={repo.name}
className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-lg backdrop-blur-sm"
>
<div className="mb-1 truncate text-xl font-bold text-white">{repo.name}</div>
<div className="flex items-center gap-6 text-base text-gray-400">
{repo.primaryLanguage && (
<span className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: repo.primaryLanguage.color }}
/>
{repo.primaryLanguage.name}
</span>
)}
<span className="flex items-center gap-1.5">
<span className="text-warning">★</span>
{repo.stargazerCount.toLocaleString()}
</span>
</div>
</div>
))}
</div>
</>
)}
</div>
);

const renderBlock = (blockId: CardBlockId) => {
if (blockId === "avatar") {
return renderAvatarBlock();
return <AvatarBlock profile={profile} />;
}
if (blockId === "bio") {
return renderBioBlock();
return <BioBlock profile={profile} options={options || {}} />;
}
if (blockId === "stats") {
return renderStatsBlock();
return <StatsBlock profile={profile} contributions={contributions} options={options || {}} />;
}
if (blockId === "topLanguages") {
return renderTopLanguagesBlock();
return (
<TopLanguagesBlock
topLanguages={topLanguages}
topTopics={topTopics}
interests={interests}
activity={activity}
options={options || {}}
/>
);
}
if (blockId === "topRepos") {
return <TopReposBlock reposToShow={reposToShow} />;
}
return renderTopReposBlock();
return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 既定ブロックの描画消失

DEFAULT_CARD_LAYOUT に含まれる profilecontributionsheatmapinterestsskills は上の分岐で処理されないため、ここで null となります。設定未保存時の標準プレビューと PNG エクスポートでは、以前リポジトリ内容を表示していたこれらのスロットが空になります。

Suggested change
return null;
return <TopReposBlock reposToShow={reposToShow} />;

Knowledge Base Used: Frontend component library (src/components)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/BusinessCard.tsx
Line: 56

Comment:
**既定ブロックの描画消失**

`DEFAULT_CARD_LAYOUT` に含まれる `profile``contributions``heatmap``interests``skills` は上の分岐で処理されないため、ここで `null` となります。設定未保存時の標準プレビューと PNG エクスポートでは、以前リポジトリ内容を表示していたこれらのスロットが空になります。

```suggestion
    return <TopReposBlock reposToShow={reposToShow} />;
```

**Knowledge Base Used:** [Frontend component library (src/components)](https://app.greptile.com/hiroki-org/-/custom-context/knowledge-base/hiroki-org/github-user-summary/-/docs/frontend-components.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines 32 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Unhandled blocks render nothing 🐞 Bug ≡ Correctness

BusinessCard.renderBlock returns null for valid CardBlockId values like "profile", "contributions",
"heatmap", "interests", and "skills", so those layout sections render no content. Since
DEFAULT_CARD_LAYOUT includes these IDs as visible full-column blocks (and layout normalization
re-adds them), the default/normalized BusinessCard output can silently drop entire sections.
Agent Prompt
### Issue description
`BusinessCard.renderBlock` only renders 5 block IDs and returns `null` for all others, but `CardBlockId`/`DEFAULT_CARD_LAYOUT` include additional visible blocks. This causes those blocks to render empty content (and still get wrapped by layout containers), which can remove intended sections from the card.

### Issue Context
- `CardBlockId` includes many IDs beyond the 5 handled by `BusinessCard`.
- `DEFAULT_CARD_LAYOUT` marks several of those extra IDs as `visible: true` in the `full` column.
- `normalizeCardLayout` appends any missing default blocks back into saved layouts, so these IDs can appear even when users configured a smaller layout.

### Fix Focus Areas
- src/components/BusinessCard.tsx[16-90]
- src/lib/types.ts[106-160]
- src/lib/cardLayout.ts[16-69]

### What to change
Implement one of the following (pick the one that matches product intent):
1) **Implement renderers for the missing block IDs** (profile/contributions/heatmap/interests/skills/repos), potentially by extracting/adding additional `business-card/*Block.tsx` components.

2) **Introduce a BusinessCard-specific layout + IDs**:
   - Create a `BUSINESS_CARD_LAYOUT` containing only `avatar|bio|stats|topLanguages|topRepos`.
   - Make `BusinessCard` default to that layout instead of `DEFAULT_CARD_LAYOUT`.
   - Ensure `loadCardSettings` / normalization for the business card uses the business-card defaults.

3) **At minimum, avoid rendering empty wrappers**:
   - Filter out unsupported blocks before mapping, or in the map do:
     - compute `const node = renderBlock(block.id)` and return `node ? <div ...>{node}</div> : null`.
   - (Still recommended) also ensure unsupported IDs cannot be present/visible in the active layout.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

};

const fullBlocks = activeLayout.blocks.filter((block) => block.column === "full" && block.visible);
const leftBlocks = activeLayout.blocks.filter((block) => block.column === "left" && block.visible);
const rightBlocks = activeLayout.blocks.filter((block) => block.column === "right" && block.visible);
Expand Down
19 changes: 19 additions & 0 deletions src/components/business-card/AvatarBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { UserProfile } from "@/lib/types";

export const AvatarBlock = ({ profile }: { profile: UserProfile }) => (
<div className="mb-10 flex items-center gap-8">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={profile.avatar_url}
alt={profile.login}
className="h-40 w-40 rounded-full border-4 border-card-border shadow-xl"
crossOrigin="anonymous"
/>
<div className="min-w-0 flex-1">
<h1 className="mb-2 break-words text-5xl font-bold leading-tight tracking-tight text-white">
{profile.name || profile.login}
</h1>
<p className="break-all text-3xl font-medium text-gray-400">@{profile.login}</p>
</div>
</div>
);
Loading
Loading