diff --git a/frontend/src/features/CLAUDE.md b/frontend/src/features/CLAUDE.md index e56fa60d..ce4288e2 100644 --- a/frontend/src/features/CLAUDE.md +++ b/frontend/src/features/CLAUDE.md @@ -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 | | `analysis` (계획) | 분석 상태 표시, 재분석 | US-11, US-12 | | `interview` | 세션 생성·진행·종료, 메시지, 음성 | US-13~22 | | `feedback` | 피드백 리포트, 점수, 키워드 | US-24, US-25 | diff --git a/frontend/src/features/repo/api/repo.ts b/frontend/src/features/repo/api/repo.ts new file mode 100644 index 00000000..91183aa7 --- /dev/null +++ b/frontend/src/features/repo/api/repo.ts @@ -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 { + const { data } = await apiClient.get( + '/api/repositories/github', { params: { page, perPage } }, + ) + return data +} + +export async function registerRepo( + githubRepoId: number, +): Promise { + const { data } = await apiClient.post( + '/api/repositories', { githubRepoId }, + ) + return data +} + +export async function listRegistered( + page = 0, + size = 20, +): Promise> { + const { data } = await apiClient.get>( + '/api/repositories', { params: { page, size, sort: 'createdAt,desc' } }, + ) + return data +} + +export async function getRepo( + id: number, +): Promise { + const { data } = await apiClient.get( + `/api/repositories/${id}`, + ) + return data +} + +export async function deleteRepo(id: number): Promise { + await apiClient.delete(`/api/repositories/${id}`) +} \ No newline at end of file diff --git a/frontend/src/features/repo/index.ts b/frontend/src/features/repo/index.ts new file mode 100644 index 00000000..0001c494 --- /dev/null +++ b/frontend/src/features/repo/index.ts @@ -0,0 +1,8 @@ +export { RepositoryPanel } from './ui/RepositoryPanel' +export type { + CandidateRepositoryPageResponse, + CandidateRepositoryResponse, + PageResponse, + RegisteredRepositoryResponse, + RepositoryStatus, +} from './model/types' diff --git a/frontend/src/features/repo/model/types.ts b/frontend/src/features/repo/model/types.ts new file mode 100644 index 00000000..8b73811f --- /dev/null +++ b/frontend/src/features/repo/model/types.ts @@ -0,0 +1,46 @@ +//중복되는 타입일 수 있으나 정의하는 상태가 달라서 일단 분리 X +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 = { + content: T[] + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean +} diff --git a/frontend/src/features/repo/model/useRepos.ts b/frontend/src/features/repo/model/useRepos.ts new file mode 100644 index 00000000..9c82436b --- /dev/null +++ b/frontend/src/features/repo/model/useRepos.ts @@ -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 | 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] }) + }, + }) +} diff --git a/frontend/src/features/repo/ui/CandidateRepositoryList.tsx b/frontend/src/features/repo/ui/CandidateRepositoryList.tsx new file mode 100644 index 00000000..af879b62 --- /dev/null +++ b/frontend/src/features/repo/ui/CandidateRepositoryList.tsx @@ -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(null) + + if (isLoading) return + if (isError) { + const message = isApiError(error) + ? error.message + : 'GitHub 레포 후보를 불러오지 못했습니다.' + return ( +
+

+ {message} +

+ +
+ ) + } + + const candidates = data?.content ?? [] + if (candidates.length === 0) { + return ( +
+

+ 가져올 수 있는 GitHub 레포가 없습니다. +

+
+ ) + } + + return ( +
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
    + {candidates.map((candidate) => ( +
  • + setErrorMessage(null)} + /> +
  • + ))} +
+
+ ) +} + +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 ( +
+
+
+ + {candidate.fullName} + + {candidate.private ? ( + + Private + + ) : null} + {candidate.alreadyRegistered ? ( + + 이미 등록됨 + + ) : null} +
+

+ {candidate.description ?? '설명 없음'} +

+

+ 기본 브랜치 {candidate.defaultBranch} +

+
+ +
+ ) +} + +function ListSkeleton() { + return ( +
    + {[0, 1, 2, 3].map((i) => ( +
  • +
    +
    +
  • + ))} +
+ ) +} diff --git a/frontend/src/features/repo/ui/RegisteredRepositoryList.tsx b/frontend/src/features/repo/ui/RegisteredRepositoryList.tsx new file mode 100644 index 00000000..410394c8 --- /dev/null +++ b/frontend/src/features/repo/ui/RegisteredRepositoryList.tsx @@ -0,0 +1,211 @@ +import { isApiError } from '@/shared/api' +import { + useDeleteRepoMutation, + useRegisteredReposQuery, +} from '@/features/repo/model/useRepos' +import type { + RegisteredRepositoryResponse, + RepositoryStatus, +} from '@/features/repo/model/types' + +type Props = { + onBrowseCandidates: () => void +} + +export function RegisteredRepositoryList({ onBrowseCandidates }: Props) { + const { data, isLoading, isError, error, refetch } = useRegisteredReposQuery() + + if (isLoading) return + if (isError) { + const message = isApiError(error) + ? error.message + : '레포지토리 목록을 불러오지 못했습니다.' + return ( +
+

+ {message} +

+ +
+ ) + } + + const repos = data?.content ?? [] + if (repos.length === 0) { + return ( +
+

+ 아직 등록된 레포지토리가 없습니다. +

+

+ GitHub에서 분석할 레포를 가져와 등록해보세요. +

+ +
+ ) + } + + return ( +
    + {repos.map((repo) => ( +
  • + +
  • + ))} +
+ ) +} + +function RegisteredRepoCard({ repo }: { repo: RegisteredRepositoryResponse }) { + const remove = useDeleteRepoMutation() + const handleDelete = () => { + const ok = window.confirm( + `'${repo.repoFullName}' 레포지토리 등록을 해제하시겠습니까?`, + ) + if (!ok) return + remove.mutate(repo.id) + } + return ( +
+ +
+ + {repo.repoFullName} + +

+ 기본 브랜치 {repo.defaultBranch} · {syncLabel(repo)} +

+
+ + +
+ ) +} + +function ListSkeleton() { + return ( +
    + {[0, 1, 2].map((i) => ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ))} +
+ ) +} + +const STATUS_LABEL: Record = { + PENDING: '분석 대기', + ANALYZING: '분석 중', + ANALYZED: '분석 완료', + FAILED: '분석 실패', +} + +const STATUS_CLASS: Record = { + PENDING: 'bg-info-50 text-info-700', + ANALYZING: 'bg-info-50 text-info-700', + ANALYZED: 'bg-success-50 text-success-700', + FAILED: 'bg-danger-50 text-danger-700', +} + +function StatusBadge({ status }: { status: RepositoryStatus }) { + const inProgress = status === 'PENDING' || status === 'ANALYZING' + return ( + + {inProgress ? : null} + {STATUS_LABEL[status]} + + ) +} + +function Spinner() { + return ( + + + + + ) +} + +function RepoIcon() { + return ( +
+ + + +
+ ) +} + +function syncLabel(repo: RegisteredRepositoryResponse): string { + if (repo.lastSyncedAt) return `최근 동기화 ${relativeTime(repo.lastSyncedAt)}` + return `등록 ${relativeTime(repo.createdAt)}` +} + +function relativeTime(iso: string): string { + const diffMs = Date.now() - new Date(iso).getTime() + if (diffMs < 0) return '방금 전' + const sec = diffMs / 1000 + if (sec < 60) return '방금 전' + const min = sec / 60 + if (min < 60) return `${Math.floor(min)}분 전` + const hr = min / 60 + if (hr < 24) return `${Math.floor(hr)}시간 전` + const day = hr / 24 + if (day < 7) return `${Math.floor(day)}일 전` + return new Date(iso).toLocaleDateString('ko-KR') +} diff --git a/frontend/src/features/repo/ui/RepositoryPanel.tsx b/frontend/src/features/repo/ui/RepositoryPanel.tsx new file mode 100644 index 00000000..7ed381c1 --- /dev/null +++ b/frontend/src/features/repo/ui/RepositoryPanel.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react' +import { useRegisteredReposQuery } from '@/features/repo/model/useRepos' +import { CandidateRepositoryList } from './CandidateRepositoryList' +import { RegisteredRepositoryList } from './RegisteredRepositoryList' + +type Tab = 'registered' | 'candidates' + +export function RepositoryPanel() { + const [tab, setTab] = useState('registered') + const { data } = useRegisteredReposQuery() + const registeredCount = data?.totalElements ?? 0 + + return ( +
+
+ setTab('registered')} + controls="repo-panel-registered" + > + 등록됨{registeredCount > 0 ? ` (${registeredCount})` : ''} + + setTab('candidates')} + controls="repo-panel-candidates" + > + GitHub에서 가져오기 + +
+ + {tab === 'registered' ? ( +
+ setTab('candidates')} + /> +
+ ) : ( +
+ +
+ )} +
+ ) +} + +function TabButton({ + active, + onClick, + controls, + children, +}: { + active: boolean + onClick: () => void + controls: string + children: React.ReactNode +}) { + return ( + + ) +} diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx index 3bbc74e8..931f9695 100644 --- a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -1,3 +1,4 @@ +import { RepositoryPanel } from '@/features/repo' import { ResumeList, UploadResumeButton } from '@/features/resume' import { SiteNav } from '@/widgets/site-nav' import { SiteFooter } from '@/widgets/site-footer' @@ -24,21 +25,10 @@ export default function WorkspacePage() { title="내 GitHub 레포지토리" description="등록한 레포를 기반으로 코드 맥락에 맞는 질문이 생성됩니다." > - + ) } - -function EmptyRepoSlot() { - return ( -
-

아직 등록된 레포지토리가 없습니다.

-

- 다음 단계에서 GitHub 레포 가져오기가 가능해집니다. -

-
- ) -}