Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bcd040d
feat(home): add GitHub followers API data layer
chaaerim Apr 13, 2026
d2ed3c7
fix(home): add network error test and remove unused import
chaaerim Apr 13, 2026
8abf4a0
feat(home): add circle-packing algorithm for followers bubble layout
chaaerim Apr 14, 2026
22de5c8
feat(home): add FollowersBubble client component with circle-packing …
chaaerim Apr 14, 2026
195c476
feat(home): add Followers server component and barrel export
chaaerim Apr 14, 2026
d733d90
feat(home): integrate Followers bubble section into main page
chaaerim Apr 14, 2026
2829b59
fix(home): add Followers barrel export index.ts
chaaerim Apr 14, 2026
e9bd35c
fix(home): remove server-only from get-followers to fix barrel export…
chaaerim Apr 14, 2026
ed550bc
feat(home): update circle-pack to 6 size tiers with tighter packing
chaaerim Apr 15, 2026
acfb14d
feat(home): add circular clip-path to FollowersBubble container
chaaerim Apr 15, 2026
2f29571
feat(home): create PeopleFollowersTabs client component with tab bar
chaaerim Apr 15, 2026
dbd0c79
feat(home): add PeopleFollowersSection server component and barrel ex…
chaaerim Apr 15, 2026
5b2d45a
feat(home): integrate PeopleFollowersSection into main page
chaaerim Apr 15, 2026
5162ffa
fix(home): move PeopleFollowersTabs to sidebar layout where People was
chaaerim Apr 15, 2026
f9184a9
style(home): reduce tab font size, limit followers to 30, shrink Foll…
chaaerim Apr 15, 2026
364e073
revert(home): restore original tab font size, icon size, and badge size
chaaerim Apr 15, 2026
17f9d07
fix(home): restore FloatingArrow show more information popover in Peo…
chaaerim Apr 15, 2026
d8b2050
feat(home): add featured followers support with random fallback
chaaerim Apr 15, 2026
bb947ef
feat(home): update featured followers list
chaaerim Apr 15, 2026
9d74c26
feat(home): reduce featured followers to 4
chaaerim Apr 15, 2026
6198de7
feat(home): shuffle non-featured followers randomly
chaaerim Apr 15, 2026
08ff66e
feat(home): remove sonsurim from featured followers
chaaerim Apr 15, 2026
41753b1
feat(home): show 27 followers in bubble instead of 30
chaaerim Apr 15, 2026
e5fa421
fix(home): show featured(3) + 27 random followers (30 total)
chaaerim Apr 15, 2026
413bda1
feat(home): 2 featured + 28 random followers, remove raon0211
chaaerim Apr 15, 2026
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
4 changes: 2 additions & 2 deletions apps/home/app/(profile-readme)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { People } from '@/_shared'
import { PeopleFollowersTabs } from '../_shared/components/PeopleFollowersTabs/PeopleFollowersTabs'
import '@hamsurang/ui/globals.css'
import { postMessageToParent } from '@hamsurang/utils'
import { usePathname, useSearchParams } from 'next/navigation'
Expand Down Expand Up @@ -31,7 +31,7 @@ export default function PeopleLayout({
<main className="flex gap-6 mobile:flex-col px-4 mt-2 max-w-[1400px] mx-auto">
<aside className="mobile:w-full w-[296px] shrink-0">
{profile}
<People />
<PeopleFollowersTabs />
</aside>

<section className="flex flex-col gap-4 flex-grow min-w-0">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* GitHub usernames that should appear as large bubbles.
* If a user unfollowed, a random follower takes their slot.
*/
export const FEATURED_FOLLOWERS = ['yejineee', 'jcha0713'] as const
Comment thread
minsoo-web marked this conversation as resolved.
38 changes: 38 additions & 0 deletions apps/home/app/_shared/components/Followers/Followers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { getFollowers } from '../../../lib/github/get-followers'
import { FollowersBubble } from './FollowersBubble'

export const Followers = async () => {
const followers = await getFollowers()

if (followers.length === 0) {
return null
}

return (
<div className="border border-gray-200 rounded-md p-5">
<div className="flex items-center gap-2 mb-5">
<span className="text-sm font-semibold">Followers</span>
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
{followers.length}
</span>
</div>

<FollowersBubble followers={followers} />

<div className="mt-5 text-center">
<a
href="https://github.com/hamsurang"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 px-6 py-2.5 bg-gray-900 text-white text-sm font-semibold rounded-lg hover:bg-gray-700 transition-colors"
>
<svg viewBox="0 0 16 16" className="w-4 h-4 fill-current" aria-label="GitHub">
<title>GitHub</title>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
Comment on lines +29 to +32
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Github icon은 이미 icons 패키지에 있는데, 재활용이 어려울까요?

Follow Us
</a>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { GitHubFollower } from '../../../lib/github/types'

export type FollowersBubbleProps = {
followers: GitHubFollower[]
featuredCount?: number
}
73 changes: 73 additions & 0 deletions apps/home/app/_shared/components/Followers/FollowersBubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import type { GitHubFollower } from '../../../lib/github/types'
import { type PackedCircle, packCircles } from './circle-pack'
import type { FollowersBubbleProps } from './Followers.types'

export const FollowersBubble = ({ followers, featuredCount = 0 }: FollowersBubbleProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const [circles, setCircles] = useState<PackedCircle[]>([])
const [size, setSize] = useState(0)

useEffect(() => {
const el = containerRef.current
if (!el) {
return
}

const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) {
return
}
const width = entry.contentRect.width
setSize(width)
setCircles(packCircles(width, followers.length, featuredCount))
})

observer.observe(el)
return () => observer.disconnect()
}, [followers.length, featuredCount])

return (
<div
ref={containerRef}
className="relative w-full mx-auto"
style={{ maxWidth: 560, aspectRatio: '1', clipPath: 'circle(50%)' }}
>
{size > 0 &&
circles.map((circle) => {
const follower = followers[circle.index] as GitHubFollower | undefined
if (!follower) {
return null
}

return (
<a
key={follower.login}
href={follower.html_url}
target="_blank"
rel="noreferrer"
title={follower.login}
className="absolute rounded-full overflow-hidden cursor-pointer transition-transform duration-200 hover:scale-110 hover:ring-2 hover:ring-blue-600 hover:z-10"
style={{
left: circle.x - circle.r,
top: circle.y - circle.r,
width: circle.r * 2,
height: circle.r * 2,
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
}}
>
<img
src={`${follower.avatar_url}&s=${Math.ceil(circle.r * 4)}`}
alt={follower.login}
className="w-full h-full object-cover rounded-full"
loading="lazy"
/>
</a>
)
})}
</div>
)
}
74 changes: 74 additions & 0 deletions apps/home/app/_shared/components/Followers/circle-pack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { packCircles, assignRadius } from './circle-pack'

describe('assignRadius', () => {
it('assigns larger radii to earlier items', () => {
const radii = assignRadius(50)
const first = radii[0]
const last = radii[49]
expect(first).toBeDefined()
expect(last).toBeDefined()
expect(first).toBeGreaterThan(last as number)
})

it('returns correct number of radii', () => {
expect(assignRadius(5)).toHaveLength(5)
expect(assignRadius(50)).toHaveLength(50)
})

it('has 5 distinct tiers without featured', () => {
const radii = assignRadius(100)
const unique = [...new Set(radii)]
expect(unique).toHaveLength(5)
expect(unique).toEqual([36, 28, 22, 16, 10])
})

it('has 6 distinct tiers with featured', () => {
const radii = assignRadius(100, 3)
const unique = [...new Set(radii)]
expect(unique).toHaveLength(6)
expect(unique).toEqual([46, 36, 28, 22, 16, 10])
expect(radii.slice(0, 3).every((r) => r === 46)).toBe(true)
})
})

describe('packCircles', () => {
it('returns positioned circles for all items', () => {
const result = packCircles(400, 10)
expect(result).toHaveLength(10)
for (const c of result) {
expect(c).toHaveProperty('x')
expect(c).toHaveProperty('y')
expect(c).toHaveProperty('r')
expect(c).toHaveProperty('index')
}
})

it('no circles overlap', () => {
const circles = packCircles(400, 30)
for (let i = 0; i < circles.length; i++) {
for (let j = i + 1; j < circles.length; j++) {
const a = circles[i]
const b = circles[j]
if (!a || !b) {
continue
}
const dist = Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
expect(dist).toBeGreaterThanOrEqual(a.r + b.r - 1)
}
}
})

it('all circles stay within the container bounds', () => {
const size = 400
const circles = packCircles(size, 20)
const cx = size / 2
const cy = size / 2
const boundary = size / 2

for (const c of circles) {
const dist = Math.sqrt((c.x - cx) ** 2 + (c.y - cy) ** 2)
expect(dist + c.r).toBeLessThanOrEqual(boundary + c.r * 0.5)
}
})
})
108 changes: 108 additions & 0 deletions apps/home/app/_shared/components/Followers/circle-pack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
export type PackedCircle = {
x: number
y: number
r: number
index: number
}

export function assignRadius(count: number, featuredCount = 0): number[] {
return Array.from({ length: count }, (_, i) => {
if (i < featuredCount) {
return 46
}
const remaining = count - featuredCount
const ri = i - featuredCount
if (ri < Math.ceil(remaining * 0.08)) {
return 36
}
if (ri < Math.ceil(remaining * 0.24)) {
return 28
}
if (ri < Math.ceil(remaining * 0.48)) {
return 22
}
if (ri < Math.ceil(remaining * 0.76)) {
return 16
}
return 10
})
}

function hasOverlap(circles: PackedCircle[], x: number, y: number, r: number): boolean {
for (const c of circles) {
const d = Math.sqrt((x - c.x) ** 2 + (y - c.y) ** 2)
if (d < r + c.r - 0.2) {
return true
}
}
return false
}

export function packCircles(
containerSize: number,
count: number,
featuredCount = 0,
): PackedCircle[] {
const radii = assignRadius(count, featuredCount)
const cx = containerSize / 2
const cy = containerSize / 2
const boundary = containerSize / 2

const circles: PackedCircle[] = []

for (let i = 0; i < count; i++) {
const r = radii[i] ?? 10
let placed = false
let attempts = 0

while (!placed && attempts < 3000) {
const angle = Math.random() * Math.PI * 2
const maxDist = boundary - r
const dist = Math.random() * maxDist
const x = cx + Math.cos(angle) * dist
const y = cy + Math.sin(angle) * dist

const distFromCenter = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
if (distFromCenter + r > boundary + r * 0.3) {
attempts++
continue
}

if (!hasOverlap(circles, x, y, r)) {
circles.push({ x, y, r, index: i })
placed = true
}
attempts++
}

if (!placed) {
let spiralAngle = i * ((Math.PI * 2) / count)
let spiralDist = 0
const step = r * 0.4
while (spiralDist <= boundary) {
const x = cx + Math.cos(spiralAngle) * spiralDist
const y = cy + Math.sin(spiralAngle) * spiralDist
if (!hasOverlap(circles, x, y, r)) {
circles.push({ x, y, r, index: i })
placed = true
break
}
spiralDist += step
spiralAngle += 0.3
}
}

if (!placed) {
const angle = i * ((Math.PI * 2) / count)
const dist = boundary * 0.6
circles.push({
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist,
r,
index: i,
})
}
}

return circles
}
1 change: 1 addition & 0 deletions apps/home/app/_shared/components/Followers/index.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Image

팔로워 컴포넌트가 사용처가 없는 것 같아요...! 의도하신 것일까용

Followers 컴포넌트가 FollowersBubble을 내장하고 있는데, Tab부분에서는 Bubble만 쓰고 있는 것 같아요

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Followers } from './Followers'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

이것 별도의 컴포넌트로 분리한 의도가 있으실지 궁금합니당

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PeopleFollowersTabs } from './PeopleFollowersTabs'

export const PeopleFollowersSection = () => {
return <PeopleFollowersTabs />
}
Loading