Skip to content
Merged
2 changes: 1 addition & 1 deletion frontend/src/features/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ features/{X} → pages/*, app/* ✗
|---------|------|---------|
| `auth` | GitHub OAuth, 토큰 관리, 로그아웃, 동의 | US-01, US-02, US-03, US-04 |
| `resume` | 이력서 업로드, 목록, 삭제 | US-05, US-06 |
| `repo` (계획) | GitHub 레포 가져오기/등록/삭제 | US-07, US-08 |
| `repo` | GitHub 레포 가져오기/등록/삭제 | US-07, US-08 |

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.

굿~

| `analysis` (계획) | 분석 상태 표시, 재분석 | US-11, US-12 |
| `interview` | 세션 생성·진행·종료, 메시지, 음성 | US-13~22 |
| `feedback` | 피드백 리포트, 점수, 키워드 | US-24, US-25 |
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/features/repo/api/repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { apiClient } from '@/shared/api'
import type {
CandidateRepositoryPageResponse,
PageResponse,
RegisteredRepositoryResponse,
} from '@/features/repo/model/types'

export async function listCandidates(
page = 1,
perPage = 30,
): Promise<CandidateRepositoryPageResponse> {
const { data } = await apiClient.get<CandidateRepositoryPageResponse>(
'/api/repositories/github', { params: { page, perPage } },
)
return data
}

export async function registerRepo(
githubRepoId: number,
): Promise<RegisteredRepositoryResponse> {
const { data } = await apiClient.post<RegisteredRepositoryResponse>(
'/api/repositories', { githubRepoId },
)
return data
}

export async function listRegistered(
page = 0,
size = 20,
): Promise<PageResponse<RegisteredRepositoryResponse>> {
const { data } = await apiClient.get<PageResponse<RegisteredRepositoryResponse>>(
'/api/repositories', { params: { page, size, sort: 'createdAt,desc' } },
)
return data
}

export async function getRepo(
id: number,
): Promise<RegisteredRepositoryResponse> {
const { data } = await apiClient.get<RegisteredRepositoryResponse>(
`/api/repositories/${id}`,
)
return data
}

export async function deleteRepo(id: number): Promise<void> {
await apiClient.delete(`/api/repositories/${id}`)
}
8 changes: 8 additions & 0 deletions frontend/src/features/repo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { RepositoryPanel } from './ui/RepositoryPanel'
export type {
CandidateRepositoryPageResponse,
CandidateRepositoryResponse,
PageResponse,
RegisteredRepositoryResponse,
RepositoryStatus,
} from './model/types'
46 changes: 46 additions & 0 deletions frontend/src/features/repo/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//중복되는 타입일 수 있으나 정의하는 상태가 달라서 일단 분리 X

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.

확인했습니다.

export type RepositoryStatus =
| 'PENDING'
| 'ANALYZING'
| 'ANALYZED'
| 'FAILED'

export type CandidateRepositoryResponse = {
githubRepoId: number
name: string
fullName: string
htmlUrl: string
defaultBranch: string
private: boolean
description: string | null
alreadyRegistered: boolean
}

export type CandidateRepositoryPageResponse = {
content: CandidateRepositoryResponse[]
page: number
perPage: number
hasNext: boolean
}

export type RegisteredRepositoryResponse = {
id: number
githubRepoId: number
repoName: string
repoFullName: string
repoUrl: string
defaultBranch: string
status: RepositoryStatus
lastSyncedAt: string | null
createdAt: string
}

export type PageResponse<T> = {
content: T[]
page: number
size: number
totalElements: number
totalPages: number
first: boolean
last: boolean
}
77 changes: 77 additions & 0 deletions frontend/src/features/repo/model/useRepos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
deleteRepo,
getRepo,
listCandidates,
listRegistered,
registerRepo,
} from '@/features/repo/api/repo'
import type {
PageResponse,
RegisteredRepositoryResponse,
} from '@/features/repo/model/types'

const REGISTERED_KEY = 'repositories' as const
const CANDIDATES_KEY = 'repositories-candidates' as const
const POLL_INTERVAL_MS = 2_000

function hasInProgress(
page: PageResponse<RegisteredRepositoryResponse> | undefined,
): boolean {
if (!page) return false
return page.content.some(
(r) => r.status === 'PENDING' || r.status === 'ANALYZING',
)
}

export function useRegisteredReposQuery(page = 0, size = 20) {
return useQuery({
queryKey: [REGISTERED_KEY, page, size],
queryFn: () => listRegistered(page, size),
refetchInterval: (query) =>
hasInProgress(query.state.data) ? POLL_INTERVAL_MS : false,
})
}

export function useRegisteredRepoQuery(id: number | null) {
return useQuery({
queryKey: [REGISTERED_KEY, 'detail', id],
queryFn: () => getRepo(id as number),
enabled: id !== null,
refetchInterval: (query) => {
const status = query.state.data?.status
return status === 'PENDING' || status === 'ANALYZING'
? POLL_INTERVAL_MS
: false
},
})
}

export function useCandidateReposQuery(page = 1, perPage = 30) {
return useQuery({
queryKey: [CANDIDATES_KEY, page, perPage],
queryFn: () => listCandidates(page, perPage),
})
}

export function useRegisterRepoMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (githubRepoId: number) => registerRepo(githubRepoId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [REGISTERED_KEY] })
qc.invalidateQueries({ queryKey: [CANDIDATES_KEY] })
},
})
}

export function useDeleteRepoMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => deleteRepo(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [REGISTERED_KEY] })
qc.invalidateQueries({ queryKey: [CANDIDATES_KEY] })
},
})
}
155 changes: 155 additions & 0 deletions frontend/src/features/repo/ui/CandidateRepositoryList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { useState } from 'react'
import { isApiError } from '@/shared/api'
import {
useCandidateReposQuery,
useRegisterRepoMutation,
} from '@/features/repo/model/useRepos'
import type { CandidateRepositoryResponse } from '@/features/repo/model/types'

export function CandidateRepositoryList() {
const { data, isLoading, isError, error, refetch } = useCandidateReposQuery()
const [errorMessage, setErrorMessage] = useState<string | null>(null)

if (isLoading) return <ListSkeleton />
if (isError) {
const message = isApiError(error)
? error.message
: 'GitHub 레포 후보를 불러오지 못했습니다.'
return (
<div className="rounded-xl border border-border bg-surface-raised p-8 text-center">
<p role="alert" className="text-body text-danger-700">
{message}
</p>
<button
type="button"
onClick={() => refetch()}
className="mt-4 inline-flex items-center px-4 py-2 rounded-pill bg-sage-900 text-white text-button hover:bg-sage-800 transition-colors duration-fast"
>
다시 시도
</button>
</div>
)
}

const candidates = data?.content ?? []
if (candidates.length === 0) {
return (
<div className="rounded-xl border border-dashed border-border-strong bg-surface p-10 text-center">
<p className="text-body text-fg-muted">
가져올 수 있는 GitHub 레포가 없습니다.
</p>
</div>
)
}

return (
<div className="space-y-3">
{errorMessage ? (
<p role="alert" className="text-caption text-danger-700">
{errorMessage}
</p>
) : null}
<ul className="grid gap-3">
{candidates.map((candidate) => (
<li key={candidate.githubRepoId}>
<CandidateCard
candidate={candidate}
onError={setErrorMessage}
onClearError={() => setErrorMessage(null)}
/>
</li>
))}
</ul>
</div>
)
}

function CandidateCard({
candidate,
onError,
onClearError,
}: {
candidate: CandidateRepositoryResponse
onError: (message: string) => void
onClearError: () => void
}) {
const register = useRegisterRepoMutation()
const disabled = candidate.alreadyRegistered || register.isPending

const handleClick = () => {
onClearError()
register.mutate(candidate.githubRepoId, {
onError: (err) => {
onError(
isApiError(err) ? err.message : '등록 중 오류가 발생했습니다.',
)
},
})
}

return (
<article className="rounded-xl bg-surface-raised border border-border shadow-sm p-5 flex items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<a
href={candidate.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="text-body text-fg-strong font-semibold hover:underline truncate"
>
{candidate.fullName}
</a>
{candidate.private ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-pill text-caption font-mono bg-warning-50 text-warning-700">
Private
</span>
) : null}
{candidate.alreadyRegistered ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-pill text-caption font-mono bg-success-50 text-success-700">
이미 등록됨
</span>
) : null}
</div>
<p className="text-caption text-fg-muted mt-1.5 line-clamp-2">
{candidate.description ?? '설명 없음'}
</p>
<p className="text-caption text-fg-subtle mt-1 font-mono">
기본 브랜치 {candidate.defaultBranch}
</p>
</div>
<button
type="button"
onClick={handleClick}
disabled={disabled}
aria-busy={register.isPending}
className="shrink-0 inline-flex items-center px-4 py-2 rounded-pill bg-sage-900 text-white text-button hover:bg-sage-800 transition-colors duration-fast disabled:opacity-60 disabled:cursor-not-allowed"
>
{register.isPending
? '등록 중…'
: candidate.alreadyRegistered
? '등록됨'
: '등록'}
</button>
</article>
)
}

function ListSkeleton() {
return (
<ul
aria-busy="true"
aria-label="GitHub 후보 로딩 중"
className="grid gap-3"
>
{[0, 1, 2, 3].map((i) => (
<li
key={i}
className="rounded-xl bg-surface-raised border border-border shadow-sm p-5"
>
<div className="h-4 w-1/2 rounded bg-sage-100 animate-pulse" />
<div className="mt-2 h-3 w-3/4 rounded bg-sage-100 animate-pulse" />
</li>
))}
</ul>
)
}
Loading