From 0b1cc33f3bbe63cdda8261520059cd3cb550be21 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Fri, 29 May 2026 01:19:32 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(shared):=20StatusBadge=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B1=83=EC=A7=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/shared/ui/StatusBadge/StatusBadge.tsx | 31 +++++++++++++++++++ frontend/src/shared/ui/StatusBadge/index.ts | 2 ++ frontend/src/shared/ui/index.ts | 2 ++ 3 files changed, 35 insertions(+) create mode 100644 frontend/src/shared/ui/StatusBadge/StatusBadge.tsx create mode 100644 frontend/src/shared/ui/StatusBadge/index.ts 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' From 0b65ac26a6d6198619b16468df051a32e40f49bd Mon Sep 17 00:00:00 2001 From: Jaeho Date: Fri, 29 May 2026 01:19:39 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(shared):=20SSE=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=9E=AC=EC=97=B0=EA=B2=B0=20useEventStream=20=ED=9B=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/shared/hooks/index.ts | 1 + frontend/src/shared/hooks/useEventStream.ts | 101 ++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 frontend/src/shared/hooks/useEventStream.ts 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]) +} From ccd1e44ca93a9ac34444e6653cc29a110ccb715b Mon Sep 17 00:00:00 2001 From: Jaeho Date: Fri, 29 May 2026 01:19:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(auth):=20SSE=20stream-token=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20API=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/auth/api/auth.ts | 8 ++++++++ frontend/src/features/auth/index.ts | 1 + 2 files changed, 9 insertions(+) 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, From 79dad5934bbaa6da046b29d5443b0fa23c9fd03d Mon Sep 17 00:00:00 2001 From: Jaeho Date: Fri, 29 May 2026 01:19:50 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(resume):=20=EC=9D=B4=EB=A0=A5=EC=84=9C?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=C2=B7=EB=AA=A9=EB=A1=9D=C2=B7?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20UI=20=EB=B0=8F=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/resume/api/resume.ts | 23 ++++++ frontend/src/features/resume/index.ts | 9 +++ frontend/src/features/resume/lib/format.ts | 6 ++ frontend/src/features/resume/model/types.ts | 14 ++++ .../src/features/resume/model/useResumes.ts | 34 +++++++++ .../src/features/resume/ui/ResumeList.tsx | 72 +++++++++++++++++++ .../src/features/resume/ui/ResumeUploader.tsx | 52 ++++++++++++++ 7 files changed, 210 insertions(+) create mode 100644 frontend/src/features/resume/api/resume.ts create mode 100644 frontend/src/features/resume/lib/format.ts create mode 100644 frontend/src/features/resume/model/types.ts create mode 100644 frontend/src/features/resume/model/useResumes.ts create mode 100644 frontend/src/features/resume/ui/ResumeList.tsx create mode 100644 frontend/src/features/resume/ui/ResumeUploader.tsx 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} +
+ ) +} From 5c42716d1d09f822127ad9e367781af5d44da239 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Fri, 29 May 2026 01:19:56 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(repo):=20GitHub=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=20=ED=9B=84=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=C2=B7=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=C2=B7=EB=AA=A9=EB=A1=9D=C2=B7=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/repo/api/repo.ts | 40 +++++++ frontend/src/features/repo/index.ts | 15 +++ frontend/src/features/repo/model/types.ts | 29 +++++ .../features/repo/model/useRepositories.ts | 54 +++++++++ frontend/src/features/repo/ui/RepoList.tsx | 82 +++++++++++++ frontend/src/features/repo/ui/RepoPicker.tsx | 111 ++++++++++++++++++ 6 files changed, 331 insertions(+) create mode 100644 frontend/src/features/repo/api/repo.ts create mode 100644 frontend/src/features/repo/index.ts create mode 100644 frontend/src/features/repo/model/types.ts create mode 100644 frontend/src/features/repo/model/useRepositories.ts create mode 100644 frontend/src/features/repo/ui/RepoList.tsx create mode 100644 frontend/src/features/repo/ui/RepoPicker.tsx 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} +
+ ) +} From 6eb1b293e6b24fc3eb8dc82130fdcc3efc328261 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Fri, 29 May 2026 01:20:01 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(analysis):=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=9A=94=EC=95=BD?= =?UTF-8?q?=C2=B7=EA=B8=B0=EC=88=A0=EC=8A=A4=ED=83=9D=C2=B7=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/analysis/api/analysis.ts | 22 +++ frontend/src/features/analysis/index.ts | 11 ++ frontend/src/features/analysis/model/types.ts | 43 ++++++ .../features/analysis/model/useDocuments.ts | 15 ++ .../src/features/analysis/ui/DocumentList.tsx | 131 ++++++++++++++++++ 5 files changed, 222 insertions(+) create mode 100644 frontend/src/features/analysis/api/analysis.ts create mode 100644 frontend/src/features/analysis/index.ts create mode 100644 frontend/src/features/analysis/model/types.ts create mode 100644 frontend/src/features/analysis/model/useDocuments.ts create mode 100644 frontend/src/features/analysis/ui/DocumentList.tsx 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} +
  • + ) +} From ca12d71b14a92825be5927f80b9ebdb69a38e9a3 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Fri, 29 May 2026 01:20:09 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat(workspace):=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20SSE=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A1=B0=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/useWorkspaceAnalysisStream.ts | 30 +++++++++++++ .../src/pages/Workspace/ui/WorkspacePage.tsx | 45 +++++++++---------- 2 files changed, 50 insertions(+), 25 deletions(-) create mode 100644 frontend/src/pages/Workspace/model/useWorkspaceAnalysisStream.ts 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 레포 가져오기가 가능해집니다. -

    -
    - ) -} From b2480d2054a8dc56cec6979980120bead4f294ff Mon Sep 17 00:00:00 2001 From: Jaeho Date: Fri, 29 May 2026 01:20:14 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs(features):=20repo=C2=B7analysis=20?= =?UTF-8?q?=EC=8A=AC=EB=9D=BC=EC=9D=B4=EC=8A=A4=20=EC=9D=B8=EB=B2=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=83=81=ED=83=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 |