diff --git a/frontend/src/features/CLAUDE.md b/frontend/src/features/CLAUDE.md index e56fa60d..f3dae5f1 100644 --- a/frontend/src/features/CLAUDE.md +++ b/frontend/src/features/CLAUDE.md @@ -78,8 +78,8 @@ 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 | -| `analysis` (계획) | 분석 상태 표시, 재분석 | US-11, US-12 | +| `repo` | GitHub 후보 조회/등록/목록/삭제 | US-07, US-08 | +| `analysis` | 분석 문서 목록·요약·기술스택·원문(presigned) | US-11, US-12 | | `interview` | 세션 생성·진행·종료, 메시지, 음성 | US-13~22 | | `feedback` | 피드백 리포트, 점수, 키워드 | US-24, US-25 | | `history` (계획) | 세션 히스토리 목록·상세, 통계 | US-15, US-16, US-26, US-27 | diff --git a/frontend/src/features/analysis/api/analysis.ts b/frontend/src/features/analysis/api/analysis.ts new file mode 100644 index 00000000..9bae1321 --- /dev/null +++ b/frontend/src/features/analysis/api/analysis.ts @@ -0,0 +1,22 @@ +import { apiClient } from '@/shared/api' +import type { AnalyzedDocument } from '../model/types' + +export type DocumentFilter = { + resumeId?: number + repositoryId?: number +} + +export async function fetchDocuments( + filter: DocumentFilter = {}, +): Promise { + const response = await apiClient.get('/api/documents', { + params: filter, + }) + return response.data +} + +// 상세 조회만 documentDownloadUrl(presigned) 을 포함한다. +export async function fetchDocument(id: number): Promise { + const response = await apiClient.get(`/api/documents/${id}`) + return response.data +} diff --git a/frontend/src/features/analysis/index.ts b/frontend/src/features/analysis/index.ts new file mode 100644 index 00000000..b5d87a99 --- /dev/null +++ b/frontend/src/features/analysis/index.ts @@ -0,0 +1,11 @@ +export { DocumentList } from './ui/DocumentList' +export { useDocuments, documentKeys } from './model/useDocuments' +export { fetchDocuments, fetchDocument } from './api/analysis' +export type { DocumentFilter } from './api/analysis' +export type { + AnalyzedDocument, + AnalysisStatus, + AnalysisSourceType, + AnalysisEventPayload, + SseEnvelope, +} from './model/types' diff --git a/frontend/src/features/analysis/model/types.ts b/frontend/src/features/analysis/model/types.ts new file mode 100644 index 00000000..71b7c1d3 --- /dev/null +++ b/frontend/src/features/analysis/model/types.ts @@ -0,0 +1,43 @@ +export type AnalysisStatus = 'PROCESSING' | 'ANALYZED' | 'FAILED' + +export type AnalysisSourceType = 'RESUME' | 'REPOSITORY' | 'WEB' + +export type AnalyzedDocument = { + id: number + sourceType: AnalysisSourceType + sourceId: number + documentPath: string | null + // 목록 응답에서는 비어 있고, 상세 조회 시에만 10분 TTL presigned URL 이 채워진다. + documentDownloadUrl: string | null + summary: string | null + techStack: string[] + embeddingChunkCount: number + analysisStatus: AnalysisStatus + errorCode: string | null + errorMessage: string | null + status: string + createdAt: string + updatedAt: string +} + +// /api/stream/me 의 DOC_STATE / REPO_STATE 이벤트 data 의 payload 필드. +export type AnalysisEventPayload = { + targetType: AnalysisSourceType + targetId: number + status: string + summary?: string | null + techStack?: string[] + documentPath?: string | null + embeddingChunkCount?: number | null + errorCode?: string | null + errorMessage?: string | null +} + +// 백엔드 SseEvent 봉투. event data 전체 형태. +export type SseEnvelope = { + id: string | null + type: string + payload: T + timestamp: string + traceId: string | null +} diff --git a/frontend/src/features/analysis/model/useDocuments.ts b/frontend/src/features/analysis/model/useDocuments.ts new file mode 100644 index 00000000..c8791881 --- /dev/null +++ b/frontend/src/features/analysis/model/useDocuments.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchDocuments, type DocumentFilter } from '../api/analysis' +import type { AnalyzedDocument } from './types' + +export const documentKeys = { + all: ['documents'] as const, + list: (filter: DocumentFilter) => ['documents', filter] as const, +} + +export function useDocuments(filter: DocumentFilter = {}) { + return useQuery({ + queryKey: documentKeys.list(filter), + queryFn: () => fetchDocuments(filter), + }) +} diff --git a/frontend/src/features/analysis/ui/DocumentList.tsx b/frontend/src/features/analysis/ui/DocumentList.tsx new file mode 100644 index 00000000..a5724168 --- /dev/null +++ b/frontend/src/features/analysis/ui/DocumentList.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { isApiError } from '@/shared/api' +import { StatusBadge, type StatusTone } from '@/shared/ui' +import { fetchDocument, type DocumentFilter } from '../api/analysis' +import { useDocuments } from '../model/useDocuments' +import type { + AnalysisSourceType, + AnalysisStatus, + AnalyzedDocument, +} from '../model/types' + +const STATUS_META: Record = + { + PROCESSING: { tone: 'warning', label: '분석 중' }, + ANALYZED: { tone: 'success', label: '분석 완료' }, + FAILED: { tone: 'danger', label: '분석 실패' }, + } + +const SOURCE_LABEL: Record = { + RESUME: '이력서', + REPOSITORY: '레포지토리', + WEB: '웹', +} + +type Props = { + filter?: DocumentFilter +} + +export function DocumentList({ filter = {} }: Props) { + const { data = [], isPending, isError, error } = useDocuments(filter) + + if (isPending) { + return

분석 결과를 불러오는 중…

+ } + if (isError) { + return ( +

+ {isApiError(error) ? error.message : '분석 결과를 불러오지 못했습니다.'} +

+ ) + } + if (data.length === 0) { + return ( +
+

아직 분석된 문서가 없습니다.

+

+ 이력서·레포 분석이 완료되면 요약과 기술 스택이 여기에 표시됩니다. +

+
+ ) + } + + return ( +
    + {data.map((doc) => ( + + ))} +
+ ) +} + +function DocumentRow({ doc }: { doc: AnalyzedDocument }) { + const meta = STATUS_META[doc.analysisStatus] + const [opening, setOpening] = useState(false) + const [openError, setOpenError] = useState(null) + + const openSource = async () => { + setOpenError(null) + setOpening(true) + try { + const detail = await fetchDocument(doc.id) + if (detail.documentDownloadUrl) { + window.open(detail.documentDownloadUrl, '_blank', 'noreferrer') + } else { + setOpenError('다운로드 URL을 가져오지 못했습니다.') + } + } catch (e) { + setOpenError(isApiError(e) ? e.message : '원문을 열지 못했습니다.') + } finally { + setOpening(false) + } + } + + return ( +
  • +
    + + {SOURCE_LABEL[doc.sourceType]} + + {meta.label} +
    + + {doc.analysisStatus === 'ANALYZED' ? ( + <> + {doc.summary ? ( +

    {doc.summary}

    + ) : null} + {doc.techStack.length > 0 ? ( +
      + {doc.techStack.map((tech) => ( +
    • + {tech} +
    • + ))} +
    + ) : null} + + {openError ? ( +

    {openError}

    + ) : null} + + ) : null} + + {doc.analysisStatus === 'FAILED' ? ( +

    + {doc.errorMessage ?? '분석에 실패했습니다.'} +

    + ) : null} +
  • + ) +} diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index c8f79e8e..3edf1577 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -34,3 +34,11 @@ export async function fetchCurrentUser(): Promise { export async function logout(): Promise { await apiClient.delete('/api/auth/logout') } + +export async function createStreamToken(): Promise { + const response = await apiClient.post<{ streamToken: string }>( + '/api/auth/stream-token', + {}, + ) + return response.data.streamToken +} diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts index e9302a3f..25e8975f 100644 --- a/frontend/src/features/auth/index.ts +++ b/frontend/src/features/auth/index.ts @@ -9,6 +9,7 @@ export { completeGithubLogin, fetchCurrentUser, logout, + createStreamToken, } from './api/auth' export type { AuthUser, diff --git a/frontend/src/features/repo/api/repo.ts b/frontend/src/features/repo/api/repo.ts new file mode 100644 index 00000000..64f23eff --- /dev/null +++ b/frontend/src/features/repo/api/repo.ts @@ -0,0 +1,40 @@ +import { apiClient } from '@/shared/api' +import type { + CandidateRepository, + RegisteredRepository, + RegisterRepositoryInput, +} from '../model/types' + +export async function fetchRegisteredRepositories(): Promise< + RegisteredRepository[] +> { + const response = await apiClient.get( + '/api/repositories', + ) + return response.data +} + +export async function fetchCandidateRepositories( + page: number, + perPage: number, +): Promise { + const response = await apiClient.get( + '/api/repositories/github', + { params: { page, perPage } }, + ) + return response.data +} + +export async function registerRepository( + input: RegisterRepositoryInput, +): Promise { + const response = await apiClient.post( + '/api/repositories', + input, + ) + return response.data +} + +export async function deleteRepository(id: number): Promise { + await apiClient.delete(`/api/repositories/${id}`) +} diff --git a/frontend/src/features/repo/index.ts b/frontend/src/features/repo/index.ts new file mode 100644 index 00000000..8ccfc370 --- /dev/null +++ b/frontend/src/features/repo/index.ts @@ -0,0 +1,15 @@ +export { RepoList } from './ui/RepoList' +export { RepoPicker } from './ui/RepoPicker' +export { + useRegisteredRepositories, + useCandidateRepositories, + useRegisterRepository, + useDeleteRepository, + repoKeys, +} from './model/useRepositories' +export type { + RegisteredRepository, + CandidateRepository, + RepositoryStatus, + RegisterRepositoryInput, +} 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..1cc73eef --- /dev/null +++ b/frontend/src/features/repo/model/types.ts @@ -0,0 +1,29 @@ +export type RepositoryStatus = 'PENDING' | 'ANALYZING' | 'ANALYZED' | 'FAILED' + +export type RegisteredRepository = { + id: number + githubRepoId: number + repoName: string + repoFullName: string + repoUrl: string + defaultBranch: string | null + status: RepositoryStatus + lastSyncedAt: string | null + createdAt: string + updatedAt: string +} + +export type CandidateRepository = { + githubRepoId: number + name: string + fullName: string + htmlUrl: string + defaultBranch: string | null + isPrivate: boolean + alreadyRegistered: boolean +} + +export type RegisterRepositoryInput = { + githubRepoId: number + fullName: string +} diff --git a/frontend/src/features/repo/model/useRepositories.ts b/frontend/src/features/repo/model/useRepositories.ts new file mode 100644 index 00000000..9902d5e3 --- /dev/null +++ b/frontend/src/features/repo/model/useRepositories.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + deleteRepository, + fetchCandidateRepositories, + fetchRegisteredRepositories, + registerRepository, +} from '../api/repo' +import type { CandidateRepository, RegisteredRepository } from './types' + +export const repoKeys = { + // registered 와 candidates 모두 ['repositories'] prefix → 한 번에 invalidate 가능 + registered: ['repositories'] as const, + candidates: (page: number, perPage: number) => + ['repositories', 'github', page, perPage] as const, +} + +export function useRegisteredRepositories() { + return useQuery({ + queryKey: repoKeys.registered, + queryFn: fetchRegisteredRepositories, + }) +} + +export function useCandidateRepositories( + page: number, + perPage: number, + enabled: boolean, +) { + return useQuery({ + queryKey: repoKeys.candidates(page, perPage), + queryFn: () => fetchCandidateRepositories(page, perPage), + enabled, + }) +} + +export function useRegisterRepository() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: registerRepository, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: repoKeys.registered }) + }, + }) +} + +export function useDeleteRepository() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: deleteRepository, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: repoKeys.registered }) + }, + }) +} diff --git a/frontend/src/features/repo/ui/RepoList.tsx b/frontend/src/features/repo/ui/RepoList.tsx new file mode 100644 index 00000000..1346a9b2 --- /dev/null +++ b/frontend/src/features/repo/ui/RepoList.tsx @@ -0,0 +1,82 @@ +import { isApiError } from '@/shared/api' +import { StatusBadge, type StatusTone } from '@/shared/ui' +import { + useDeleteRepository, + useRegisteredRepositories, +} from '../model/useRepositories' +import type { RepositoryStatus } from '../model/types' + +const STATUS_META: Record< + RepositoryStatus, + { tone: StatusTone; label: string } +> = { + PENDING: { tone: 'info', label: '대기 중' }, + ANALYZING: { tone: 'warning', label: '분석 중' }, + ANALYZED: { tone: 'success', label: '분석 완료' }, + FAILED: { tone: 'danger', label: '분석 실패' }, +} + +export function RepoList() { + const { data = [], isPending, isError, error } = useRegisteredRepositories() + const remove = useDeleteRepository() + + if (isPending) { + return

    레포지토리를 불러오는 중…

    + } + if (isError) { + return ( +

    + {isApiError(error) ? error.message : '레포지토리를 불러오지 못했습니다.'} +

    + ) + } + if (data.length === 0) { + return ( +
    +

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

    +

    + 위에서 GitHub 레포를 가져오면 분석이 자동으로 시작됩니다. +

    +
    + ) + } + + return ( +
      + {data.map((repo) => { + const meta = STATUS_META[repo.status] + return ( +
    • +
      + + {repo.repoFullName} + +

      + {repo.defaultBranch ?? 'main'} +

      +
      +
      + {meta.label} + +
      +
    • + ) + })} +
    + ) +} diff --git a/frontend/src/features/repo/ui/RepoPicker.tsx b/frontend/src/features/repo/ui/RepoPicker.tsx new file mode 100644 index 00000000..666c101a --- /dev/null +++ b/frontend/src/features/repo/ui/RepoPicker.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react' +import { isApiError } from '@/shared/api' +import { + useCandidateRepositories, + useRegisterRepository, +} from '../model/useRepositories' +import type { CandidateRepository } from '../model/types' + +const PER_PAGE = 30 + +export function RepoPicker() { + const [open, setOpen] = useState(false) + const [page, setPage] = useState(1) + const candidates = useCandidateRepositories(page, PER_PAGE, open) + const register = useRegisterRepository() + const [error, setError] = useState(null) + + const onRegister = (repo: CandidateRepository) => { + setError(null) + register.mutate( + { githubRepoId: repo.githubRepoId, fullName: repo.fullName }, + { + onError: (e) => + setError(isApiError(e) ? e.message : '레포 등록에 실패했습니다.'), + }, + ) + } + + return ( +
    +
    +

    GitHub에서 레포 가져오기

    + +
    + + {open ? ( +
    + {error ? ( +

    {error}

    + ) : null} + + {candidates.isPending ? ( +

    불러오는 중…

    + ) : candidates.isError ? ( +

    + {isApiError(candidates.error) + ? candidates.error.message + : 'GitHub 레포를 불러오지 못했습니다.'} +

    + ) : candidates.data.length === 0 ? ( +

    표시할 레포가 없습니다.

    + ) : ( +
      + {candidates.data.map((repo) => ( +
    • +
      +

      + {repo.fullName} + {repo.isPrivate ? ( + + private + + ) : null} +

      +
      + +
    • + ))} +
    + )} + +
    + + {page} + +
    +
    + ) : null} +
    + ) +} diff --git a/frontend/src/features/resume/api/resume.ts b/frontend/src/features/resume/api/resume.ts new file mode 100644 index 00000000..abf576bb --- /dev/null +++ b/frontend/src/features/resume/api/resume.ts @@ -0,0 +1,23 @@ +import { apiClient } from '@/shared/api' +import type { Resume } from '../model/types' + +export async function fetchResumes(): Promise { + const response = await apiClient.get('/api/resumes') + return response.data +} + +// multipart 업로드. apiClient 의 기본 Content-Type 이 application/json 이라 +// 그대로 두면 axios 가 FormData 를 JSON 으로 직렬화해 버린다(415 발생). +// 요청별로 multipart/form-data 로 덮어쓰면 axios 가 boundary 를 다시 채워 전송한다. +export async function uploadResume(file: File): Promise { + const form = new FormData() + form.append('file', file) + const response = await apiClient.post('/api/resumes', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data +} + +export async function deleteResume(id: number): Promise { + await apiClient.delete(`/api/resumes/${id}`) +} diff --git a/frontend/src/features/resume/index.ts b/frontend/src/features/resume/index.ts index e69de29b..45a24fca 100644 --- a/frontend/src/features/resume/index.ts +++ b/frontend/src/features/resume/index.ts @@ -0,0 +1,9 @@ +export { ResumeUploader } from './ui/ResumeUploader' +export { ResumeList } from './ui/ResumeList' +export { + useResumes, + useUploadResume, + useDeleteResume, + resumeKeys, +} from './model/useResumes' +export type { Resume, ResumeStatus, ResumeFileType } from './model/types' diff --git a/frontend/src/features/resume/lib/format.ts b/frontend/src/features/resume/lib/format.ts new file mode 100644 index 00000000..55236e45 --- /dev/null +++ b/frontend/src/features/resume/lib/format.ts @@ -0,0 +1,6 @@ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(0)} KB` + return `${(kb / 1024).toFixed(1)} MB` +} diff --git a/frontend/src/features/resume/model/types.ts b/frontend/src/features/resume/model/types.ts new file mode 100644 index 00000000..0d6698e2 --- /dev/null +++ b/frontend/src/features/resume/model/types.ts @@ -0,0 +1,14 @@ +export type ResumeStatus = 'PENDING' | 'ANALYZING' | 'ANALYZED' | 'FAILED' + +export type ResumeFileType = 'PDF' + +export type Resume = { + id: number + originalFilename: string + filePath: string + fileType: ResumeFileType + fileSize: number + status: ResumeStatus + createdAt: string + updatedAt: string +} diff --git a/frontend/src/features/resume/model/useResumes.ts b/frontend/src/features/resume/model/useResumes.ts new file mode 100644 index 00000000..36f2fc4b --- /dev/null +++ b/frontend/src/features/resume/model/useResumes.ts @@ -0,0 +1,34 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { deleteResume, fetchResumes, uploadResume } from '../api/resume' +import type { Resume } from './types' + +export const resumeKeys = { + all: ['resumes'] as const, +} + +export function useResumes() { + return useQuery({ + queryKey: resumeKeys.all, + queryFn: fetchResumes, + }) +} + +export function useUploadResume() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: uploadResume, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: resumeKeys.all }) + }, + }) +} + +export function useDeleteResume() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: deleteResume, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: resumeKeys.all }) + }, + }) +} diff --git a/frontend/src/features/resume/ui/ResumeList.tsx b/frontend/src/features/resume/ui/ResumeList.tsx new file mode 100644 index 00000000..ee86bd93 --- /dev/null +++ b/frontend/src/features/resume/ui/ResumeList.tsx @@ -0,0 +1,72 @@ +import { isApiError } from '@/shared/api' +import { StatusBadge, type StatusTone } from '@/shared/ui' +import { useDeleteResume, useResumes } from '../model/useResumes' +import { formatFileSize } from '../lib/format' +import type { ResumeStatus } from '../model/types' + +const STATUS_META: Record = { + PENDING: { tone: 'info', label: '대기 중' }, + ANALYZING: { tone: 'warning', label: '분석 중' }, + ANALYZED: { tone: 'success', label: '분석 완료' }, + FAILED: { tone: 'danger', label: '분석 실패' }, +} + +export function ResumeList() { + const { data = [], isPending, isError, error } = useResumes() + const remove = useDeleteResume() + + if (isPending) { + return

    이력서를 불러오는 중…

    + } + if (isError) { + return ( +

    + {isApiError(error) ? error.message : '이력서를 불러오지 못했습니다.'} +

    + ) + } + if (data.length === 0) { + return ( +
    +

    아직 등록된 이력서가 없습니다.

    +

    + PDF 이력서를 업로드하면 분석이 자동으로 시작됩니다. +

    +
    + ) + } + + return ( +
      + {data.map((resume) => { + const meta = STATUS_META[resume.status] + return ( +
    • +
      +

      + {resume.originalFilename} +

      +

      + {formatFileSize(resume.fileSize)} +

      +
      +
      + {meta.label} + +
      +
    • + ) + })} +
    + ) +} diff --git a/frontend/src/features/resume/ui/ResumeUploader.tsx b/frontend/src/features/resume/ui/ResumeUploader.tsx new file mode 100644 index 00000000..04ad1cc9 --- /dev/null +++ b/frontend/src/features/resume/ui/ResumeUploader.tsx @@ -0,0 +1,52 @@ +import { useRef, useState } from 'react' +import { isApiError } from '@/shared/api' +import { useUploadResume } from '../model/useResumes' + +const MAX_FILE_SIZE = 20 * 1024 * 1024 + +export function ResumeUploader() { + const inputRef = useRef(null) + const [error, setError] = useState(null) + const upload = useUploadResume() + + const handleFile = (file: File | undefined) => { + setError(null) + if (!file) return + if (!file.name.toLowerCase().endsWith('.pdf')) { + setError('PDF 파일만 업로드할 수 있습니다.') + return + } + if (file.size > MAX_FILE_SIZE) { + setError('파일이 20MB를 초과합니다.') + return + } + upload.mutate(file, { + onError: (e) => + setError(isApiError(e) ? e.message : '업로드에 실패했습니다.'), + onSuccess: () => { + if (inputRef.current) inputRef.current.value = '' + }, + }) + } + + return ( +
    + handleFile(e.target.files?.[0])} + /> + + {error ?

    {error}

    : null} +
    + ) +} diff --git a/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts b/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts new file mode 100644 index 00000000..53a43e4c --- /dev/null +++ b/frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { createStreamToken } from '@/features/auth' +import { resumeKeys } from '@/features/resume' +import { repoKeys } from '@/features/repo' +import { documentKeys } from '@/features/analysis' +import { useEventStream } from '@/shared/hooks' + +// /api/stream/me 를 구독해 분석 상태(DOC_STATE/REPO_STATE) 가 오면 +// 이력서·레포·문서 쿼리를 무효화 → 화면이 자동으로 최신 상태로 갱신된다. +export function useWorkspaceAnalysisStream() { + const queryClient = useQueryClient() + + const getToken = useCallback(() => createStreamToken(), []) + + const invalidateAll = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: resumeKeys.all }) + void queryClient.invalidateQueries({ queryKey: repoKeys.registered }) + void queryClient.invalidateQueries({ queryKey: documentKeys.all }) + }, [queryClient]) + + useEventStream({ + path: '/api/stream/me', + getToken, + handlers: { + DOC_STATE: invalidateAll, + REPO_STATE: invalidateAll, + }, + }) +} diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx index 3b964b3b..a00862b5 100644 --- a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -2,9 +2,15 @@ import { SiteNav } from '@/widgets/site-nav' import { SiteFooter } from '@/widgets/site-footer' import { WorkspaceProfileCard } from '@/widgets/workspace-profile-card' import { WorkspaceSection } from '@/widgets/workspace-section' +import { ResumeList, ResumeUploader } from '@/features/resume' +import { RepoList, RepoPicker } from '@/features/repo' +import { DocumentList } from '@/features/analysis' +import { useWorkspaceAnalysisStream } from '../model/useWorkspaceAnalysisStream' -//이 컴포넌트는 아직 초기 프로토타입 입니다. export default function WorkspacePage() { + // 분석 상태 실시간 구독 (SSE) — 완료되면 목록이 자동 갱신된다. + useWorkspaceAnalysisStream() + return (
    @@ -14,40 +20,29 @@ export default function WorkspacePage() { } > - + - +
    + + +
    +
    + + +
    ) } - -function EmptyResumeSlot() { - return ( -
    -

    아직 등록된 이력서가 없습니다.

    -

    - 다음 단계에서 PDF 업로드가 가능해집니다. -

    -
    - ) -} - -function EmptyRepoSlot() { - return ( -
    -

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

    -

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

    -
    - ) -} diff --git a/frontend/src/shared/hooks/index.ts b/frontend/src/shared/hooks/index.ts index f0e17fce..d85629f0 100644 --- a/frontend/src/shared/hooks/index.ts +++ b/frontend/src/shared/hooks/index.ts @@ -1,2 +1,3 @@ export { useTypewriter } from './useTypewriter' export type { UseTypewriterOptions } from './useTypewriter' +export { useEventStream } from './useEventStream' diff --git a/frontend/src/shared/hooks/useEventStream.ts b/frontend/src/shared/hooks/useEventStream.ts new file mode 100644 index 00000000..e60c574c --- /dev/null +++ b/frontend/src/shared/hooks/useEventStream.ts @@ -0,0 +1,101 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { env } from '@/shared/config/env' + +type EventHandler = (data: unknown) => void +type EventHandlers = Record + +type UseEventStreamOptions = { + /** SSE_BASE_URL 에 붙일 경로. null 이면 연결하지 않음. */ + path: string | null + /** 매 (재)연결 시점에 stream token 을 새로 발급받는다. */ + getToken: () => Promise + /** SSE `event:` 이름 → 핸들러. data 는 JSON 파싱되어 전달됨(파싱 실패 시 raw 문자열). */ + handlers: EventHandlers + enabled?: boolean +} + +const BASE_BACKOFF_MS = 1_000 +const MAX_BACKOFF_MS = 30_000 + +// EventSource 는 커스텀 헤더를 못 싣는다 → 인증은 ?token= 쿼리(stream token)로 전달. +export function useEventStream({ + path, + getToken, + handlers, + enabled = true, +}: UseEventStreamOptions): void { + const handlersRef = useRef(handlers) + const getTokenRef = useRef(getToken) + + // 렌더 중 ref 쓰기는 금지 → effect 에서 최신 값으로 동기화. + useLayoutEffect(() => { + handlersRef.current = handlers + getTokenRef.current = getToken + }) + + useEffect(() => { + if (!enabled || !path) return + + let source: EventSource | null = null + let reconnectTimer: ReturnType | null = null + let attempt = 0 + let cancelled = false + + const scheduleReconnect = () => { + const delay = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS) + attempt += 1 + reconnectTimer = setTimeout(() => { + if (!cancelled) void connect() + }, delay) + } + + const connect = async () => { + if (cancelled) return + let token: string | null = null + try { + token = await getTokenRef.current() + } catch { + scheduleReconnect() + return + } + if (cancelled) return + + const query = token ? `?token=${encodeURIComponent(token)}` : '' + const es = new EventSource(`${env.SSE_BASE_URL}${path}${query}`, { + withCredentials: true, + }) + source = es + + es.onopen = () => { + attempt = 0 + } + es.onerror = () => { + es.close() + if (source === es) source = null + if (!cancelled) scheduleReconnect() + } + + for (const name of Object.keys(handlersRef.current)) { + es.addEventListener(name, (event) => { + const raw = (event as MessageEvent).data + let parsed: unknown = raw + try { + parsed = JSON.parse(raw) + } catch { + // keep-alive 등 비 JSON payload — raw 전달 + } + handlersRef.current[name]?.(parsed) + }) + } + } + + void connect() + + return () => { + cancelled = true + if (reconnectTimer) clearTimeout(reconnectTimer) + source?.close() + source = null + } + }, [path, enabled]) +} diff --git a/frontend/src/shared/ui/StatusBadge/StatusBadge.tsx b/frontend/src/shared/ui/StatusBadge/StatusBadge.tsx new file mode 100644 index 00000000..3f4d0b4c --- /dev/null +++ b/frontend/src/shared/ui/StatusBadge/StatusBadge.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react' + +export type StatusTone = + | 'neutral' + | 'info' + | 'warning' + | 'success' + | 'danger' + +const toneClass: Record = { + neutral: 'bg-surface text-fg-muted', + info: 'bg-info-50 text-info-700', + warning: 'bg-warning-50 text-warning-700', + success: 'bg-success-50 text-success-700', + danger: 'bg-danger-50 text-danger-700', +} + +export type StatusBadgeProps = { + tone?: StatusTone + children: ReactNode +} + +export function StatusBadge({ tone = 'neutral', children }: StatusBadgeProps) { + return ( + + {children} + + ) +} diff --git a/frontend/src/shared/ui/StatusBadge/index.ts b/frontend/src/shared/ui/StatusBadge/index.ts new file mode 100644 index 00000000..2b1b6b2f --- /dev/null +++ b/frontend/src/shared/ui/StatusBadge/index.ts @@ -0,0 +1,2 @@ +export { StatusBadge } from './StatusBadge' +export type { StatusBadgeProps, StatusTone } from './StatusBadge' diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index e69de29b..2b1b6b2f 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -0,0 +1,2 @@ +export { StatusBadge } from './StatusBadge' +export type { StatusBadgeProps, StatusTone } from './StatusBadge'