Skip to content

Fix(#157): 드래그앤드롭 추가 및 노트 leftpanel ux/ui 수정#166

Merged
dev-ldy03 merged 5 commits into
devfrom
feat/157-assets-drag-and-drop
Feb 12, 2026
Merged

Fix(#157): 드래그앤드롭 추가 및 노트 leftpanel ux/ui 수정#166
dev-ldy03 merged 5 commits into
devfrom
feat/157-assets-drag-and-drop

Conversation

@dev-ldy03
Copy link
Copy Markdown
Collaborator

@dev-ldy03 dev-ldy03 commented Feb 12, 2026

📌 관련 이슈

🏷️ PR 타입

  • ✨ 기능 추가 (Feature)
  • 🐛 버그 수정 (Bug Fix)
  • ♻️ 리팩토링 (Refactoring)
  • 📝 문서 수정 (Documentation)
  • 🎨 스타일 변경 (Style)
  • ✅ 테스트 추가 (Test)

📝 작업 내용

  • 사용자 편의성 개선을 위해 뷰어 영역 내 파일 드래그 앤 드롭(Drag & Drop) 업로드 기능을 추가했다.
  • 노트 leftPanel 뷰어탭과 스토리지탭 ui/ux 수정

📸 스크린샷

  • 테스트 결과를 스크린샷으로 첨부해주세요.

✅ 체크리스트

  • 코드 리뷰를 받을 준비가 완료되었습니다
  • 테스트를 작성하고 모두 통과했습니다
  • 문서를 업데이트했습니다 (필요한 경우)
  • 코드 스타일 가이드를 준수했습니다
  • 셀프 리뷰를 완료했습니다

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 저장소와 뷰어에 파일 업로드 추가(업로드 버튼, 파일 선택, 드래그 앤 드롭).
    • 업로드된 PDF에 대해 원본 다운로드 URL을 이용한 미리보기 지원.
  • 품질 향상

    • PDF/이미지 형식 검증, 플랜별 업로드 용량 제한 적용, 업로드 중 로딩·오류 UI 및 업로드 후 목록 자동 갱신.
  • 스타일/사용성

    • 탭 버튼 커서 개선 및 드래그 중 시각적 오버레이, 빈 상태에서 클릭으로 파일 탐색기 열기 가능.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 12, 2026

📝 Walkthrough

Walkthrough

Viewer 및 Storage 좌측 패널에 파일 업로드(선택·드래그앤드롭) 기능이 추가되었고, 이를 지원하는 훅(useFileUpload, useViewerFile)과 뷰어컴포넌트(ViewerEmpty, ViewerUploadCard, NoteCard)들이 연동되어 업로드, 검증(형식·플랜별 크기), 업로드 요청 및 쿼리 리프레시 흐름을 구현했습니다.

Changes

Cohort / File(s) Summary
Left panel UI / 작은 포맷 변경
src/features/chat/components/leftpanel/LeftPanelHeader.tsx, src/features/chat/components/leftpanel/StorageActionButtons.tsx
클래스명/JSX 포맷팅 변경(예: cursor-pointer 추가, JSX 줄바꿈). 동작 변경 없음.
스토리지 업로드 통합
src/features/chat/components/leftpanel/StorageContent.tsx
파일 선택·검증(PDF/이미지), 플랜 기반 최대 업로드 크기 검사(parseSize/PLAN_DETAILS), isUploading 상태, uploadAsset 호출 및 업로드 후 note-detail 쿼리 리프레시, 숨김 파일 입력/업로드 버튼 UI 추가.
뷰어 업로드·빈 상태 · 드래그앤드롭
src/features/chat/components/leftpanel/ViewerContent.tsx, src/features/chat/components/leftpanel/ViewerEmpty.tsx
ViewerContent에 업로드/드래그앤드롭 통합(DragDropOverlay, useFileUpload), 로딩/에러/낙관적 처리 및 업로드 흐름 추가. ViewerEmpty의 공개 API가 noteIdonOpenFileExplorer로 변경됨(컴포넌트 시그니처 변경).
파일 업로드 훅 확장 (드래그앤드롭)
src/shared/hooks/useFileUpload.tsx
isDragging 상태·dragCounter·onDrag handlers 추가, 드롭 시 파일 처리, fileInput 리셋 개선. 반환값에 isDragging, dragProps 추가(공개 API 변경).
Viewer 훅·페이지·카드 연동
src/pages/hooks/useViewerFile.ts, src/pages/HomePage.tsx, src/pages/components/ViewerUploadCard.tsx
useViewerFile 반환에 isDragging/dragProps 추가; HomePage가 이를 ViewerUploadCard에 전달. ViewerUploadCard가 dragProps를 외부 div에 적용하고 isDragging 시 DragDropOverlay 렌더링, prop 타입 변경.
NoteCard: PDF 다운로드 처리 추가
src/features/storage/components/NoteCard.tsx
업로드된 PDF에 대해 서버 다운로드 URL을 별도로 조회해 pdfDownloadUrl로 사용, PdfPreview 조건부 렌더링 추가. NoteCardPropsid: number 필수화.
기타 포맷/경미한 변경
src/features/assets/api/assetApi.ts, src/features/assets/utils/upload_attachments.ts, src/features/chat/hooks/useChatMessages.ts, src/features/sidebar/components/SidebarHeader.tsx
헤더 클릭 영역 마크업 조정, 문자열/헤더 포맷 및 refetchQueries 인수 포맷 변경 등 경미한 리포맷/마크업 조정. 기능적 변화 거의 없음.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as ViewerContent / StorageContent
    participant Hook as useFileUpload
    participant Server as uploadAsset API
    participant Q as QueryClient

    User->>UI: 파일 드래그 또는 파일 선택
    UI->>Hook: openFileExplorer / 드롭 / handleFileChange 호출
    Hook->>Hook: isDragging = true (드래그 진입)
    Hook-->>UI: onFileSelect로 파일 전달
    UI->>UI: 파일 형식 검사 및 플랜별 크기 검사(parseSize, PLAN_DETAILS)
    alt 검사 실패
        UI-->>User: 오류 알림
    else 검사 통과
        UI->>Server: uploadAsset(file)
        Server-->>UI: 업로드 응답(성공/실패)
        alt 성공
            UI->>Q: queryClient.refetchQueries(note detail, assets)
            Q-->>UI: 최신 데이터
            UI-->>User: 뷰어/스토리지 갱신
        else 실패
            UI-->>User: 업로드 실패 알림
        end
    end
    Hook->>Hook: isDragging = false (드래그 종료)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

bug, enhancement

Suggested reviewers

  • stringnine
  • haein45
  • lucy7noh

Poem

🐰 나는 토끼, 파일을 품에 안고
드래그하면 춤추고 클릭하면 달려가요
플랜 검사 통과하면 삐끗하지 않고 쏴아—
쿼리 리프레시로 뷰어가 반짝, 모두 환호! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 주요 변경사항인 드래그앤드롭 기능 추가와 노트 leftpanel UI/UX 수정을 명확하게 요약하고 있습니다.
Description check ✅ Passed PR 설명은 관련 이슈, PR 타입, 작업 내용을 포함하며 필수 섹션이 대부분 완성되어 있습니다.
Linked Issues check ✅ Passed 코드 변경사항이 #155 이슈의 목표인 썸네일 표시 문제 해결을 충족합니다. NoteCard.tsx에서 업로드된 PDF의 다운로드 URL을 구하는 로직이 추가되었고, ViewerContent 및 StorageContent에 파일 업로드 기능이 구현되어 있습니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 드래그앤드롭 파일 업로드 기능과 leftpanel UI/UX 개선의 목표에 직접적으로 관련되어 있습니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/157-assets-drag-and-drop

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/features/chat/components/leftpanel/StorageContent.tsx (5)

65-72: parseSize가 소수점이 포함된 크기 문자열을 잘못 파싱할 수 있습니다.

parseInt(sizeStr.replace(/\D/g, ""), 10)는 소수점(.)도 제거하므로, 예를 들어 "1.5GB"가 입력되면 "15"15GB로 잘못 계산됩니다. 현재 PLAN_DETAILS 값은 정수만 사용하므로 즉시 문제가 되지는 않지만, 향후 확장 시 버그 원인이 될 수 있습니다.

제안: parseFloat 사용
 const parseSize = (sizeStr: string) => {
-  const value = parseInt(sizeStr.replace(/\D/g, ""), 10);
+  const value = parseFloat(sizeStr);
   const unit = sizeStr.replace(/[^A-Za-z]/g, "").toUpperCase();
   if (unit.includes("GB")) return value * 1024 * 1024 * 1024;
   if (unit.includes("MB")) return value * 1024 * 1024;
   if (unit.includes("KB")) return value * 1024;
   return value;
 };

84-85: isUploading 상태가 중복 관리되고 있습니다.

useAssetUpload()에서 이미 isUploading을 반환하지만, Line 85에서 별도의 로컬 상태를 선언하고 handleFileSelect 내에서 수동으로 관리하고 있습니다. useAssetUploadisUploading은 실제 업로드 구간만 커버하고, 로컬 상태는 유효성 검사부터 업로드 완료까지를 커버하려는 의도로 보이지만, 두 상태가 공존하면 동기화 문제가 발생할 수 있습니다.

useAssetUploadisUploading을 활용하거나, 로컬 상태만 사용하도록 통일하는 것을 권장합니다.

제안: useAssetUpload의 isUploading 활용
-  const { uploadAsset } = useAssetUpload();
-  const [isUploading, setIsUploading] = useState(false);
+  const { uploadAsset, isUploading } = useAssetUpload();

그리고 handleFileSelect 내의 setIsUploading(true/false) 호출을 제거하세요. 유효성 검사 단계에서 별도의 로딩 표시가 필요하다면, 유효성 검사 실패 시 early return하므로 uploadAsset 호출 시점부터의 isUploading으로 충분합니다.


197-256: handleFileSelectuseCallback으로 감싸져 있지 않아 불필요한 리렌더가 발생합니다.

handleFileSelect는 매 렌더마다 새로운 참조가 생성되며, useFileUpload 훅의 handleFileChange[onFileSelect] 의존성으로 이를 참조하므로 매 렌더마다 handleFileChange도 재생성됩니다. useCallback으로 감싸서 안정적인 참조를 유지하는 것이 좋습니다.


384-409: 빈 배열의 삼항 연산자와 빈 프래그먼트가 불필요합니다.

boxFiles가 빈 배열이면 .map()이 아무것도 렌더링하지 않으므로, 삼항 연산자 대신 단순히 boxFiles.map(...)만 사용하면 됩니다. <></>를 반환하는 else 분기와 긴 주석 블록도 제거할 수 있습니다.

제안: 단순화
-            {boxFiles.length > 0 ? (
-              boxFiles.map((file) => (
-                <NoteCard
-                  key={file.id}
-                  id={file.id}
-                  ...
-                />
-              ))
-            ) : (
-              /* 긴 주석 블록 ... */
-              <></>
-            )}
+            {boxFiles.map((file) => (
+              <NoteCard
+                key={file.id}
+                id={file.id}
+                label={file.label}
+                type={file.type}
+                thumbnailUrl={file.fileUrl}
+                mimeType={file.mimeType}
+                ocrStatus={file.ocrStatus}
+                isSelected={selectedIds.includes(file.id)}
+                isSelectMode={isSelectMode}
+                onSelect={() => toggleIdSelection(file.id)}
+                onClick={() => handleFileClick(file.id)}
+              />
+            ))}

208-210: user?.plan as PlanType 타입 캐스팅이 안전하지 않습니다.

user?.planPlanType에 정의되지 않은 값(예: "Team")을 가질 경우, PLAN_DETAILS[userPlan]undefined를 반환하여 maxUploadSize"10MB" 폴백으로 처리됩니다. 현재는 || "10MB" 폴백이 있어 런타임 에러는 아니지만, 타입 안전성을 위해 런타임 검증을 추가하는 것이 좋습니다.

제안: 타입 가드 추가
-    const userPlan = (user?.plan as PlanType) || "Free";
-    const maxUploadSizeStr = PLAN_DETAILS[userPlan]?.maxUploadSize || "10MB";
+    const planValue = user?.plan ?? "Free";
+    const userPlan: PlanType = planValue in PLAN_DETAILS ? (planValue as PlanType) : "Free";
+    const maxUploadSizeStr = PLAN_DETAILS[userPlan].maxUploadSize;
src/features/storage/components/NoteCard.tsx (3)

52-64: useEffect에서 비동기 요청 cleanup 누락

컴포넌트가 언마운트되거나 id/type/mimeType이 변경될 때, 진행 중인 fetch 요청이 취소되지 않습니다. 이전 요청의 응답이 새로운 상태를 덮어쓸 수 있는 race condition이 발생할 수 있습니다.

♻️ AbortController 또는 cancelled 플래그 추가 제안
  useEffect(() => {
    if (type === "upload" && mimeType === "application/pdf") {
+     let isCancelled = false;
      const fetchUrl = async () => {
        try {
          const response = await getDownloadUrl(id);
-         setPdfDownloadUrl(response.result.downloadUrl);
+         if (!isCancelled) {
+           setPdfDownloadUrl(response.result.downloadUrl);
+         }
        } catch (error) {
-         console.error("PDF 다운로드 URL 조회 실패:", error);
+         if (!isCancelled) {
+           console.error("PDF 다운로드 URL 조회 실패:", error);
+         }
        }
      };
      fetchUrl();
+     return () => { isCancelled = true; };
    }
  }, [id, type, mimeType]);

15-16: getDownloadUrl 함수가 두 모듈에 중복 정의되어 있음

@/features/assets/api/assetApi.ts@/features/storage/api/assets_api.ts에 동일한 이름의 getDownloadUrl 함수가 존재하며, 반환 타입이 서로 다릅니다. 이는 혼동의 원인이 되며, 위의 response.result 접근 버그도 이 중복에서 비롯된 것으로 보입니다.

하나의 공유 API 함수로 통합하는 것을 권장합니다.


158-169: 인라인 style 속성 대신 Tailwind CSS 사용 권장

Lines 162-169에서 borderRadius, border, background, overflow 등을 인라인 스타일로 지정하고 있습니다. 동적 값(isSelected 기반 조건부 스타일)은 Tailwind의 조건부 클래스로 표현할 수 있습니다.

기존 코드(변경되지 않은 부분)에도 인라인 스타일이 다수 사용되고 있어 이번 PR 범위에서 일괄 수정이 어려울 수 있지만, 향후 리팩토링 시 참고 부탁드립니다. As per coding guidelines, "Use Tailwind CSS for styling; avoid CSS-in-JS or .css files unless necessary".


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@src/features/chat/components/leftpanel/StorageContent.tsx`:
- Around line 221-228: The code in StorageContent.tsx redundantly calls
queryClient.refetchQueries({ queryKey: noteKeys.detail(noteId) }) after
uploadAsset, but uploadAsset (see useAssetUpload.uploadAsset) already refetches
noteKeys.detail(String(noteId)) and assetKeys.storage; remove the duplicate
refetch in StorageContent.tsx (the await queryClient.refetchQueries(...) block)
so you don't trigger the same network request twice, or if you must refetch
additional keys only, call queryClient.refetchQueries for those specific keys
rather than noteKeys.detail.

In `@src/features/chat/components/leftpanel/ViewerContent.tsx`:
- Around line 24-31: The parseSize function incorrectly strips decimal points
causing "1.5GB" to parse as 15GB; update parseSize to extract the numeric
portion including the decimal (e.g. using a regex that captures ([\d.]+)) and
parse it with parseFloat, then extract the unit string (e.g. the captured
[A-Za-z]+) and normalize (toUpperCase) and apply the 1024-based multipliers to
the parsed float value; ensure this change is made in the parseSize function so
fractional sizes like 1.5GB are converted to bytes correctly.
- Around line 24-31: Extract the duplicated parseSize function into a single
shared utility module named parseSize.ts and export it; then replace the inline
definitions in ViewerContent and StorageContent by importing and using the
shared parseSize export. Ensure the extracted function preserves the existing
behavior (parses numeric value and unit, handles KB/MB/GB multipliers) and
update imports in both components to reference the new utility.
- Around line 71-97: The finally block unconditionally calls setIsLoading(false)
which conflicts with the component's useEffect that sets loading based on
activeFileId changes after a successful upload; modify the upload handler (the
async function that calls uploadAsset and uses setViewerFileId and
setSearchParams) to avoid clearing loading in finally on success — only call
setIsLoading(false) when the upload did not produce a valid result (no
result?.assetId) or when an error is caught; keep the success path reliant on
the useEffect that observes activeFileId to manage loading. Ensure to reference
the existing symbols: uploadAsset, setViewerFileId, setSearchParams,
setIsLoading, useEffect, and activeFileId when making the conditional change.

In `@src/features/chat/components/leftpanel/ViewerEmpty.tsx`:
- Around line 9-12: The clickable container in the ViewerEmpty component is a
<div> without accessibility attributes; update the element that currently uses
onOpenFileExplorer to be an actual <button> or, if you must keep a div, add
keyboard and ARIA support: give it role="button", tabIndex={0}, an appropriate
aria-label (or aria-labelledby), and handle Enter/Space in an onKeyDown that
calls onOpenFileExplorer; ensure the element retains its existing className and
onClick behavior so screen-reader and keyboard users can focus and activate it.

In `@src/pages/hooks/useViewerFile.ts`:
- Around line 36-47: When the useFileUpload callback receives a file and
isFileAllowed(file) returns false, the code currently silently ignores it;
update the callback in useViewerFile (the function passed to useFileUpload) to
notify the user (match StorageContent.tsx behavior) — e.g., call alert(...) or
the same feedback helper used in StorageContent.tsx when isFileAllowed(file) is
false before returning. Keep existing behavior when isFileAllowed(file) is true
(setFileName, viewerFileRef.current, setPdfUrl) and ensure you reference the
same validation path (isFileAllowed) so invalid files always produce
user-visible feedback.
🧹 Nitpick comments (9)
src/features/chat/components/leftpanel/StorageContent.tsx (4)

50-57: parseSize는 소수점 값을 처리하지 못합니다.

parseInt는 소수점 이하를 버리므로 "1.5GB" 같은 값이 입력되면 1GB로 처리됩니다. 현재 플랜 설정이 정수값만 사용한다면 당장 문제는 없지만, 향후 변경 시 버그가 될 수 있습니다.

제안: parseFloat 사용
 const parseSize = (sizeStr: string) => {
-  const value = parseInt(sizeStr.replace(/\D/g, ""), 10);
+  const value = parseFloat(sizeStr.replace(/[^0-9.]/g, ""));
   const unit = sizeStr.replace(/[^A-Za-z]/g, "").toUpperCase();
   if (unit.includes("GB")) return value * 1024 * 1024 * 1024;
   if (unit.includes("MB")) return value * 1024 * 1024;
   if (unit.includes("KB")) return value * 1024;
   return value;
 };

181-240: handleFileSelectuseCallback으로 감싸져 있지 않아 드래그 핸들러가 매 렌더마다 재생성됩니다.

handleFileSelect가 일반 함수로 정의되어 useFileUpload에 전달되고 있습니다. useFileUpload 내부의 handleFileChange, onDrop 등이 [onFileSelect]를 의존성으로 갖고 있어, StorageContent가 리렌더될 때마다 모든 드래그 핸들러가 새로 생성됩니다. 이로 인해 드래그 중 상태가 예기치 않게 리셋될 수 있습니다.

제안: useCallback 적용
- const handleFileSelect = async (file: File) => {
+ const handleFileSelect = useCallback(async (file: File) => {
     // ... 기존 로직 동일
- };
+ }, [user, noteId, uploadAsset, queryClient]);

useCallback import에 추가 필요:

- import { useState, useMemo, useEffect } from "react";
+ import { useState, useMemo, useEffect, useCallback } from "react";

389-398: 빈 프래그먼트 <></> 불필요.

boxFiles가 비어있을 때 <></>를 렌더링하는 것은 .map()이 빈 배열에서 아무것도 반환하지 않는 것과 동일합니다. 삼항 연산자 대신 단순히 boxFiles.map(...)만 사용하면 됩니다.

제안: 간소화
-            {boxFiles.length > 0 ? (
-              boxFiles.map((file) => (
+            {boxFiles.map((file) => (
                 <NoteCard
                   key={file.id}
                   ...
                 />
-              ))
-            ) : (
-              /* ... 긴 주석 ... */
-              <></>
-            )}
+            ))}

184-190: handleFileSelect에서 isFileAllowed 함수로 검증 로직을 통일하세요.

현재 인라인 검증 file.type.startsWith("image/")는 모든 이미지 형식을 허용하지만, isFileAllowedALLOWED_MIME_TYPES에 정의된 특정 형식(png, jpeg, webp)만 검증합니다. 추가로 isFileAllowed는 확장자 폴백 처리와 파일 크기 검사도 포함하고 있어 더 견고합니다. 파일 탐색기와 드래그 앤 드롭 간의 불일치를 방지하려면 isFileAllowed를 사용하여 검증을 통일하는 것을 권장합니다.

src/shared/hooks/useFileUpload.tsx (1)

1-96: 드래그 앤 드롭 구현이 잘 되어 있습니다.

dragCounter ref를 사용한 중첩 드래그 이벤트 처리 패턴이 올바르게 적용되어 있고, 모든 핸들러가 useCallback으로 적절히 메모이제이션되어 있습니다.

참고: 이 파일은 JSX를 포함하지 않으므로 .tsx 대신 .ts 확장자가 더 적합합니다. As per coding guidelines, "Name hooks in camelCase with use prefix (e.g., useAuth.ts, useMathKeyboard.ts)"

src/pages/components/ViewerUploadCard.tsx (1)

46-51: DragDropOverlay 래핑 방식이 ViewerContent.tsx와 일관성이 없습니다.

ViewerContent.tsx(Line 214)에서는 DragDropOverlay를 래퍼 없이 직접 렌더링하는 반면, 여기서는 z-50 래퍼 div로 감싸고 있습니다. DragDropOverlay 컴포넌트 자체에 이미 absolute inset-0 z-40이 포함되어 있으므로, 래퍼의 z-50과 내부의 z-40이 혼재되어 z-index 관리가 혼란스러워질 수 있습니다.

rounded-[12px] 클리핑이 필요한 것이라면, DragDropOverlay 자체에 rounded-xl이 이미 적용되어 있으므로 overflow-hidden 래퍼만 유지하거나, 두 파일에서 동일한 패턴을 사용하는 것이 좋겠습니다.

src/features/chat/components/leftpanel/ViewerContent.tsx (3)

24-31: parseSize 헬퍼 함수 위치가 코딩 가이드라인과 다릅니다.

가이드라인에 따르면 메인 컴포넌트를 파일 상단에 배치하고, 헬퍼 함수는 메인 컴포넌트 아래 또는 별도 파일에 정의해야 합니다. parseSize는 컴포넌트 위에 정의되어 있습니다. 별도 유틸리티 파일(예: fileValidation.ts)로 이동하면 재사용성도 높일 수 있습니다.

As per coding guidelines, "Place the main component at the top of the file; define helper functions or sub-components below the main component or in separate files".


176-188: 에러 재시도 시 window.location.reload() 대신 데이터 재요청을 고려해 주세요.

페이지 전체 새로고침은 모든 상태를 잃게 되므로, activeFileId를 트리거하여 useEffect만 다시 실행하는 방식이 더 가벼운 재시도 방법입니다.

♻️ 리트라이 로직 개선 예시

retryCount 같은 상태를 useEffect 의존성에 추가하여, 버튼 클릭 시 해당 상태를 증가시키는 방식으로 재요청을 유도할 수 있습니다.

+ const [retryCount, setRetryCount] = useState(0);
  // ...
- }, [activeFileId]);
+ }, [activeFileId, retryCount]);

  // 에러 UI에서:
- onClick={() => window.location.reload()}
+ onClick={() => setRetryCount((c) => c + 1)}

49-105: handleFileSelectuseCallback으로 감싸기.

현재 handleFileSelect는 매 렌더링마다 새로운 함수가 생성되며, 이는 useFileUploadhandleFileChangeonDrop 콜백을 불필요하게 재생성하게 합니다. useCallback으로 감싸고 의존성 배열에 noteId, user, uploadAsset 등을 추가하면, 필요한 경우에만 함수가 업데이트되고 useFileUpload의 내부 콜백들도 안정적으로 유지됩니다.

const handleFileSelect = useCallback(async (file: File) => {
  // ... 기존 로직
}, [noteId, user, uploadAsset, setViewerFileId, setSearchParams, setIsLoading, setError]);

Comment on lines +221 to +228
const result = await uploadAsset(parsed, file);

if (result?.assetId) {
// 업로드 성공 시 쿼리 갱신
await queryClient.refetchQueries({ queryKey: noteKeys.detail(noteId) });
// 선택적으로 뷰어로 바로 이동할 수도 있지만, 요구사항은 "서버로 파일 업로드" 임.
// 여기서는 업로드 후 목록에 표시되는 것이 우선.
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

noteKeys.detail 쿼리가 중복으로 refetch됩니다.

useAssetUploaduploadAsset 내부에서 이미 noteKeys.detail(String(noteId))assetKeys.storage를 refetch하고 있습니다 (src/features/assets/hooks/useAssetUpload.ts Line 38-41 참조). Line 225에서 동일한 쿼리를 다시 refetch하므로 불필요한 네트워크 요청이 발생합니다.

제안: 중복 refetch 제거
       const result = await uploadAsset(parsed, file);

-      if (result?.assetId) {
-        // 업로드 성공 시 쿼리 갱신
-        await queryClient.refetchQueries({ queryKey: noteKeys.detail(noteId) });
-        // 선택적으로 뷰어로 바로 이동할 수도 있지만, 요구사항은 "서버로 파일 업로드" 임.
-        // 여기서는 업로드 후 목록에 표시되는 것이 우선.
-      }
+      // uploadAsset 내부에서 noteKeys.detail 및 assetKeys.storage refetch를 이미 수행함
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const result = await uploadAsset(parsed, file);
if (result?.assetId) {
// 업로드 성공 시 쿼리 갱신
await queryClient.refetchQueries({ queryKey: noteKeys.detail(noteId) });
// 선택적으로 뷰어로 바로 이동할 수도 있지만, 요구사항은 "서버로 파일 업로드" 임.
// 여기서는 업로드 후 목록에 표시되는 것이 우선.
}
const result = await uploadAsset(parsed, file);
// uploadAsset 내부에서 noteKeys.detail 및 assetKeys.storage refetch를 이미 수행함
🤖 Prompt for AI Agents
In `@src/features/chat/components/leftpanel/StorageContent.tsx` around lines 221 -
228, The code in StorageContent.tsx redundantly calls
queryClient.refetchQueries({ queryKey: noteKeys.detail(noteId) }) after
uploadAsset, but uploadAsset (see useAssetUpload.uploadAsset) already refetches
noteKeys.detail(String(noteId)) and assetKeys.storage; remove the duplicate
refetch in StorageContent.tsx (the await queryClient.refetchQueries(...) block)
so you don't trigger the same network request twice, or if you must refetch
additional keys only, call queryClient.refetchQueries for those specific keys
rather than noteKeys.detail.

Comment on lines +24 to +31
const parseSize = (sizeStr: string) => {
const value = parseInt(sizeStr.replace(/\D/g, ""), 10);
const unit = sizeStr.replace(/[^A-Za-z]/g, "").toUpperCase();
if (unit.includes("GB")) return value * 1024 * 1024 * 1024;
if (unit.includes("MB")) return value * 1024 * 1024;
if (unit.includes("KB")) return value * 1024;
return value;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

parseSize: 소수점이 포함된 크기 문자열 파싱 시 버그 가능성.

replace(/\D/g, "") 는 소수점(.)도 제거하므로, "1.5GB" 같은 입력이 15GB로 잘못 파싱됩니다. 현재 PLAN_DETAILS의 값이 정수 기반이면 당장 문제가 되지 않지만, 향후 소수점 값이 추가되면 잘못된 크기 제한이 적용됩니다.

🛠️ 소수점 지원을 위한 수정 제안
 const parseSize = (sizeStr: string) => {
-  const value = parseInt(sizeStr.replace(/\D/g, ""), 10);
+  const value = parseFloat(sizeStr.replace(/[^0-9.]/g, ""));
   const unit = sizeStr.replace(/[^A-Za-z]/g, "").toUpperCase();
   if (unit.includes("GB")) return value * 1024 * 1024 * 1024;
   if (unit.includes("MB")) return value * 1024 * 1024;
   if (unit.includes("KB")) return value * 1024;
   return value;
 };
🤖 Prompt for AI Agents
In `@src/features/chat/components/leftpanel/ViewerContent.tsx` around lines 24 -
31, The parseSize function incorrectly strips decimal points causing "1.5GB" to
parse as 15GB; update parseSize to extract the numeric portion including the
decimal (e.g. using a regex that captures ([\d.]+)) and parse it with
parseFloat, then extract the unit string (e.g. the captured [A-Za-z]+) and
normalize (toUpperCase) and apply the 1024-based multipliers to the parsed float
value; ensure this change is made in the parseSize function so fractional sizes
like 1.5GB are converted to bytes correctly.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "parseSize" --type=ts --type=tsx

Repository: Team-Proovy/Proovy-front

Length of output: 95


🏁 Script executed:

rg -n "parseSize" --type-list | head -20

Repository: Team-Proovy/Proovy-front

Length of output: 457


🏁 Script executed:

rg -n "parseSize" -e "parseSize"

Repository: Team-Proovy/Proovy-front

Length of output: 117


🏁 Script executed:

rg "parseSize" -A 3 -B 1

Repository: Team-Proovy/Proovy-front

Length of output: 2042


parseSize 유틸리티 함수를 공통 모듈로 추출하세요.

parseSize 함수가 ViewerContent.tsxStorageContent.tsx에 동일하게 중복 정의되어 있습니다. DRY 원칙을 준수하기 위해 공통 유틸리티 파일(예: src/features/chat/utils/parseSize.ts)로 추출하고 두 파일에서 임포트하여 사용하도록 리팩토링하세요.

🤖 Prompt for AI Agents
In `@src/features/chat/components/leftpanel/ViewerContent.tsx` around lines 24 -
31, Extract the duplicated parseSize function into a single shared utility
module named parseSize.ts and export it; then replace the inline definitions in
ViewerContent and StorageContent by importing and using the shared parseSize
export. Ensure the extracted function preserves the existing behavior (parses
numeric value and unit, handles KB/MB/GB multipliers) and update imports in both
components to reference the new utility.

Comment on lines +71 to +97
try {
setIsLoading(true);
setError(null);
const parsed = parseInt(noteId, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
setError("유효하지 않은 노트입니다. 노트를 다시 열어주세요.");
return;
}

const result = await uploadAsset(parsed, file);

if (result?.assetId) {
setViewerFileId(result.assetId);
setSearchParams((prev) => {
prev.set("panel", "viewer");
prev.set("file", result.assetId.toString());
return prev;
});
// 업로드 성공 후 로딩 상태 해제는 activeFileId 변경에 의한 useEffect나 완료 시점에서 처리
}
} catch (err) {
console.error("파일 업로드 실패:", err);
setError("파일 업로드에 실패했습니다.");
} finally {
setIsLoading(false);
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

업로드 성공 시 isLoading 상태 플리커 가능성.

finally 블록(Line 94-96)에서 setIsLoading(false)를 무조건 호출하는데, 업로드 성공 후 setViewerFileId(Line 83)로 인해 activeFileId가 변경되면 useEffect(Line 108)가 다시 setIsLoading(true)를 설정합니다. 이로 인해 로딩 상태가 true → false → true로 깜빡이는 현상이 발생할 수 있습니다.

Line 89의 주석 의도("업로드 성공 후 로딩 상태 해제는 activeFileId 변경에 의한 useEffect에서 처리")와 finally의 동작이 충돌합니다.

🛠️ 성공 시 finally에서 isLoading을 리셋하지 않도록 수정
+    let uploadSucceeded = false;
     try {
       setIsLoading(true);
       setError(null);
       const parsed = parseInt(noteId, 10);
       if (!Number.isInteger(parsed) || parsed <= 0) {
         setError("유효하지 않은 노트입니다. 노트를 다시 열어주세요.");
         return;
       }

       const result = await uploadAsset(parsed, file);

       if (result?.assetId) {
+        uploadSucceeded = true;
         setViewerFileId(result.assetId);
         setSearchParams((prev) => {
           prev.set("panel", "viewer");
           prev.set("file", result.assetId.toString());
           return prev;
         });
       }
     } catch (err) {
       console.error("파일 업로드 실패:", err);
       setError("파일 업로드에 실패했습니다.");
     } finally {
-      setIsLoading(false);
+      if (!uploadSucceeded) {
+        setIsLoading(false);
+      }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
setIsLoading(true);
setError(null);
const parsed = parseInt(noteId, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
setError("유효하지 않은 노트입니다. 노트를 다시 열어주세요.");
return;
}
const result = await uploadAsset(parsed, file);
if (result?.assetId) {
setViewerFileId(result.assetId);
setSearchParams((prev) => {
prev.set("panel", "viewer");
prev.set("file", result.assetId.toString());
return prev;
});
// 업로드 성공 후 로딩 상태 해제는 activeFileId 변경에 의한 useEffect나 완료 시점에서 처리
}
} catch (err) {
console.error("파일 업로드 실패:", err);
setError("파일 업로드에 실패했습니다.");
} finally {
setIsLoading(false);
}
};
let uploadSucceeded = false;
try {
setIsLoading(true);
setError(null);
const parsed = parseInt(noteId, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
setError("유효하지 않은 노트입니다. 노트를 다시 열어주세요.");
return;
}
const result = await uploadAsset(parsed, file);
if (result?.assetId) {
uploadSucceeded = true;
setViewerFileId(result.assetId);
setSearchParams((prev) => {
prev.set("panel", "viewer");
prev.set("file", result.assetId.toString());
return prev;
});
// 업로드 성공 후 로딩 상태 해제는 activeFileId 변경에 의한 useEffect나 완료 시점에서 처리
}
} catch (err) {
console.error("파일 업로드 실패:", err);
setError("파일 업로드에 실패했습니다.");
} finally {
if (!uploadSucceeded) {
setIsLoading(false);
}
}
🤖 Prompt for AI Agents
In `@src/features/chat/components/leftpanel/ViewerContent.tsx` around lines 71 -
97, The finally block unconditionally calls setIsLoading(false) which conflicts
with the component's useEffect that sets loading based on activeFileId changes
after a successful upload; modify the upload handler (the async function that
calls uploadAsset and uses setViewerFileId and setSearchParams) to avoid
clearing loading in finally on success — only call setIsLoading(false) when the
upload did not produce a valid result (no result?.assetId) or when an error is
caught; keep the success path reliant on the useEffect that observes
activeFileId to manage loading. Ensure to reference the existing symbols:
uploadAsset, setViewerFileId, setSearchParams, setIsLoading, useEffect, and
activeFileId when making the conditional change.

Comment on lines +9 to +12
<div
onClick={onOpenFileExplorer}
className="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-[16px] text-center transition-colors hover:text-[#2542F0]"
>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

클릭 가능한 <div>에 접근성 속성이 누락되어 있습니다.

키보드 사용자가 이 영역에 포커스하거나 활성화할 수 없습니다. <button> 또는 접근성 속성을 추가해 주세요.

제안: 접근성 속성 추가
     <div
       onClick={onOpenFileExplorer}
+      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenFileExplorer(); }}
+      role="button"
+      tabIndex={0}
       className="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-[16px] text-center transition-colors hover:text-[`#2542F0`]"
     >

또는 <div> 대신 <button>으로 변경하는 것이 더 바람직합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
onClick={onOpenFileExplorer}
className="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-[16px] text-center transition-colors hover:text-[#2542F0]"
>
<div
onClick={onOpenFileExplorer}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenFileExplorer(); }}
role="button"
tabIndex={0}
className="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-[16px] text-center transition-colors hover:text-[`#2542F0`]"
>
🤖 Prompt for AI Agents
In `@src/features/chat/components/leftpanel/ViewerEmpty.tsx` around lines 9 - 12,
The clickable container in the ViewerEmpty component is a <div> without
accessibility attributes; update the element that currently uses
onOpenFileExplorer to be an actual <button> or, if you must keep a div, add
keyboard and ARIA support: give it role="button", tabIndex={0}, an appropriate
aria-label (or aria-labelledby), and handle Enter/Space in an onKeyDown that
calls onOpenFileExplorer; ensure the element retains its existing className and
onClick behavior so screen-reader and keyboard users can focus and activate it.

Comment on lines +36 to +47
} = useFileUpload((file) => {
if (file && isFileAllowed(file)) {
setFileName(file.name);
viewerFileRef.current = file;
setPdfUrl(URL.createObjectURL(file));
// 파일 크기나 형식 검증은 isFileAllowed에서 처리하지만,
// 추가적인 검증이 필요하면 여기서 처리.
// 하지만 isFileAllowed는 boolean만 반환.

// 파일 선택 시 note 생성을 바로 하진 않음.
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

유효하지 않은 파일 선택 시 사용자 피드백 없음.

isFileAllowed(file)false를 반환하면 아무런 피드백 없이 파일이 무시됩니다. 특히 드래그 앤 드롭 시 사용자가 파일을 놓았는데 아무 반응이 없으면 혼란스러울 수 있습니다. StorageContent.tsx(Line 188)에서는 alert으로 안내하고 있으므로, 여기서도 동일하게 피드백을 제공하는 것이 좋습니다.

제안: 유효하지 않은 파일에 대한 피드백 추가
   } = useFileUpload((file) => {
     if (file && isFileAllowed(file)) {
       setFileName(file.name);
       viewerFileRef.current = file;
       setPdfUrl(URL.createObjectURL(file));
-      // 파일 크기나 형식 검증은 isFileAllowed에서 처리하지만,
-      // 추가적인 검증이 필요하면 여기서 처리.
-      // 하지만 isFileAllowed는 boolean만 반환.
-
-      // 파일 선택 시 note 생성을 바로 하진 않음.
+    } else if (file) {
+      alert("지원하지 않는 파일 형식이거나 파일 크기가 초과되었습니다.");
     }
   });
🤖 Prompt for AI Agents
In `@src/pages/hooks/useViewerFile.ts` around lines 36 - 47, When the
useFileUpload callback receives a file and isFileAllowed(file) returns false,
the code currently silently ignores it; update the callback in useViewerFile
(the function passed to useFileUpload) to notify the user (match
StorageContent.tsx behavior) — e.g., call alert(...) or the same feedback helper
used in StorageContent.tsx when isFileAllowed(file) is false before returning.
Keep existing behavior when isFileAllowed(file) is true (setFileName,
viewerFileRef.current, setPdfUrl) and ensure you reference the same validation
path (isFileAllowed) so invalid files always produce user-visible feedback.

Copy link
Copy Markdown
Collaborator

@stringnine stringnine left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

뷰어 업로드에서 드래그 앤 드롭 확인했씁니다

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/features/chat/components/leftpanel/StorageContent.tsx`:
- Line 82: The component StorageContent uses the hook useAuthStore but it isn't
imported; add the appropriate import for useAuthStore at the top of the file
(matching how it's exported from your auth store module) so the line "const {
user } = useAuthStore();" resolves correctly — update the import list in
StorageContent to import useAuthStore (respecting whether it's a named or
default export) and run type checks to confirm no further fixes are needed.
🧹 Nitpick comments (3)
src/features/chat/components/leftpanel/StorageContent.tsx (3)

83-84: 로컬 isUploading 상태와 useAssetUploadisUploading이 중복됩니다.

useAssetUpload 훅이 이미 isUploading 상태를 제공하는데(relevant snippet 참조), Line 84에서 별도의 로컬 isUploading state를 관리하고 있습니다. 업로드 중 예외가 발생하면 훅 내부의 isUploading과 로컬 상태가 불일치할 수 있습니다.

useAssetUpload에서 반환하는 isUploading을 직접 사용하면 상태 동기화 문제를 방지할 수 있습니다.

♻️ 로컬 state 제거 및 훅 상태 활용 제안
-  const { uploadAsset } = useAssetUpload();
-  const [isUploading, setIsUploading] = useState(false);
+  const { uploadAsset, isUploading } = useAssetUpload();

그리고 handleFileSelect 내부의 setIsUploading(true) / setIsUploading(false) 호출을 모두 제거하세요.

Also applies to: 219-219, 247-248


196-234: noteId 유효성 검사를 파일 검증보다 먼저 수행하는 것이 좋습니다.

현재 파일 타입/크기 검증 후에야 noteId 존재 및 유효성을 확인합니다. noteId가 없으면 업로드 자체가 불가하므로, 불필요한 검증 로직을 실행하기 전에 먼저 확인하는 것이 사용자 경험과 로직 흐름에 더 자연스럽습니다.

♻️ noteId 검증 순서 조정 제안
  const handleFileSelect = async (file: File) => {
    if (!file) return;

+   const parsed = Number(noteId);
+   if (!noteId || isNaN(parsed) || parsed <= 0) {
+     alert("유효하지 않은 노트입니다. 노트를 다시 열어주세요.");
+     return;
+   }
+
    const isValidType =
      file.type === "application/pdf" || file.type.startsWith("image/");
    // ... 나머지 검증 ...

397-407: 빈 Fragment와 과도한 주석 블록을 정리해 주세요.

boxFiles가 비어있을 때 빈 <></>를 반환하고 있으며, 6줄에 걸친 주석이 JSX 안에 남아 있습니다. 빈 배열에 대한 .map()은 자연스럽게 아무것도 렌더링하지 않으므로, ternary 자체가 불필요합니다.

♻️ 간소화 제안
-            {boxFiles.length > 0 ? (
-              boxFiles.map((file) => (
+            {boxFiles.map((file) => (
                <NoteCard
                  ...
                />
-              ))
-            ) : (
-              /* 파일이 없을 때 메시지 ... */
-              <></>
-            )}
+            ))}

Comment thread src/features/chat/components/leftpanel/StorageContent.tsx
@gaeunee2 gaeunee2 changed the title Fix (#155): 드래그앤드롭 추가 및 노트 leftpanel ux/ui 수정 Fix(#155): 드래그앤드롭 추가 및 노트 leftpanel ux/ui 수정 Feb 12, 2026
@dev-ldy03 dev-ldy03 changed the title Fix(#155): 드래그앤드롭 추가 및 노트 leftpanel ux/ui 수정 Fix(#157): 드래그앤드롭 추가 및 노트 leftpanel ux/ui 수정 Feb 12, 2026
Copy link
Copy Markdown
Collaborator

@gaeunee2 gaeunee2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아주 조은 코드인걸 확인했습니다. 국잡.

@dev-ldy03 dev-ldy03 merged commit 96d6df7 into dev Feb 12, 2026
1 check passed
@dev-ldy03 dev-ldy03 added the fix 코드 및 버그 수정 label Feb 14, 2026
@dev-ldy03 dev-ldy03 self-assigned this Feb 14, 2026
@coderabbitai coderabbitai Bot mentioned this pull request May 20, 2026
13 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix 코드 및 버그 수정

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Fix] 자산 썸네일 안 뜨는 오류 수정

3 participants