-
Notifications
You must be signed in to change notification settings - Fork 0
feat(home): Followers bubble UI with People/Followers tab #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bcd040d
d2ed3c7
8abf4a0
22de5c8
195c476
d733d90
2829b59
e9bd35c
ed550bc
acfb14d
2f29571
dbd0c79
5b2d45a
5162ffa
f9184a9
364e073
17f9d07
d8b2050
bb947ef
9d74c26
6198de7
08ff66e
41753b1
e5fa421
413bda1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } |
| 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> | ||
| ) | ||
| } |
| 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) | ||
| } | ||
| }) | ||
| }) |
| 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 | ||
| } |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 @@ | ||
| export { Followers } from './Followers' |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 /> | ||
| } |

Uh oh!
There was an error while loading. Please reload this page.