# 미디어 라이브러리 탐색기(Explorer Modal) 기능 개선 및 개발 작업 일지

## 1. 개요 및 목적
* **작업 배경**: 데스크톱 파일 탐색기(Windows Explorer / macOS Finder) 수준의 UX를 제공하는 웹 기반 미디어 라이브러리 모달의 안정성 확보 및 사용자 편의성 극대화
* **주요 목표**:
  1. 단일 적용(`single`) 모드와 다중 적용(`multiple`) 모드 간의 명확한 정책 분기
  2. 윈도우 OS 탐색기 파일 드래그앤드롭(Spotlight Backdrop Drop Zone) 인터랙션 구축
  3. 계층형 폴더 트리 간소화 및 원클릭 통합(토글 + 디렉터리 이동) 구현
  4. 우측 메인 갤러리 캔버스 내 하위 폴더 표시 및 더블클릭 탐색 지원
  5. 파일 보호 잠금(🔒/🔓) 상태의 실시간 DOM 및 우클릭 컨텍스트 메뉴 동기화

---

## 2. 세부 구현 및 개선 내역

### 2.1 단일(`single`) 모드 제약 조건 및 컨텍스트 메뉴 분기
* **배경 및 이슈**: `single` 모드에서 여러 이미지를 선택한 상태로 우클릭 메뉴의 `선택 완료`를 클릭하거나 하단 버튼을 누를 경우, 단일 슬롯에 예기치 않은 데이터가 들어가거나 사용자 혼선 발생
* **개선 사항**:
  * 단일(`single`) 모드에서 2개 이상의 이미지가 선택된 경우:
    * 모달 하단 푸터의 `선택 완료` 버튼 숨김 처리(`display: none !important`)
    * 우클릭 컨텍스트 메뉴의 `선택 완료` 탭 숨김 처리
    * 하단 선택 안내 텍스트를 경고 배지(폴더 이동 전용 안내)로 전환
  * 더블클릭(`ondblclick`) 시:
    * 선택된 수량과 무관하게 더블클릭한 해당 이미지 1개만 단일 슬롯에 즉시 바인딩 후 모달 닫기
  * 다중 선택(2개 이상) 시:
    * 1개 전용 메뉴인 `새 탭에서 원본 보기`, `이름 바꾸기` 자동 숨김
    * 삭제 메뉴명을 `선택한 N개 삭제`로 동적 변경

---

### 2.2 OS 파일 드래그앤드롭 (Backdrop Dim + Spotlight Drop Zone)
* **배경 및 이슈**: 윈도우 탐색기에서 파일을 모달로 끌어올 때 브라우저가 새 탭으로 파일을 열어버리는 현상 및 드롭 가능 영역에 대한 시각적 인지 부족
* **개선 사항**:
  * 모달 진입 감지(`dragenter`):
    * 모달 전체(헤더, 사이드바, 푸터)를 어둡게 가리는 반투명 딤 레이어(`mediaModalGlobalDim`) 활성화
    * 실제 파일이 저장될 우측 메인 갤러리 영역만 파란 점선 테두리의 스포트라이트 드롭존(`mediaExternalDropZone`)으로 밝게 노출
  * 안전 이탈 및 무효화(`dragleave`, `drop`):
    * 마우스가 모달창 외부로 빠져나가거나 드롭 허용 영역이 아닌 곳(헤더, 폴더 트리 등)에 드롭 시 즉시 초기화
    * 브라우저 기본 파일 열기 동작 원천 차단
  * 자동 업로드 연동:
    * 허용 점선 영역에 드롭 시 현재 선택된 폴더 ID(`currentMediaFolderId`)를 기준으로 비동기 순차 업로드 파이프라인 자동 실행

---

### 2.3 좌측 계층형 폴더 트리 간소화 및 원클릭 통합
* **배경 및 이슈**:
  * 폴더명 옆의 숫자 배지와 삭제 버튼이 시각적 노이즈를 유발
  * 화살표 아이콘(`>`) 버튼과 폴더명 텍스트를 따로 클릭해야 하는 불편함 존재
* **개선 사항**:
  * **원클릭 통합(`handleFolderClick`)**:
    * 화살표 분리 버튼을 제거하고 단순 표시용 아이콘(`fa-chevron-right` / `fa-chevron-down`)으로 전환
    * 폴더 행 전체(아이콘 + 텍스트) 클릭 시 자식 폴더가 있으면 하위 목록을 펼치고/접으면서 동시에 해당 폴더로 포커싱되어 우측 갤러리 이미지 로드
  * **시각적 군더더기 제거**:
    * 폴더명 우측의 숫자 배지와 휴지통 버튼을 제거하여 정돈된 트리 뷰 구성
  * **상단/하단 고정 및 중앙 독립 스크롤 레이아웃**:
    * 상단 고정 헤더: `📁 폴더 디렉터리` 텍스트 바를 제거하고 `[전체 이미지]` 버튼과 `[+]`, `[↻]` 버튼을 1줄로 컴팩트하게 통합
    * 중앙 영역: 순수 하위 디렉터리 트리만 배치하여 가로(`white-space: nowrap`) 및 세로 독립 스크롤 공간 온전히 확보
    * 하단 고정 푸터: `[임시 파일]` 탭 및 안전 삭제 안내 문구 고정

---

### 2.4 우측 메인 영역 내 하위 폴더 표시 및 더블클릭 탐색
* **배경 및 요구사항**: 특정 폴더(예: 뷰티샵)로 이동했을 때, 그 폴더에 속한 하위 폴더(예: 365비비클럽)와 이미지가 함께 보여야 탐색기다운 직관성을 가짐
* **개선 사항**:
  * 특정 폴더 진입 시 직속 하위 폴더 목록(`parent_id` 매칭)을 추출하여 메인 갤러리 상단에 폴더 카드로 우선 배치
  * **이미지 카드와 동일한 룩앤필 적용**:
    * 투명 배경(`bg-transparent`) 및 호버 시 은은한 반투명 하이라이트 적용
    * 불필요한 '폴더' 라벨 텍스트를 삭제하고 폴더 아이콘과 폴더명만 단독 노출
  * **하위 폴더 더블클릭 진입(`enterSubFolder`)**:
    * 우측 폴더 카드를 더블클릭하면 해당 하위 폴더로 즉시 진입하며 좌측 트리에서도 상위 부모 폴더가 자동으로 열린 상태(`expandedFolderIds`)를 유지
  * **하위 폴더로 드래그 앤 드롭 이동**:
    * 우측에 떠 있는 하위 폴더 카드로 이미지를 드래그 앤 드롭하여 바로 이동 가능
  * **전체 이미지 뷰 분리**:
    * 상단의 `전체 이미지` 선택 시에는 하위 폴더 카드를 렌더링하지 않고 전체 이미지만 깔끔하게 나열(Flat View)

---

### 2.5 폴더 CRUD 발생 시 메인 갤러리 동시 갱신
* **이슈**: 신규 폴더를 생성하거나 기존 빈 폴더를 삭제했을 때 좌측 트리만 갱신되고 우측 메인 캔버스에는 변화가 없는 현상
* **해결**:
  * `submitCreateFolder` 및 `deleteMediaFolder` 성공 콜백에 `fetchMediaLibraryImages(true)`를 추가하여 좌측 트리와 우측 메인 영역이 동시에 실시간 갱신되도록 보완

---

### 2.6 파일 보호 잠금(🔒/🔓) 상태 우클릭 메뉴 실시간 동기화
* **이슈**: 카드 내 자물쇠 버튼을 클릭해 잠금/해제 시 인라인 비주얼은 바뀌지만, 재클릭 없이 바로 우클릭했을 때 컨텍스트 메뉴는 이전 상태로 열리는 현상
* **원인**: 카드의 인라인 `oncontextmenu` 매개변수에 초기 렌더링 당시의 문자열이 고정되어 전달되었기 때문
* **해결**:
  * `handleCardContextMenu` 시작 부분에서 인라인 인자 대신 카드의 최신 DOM 데이터셋(`card.dataset.isProtected`)을 우선 참조하도록 방어 로직 추가
  * `toggleImageProtection` 성공 시 카드 DOM의 `data-is-protected` 및 인라인 `oncontextmenu` 속성을 실시간 교체하고 `contextMenuTargetImage` 객체도 즉시 동기화

---

## 3. 핵심 적용 소스코드

### 3.1 `modal_media.blade.php` (좌측 사이드바 3단 레이아웃 및 드롭존 오버레이)

    <!-- 1. 모달 전체 백드롭 딤 (OS 파일 드래그 시 활성화) -->
    <div id="mediaModalGlobalDim" class="position-absolute top-0 start-0 w-100 h-100 d-none" 
         style="background-color: rgba(15, 23, 42, 0.45); z-index: 10030; backdrop-filter: blur(1px); pointer-events: none;">
    </div>

    <!-- 2. 좌측 사이드바: 계층형 폴더 트리 (너비 260px 고정) -->
    <div class="border-end bg-white d-flex flex-column flex-shrink-0 h-100" style="width: 260px; min-width: 260px;">
        <!-- [상단 고정 헤더] 전체 이미지 + [+] + [↻] 1줄 통합 -->
        <div class="p-2 border-bottom bg-light bg-opacity-50 flex-shrink-0">
            <div class="d-flex align-items-center justify-content-between gap-1">
                <div id="mediaFolderFixedAllContainer" class="flex-grow-1"></div>
                <div class="d-flex align-items-center gap-1 flex-shrink-0">
                    <button type="button" class="btn btn-outline-secondary btn-sm p-0 d-flex align-items-center justify-content-center bg-white common-rounded-xs" 
                            style="width: 28px; height: 28px;" onclick="showCreateFolderInput()" title="새 폴더 생성">
                        <i class="fa-solid fa-plus text-primary" style="font-size: 0.75rem;"></i>
                    </button>
                    <button type="button" class="btn btn-outline-secondary btn-sm p-0 d-flex align-items-center justify-content-center bg-white common-rounded-xs" 
                            style="width: 28px; height: 28px;" onclick="fetchMediaFolders()" title="새로고침">
                        <i class="fa-solid fa-rotate-right text-secondary" style="font-size: 0.75rem;"></i>
                    </button>
                </div>
            </div>

            <!-- 새 폴더 인라인 입력 박스 -->
            <div id="mediaCreateFolderBox" class="mt-2 d-none">
                <div class="input-group input-group-sm">
                    <input type="text" id="newFolderNameInput" class="form-control common-rounded-start" placeholder="폴더명 입력..." maxlength="30" onkeydown="if(event.key==='Enter') submitCreateFolder()">
                    <button class="btn btn-primary common-rounded-end px-2" type="button" onclick="submitCreateFolder()">
                        <i class="fa-solid fa-check"></i>
                    </button>
                </div>
            </div>
        </div>

        <!-- [중앙 독립 스크롤 영역] 하위 폴더 트리 전용 (가로/세로 auto 스크롤) -->
        <div class="flex-grow-1 overflow-auto p-2" id="mediaFolderTreeList" style="white-space: nowrap; min-height: 0;"></div>

        <!-- [하단 고정 푸터] 임시 파일 탭 + 안내 문구 -->
        <div class="p-2 border-top bg-light bg-opacity-25 flex-shrink-0">
            <div id="mediaFolderFixedOrphansContainer" class="mb-2"></div>
            <div class="text-center">
                <small class="text-muted" style="font-size: 0.7rem;">
                    <i class="fa-solid fa-circle-info me-1"></i> 빈 폴더(하위 파일·폴더 없음)만 삭제 가능
                </small>
            </div>
        </div>
    </div>

    <!-- 3. 우측 메인 영역 내 허용 드롭존 오버레이 -->
    <div id="mediaExternalDropZone" class="position-absolute top-0 start-0 w-100 h-100 d-none flex-column align-items-center justify-content-center p-3" 
         style="z-index: 10040; pointer-events: none;">
        <div class="w-100 h-100 d-flex flex-column align-items-center justify-content-center bg-white common-rounded-card shadow-lg" 
             style="border: 2.5px dashed #0d6efd; background-color: rgba(255, 255, 255, 0.96) !important;">
            <div class="bg-primary bg-opacity-10 text-primary p-3 rounded-circle mb-3 d-flex align-items-center justify-content-center" style="width: 65px; height: 65px;">
                <i class="fa-solid fa-cloud-arrow-up fs-2"></i>
            </div>
            <h5 class="fw-bold text-primary mb-1">여기에 파일을 떨어뜨리세요</h5>
            <p class="text-secondary small m-0">현재 선택된 폴더로 즉시 업로드됩니다.</p>
        </div>
    </div>

---

### 3.2 `public/js/modal_media.js` 주요 변경 함수 스니펫

#### A. 보호 잠금 토글 및 컨텍스트 메뉴 동기화 (`toggleImageProtection`, `handleCardContextMenu`)

    function toggleImageProtection(imageId) {
        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');

        fetch(`/admin/images/${imageId}/toggle-protect`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': csrfToken,
                'X-Requested-With': 'XMLHttpRequest'
            }
        })
        .then(res => res.json())
        .then(data => {
            if (data.success) {
                const card = document.getElementById(`media-card-${imageId}`);
                const lockBox = document.getElementById(`lock-container-${imageId}`);
                const newProtected = Boolean(data.is_protected);
                
                if (card) {
                    card.dataset.isProtected = newProtected ? '1' : '0';
                    const oldContextMenu = card.getAttribute('oncontextmenu') || '';
                    const updatedContextMenu = oldContextMenu.replace(/(true|false)\)$/, `${newProtected})`);
                    card.setAttribute('oncontextmenu', updatedContextMenu);
                }

                if (lockBox) {
                    lockBox.innerHTML = renderLockIconHtml(imageId, false, newProtected);
                }

                if (contextMenuTargetImage && contextMenuTargetImage.id === parseInt(imageId, 10)) {
                    contextMenuTargetImage.isProtected = newProtected;
                }
            } else {
                alert(data.message || '잠금 상태 변경에 실패했습니다.');
            }
        })
        .catch(err => {
            console.error('Protect Toggle Error:', err);
            alert('보호 잠금 변경 중 오류가 발생했습니다.');
        });
    }

    function handleCardContextMenu(event, id, path, name, isUsed, isProtected) {
        event.preventDefault();
        event.stopPropagation();

        const menu = document.getElementById('mediaFileContextMenu');
        if (!menu) return;

        id = parseInt(id, 10);
        const card = document.getElementById(`media-card-${id}`);

        const isUsedBool = card 
            ? card.dataset.isUsed === '1' 
            : Boolean(isUsed === true || isUsed === 'true' || isUsed === 1 || isUsed === '1');

        const isProtectedBool = card 
            ? card.dataset.isProtected === '1' 
            : Boolean(isProtected === true || isProtected === 'true' || isProtected === 1 || isProtected === '1');

        contextMenuTargetImage = { id, path, name, isUsed: isUsedBool, isProtected: isProtectedBool };

        if (!selectedMediaImagesMap.has(id)) {
            if (currentMediaMode === 'single') {
                selectedMediaImagesMap.clear();
            }
            selectedMediaImagesMap.set(id, { id, path, name });
            updateCardVisuals();
        }

        const selectedCount = selectedMediaImagesMap.size;
        const selectMenuBtn = menu.querySelector('a[onclick*="contextMenuAction(\'select\')"]');
        const viewMenuBtn = menu.querySelector('a[onclick*="contextMenuAction(\'view\')"]');
        const renameMenuBtn = menu.querySelector('a[onclick*="contextMenuAction(\'rename\')"]');
        const protectMenuBtn = document.getElementById('contextMenuProtectBtn');
        const deleteMenuBtn = document.getElementById('contextMenuDeleteBtn');
        const deleteDivider = document.getElementById('contextMenuDeleteDivider');

        // single 모드에서는 우클릭 '선택 완료' 완전 은닉
        if (selectMenuBtn) {
            if (currentMediaMode === 'single') {
                selectMenuBtn.style.setProperty('display', 'none', 'important');
            } else {
                selectMenuBtn.style.removeProperty('display');
                selectMenuBtn.innerHTML = `<i class="fa-solid fa-check text-primary me-2"></i> 선택 완료 (${selectedCount}개)`;
            }
        }

        // 1개 전용 메뉴 제어
        if (selectedCount > 1) {
            if (viewMenuBtn) viewMenuBtn.style.setProperty('display', 'none', 'important');
            if (renameMenuBtn) renameMenuBtn.style.setProperty('display', 'none', 'important');
        } else {
            if (viewMenuBtn) viewMenuBtn.style.removeProperty('display');
            if (renameMenuBtn) renameMenuBtn.style.removeProperty('display');
        }

        // 보호 및 삭제 메뉴 제어
        if (selectedCount === 1) {
            if (isUsedBool) {
                if (protectMenuBtn) {
                    protectMenuBtn.classList.add('context-item-disabled');
                    protectMenuBtn.innerHTML = '<i class="fa-solid fa-lock text-secondary me-2"></i> 보호 잠금 (상시 보호됨)';
                }
                if (deleteMenuBtn) deleteMenuBtn.style.setProperty('display', 'none', 'important');
                if (deleteDivider) deleteDivider.style.setProperty('display', 'none', 'important');
            } else {
                if (protectMenuBtn) {
                    protectMenuBtn.classList.remove('context-item-disabled');
                    protectMenuBtn.innerHTML = isProtectedBool 
                        ? '<i class="fa-solid fa-lock-open text-warning me-2"></i> 보호 잠금 해제' 
                        : '<i class="fa-solid fa-lock text-warning me-2"></i> 보호 잠금 설정';
                }
                if (isProtectedBool) {
                    if (deleteMenuBtn) deleteMenuBtn.style.setProperty('display', 'none', 'important');
                    if (deleteDivider) deleteDivider.style.setProperty('display', 'none', 'important');
                } else {
                    if (deleteMenuBtn) {
                        deleteMenuBtn.style.removeProperty('display');
                        deleteMenuBtn.innerHTML = '<i class="fa-solid fa-trash me-2"></i> 삭제하기';
                    }
                    if (deleteDivider) deleteDivider.style.removeProperty('display');
                }
            }
        } else {
            if (protectMenuBtn) {
                protectMenuBtn.classList.remove('context-item-disabled');
                protectMenuBtn.innerHTML = `<i class="fa-solid fa-lock text-warning me-2"></i> 선택한 ${selectedCount}개 일괄 보호`;
            }
            if (deleteMenuBtn) {
                deleteMenuBtn.style.removeProperty('display');
                deleteMenuBtn.innerHTML = `<i class="fa-solid fa-trash me-2"></i> 선택한 ${selectedCount}개 삭제`;
            }
            if (deleteDivider) deleteDivider.style.removeProperty('display');
        }

        menu.style.display = 'block';
        let posX = event.clientX;
        let posY = event.clientY;
        const menuWidth = menu.offsetWidth || 160;
        const menuHeight = menu.offsetHeight || 180;
        if (posX + menuWidth > window.innerWidth) posX = window.innerWidth - menuWidth - 10;
        if (posY + menuHeight > window.innerHeight) posY = window.innerHeight - menuHeight - 10;
        menu.style.left = `${posX}px`;
        menu.style.top = `${posY}px`;
    }

---

## 4. 향후 계획 및 권장 작업
1. **좌측 폴더 전용 우클릭 메뉴 구현**:
   * 폴더 행 우클릭 시 `새 하위 폴더 생성`, `폴더명 변경`, `빈 폴더 삭제` 팝업 지원
2. **윈도우 탐색기 폴더 통째 업로드(`webkitGetAsEntry`) 구현**:
   * 윈도우에서 디렉터리 드롭 시 내부 하위 폴더 구조를 DB에 자동 생성하며 파일 재귀 업로드