# 관리자 미디어 라이브러리 및 탐색기 고도화 작업 명세서

## 1. 개요 및 설계 원칙
* **디자인 시스템 일관성**: 모든 블레이드 컴포넌트 및 동적 DOM 요소에 `admin_custom.css` 공용 라운드 클래스(`common-rounded-*`)와 CSS 변수(`var(--admin-radius-*)`) 적용.
* **선택 시각화 표준**: 선택 항목에 두꺼운 테두리(Border)를 배제하고 은은한 반투명 블루 하이라이트 배경(`rgba(13, 110, 253, 0.12)`)을 적용하여 썸네일 왜곡 방지 및 Windows/macOS 네이티브 탐색기 스타일 유지.
* **고성능 페이징**: 50개 단위 비동기 무한 스크롤(`IntersectionObserver`) 구축으로 대용량 이미지 렌더링 부하 최소화.
* **보안 및 무결성 보장**:
    * 실사용 이미지(서비스 연결) 및 보호 잠금(🔒) 처리된 파일은 우클릭 컨텍스트 메뉴에서 삭제 옵션 완전 차단.
    * 빈 폴더 검증 삭제 정책 적용(직속 파일뿐만 아니라 모든 하위 계층 트리 내 파일/폴더 존재 시 삭제 원천 거부).

---

## 2. 작업 내역 상세

### 가. 뷰 모드 및 4대 컬럼 정렬 시스템
1. **뷰 모드 전환 (View Modes)**
    * **큰 아이콘 (`large`)**: `col-6 col-md-4 col-lg-3` 반응형 그리드.
    * **중간 아이콘 (`small`)**: `col-4 col-md-3 col-lg-2` 고밀도 카드 그리드.
    * **자세히 보기 (`list`)**: 파일명, 수정일자, 파일유형, 용량, 상태가 표기되는 테이블 뷰.
2. **독립 4단 정렬 토글**
    * **날짜순 (`date`)**: 기본 내림차순(최신순), 클릭 시 오름차순/내림차순 전환.
    * **파일이름 (`name`)**: 기본 오름차순(가나다순), 클릭 시 토글.
    * **파일용량 (`size`)**: 바이트 단위 기준 asc/desc 토글.
    * **파일유형 (`extension`)**: 자세히 보기 테이블 헤더 연동, 확장자 기준 정렬.
    * FontAwesome SVG 자동 치환 대응을 위한 안전한 DOM 생성 로직 구현.

### 나. 폴더 계층 트리 및 가로 스크롤 레이아웃
1. **깊은 계층 가로 스크롤 지원**
    * 사이드바 폴더 리스트에 `overflow-x: auto` 및 `white-space: nowrap` 적용.
    * 뎁스(Depth)당 `14px` 좌측 패딩 들여쓰기 적용으로 하위 폴더가 깊어져도 줄바꿈 없이 가로 스크롤 생성.
2. **폴더 트리 상태별 UI 규칙**
    * **전체 이미지 탭 (상단)**:
        * 활성 시: `bg-primary text-white shadow-sm`
        * 타 폴더 활성 시: `bg-secondary bg-opacity-10 text-secondary`
    * **일반 디렉터리 폴더**:
        * 활성 시: `bg-primary text-white shadow-sm` + 흰색 배지(`bg-white text-primary`)
        * 비활성 시: 투명 배경 + 짙은 텍스트 + 회색 배지
    * **임시 파일 탭 (하단)**:
        * '고아 파일' 명칭을 '임시 파일'로 변경하고 폴더 트리 최하단에 배치.
        * 활성 시: `bg-danger text-white shadow-sm`
3. **폴더 생성 비동기 UX 개선**
    * 폴더명 입력 후 전송 시 인라인 입력 박스 즉시 숨김(`d-none`) 및 입력값 초기화.
    * 폴더 트리 영역에 파란색 스피너 및 "새 폴더 생성 중..." 로딩 인디케이터 노출.
4. **빈 폴더 재귀 삭제 방어**
    * 폴더 직속 파일 수(`images_count`)가 0개이고 하위 폴더(`has_children`)가 없을 때만 휴지통 아이콘 표시.
    * 백엔드 컨트롤러에서 DFS 재귀 탐색을 통해 하위 자손 폴더의 파일 합산 체크 후 단 1개라도 존재하면 422 상태코드로 삭제 차단.

### 다. 자물쇠 및 삭제 권한 정책
1. **열린 자물쇠 아이콘 색상 변경**
    * 미사용 임시 파일(삭제 가능 상태)의 열린 자물쇠 아이콘을 회색(`text-muted`)에서 노란색(`text-warning`, `btn-outline-warning`)으로 교체.
2. **우클릭 컨텍스트 메뉴 삭제 탭 완전 은닉**
    * 실사용 이미지(`isUsed === true`) 및 보호 잠금 상태(`isProtected === true`)인 경우, 우클릭 메뉴의 하단 구분선(`hr`)과 `삭제하기` 메뉴 항목 자체를 숨김(`display: none !important`).

### 라. 안정성 및 500 에러 해결
1. **ORM 직렬화 충돌 및 스키마 검사 오버헤드 차단**
    * 모델 가상 속성(`$appends`) 및 접근자 예외를 방어하기 위해 컨트롤러 반환 직전 순수 배열(`array`) 변환 매핑 적용.
    * `formatBytes` 함수에 0 이하, null, 비숫자 데이터 유입 시 `log()` 함수 Fatal Error 발생을 원천 차단하는 가드 조건 수립.

---

## 3. 전체 구현 소스 코드

### 가. 블레이드 템플릿: `resources/views/components/modal_media.blade.php`

    <!-- 중앙 집중형 미디어 라이브러리 모달 -->
    <div class="modal fade" id="globeauMediaModal" tabindex="-1" aria-labelledby="globeauMediaModalLabel" aria-hidden="true" data-bs-backdrop="static">
        <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" style="max-width: 1240px;">
            <div class="modal-content border-0 shadow-lg common-rounded-modal overflow-hidden" style="height: 85vh; background-color: #f8fafc;">
                
                <!-- 모달 헤더 -->
                <div class="modal-header border-bottom bg-white px-4 py-3">
                    <div class="d-flex align-items-center gap-2">
                        <div class="bg-primary bg-opacity-10 text-primary p-2 common-rounded-sm d-flex align-items-center justify-content-center" style="width: 38px; height: 38px;">
                            <i class="fa-solid fa-photo-film fs-5"></i>
                        </div>
                        <div>
                            <h5 class="modal-title fw-bold text-dark m-0" id="globeauMediaModalLabel">미디어 라이브러리</h5>
                            <p class="text-muted small m-0" style="font-size: 0.75rem;">이미지를 탐색, 관리하거나 서비스에 선택 적용합니다.</p>
                        </div>
                    </div>
                    <div class="d-flex align-items-center gap-2">
                        <button type="button" class="btn btn-primary btn-sm px-3 shadow-sm common-rounded fw-semibold" onclick="document.getElementById('mediaModalFileInput').click()">
                            <i class="fa-solid fa-cloud-arrow-up me-1"></i> 파일 업로드
                        </button>
                        <input type="file" id="mediaModalFileInput" class="d-none" multiple accept="image/*" onchange="handleMediaModalUpload(this.files)">
                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                    </div>
                </div>

                <!-- 모달 본문 (2단 레이아웃) -->
                <div class="modal-body p-0 d-flex overflow-hidden">
                    
                    <!-- 좌측 사이드바: 계층형 폴더 트리 -->
                    <div class="border-end bg-white d-flex flex-column flex-shrink-0" style="width: 250px;">
                        <div class="p-3 border-bottom d-flex justify-content-between align-items-center bg-light bg-opacity-50 flex-shrink-0">
                            <span class="fw-bold small text-secondary"><i class="fa-solid fa-folder-tree me-1"></i> 폴더 디렉터리</span>
                            <div class="d-flex align-items-center gap-2">
                                <button type="button" class="btn btn-outline-primary btn-sm p-0 d-flex align-items-center justify-content-center common-rounded-xs" style="width: 22px; height: 22px;" onclick="showCreateFolderInput()" title="새 폴더 생성">
                                    <i class="fa-solid fa-plus" style="font-size: 0.7rem;"></i>
                                </button>
                                <button type="button" class="btn btn-link btn-sm p-0 text-decoration-none text-secondary" onclick="fetchMediaFolders()" title="새로고침">
                                    <i class="fa-solid fa-rotate-right" style="font-size: 0.8rem;"></i>
                                </button>
                            </div>
                        </div>

                        <!-- 인라인 폴더 생성창 -->
                        <div id="mediaCreateFolderBox" class="p-2 border-bottom bg-light d-none flex-shrink-0">
                            <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 class="flex-grow-1 overflow-auto p-2" id="mediaFolderTreeList" style="white-space: nowrap;">
                            <div class="text-center py-4 text-muted small">
                                <div class="spinner-border spinner-border-sm text-secondary mb-2" role="status"></div>
                                <div>폴더 목록 불러오는 중...</div>
                            </div>
                        </div>

                        <!-- 좌측 하단 정보 -->
                        <div class="p-2 border-top bg-light bg-opacity-25 text-center flex-shrink-0">
                            <small class="text-muted" style="font-size: 0.7rem;">
                                <i class="fa-solid fa-circle-info me-1"></i> 빈 폴더(하위 파일·폴더 없음)만 삭제 가능
                            </small>
                        </div>
                    </div>

                    <!-- 우측 메인 영역: 툴바 + 갤러리/리스트 -->
                    <div class="flex-grow-1 d-flex flex-column overflow-hidden bg-transparent">
                        
                        <!-- 상단 통합 툴바 -->
                        <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 px-3 py-2 border-bottom bg-white">
                            
                            <!-- 전체선택 및 카운트 -->
                            <div class="d-flex align-items-center gap-3">
                                <div class="form-check m-0 d-flex align-items-center">
                                    <input class="form-check-input shadow-none cursor-pointer common-rounded-xs" type="checkbox" id="mediaMasterCheckbox" onchange="toggleSelectAllMedia(this)">
                                    <label class="form-check-label small ms-2 text-dark fw-semibold cursor-pointer" for="mediaMasterCheckbox">전체 선택</label>
                                </div>
                                <span id="mediaSelectedCountText" class="badge bg-primary-subtle text-primary border border-primary-subtle common-rounded-badge px-2 py-1 small fw-normal">
                                    선택된 이미지: 0개
                                </span>
                            </div>

                            <!-- 정렬 버튼 그룹 + 뷰 모드 + 검색창 -->
                            <div class="d-flex align-items-center gap-2 flex-wrap">
                                
                                <!-- 정렬 토글 그룹 -->
                                <div class="btn-group btn-group-sm common-rounded-btn-group overflow-hidden" role="group" aria-label="정렬 토글">
                                    <button type="button" class="btn btn-outline-secondary py-1 px-2 sort-toggle-btn active" id="sort-btn-date" onclick="toggleMediaSort('date')" title="날짜순 정렬 토글">
                                        <span>날짜순</span> <i class="fa-solid fa-arrow-down-short-wide ms-1 sort-icon"></i>
                                    </button>
                                    <button type="button" class="btn btn-outline-secondary py-1 px-2 sort-toggle-btn" id="sort-btn-name" onclick="toggleMediaSort('name')" title="파일이름 정렬 토글">
                                        <span>파일이름</span> <i class="fa-solid fa-sort ms-1 sort-icon text-muted"></i>
                                    </button>
                                    <button type="button" class="btn btn-outline-secondary py-1 px-2 sort-toggle-btn" id="sort-btn-size" onclick="toggleMediaSort('size')" title="파일용량 정렬 토글">
                                        <span>파일용량</span> <i class="fa-solid fa-sort ms-1 sort-icon text-muted"></i>
                                    </button>
                                </div>

                                <!-- 보기 방식 전환 (큰아이콘 / 중간아이콘 / 자세히보기) -->
                                <div class="btn-group btn-group-sm common-rounded-btn-group overflow-hidden" role="group" aria-label="보기 방식">
                                    <button type="button" class="btn btn-outline-secondary py-1 px-2 view-mode-btn active" id="view-mode-large" onclick="changeMediaViewMode('large')" title="큰 아이콘 보기">
                                        <i class="fa-solid fa-table-cells-large"></i>
                                    </button>
                                    <button type="button" class="btn btn-outline-secondary py-1 px-2 view-mode-btn" id="view-mode-small" onclick="changeMediaViewMode('small')" title="중간 아이콘 보기">
                                        <i class="fa-solid fa-table-cells"></i>
                                    </button>
                                    <button type="button" class="btn btn-outline-secondary py-1 px-2 view-mode-btn" id="view-mode-list" onclick="changeMediaViewMode('list')" title="자세히 보기 (목록형)">
                                        <i class="fa-solid fa-list"></i>
                                    </button>
                                </div>

                                <!-- 검색창 -->
                                <div class="input-group input-group-sm common-rounded-input" style="width: 180px;">
                                    <input type="text" class="form-control common-rounded-start" id="mediaKeywordSearch" placeholder="파일명 검색..." onkeyup="if(event.key==='Enter') fetchMediaLibraryImages(true)">
                                    <button class="btn btn-outline-secondary common-rounded-end" type="button" onclick="fetchMediaLibraryImages(true)">
                                        <i class="fa-solid fa-magnifying-glass"></i>
                                    </button>
                                </div>

                            </div>
                        </div>

                        <!-- 캔버스 영역 -->
                        <div class="flex-grow-1 overflow-y-auto p-3 position-relative" id="mediaImageContainer" style="background-color: transparent;"></div>

                    </div>
                </div>

                <!-- 모달 푸터 -->
                <div class="modal-footer border-top bg-white px-4 py-2 d-flex justify-content-between align-items-center">
                    <div class="text-muted small d-flex align-items-center gap-3" style="font-size: 0.75rem;">
                        <span><i class="fa-solid fa-computer-mouse me-1 text-primary"></i> <strong>더블클릭</strong>: 즉시 적용</span>
                        <span><i class="fa-solid fa-arrow-pointer me-1 text-primary"></i> <strong>빈 공간 드래그</strong>: 영역 다중 선택</span>
                        <span><i class="fa-solid fa-keyboard me-1 text-primary"></i> <strong>Ctrl + A</strong>: 전체 선택</span>
                        <span><i class="fa-solid fa-hand-pointer me-1 text-primary"></i> <strong>우클릭</strong>: 상세 메뉴</span>
                    </div>
                    <div class="d-flex align-items-center gap-2">
                        <button type="button" class="btn btn-light btn-sm px-3 border common-rounded" data-bs-dismiss="modal">취소</button>
                        <button type="button" class="btn btn-primary btn-sm px-4 fw-semibold shadow-sm common-rounded" onclick="confirmMediaSelection()">
                            선택 완료
                        </button>
                    </div>
                </div>

            </div>
        </div>
    </div>

    <!-- 우클릭 커스텀 컨텍스트 메뉴 -->
    <div id="mediaFileContextMenu" class="dropdown-menu shadow border-0 py-1 common-rounded-dropdown" style="display: none; position: fixed; z-index: 10050; min-width: 175px; font-size: 0.82rem;">
        <a class="dropdown-item py-1 px-3" href="javascript:void(0)" onclick="contextMenuAction('select')">
            <i class="fa-solid fa-check text-primary me-2"></i> 선택 완료
        </a>
        <a class="dropdown-item py-1 px-3" href="javascript:void(0)" onclick="contextMenuAction('view')">
            <i class="fa-solid fa-arrow-up-right-from-square text-info me-2"></i> 새 탭에서 원본 보기
        </a>
        <div class="dropdown-divider my-1"></div>
        <a class="dropdown-item py-1 px-3" href="javascript:void(0)" onclick="contextMenuAction('rename')">
            <i class="fa-solid fa-pen text-secondary me-2"></i> 이름 바꾸기
        </a>
        <a class="dropdown-item py-1 px-3" href="javascript:void(0)" onclick="contextMenuAction('protect')" id="contextMenuProtectBtn">
            <i class="fa-solid fa-lock text-warning me-2"></i> 보호 잠금 설정/해제
        </a>
        <div class="dropdown-divider my-1" id="contextMenuDeleteDivider"></div>
        <a class="dropdown-item py-1 px-3 text-danger" href="javascript:void(0)" onclick="contextMenuAction('delete')" id="contextMenuDeleteBtn">
            <i class="fa-solid fa-trash me-2"></i> 삭제하기
        </a>
    </div>

---

### 나. 프론트엔드 통합 스크립트: `public/js/modal_media.js`

    let globeauMediaModal = null;
    let currentMediaMode = 'single';
    let currentMediaType = 'general';
    let selectedMediaCallback = null;
    let selectedMediaImagesMap = new Map();
    let currentMediaFolderId = null;
    let currentMediaViewMode = 'large';

    let currentSortBy = 'date';
    let currentSortDirection = 'desc';

    let currentMediaPage = 1;
    const mediaPageSize = 50;
    let hasMoreMediaPages = true;
    let isMediaLoading = false;
    let scrollObserver = null;

    let isMarqueeSelecting = false;
    let marqueeStartX = 0;
    let marqueeStartY = 0;
    let marqueeBoxEl = null;

    let contextMenuTargetImage = null;

    document.addEventListener('DOMContentLoaded', function () {
        const modalElement = document.getElementById('globeauMediaModal');
        if (modalElement) {
            globeauMediaModal = new bootstrap.Modal(modalElement);

            modalElement.addEventListener('hidden.bs.modal', function () {
                currentMediaFolderId = null;
                currentMediaType = 'general';
                currentMediaMode = 'single';
                selectedMediaCallback = null;
                selectedMediaImagesMap.clear();
                currentMediaPage = 1;
                hasMoreMediaPages = true;
                isMediaLoading = false;
                closeContextMenu();

                const searchInput = document.getElementById('mediaKeywordSearch');
                if (searchInput) searchInput.value = '';

                const container = document.getElementById('mediaImageContainer');
                if (container) container.innerHTML = '';
            });
        }

        document.addEventListener('click', function () {
            closeContextMenu();
        });

        document.addEventListener('keydown', function (e) {
            if (e.key === 'Escape') {
                closeContextMenu();
            }
        });

        if (!document.getElementById('globeau-media-explorer-style')) {
            const style = document.createElement('style');
            style.id = 'globeau-media-explorer-style';
            style.innerHTML = `
                #mediaImageContainer {
                    user-select: none !important;
                    -webkit-user-select: none !important;
                    position: relative !important;
                    background-color: transparent !important;
                    min-height: 100%;
                }
                .media-item-card {
                    user-select: none !important;
                    background-color: transparent !important;
                    transition: background-color 0.15s ease, transform 0.15s ease;
                    cursor: grab;
                    border-radius: var(--admin-radius-card, 0.5rem) !important;
                    border: 1px solid transparent !important;
                    box-shadow: none !important;
                }
                .media-item-card:active {
                    cursor: grabbing;
                }
                .media-item-card:hover {
                    transform: translateY(-1px);
                    background-color: rgba(13, 110, 253, 0.05) !important;
                    border-color: transparent !important;
                    box-shadow: none !important;
                }
                .media-item-card.is-selected {
                    background-color: rgba(13, 110, 253, 0.12) !important;
                    border-color: transparent !important;
                    box-shadow: none !important;
                }
                .media-thumb-box {
                    background-color: transparent !important;
                    border-radius: var(--admin-radius-sm, 0.375rem) !important;
                    cursor: pointer;
                }
                .media-thumbnail-img {
                    max-width: 100%;
                    max-height: 100%;
                    object-fit: contain;
                    pointer-events: none;
                    filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.08)) !important;
                    border-radius: var(--admin-radius-xs, 0.25rem) !important;
                }
                #media-marquee-box {
                    position: absolute;
                    border: 1.5px solid #0d6efd;
                    background-color: rgba(13, 110, 253, 0.15);
                    pointer-events: none;
                    z-index: 9999;
                    display: none;
                }
                .badge-lock-fixed {
                    background-color: #6c757d !important;
                    color: #ffffff !important;
                    padding: 3px 6px !important;
                    font-size: 0.68rem !important;
                    border-radius: var(--admin-radius-xs, 0.25rem) !important;
                    cursor: not-allowed;
                    opacity: 0.85;
                }
                .btn-lock-toggle {
                    padding: 1px 5px !important;
                    font-size: 0.72rem !important;
                    line-height: 1.2 !important;
                    border-radius: var(--admin-radius-xs, 0.25rem) !important;
                }
                .context-item-disabled {
                    opacity: 0.45 !important;
                    pointer-events: none !important;
                    cursor: not-allowed !important;
                    color: #6c757d !important;
                }
                .sort-toggle-btn.active, .view-mode-btn.active {
                    background-color: #0d6efd !important;
                    color: #ffffff !important;
                    border-color: #0d6efd !important;
                }
                .sort-toggle-btn.active .sort-icon {
                    color: #ffffff !important;
                }
                .media-list-table {
                    width: 100%;
                    border-collapse: separate;
                    border-spacing: 0 4px;
                }
                .media-list-table thead th {
                    font-size: 0.75rem;
                    color: #6c757d;
                    font-weight: 600;
                    padding: 8px 10px;
                    border-bottom: 1px solid #dee2e6;
                    background-color: transparent;
                    user-select: none;
                }
                .media-list-table thead th.sortable-th {
                    cursor: pointer;
                    transition: color 0.15s ease;
                }
                .media-list-table thead th.sortable-th:hover {
                    color: #0d6efd;
                }
                .media-list-row {
                    background-color: transparent;
                    transition: background-color 0.15s ease;
                    cursor: grab;
                    border: none !important;
                    border-radius: var(--admin-radius-sm, 0.375rem);
                }
                .media-list-row:hover {
                    background-color: rgba(13, 110, 253, 0.05) !important;
                }
                .media-list-row.is-selected {
                    background-color: rgba(13, 110, 253, 0.12) !important;
                    outline: none !important;
                }
                .media-list-row td {
                    padding: 6px 10px;
                    font-size: 0.8rem;
                    vertical-align: middle;
                }
                .media-list-thumb {
                    width: 32px;
                    height: 32px;
                    object-fit: contain;
                    border-radius: var(--admin-radius-xs, 0.25rem);
                    background-color: rgba(0, 0, 0, 0.03);
                }
                .folder-delete-btn {
                    opacity: 0;
                    transition: opacity 0.15s ease;
                }
                .folder-drop-target:hover .folder-delete-btn {
                    opacity: 1;
                }
                .hover-text-white:hover {
                    color: #ffffff !important;
                }
            `;
            document.head.appendChild(style);
        }

        initMarqueeSelection();
    });

    function handleMediaModalUpload(files) {
        if (!files || files.length === 0) return;

        const fileInput = document.getElementById('mediaModalFileInput');
        const container = document.getElementById('mediaImageContainer');
        const uploadRoute = window.mediaUploadRoute || window.defaultMediaUploadRoute || '/admin/images';
        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');

        let uploadOverlay = document.getElementById('mediaUploadOverlay');
        if (!uploadOverlay && container) {
            uploadOverlay = document.createElement('div');
            uploadOverlay.id = 'mediaUploadOverlay';
            uploadOverlay.className = 'position-absolute top-50 start-50 translate-middle bg-white border shadow-lg p-3 text-center common-rounded-card';
            uploadOverlay.style.zIndex = '10060';
            uploadOverlay.innerHTML = `
                <div class="spinner-border spinner-border-sm text-primary mb-2" role="status"></div>
                <div class="small fw-semibold text-dark" id="mediaUploadStatusText">이미지 업로드 중 (0/${files.length})...</div>
            `;
            container.appendChild(uploadOverlay);
        }

        const filesArray = Array.from(files);
        let completedCount = 0;
        let successCount = 0;
        let failMessages = [];

        filesArray.reduce((promiseChain, file, index) => {
            return promiseChain.then(() => {
                const formData = new FormData();
                formData.append('image', file);
                formData.append('type', currentMediaType || 'general');

                if (currentMediaFolderId && currentMediaFolderId !== 'orphans') {
                    formData.append('folder_id', currentMediaFolderId);
                }

                const statusText = document.getElementById('mediaUploadStatusText');
                if (statusText) {
                    statusText.innerText = `이미지 업로드 중 (${index + 1}/${files.length})...`;
                }

                return fetch(uploadRoute, {
                    method: 'POST',
                    headers: {
                        'X-CSRF-TOKEN': csrfToken,
                        'X-Requested-With': 'XMLHttpRequest',
                        'Accept': 'application/json'
                    },
                    body: formData
                })
                .then(res => res.json())
                .then(data => {
                    completedCount++;
                    if (data.success) {
                        successCount++;
                    } else {
                        failMessages.push(`[${file.name}] ${data.message || '업로드 실패'}`);
                    }
                })
                .catch(err => {
                    completedCount++;
                    failMessages.push(`[${file.name}] 네트워크 오류`);
                });
            });
        }, Promise.resolve())
        .then(() => {
            if (fileInput) fileInput.value = '';
            if (uploadOverlay) uploadOverlay.remove();

            if (failMessages.length > 0) {
                alert(`업로드 완료: ${successCount}건 성공\n실패 내역:\n` + failMessages.join('\n'));
            }

            fetchMediaFolders();
            fetchMediaLibraryImages(true);
        });
    }

    function openMediaModal(type, mode, callback = null, uploadRoute = '', indexRoute = '') {
        currentMediaType = type;
        currentMediaMode = mode;
        selectedMediaCallback = (typeof callback === 'function') ? callback : null;
        selectedMediaImagesMap.clear();
        currentMediaPage = 1;
        hasMoreMediaPages = true;
        isMediaLoading = false;
        closeContextMenu();

        const fileInput = document.getElementById('mediaModalFileInput');
        if (fileInput) {
            if (mode === 'single') {
                fileInput.removeAttribute('multiple');
            } else {
                fileInput.setAttribute('multiple', 'multiple');
            }
        }

        const masterCheckbox = document.getElementById('mediaMasterCheckbox');
        if (masterCheckbox) {
            masterCheckbox.checked = false;
            masterCheckbox.closest('.form-check')?.classList.toggle('d-none', mode === 'single');
        }

        window.mediaUploadRoute = uploadRoute || window.defaultMediaUploadRoute || '/admin/images';
        window.mediaIndexRoute = indexRoute || window.defaultMediaIndexRoute || '/admin/images';

        if (globeauMediaModal) {
            globeauMediaModal.show();
            fetchMediaFolders();
            fetchMediaLibraryImages(true);
        }
    }

    function changeMediaViewMode(mode) {
        currentMediaViewMode = mode;

        ['large', 'small', 'list'].forEach(m => {
            const btn = document.getElementById(`view-mode-${m}`);
            if (btn) btn.classList.toggle('active', m === mode);
        });

        fetchMediaLibraryImages(true);
    }

    function toggleMediaSort(type) {
        if (currentSortBy === type) {
            currentSortDirection = (currentSortDirection === 'asc') ? 'desc' : 'asc';
        } else {
            currentSortBy = type;
            currentSortDirection = (type === 'name' || type === 'extension') ? 'asc' : 'desc';
        }

        const sortConfigs = [
            { type: 'date', label: '날짜순' },
            { type: 'name', label: '파일이름' },
            { type: 'size', label: '파일용량' }
        ];

        sortConfigs.forEach(cfg => {
            const btn = document.getElementById(`sort-btn-${cfg.type}`);
            if (!btn) return;

            if (cfg.type === currentSortBy) {
                btn.classList.add('active');
                const iconClass = (currentSortDirection === 'asc') 
                    ? 'fa-solid fa-arrow-up-short-wide ms-1 sort-icon' 
                    : 'fa-solid fa-arrow-down-short-wide ms-1 sort-icon';
                
                btn.innerHTML = `<span>${cfg.label}</span> <i class="${iconClass}"></i>`;
            } else {
                btn.classList.remove('active');
                btn.innerHTML = `<span>${cfg.label}</span> <i class="fa-solid fa-sort ms-1 sort-icon text-muted"></i>`;
            }
        });

        fetchMediaLibraryImages(true);
    }

    function fetchMediaLibraryImages(reset = false) {
        if (!window.mediaIndexRoute || isMediaLoading) return;

        if (reset) {
            currentMediaPage = 1;
            hasMoreMediaPages = true;
        }

        if (!hasMoreMediaPages) return;

        isMediaLoading = true;
        const keyword = document.getElementById('mediaKeywordSearch')?.value || '';

        const params = new URLSearchParams();
        params.set('keyword', keyword.trim());
        params.set('sort_by', currentSortBy);
        params.set('sort_direction', currentSortDirection);
        params.set('page', currentMediaPage);
        params.set('per_page', mediaPageSize);
        params.set('ajax', '1');

        if (currentMediaFolderId) {
            params.set('folder_id', currentMediaFolderId);
        }

        const requestUrl = `${window.mediaIndexRoute}?${params.toString()}`;

        showLoadingSpinner(reset);

        fetch(requestUrl, {
            headers: {
                'X-Requested-With': 'XMLHttpRequest',
                'Accept': 'application/json'
            }
        })
        .then(res => {
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            return res.json();
        })
        .then(data => {
            isMediaLoading = false;
            hideLoadingSpinner();

            const paginator = data.images || {};
            const images = paginator.data || (Array.isArray(paginator) ? paginator : []);

            if (paginator.current_page && paginator.last_page) {
                hasMoreMediaPages = paginator.current_page < paginator.last_page;
            } else {
                hasMoreMediaPages = images.length >= mediaPageSize;
            }

            const alreadySelectedIds = new Set();
            if (currentMediaMode === 'single') {
                const hiddenInput = document.getElementById(`${currentMediaType}_id`)
                                 || document.getElementById(`${currentMediaType}_image_id`)
                                 || document.querySelector(`input[name="${currentMediaType}_id"]`)
                                 || document.querySelector(`input[name="${currentMediaType}_image_id"]`);

                if (hiddenInput && hiddenInput.value) {
                    alreadySelectedIds.add(String(hiddenInput.value));
                }
            } else {
                document.querySelectorAll(`input[name="${currentMediaType}_image_ids[]"]`).forEach(input => {
                    if (input.value) alreadySelectedIds.add(String(input.value));
                });
                document.querySelectorAll(`input[name="image_ids[]"]`).forEach(input => {
                    if (input.value) alreadySelectedIds.add(String(input.value));
                });
                document.querySelectorAll(`#${currentMediaType}-gallery-preview [data-id], .${currentMediaType}-gallery-preview [data-id]`).forEach(el => {
                    if (el.dataset.id) alreadySelectedIds.add(String(el.dataset.id));
                });
                document.querySelectorAll(`#${currentMediaType}-hidden-inputs input`).forEach(input => {
                    if (input.value) alreadySelectedIds.add(String(input.value));
                });
            }

            const filteredImages = images.filter(img => !alreadySelectedIds.has(String(img.id)));

            renderMediaLibraryImages(filteredImages, reset);

            if (hasMoreMediaPages) {
                currentMediaPage++;
                setupScrollSentinel();
            }
        })
        .catch(err => {
            isMediaLoading = false;
            hideLoadingSpinner();
            console.error('Library Fetch Error:', err);
        });
    }

    function renderMediaLibraryImages(images, reset = false) {
        const container = document.getElementById('mediaImageContainer');
        if (!container) return;

        if (reset) {
            container.innerHTML = '';
            if (marqueeBoxEl) container.appendChild(marqueeBoxEl);
        }

        const existingSentinel = document.getElementById('mediaScrollSentinel');
        if (existingSentinel) existingSentinel.remove();

        if (reset && images.length === 0) {
            const emptyDiv = document.createElement('div');
            emptyDiv.className = 'text-center py-5 text-muted small bg-transparent';
            emptyDiv.innerText = '등록 가능한 이미지가 없습니다.';
            container.appendChild(emptyDiv);
            return;
        }

        if (currentMediaViewMode === 'large' || currentMediaViewMode === 'small') {
            let row = container.querySelector('#mediaImageGridRow');
            if (!row || reset) {
                row = document.createElement('div');
                row.id = 'mediaImageGridRow';
                row.className = currentMediaViewMode === 'large' ? 'row g-3' : 'row g-2';
                container.appendChild(row);
            }

            images.forEach(img => {
                const imgId = parseInt(img.id, 10);
                const isSelected = selectedMediaImagesMap.has(imgId);
                const colClass = currentMediaViewMode === 'large' ? 'col-6 col-md-4 col-lg-3' : 'col-4 col-md-3 col-lg-2';

                const isUsed = Boolean(img.is_used);
                const isProtected = Boolean(img.is_protected);
                const lockButtonHtml = renderLockIconHtml(imgId, isUsed, isProtected);

                const col = document.createElement('div');
                col.className = colClass;
                
                col.innerHTML = `
                    <div class="card h-100 position-relative image-card p-2 text-center media-item-card bg-transparent common-rounded-card ${isSelected ? 'is-selected' : ''}" 
                         id="media-card-${imgId}" 
                         data-id="${imgId}"
                         data-path="${img.path}"
                         data-name="${img.original_name}"
                         data-is-used="${isUsed ? '1' : '0'}"
                         data-is-protected="${isProtected ? '1' : '0'}"
                         draggable="true" 
                         ondragstart="handleCardDragStart(event, ${imgId})"
                         onclick="handleCardClick(${imgId}, '${img.path}', '${img.original_name}', event)"
                         ondblclick="doubleClickMediaImage(${imgId}, '${img.path}', '${img.original_name}', event)"
                         oncontextmenu="handleCardContextMenu(event, ${imgId}, '${img.path}', '${img.original_name}', ${isUsed ? 'true' : 'false'}, ${isProtected ? 'true' : 'false'})"
                         title="더블클릭: 즉시 선택 / 우클릭: 상세 메뉴">
                        
                        <div class="position-absolute top-0 start-0 end-0 p-2 d-flex justify-content-between align-items-center" style="z-index: 2;" onclick="event.stopPropagation();">
                            <input type="checkbox" class="form-check-input media-checkbox shadow-sm m-0 common-rounded-xs" value="${imgId}" ${isSelected ? 'checked' : ''} onchange="toggleCheckboxSelection(${imgId}, '${img.path}', '${img.original_name}', this)">
                            <div id="lock-container-${imgId}">${lockButtonHtml}</div>
                        </div>

                        <div class="w-100 d-flex align-items-center justify-content-center overflow-hidden p-1 media-thumb-box bg-transparent common-rounded-sm" 
                             style="aspect-ratio: 3 / 2; width: 100%;">
                            <img src="/storage/${img.path}" class="media-thumbnail-img" draggable="false" style="display: block;">
                        </div>

                        <div class="card-body p-1 text-center bg-transparent border-0">
                            <div class="file-name-container" id="name-container-${imgId}">
                                <small class="text-dark text-truncate d-block fw-semibold px-1" style="font-size: 0.75rem;">
                                    ${img.original_name}
                                </small>
                            </div>
                        </div>
                    </div>
                `;
                row.appendChild(col);
            });

        } else if (currentMediaViewMode === 'list') {
            let table = container.querySelector('.media-list-table');
            let tbody = container.querySelector('#mediaListTableBody');

            if (!table || reset) {
                table = document.createElement('table');
                table.className = 'media-list-table';

                const nameIndicator = currentSortBy === 'name' ? (currentSortDirection === 'asc' ? ' ▲' : ' ▼') : '';
                const dateIndicator = currentSortBy === 'date' ? (currentSortDirection === 'asc' ? ' ▲' : ' ▼') : '';
                const typeIndicator = (currentSortBy === 'extension' || currentSortBy === 'type') ? (currentSortDirection === 'asc' ? ' ▲' : ' ▼') : '';
                const sizeIndicator = currentSortBy === 'size' ? (currentSortDirection === 'asc' ? ' ▲' : ' ▼') : '';

                table.innerHTML = `
                    <thead>
                        <tr>
                            <th style="width: 35px;" class="text-center">
                                <input type="checkbox" class="form-check-input m-0 shadow-none common-rounded-xs" id="listTableMasterCheck" onchange="toggleSelectAllMedia(this)">
                            </th>
                            <th style="width: 45px;"></th>
                            <th class="sortable-th" onclick="toggleMediaSort('name')">
                                이름 <span class="text-primary fw-bold">${nameIndicator}</span>
                            </th>
                            <th style="width: 140px;" class="sortable-th" onclick="toggleMediaSort('date')">
                                수정한 날짜 <span class="text-primary fw-bold">${dateIndicator}</span>
                            </th>
                            <th style="width: 90px;" class="sortable-th" onclick="toggleMediaSort('extension')">
                                유형 <span class="text-primary fw-bold">${typeIndicator}</span>
                            </th>
                            <th style="width: 90px; text-align: right;" class="sortable-th pe-3" onclick="toggleMediaSort('size')">
                                크기 <span class="text-primary fw-bold">${sizeIndicator}</span>
                            </th>
                            <th style="width: 70px;" class="text-center">상태</th>
                        </tr>
                    </thead>
                    <tbody id="mediaListTableBody"></tbody>
                `;

                container.appendChild(table);
                tbody = table.querySelector('#mediaListTableBody');
            }

            images.forEach(img => {
                const imgId = parseInt(img.id, 10);
                const isSelected = selectedMediaImagesMap.has(imgId);
                const isUsed = Boolean(img.is_used);
                const isProtected = Boolean(img.is_protected);
                const lockButtonHtml = renderLockIconHtml(imgId, isUsed, isProtected);

                const tr = document.createElement('tr');
                tr.className = `media-list-row media-item-card ${isSelected ? 'is-selected' : ''}`;
                tr.id = `media-card-${imgId}`;
                tr.dataset.id = imgId;
                tr.dataset.path = img.path;
                tr.dataset.name = img.original_name;
                tr.dataset.isUsed = isUsed ? '1' : '0';
                tr.dataset.isProtected = isProtected ? '1' : '0';
                tr.draggable = true;

                tr.setAttribute('ondragstart', `handleCardDragStart(event, ${imgId})`);
                tr.setAttribute('onclick', `handleCardClick(${imgId}, '${img.path}', '${img.original_name}', event)`);
                tr.setAttribute('ondblclick', `doubleClickMediaImage(${imgId}, '${img.path}', '${img.original_name}', event)`);
                tr.setAttribute('oncontextmenu', `handleCardContextMenu(event, ${imgId}, '${img.path}', '${img.original_name}', ${isUsed ? 'true' : 'false'}, ${isProtected ? 'true' : 'false'})`);

                tr.innerHTML = `
                    <td class="text-center" onclick="event.stopPropagation();">
                        <input type="checkbox" class="form-check-input media-checkbox shadow-none m-0 common-rounded-xs" value="${imgId}" ${isSelected ? 'checked' : ''} onchange="toggleCheckboxSelection(${imgId}, '${img.path}', '${img.original_name}', this)">
                    </td>
                    <td class="p-1 text-center">
                        <img src="/storage/${img.path}" class="media-list-thumb" draggable="false">
                    </td>
                    <td>
                        <div class="file-name-container" id="name-container-${imgId}">
                            <span class="text-dark fw-semibold text-truncate d-inline-block" style="max-width: 380px;">${img.original_name}</span>
                        </div>
                    </td>
                    <td class="text-muted small">${img.formatted_date || '-'}</td>
                    <td class="text-muted small">${img.extension || 'IMG'} 파일</td>
                    <td class="text-end text-muted small pe-3">${img.formatted_size || '-'}</td>
                    <td class="text-center" onclick="event.stopPropagation();">
                        <div id="lock-container-${imgId}">${lockButtonHtml}</div>
                    </td>
                `;

                tbody.appendChild(tr);
            });
        }

        updateCardVisuals();
    }

    function setupScrollSentinel() {
        const container = document.getElementById('mediaImageContainer');
        if (!container) return;

        if (scrollObserver) {
            scrollObserver.disconnect();
        }

        const sentinel = document.createElement('div');
        sentinel.id = 'mediaScrollSentinel';
        sentinel.style.height = '20px';
        sentinel.style.width = '100%';
        sentinel.style.pointerEvents = 'none';
        container.appendChild(sentinel);

        scrollObserver = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting && hasMoreMediaPages && !isMediaLoading) {
                    fetchMediaLibraryImages(false);
                }
            });
        }, {
            root: container,
            rootMargin: '150px'
        });

        scrollObserver.observe(sentinel);
    }

    function showLoadingSpinner(reset) {
        if (reset) return;
        const container = document.getElementById('mediaImageContainer');
        if (!container || document.getElementById('mediaInfiniteSpinner')) return;

        const spinner = document.createElement('div');
        spinner.id = 'mediaInfiniteSpinner';
        spinner.className = 'text-center py-3 text-secondary';
        spinner.innerHTML = `<div class="spinner-border spinner-border-sm" role="status"></div> <small class="ms-1">추가 이미지 불러오는 중...</small>`;
        container.appendChild(spinner);
    }

    function hideLoadingSpinner() {
        const spinner = document.getElementById('mediaInfiniteSpinner');
        if (spinner) spinner.remove();
    }

    function fetchMediaFolders() {
        const container = document.getElementById('mediaFolderTreeList');
        if (container) {
            container.innerHTML = `
                <div class="text-center py-4 text-muted small">
                    <div class="spinner-border spinner-border-sm text-primary mb-2" role="status"></div>
                    <div style="font-size: 0.75rem;">폴더 목록 불러오는 중...</div>
                </div>
            `;
        }

        fetch('/admin/image-folders', {
            headers: {
                'X-Requested-With': 'XMLHttpRequest',
                'Accept': 'application/json'
            }
        })
        .then(res => res.json())
        .then(data => {
            if (data.success && data.folders) {
                renderFolderTree(data.folders);
            } else {
                if (container) {
                    container.innerHTML = `<div class="text-center py-3 text-danger small">폴더를 불러오지 못했습니다.</div>`;
                }
            }
        })
        .catch(err => {
            console.error('Fetch Folders Error:', err);
            if (container) {
                container.innerHTML = `<div class="text-center py-3 text-danger small">폴더 통신 오류가 발생했습니다.</div>`;
            }
        });
    }

    function renderFolderTree(folders) {
        const container = document.getElementById('mediaFolderTreeList');
        if (!container) return;

        const isAllActive = (currentMediaFolderId === null);
        const isOrphanActive = (currentMediaFolderId === 'orphans');

        const allImagesClass = isAllActive
            ? 'active bg-primary text-white shadow-sm'
            : 'bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-10';

        let html = `
            <a href="javascript:void(0)" 
               id="folder-item-all"
               class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2 px-2 border-0 common-rounded mb-1 ${allImagesClass}" 
               style="min-width: 100%; width: max-content;"
               onclick="selectMediaFolder(null, this)">
                <span class="fw-semibold">
                    <i class="fa-solid fa-layer-group me-2 ${isAllActive ? 'text-white' : 'text-primary'}"></i>전체 이미지
                </span>
            </a>
        `;

        if (folders && folders.length > 0) {
            folders.forEach(f => {
                const isActive = String(currentMediaFolderId) === String(f.id);
                const count = f.images_count ?? 0;
                const depth = f.depth ?? 0;
                const hasChildren = Boolean(f.has_children);

                const indentPadding = depth * 14;
                const treeIcon = depth > 0 
                    ? `<i class="fa-solid fa-turn-up fa-rotate-90 ${isActive ? 'text-white-50' : 'text-muted opacity-50'} me-1" style="font-size: 0.7rem;"></i>` 
                    : '';

                let deleteBtnHtml = '';
                if (count === 0 && !hasChildren) {
                    const trashColorClass = isActive ? 'text-white-50 hover-text-white' : 'text-danger';
                    deleteBtnHtml = `
                        <button type="button" class="btn btn-link btn-sm ${trashColorClass} p-0 ms-1 folder-delete-btn" 
                                onclick="deleteMediaFolder(event, ${f.id}, '${f.name}')" title="빈 폴더 삭제">
                            <i class="fa-regular fa-trash-can" style="font-size: 0.75rem;"></i>
                        </button>
                    `;
                }

                const folderActiveClass = isActive 
                    ? 'active bg-primary text-white shadow-sm' 
                    : 'text-dark bg-transparent';

                const badgeClass = isActive 
                    ? 'bg-white text-primary fw-bold' 
                    : 'bg-secondary-subtle text-secondary';

                html += `
                    <div class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2 px-2 border-0 common-rounded mb-1 folder-drop-target ${folderActiveClass}" 
                         data-folder-id="${f.id}" 
                         style="min-width: 100%; width: max-content; padding-left: ${indentPadding + 8}px !important;"
                         onclick="selectMediaFolder(${f.id}, this)">
                        <span class="d-inline-flex align-items-center me-3" style="font-size: 0.85rem;">
                            ${treeIcon}
                            <i class="fa-solid fa-folder me-2 ${isActive ? 'text-white' : 'text-warning'}"></i>
                            <span class="${isActive ? 'fw-semibold' : ''}">${f.name}</span>
                        </span>
                        <div class="d-inline-flex align-items-center flex-shrink-0">
                            <span class="badge ${badgeClass} rounded-pill" style="font-size: 0.7rem;">${count}</span>
                            ${deleteBtnHtml}
                        </div>
                    </div>
                `;
            });
        }

        const orphanClass = isOrphanActive 
            ? 'active bg-danger text-white shadow-sm' 
            : 'text-dark bg-transparent';

        html += `
            <hr class="my-2 text-muted opacity-25">
            <a href="javascript:void(0)" 
               id="folder-item-orphans"
               class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2 px-2 border-0 common-rounded mb-1 ${orphanClass}" 
               style="min-width: 100%; width: max-content;"
               onclick="selectMediaFolder('orphans', this)">
                <span><i class="fa-solid fa-broom me-2 ${isOrphanActive ? 'text-white' : 'text-warning'}"></i>임시 파일</span>
            </a>
        `;

        container.innerHTML = html;

        document.querySelectorAll('#mediaFolderTreeList .folder-drop-target').forEach(el => {
            el.addEventListener('dragover', (e) => {
                e.preventDefault();
                e.stopPropagation();
                el.classList.add('bg-warning-subtle');
            });
            el.addEventListener('dragleave', (e) => {
                e.preventDefault();
                e.stopPropagation();
                el.classList.remove('bg-warning-subtle');
            });
            el.addEventListener('drop', (e) => {
                e.preventDefault();
                e.stopPropagation();
                el.classList.remove('bg-warning-subtle');

                const targetFolderId = el.dataset.folderId;
                let imageIds = [];

                try {
                    const jsonData = e.dataTransfer.getData('application/json');
                    if (jsonData) imageIds = JSON.parse(jsonData);
                } catch (err) {
                    imageIds = [];
                }

                if (!imageIds || imageIds.length === 0) {
                    const textData = e.dataTransfer.getData('text/plain');
                    if (textData) {
                        imageIds = textData.split(',').map(id => parseInt(id.trim(), 10)).filter(Boolean);
                    }
                }

                if (imageIds.length > 0 && targetFolderId) {
                    moveFilesToFolder(imageIds, targetFolderId);
                }
            });
        });
    }

    function selectMediaFolder(folderId, element) {
        currentMediaFolderId = folderId;
        fetchMediaFolders();
        fetchMediaLibraryImages(true);
    }

    function showCreateFolderInput() {
        const box = document.getElementById('mediaCreateFolderBox');
        const input = document.getElementById('newFolderNameInput');
        if (!box || !input) return;

        box.classList.toggle('d-none');
        if (!box.classList.contains('d-none')) {
            input.value = '';
            input.focus();
        }
    }

    function submitCreateFolder() {
        const box = document.getElementById('mediaCreateFolderBox');
        const input = document.getElementById('newFolderNameInput');
        const treeContainer = document.getElementById('mediaFolderTreeList');
        if (!input) return;

        const folderName = input.value.trim();
        if (!folderName) {
            alert('생성할 폴더명을 입력해 주세요.');
            input.focus();
            return;
        }

        if (box) box.classList.add('d-none');
        input.value = '';

        if (treeContainer) {
            treeContainer.innerHTML = `
                <div class="text-center py-4 text-muted small">
                    <div class="spinner-border spinner-border-sm text-primary mb-2" role="status"></div>
                    <div style="font-size: 0.75rem;">새 폴더 생성 중...</div>
                </div>
            `;
        }

        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');

        fetch('/admin/image-folders', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': csrfToken,
                'X-Requested-With': 'XMLHttpRequest'
            },
            body: JSON.stringify({ 
                name: folderName,
                parent_id: (currentMediaFolderId && currentMediaFolderId !== 'orphans') ? currentMediaFolderId : null
            })
        })
        .then(res => res.json())
        .then(data => {
            if (data.success) {
                fetchMediaFolders();
            } else {
                alert(data.message || '폴더 생성에 실패했습니다.');
                if (box) {
                    box.classList.remove('d-none');
                    input.value = folderName;
                    input.focus();
                }
                fetchMediaFolders();
            }
        })
        .catch(err => {
            console.error('Create Folder Error:', err);
            alert('폴더 생성 중 통신 오류가 발생했습니다.');
            if (box) {
                box.classList.remove('d-none');
                input.value = folderName;
            }
            fetchMediaFolders();
        });
    }

    function deleteMediaFolder(event, folderId, folderName) {
        event.stopPropagation();
        event.preventDefault();

        if (!confirm(`'${folderName}' 폴더를 삭제하시겠습니까?\n(하위 폴더나 이미지가 없는 빈 폴더만 삭제 가능합니다)`)) {
            return;
        }

        const treeContainer = document.getElementById('mediaFolderTreeList');
        if (treeContainer) {
            treeContainer.innerHTML = `
                <div class="text-center py-4 text-muted small">
                    <div class="spinner-border spinner-border-sm text-danger mb-2" role="status"></div>
                    <div style="font-size: 0.75rem;">폴더 삭제 중...</div>
                </div>
            `;
        }

        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');

        fetch(`/admin/image-folders/${folderId}`, {
            method: 'DELETE',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': csrfToken,
                'X-Requested-With': 'XMLHttpRequest'
            }
        })
        .then(res => res.json())
        .then(data => {
            if (data.success) {
                if (currentMediaFolderId == folderId) {
                    currentMediaFolderId = null;
                    fetchMediaLibraryImages(true);
                }
                fetchMediaFolders();
            } else {
                alert(data.message || '폴더를 삭제할 수 없습니다.');
                fetchMediaFolders();
            }
        })
        .catch(err => {
            console.error('Delete Folder Error:', err);
            alert('폴더 삭제 중 오류가 발생했습니다.');
            fetchMediaFolders();
        });
    }

    function moveFilesToFolder(imageIds, targetFolderId) {
        if (!Array.isArray(imageIds)) imageIds = [imageIds];
        if (imageIds.length === 0) return;

        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');

        fetch('/admin/image-folders/move-copy', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': csrfToken,
                'X-Requested-With': 'XMLHttpRequest'
            },
            body: JSON.stringify({
                image_ids: imageIds,
                target_folder_id: targetFolderId,
                source_folder_id: (currentMediaFolderId && currentMediaFolderId !== 'orphans') ? currentMediaFolderId : null,
                action: 'move'
            })
        })
        .then(res => res.json())
        .then(data => {
            if (data.success) {
                selectedMediaImagesMap.clear();
                fetchMediaFolders();
                fetchMediaLibraryImages(true);
            } else {
                alert(data.message || '파일 이동에 실패했습니다.');
            }
        })
        .catch(err => {
            console.error('Move files error:', err);
            alert('파일 이동 중 오류가 발생했습니다.');
        });
    }

    function renderLockIconHtml(imgId, isUsed, isProtected) {
        if (isUsed) {
            return `
                <span class="badge shadow-sm badge-lock-fixed" 
                      onclick="handleLockIconClick(event, ${imgId}, true, false)"
                      title="실제 서비스에서 사용 중인 이미지 (상시 보호됨 / 잠금 해제 불가)">
                    <i class="fa-solid fa-lock"></i>
                </span>
            `;
        } else {
            if (isProtected) {
                return `
                    <button type="button" 
                            class="btn btn-warning btn-sm shadow-sm btn-lock-toggle fw-bold text-dark" 
                            onclick="handleLockIconClick(event, ${imgId}, false, true)"
                            title="보호 잠금됨 (임시 파일 정리 대상 제외 - 클릭하여 해제)">
                        <i class="fa-solid fa-lock"></i>
                    </button>
                `;
            } else {
                return `
                    <button type="button" 
                            class="btn btn-outline-warning btn-sm shadow-sm btn-lock-toggle bg-white" 
                            onclick="handleLockIconClick(event, ${imgId}, false, false)"
                            title="미사용 임시 파일 (정리 대상 - 클릭하여 보호 잠금 설정)">
                        <i class="fa-solid fa-lock-open text-warning"></i>
                    </button>
                `;
            }
        }
    }

    function handleLockIconClick(event, imageId, isUsed, isProtected) {
        event.stopPropagation();
        event.preventDefault();

        if (isUsed) {
            alert('이 이미지는 실제 서비스에서 사용 중이므로 상시 보호되며 잠금을 변경할 수 없습니다.');
            return;
        }

        toggleImageProtection(imageId);
    }

    function toggleImageProtection(imageId) {
        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');

        fetch(`/admin/images/${imageId}/toggle-protect`, {
            method: 'POST',
            headers: {
                '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}`);
                
                if (card && lockBox) {
                    const newProtected = Boolean(data.is_protected);
                    card.dataset.isProtected = newProtected ? '1' : '0';
                    lockBox.innerHTML = renderLockIconHtml(imageId, false, 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 isUsedBool = Boolean(isUsed === true || isUsed === 'true' || isUsed === 1 || isUsed === '1');
        const isProtectedBool = 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 protectMenuBtn = document.getElementById('contextMenuProtectBtn');
        const deleteMenuBtn = document.getElementById('contextMenuDeleteBtn');
        const deleteDivider = document.getElementById('contextMenuDeleteDivider');

        if (isUsedBool) {
            if (protectMenuBtn) {
                protectMenuBtn.classList.add('context-item-disabled');
                protectMenuBtn.innerHTML = '<i class="fa-solid fa-lock text-secondary me-2"></i> 보호 잠금 (상시 보호됨)';
                protectMenuBtn.setAttribute('title', '실사용 중인 이미지는 상시 보호되므로 변경할 수 없습니다.');
            }
            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.removeAttribute('title');
                if (isProtectedBool) {
                    protectMenuBtn.innerHTML = '<i class="fa-solid fa-lock-open text-warning me-2"></i> 보호 잠금 해제';
                } else {
                    protectMenuBtn.innerHTML = '<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.setProperty('display', 'block', 'important');
                if (deleteDivider) deleteDivider.style.setProperty('display', 'block', 'important');
            }
        }

        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`;
    }

    function closeContextMenu() {
        const menu = document.getElementById('mediaFileContextMenu');
        if (menu) menu.style.display = 'none';
    }

    function contextMenuAction(action) {
        if (!contextMenuTargetImage) {
            closeContextMenu();
            return;
        }

        const img = contextMenuTargetImage;
        closeContextMenu();

        switch (action) {
            case 'select':
                confirmMediaSelection();
                break;
            case 'view':
                window.open(`/storage/${img.path}`, '_blank');
                break;
            case 'rename':
                inlineRenameMediaImage(img.id, img.name);
                break;
            case 'protect':
                if (img.isUsed) return;
                toggleImageProtection(img.id);
                break;
            case 'delete':
                if (img.isUsed || img.isProtected) return;
                deleteSingleImageViaContextMenu(img.id, img.isUsed, img.isProtected);
                break;
        }
    }

    function deleteSingleImageViaContextMenu(imageId, isUsed, isProtected) {
        if (isUsed) {
            alert('실제 서비스에서 사용 중인 이미지는 삭제할 수 없습니다.');
            return;
        }
        if (isProtected) {
            alert('보호 잠금(🔒)이 설정된 이미지입니다. 잠금을 해제한 후 삭제해 주세요.');
            return;
        }

        if (!confirm('이 임시 이미지를 영구 삭제하시겠습니까?')) return;

        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
        fetch(`/admin/images/${imageId}`, {
            method: 'DELETE',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': csrfToken,
                'X-Requested-With': 'XMLHttpRequest'
            },
            body: JSON.stringify({ current_folder_id: currentMediaFolderId })
        })
        .then(res => res.json())
        .then(data => {
            if (data.success) {
                selectedMediaImagesMap.delete(imageId);
                fetchMediaLibraryImages(true);
                fetchMediaFolders();
            } else {
                alert(data.message || '삭제할 수 없습니다.');
            }
        })
        .catch(err => console.error('Delete Error:', err));
    }

    function initMarqueeSelection() {
        const container = document.getElementById('mediaImageContainer');
        if (!container) return;

        marqueeBoxEl = document.getElementById('media-marquee-box');
        if (!marqueeBoxEl) {
            marqueeBoxEl = document.createElement('div');
            marqueeBoxEl.id = 'media-marquee-box';
            container.appendChild(marqueeBoxEl);
        }

        container.addEventListener('mousedown', function (e) {
            closeContextMenu();

            if (currentMediaMode === 'single') return;
            if (e.target.closest('.media-item-card')) return;
            if (e.target.closest('button') || e.target.closest('input') || e.target.closest('a')) return;
            if (e.button !== 0) return;

            isMarqueeSelecting = true;

            const containerRect = container.getBoundingClientRect();
            marqueeStartX = e.clientX - containerRect.left + container.scrollLeft;
            marqueeStartY = e.clientY - containerRect.top + container.scrollTop;

            marqueeBoxEl.style.left = `${marqueeStartX}px`;
            marqueeBoxEl.style.top = `${marqueeStartY}px`;
            marqueeBoxEl.style.width = '0px';
            marqueeBoxEl.style.height = '0px';
            marqueeBoxEl.style.display = 'block';

            if (!e.ctrlKey && !e.shiftKey && !e.metaKey) {
                selectedMediaImagesMap.clear();
                updateCardVisuals();
            }

            e.preventDefault();
        });

        window.addEventListener('mousemove', function (e) {
            if (!isMarqueeSelecting || !marqueeBoxEl) return;

            const containerRect = container.getBoundingClientRect();
            const currentX = e.clientX - containerRect.left + container.scrollLeft;
            const currentY = e.clientY - containerRect.top + container.scrollTop;

            const boxLeft = Math.min(marqueeStartX, currentX);
            const boxTop = Math.min(marqueeStartY, currentY);
            const boxWidth = Math.abs(currentX - marqueeStartX);
            const boxHeight = Math.abs(currentY - marqueeStartY);

            marqueeBoxEl.style.left = `${boxLeft}px`;
            marqueeBoxEl.style.top = `${boxTop}px`;
            marqueeBoxEl.style.width = `${boxWidth}px`;
            marqueeBoxEl.style.height = `${boxHeight}px`;

            const boxRight = boxLeft + boxWidth;
            const boxBottom = boxTop + boxHeight;

            const cards = container.querySelectorAll('.media-item-card');
            cards.forEach(card => {
                const cardLeft = card.offsetLeft;
                const cardTop = card.offsetTop;
                const cardRight = cardLeft + card.offsetWidth;
                const cardBottom = cardTop + card.offsetHeight;

                const isOverlap = (
                    boxLeft < cardRight &&
                    boxRight > cardLeft &&
                    boxTop < cardBottom &&
                    boxBottom > cardTop
                );

                const cardId = parseInt(card.dataset.id, 10);
                const path = card.dataset.path;
                const name = card.dataset.name;

                if (isOverlap) {
                    selectedMediaImagesMap.set(cardId, { id: cardId, path, name });
                }
            });

            updateCardVisuals();
        });

        window.addEventListener('mouseup', function () {
            if (isMarqueeSelecting) {
                isMarqueeSelecting = false;
                if (marqueeBoxEl) marqueeBoxEl.style.display = 'none';
            }
        });
    }

    function handleCardDragStart(event, draggedImageId) {
        closeContextMenu();

        let idsToSend = [];
        const currentId = parseInt(draggedImageId, 10);

        if (selectedMediaImagesMap.has(currentId)) {
            idsToSend = Array.from(selectedMediaImagesMap.keys());
        } else {
            if (currentMediaMode === 'single') {
                selectedMediaImagesMap.clear();
            }
            const card = document.getElementById(`media-card-${currentId}`);
            if (card) {
                selectedMediaImagesMap.set(currentId, {
                    id: currentId,
                    path: card.dataset.path,
                    name: card.dataset.name
                });
                updateCardVisuals();
            }
            idsToSend = [currentId];
        }

        if (idsToSend.length > 1) {
            const ghost = document.createElement('div');
            ghost.style.position = 'absolute';
            ghost.style.top = '-9999px';
            ghost.style.padding = '6px 12px';
            ghost.style.backgroundColor = '#0d6efd';
            ghost.style.color = '#ffffff';
            ghost.style.borderRadius = '20px';
            ghost.style.fontWeight = 'bold';
            ghost.style.fontSize = '12px';
            ghost.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
            ghost.innerText = `📁 ${idsToSend.length}개 파일 이동`;
            document.body.appendChild(ghost);
            event.dataTransfer.setDragImage(ghost, 10, 10);
            setTimeout(() => ghost.remove(), 0);
        }

        event.dataTransfer.setData('application/json', JSON.stringify(idsToSend));
        event.dataTransfer.setData('text/plain', idsToSend.join(','));
        event.dataTransfer.effectAllowed = 'move';
    }

    function handleCardClick(id, path, name, event) {
        closeContextMenu();

        if (event.target.closest('input') || event.target.closest('button')) return;
        id = parseInt(id, 10);

        if (currentMediaMode === 'single') {
            selectedMediaImagesMap.clear();
            selectedMediaImagesMap.set(id, { id, path, name });
        } else {
            if (event.ctrlKey || event.metaKey) {
                if (selectedMediaImagesMap.has(id)) {
                    selectedMediaImagesMap.delete(id);
                } else {
                    selectedMediaImagesMap.set(id, { id, path, name });
                }
            } else {
                selectedMediaImagesMap.clear();
                selectedMediaImagesMap.set(id, { id, path, name });
            }
        }

        updateCardVisuals();
    }

    function toggleCheckboxSelection(id, path, name, checkbox) {
        id = parseInt(id, 10);

        if (currentMediaMode === 'single') {
            selectedMediaImagesMap.clear();
            if (checkbox.checked) selectedMediaImagesMap.set(id, { id, path, name });
        } else {
            if (checkbox.checked) {
                selectedMediaImagesMap.set(id, { id, path, name });
            } else {
                selectedMediaImagesMap.delete(id);
            }
        }

        updateCardVisuals();
    }

    function updateCardVisuals() {
        const selectedIds = new Set(selectedMediaImagesMap.keys());

        document.querySelectorAll('#mediaImageContainer .media-item-card').forEach(card => {
            const checkbox = card.querySelector('.media-checkbox');
            const cardId = parseInt(card.dataset.id, 10);
            const isSelected = selectedIds.has(cardId);

            if (checkbox) checkbox.checked = isSelected;
            card.classList.toggle('is-selected', isSelected);
        });

        const countText = document.getElementById('mediaSelectedCountText');
        if (countText) {
            countText.innerText = `선택된 이미지: ${selectedMediaImagesMap.size}개`;
        }

        const masterCheckbox = document.getElementById('mediaMasterCheckbox');
        if (masterCheckbox && currentMediaMode !== 'single') {
            const total = document.querySelectorAll('#mediaImageContainer .media-item-card').length;
            masterCheckbox.checked = (total > 0 && selectedMediaImagesMap.size >= total);
        }
    }

    function doubleClickMediaImage(id, path, name, event) {
        closeContextMenu();

        if (event) {
            event.stopPropagation();
            event.preventDefault();
        }
        id = parseInt(id, 10);

        selectedMediaImagesMap.clear();
        selectedMediaImagesMap.set(id, { id, path, name });

        confirmMediaSelection();
    }

    function toggleSelectAllMedia(masterCheckbox) {
        if (currentMediaMode === 'single') return;

        if (masterCheckbox.checked) {
            selectAllMediaCardsNow();
        } else {
            selectedMediaImagesMap.clear();
            updateCardVisuals();
        }
    }

    function selectAllMediaCardsNow() {
        const cards = document.querySelectorAll('#mediaImageContainer .media-item-card');
        if (cards.length === 0) return;

        cards.forEach(card => {
            const id = parseInt(card.dataset.id, 10);
            const path = card.dataset.path;
            const name = card.dataset.name;
            selectedMediaImagesMap.set(id, { id, path, name });
        });

        updateCardVisuals();
    }

    function inlineRenameMediaImage(id, currentName) {
        const container = document.getElementById(`name-container-${id}`);
        if (!container || container.querySelector('input')) return;

        container.innerHTML = `
            <div class="input-group input-group-sm">
                <input type="text" class="form-control form-control-sm px-1 py-0 text-center border-secondary" style="font-size: 0.7rem;" id="inline-input-${id}" value="${currentName}">
                <button class="btn btn-outline-secondary btn-sm px-1 py-0 text-secondary" type="button" onclick="submitInlineRename(${id}, event)" style="font-size: 0.65rem;">확인</button>
            </div>
        `;

        const input = document.getElementById(`inline-input-${id}`);
        if (!input) return;

        input.focus();
        input.select();

        let isProcessing = false;

        input.addEventListener('blur', function (e) {
            setTimeout(() => {
                if (!isProcessing && document.activeElement !== input) {
                    isProcessing = true;
                    submitInlineRename(id, e);
                }
            }, 200);
        });

        input.addEventListener('keydown', function (e) {
            if (e.key === 'Enter') {
                e.preventDefault();
                if (!isProcessing) {
                    isProcessing = true;
                    submitInlineRename(id, e);
                }
            } else if (e.key === 'Escape') {
                isProcessing = true;
                fetchMediaLibraryImages(true);
            }
        });
    }

    function submitInlineRename(id, e) {
        if (e && typeof e.stopPropagation === 'function') e.stopPropagation();

        const input = document.getElementById(`inline-input-${id}`);
        if (!input) return;

        const newName = input.value.trim();
        if (!newName) {
            fetchMediaLibraryImages(true);
            return;
        }

        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
        fetch(`/admin/images/${id}/rename`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': csrfToken,
                'X-Requested-With': 'XMLHttpRequest'
            },
            body: JSON.stringify({ name: newName })
        })
        .then(res => res.json())
        .then(data => {
            if (data.success) {
                fetchMediaLibraryImages(true);
            } else {
                alert(data.message || '파일명 변경 실패');
                fetchMediaLibraryImages(true);
            }
        })
        .catch(err => {
            console.error('Rename Error:', err);
            fetchMediaLibraryImages(true);
        });
    }

    function confirmMediaSelection() {
        if (selectedMediaImagesMap.size === 0) {
            alert('선택된 이미지가 없습니다.');
            return;
        }

        const imagesArray = Array.from(selectedMediaImagesMap.values());

        if (selectedMediaCallback && typeof selectedMediaCallback === 'function') {
            selectedMediaCallback(imagesArray, currentMediaType);
            if (globeauMediaModal) globeauMediaModal.hide();
            return;
        }

        if (currentMediaMode === 'single') {
            const imgObj = imagesArray[0];
            if (!imgObj) return;

            const hiddenInput = document.getElementById(`${currentMediaType}_id`) 
                             || document.getElementById(`${currentMediaType}_image_id`) 
                             || document.querySelector(`input[name="${currentMediaType}_id"]`)
                             || document.querySelector(`input[name="${currentMediaType}_image_id"]`);

            const previewBox = document.getElementById(`${currentMediaType}-preview`)
                            || document.querySelector(`.${currentMediaType}-preview`);

            if (hiddenInput) {
                hiddenInput.value = imgObj.id;
                hiddenInput.dispatchEvent(new Event('change'));
            }
            if (previewBox) {
                previewBox.innerHTML = `<img src="/storage/${imgObj.path}" class="w-100 h-100 object-fit-cover rounded">`;
            }
        } else {
            const previewContainer = document.getElementById(`${currentMediaType}-gallery-preview`) 
                                  || document.querySelector(`.${currentMediaType}-gallery-preview`);
            let hiddenInputContainer = document.getElementById(`${currentMediaType}-hidden-inputs`) 
                                    || document.querySelector(`.${currentMediaType}-hidden-inputs`);

            if (previewContainer) {
                if (!hiddenInputContainer) {
                    const form = previewContainer.closest('form') || document.body;
                    hiddenInputContainer = document.createElement('div');
                    hiddenInputContainer.id = `${currentMediaType}-hidden-inputs`;
                    form.appendChild(hiddenInputContainer);
                }

                imagesArray.forEach(img => {
                    appendMultiPreviewCard(img, previewContainer, hiddenInputContainer, currentMediaType);
                });
            }
        }

        if (globeauMediaModal) globeauMediaModal.hide();
    }

    function appendMultiPreviewCard(img, container, hiddenContainer, type) {
        const exists = container.querySelector(`[data-id="${img.id}"]`) 
                    || (hiddenContainer && hiddenContainer.querySelector(`input[value="${img.id}"]`));
        if (exists) return;

        const cleanPath = img.path.replace(/^\/?storage\//, '').replace(/^https?:\/\/[^\/]+\/storage\//, '');
        const imgName = img.name || img.original_name || '이미지';

        const col = document.createElement('div');
        col.className = `col-6 col-md-3 col-xxl-2 text-center position-relative multi-preview-item ${type}-preview-col mb-2`;
        col.dataset.id = img.id;
        col.innerHTML = `
            <div class="card border shadow-sm h-100 position-relative bg-transparent common-rounded-card overflow-hidden p-0">
                <button type="button" class="position-absolute top-0 end-0 btn btn-dark btn-sm p-0 d-flex align-items-center justify-content-center shadow-sm" 
                        style="width: 20px; height: 20px; border-radius: 50%; z-index: 5; margin-top: 5px; margin-right: 5px; font-size: 0.65rem;"
                        onclick="removeMultiMediaItem(this, ${img.id}, '${type}')" title="이미지 제거">
                    <i class="fa-solid fa-xmark"></i>
                </button>
                <div class="card-img-wrapper position-relative d-flex p-1 align-items-center justify-content-center overflow-hidden bg-transparent" 
                     style="aspect-ratio: 1 / 1; width: 100%;">
                    <img src="/storage/${cleanPath}" alt="${imgName}" style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;">
                </div>
                <div class="card-body p-2 bg-transparent d-flex flex-column gap-1">
                    <div class="input-group input-group-sm">
                        <span class="input-group-text bg-transparent small px-1 text-muted" style="font-size: 0.75rem;">순서</span>
                        <input type="number" name="image_sorts[${img.id}]" value="0" class="form-control text-center text-dark fw-bold px-1 py-0 bg-transparent common-rounded-sm" min="0">
                    </div>
                    <small class="text-dark text-truncate d-block fw-semibold px-1" style="font-size: 0.7rem;" title="${imgName}">${imgName}</small>
                </div>
            </div>
        `;

        container.appendChild(col);

        if (hiddenContainer) {
            const hiddenInput = document.createElement('input');
            hiddenInput.type = 'hidden';
            hiddenInput.name = `${type}_image_ids[]`;
            hiddenInput.value = img.id;
            hiddenInput.dataset.id = img.id;
            hiddenContainer.appendChild(hiddenInput);
        }
    }

    function removeMultiMediaItem(buttonElement, imageId, type) {
        const previewCol = buttonElement.closest(`.${type}-preview-col`) || buttonElement.closest('.multi-preview-item');
        if (previewCol) previewCol.remove();

        document.querySelectorAll(`input[name="${type}_image_ids[]"][value="${imageId}"]`).forEach(input => {
            input.remove();
        });
    }

    document.addEventListener('keydown', function (e) {
        const isCtrlA = (e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A' || e.keyCode === 65);
        if (!isCtrlA) return;

        const modalElement = document.getElementById('globeauMediaModal');
        if (!modalElement || !modalElement.classList.contains('show')) return;

        const activeEl = document.activeElement;
        if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) {
            if (activeEl.id === 'mediaKeywordSearch' || activeEl.id === 'newFolderNameInput') return;
        }

        e.preventDefault();
        e.stopPropagation();

        if (currentMediaMode === 'single') return;

        selectAllMediaCardsNow();
    }, true);

---

### 다. 백엔드 이미지 컨트롤러: `app/Http/Controllers/Admin/ImageController.php`

    namespace App\Http\Controllers\Admin;

    use App\Http\Controllers\Controller;
    use Illuminate\Http\Request;
    use App\Models\Image;
    use App\Services\ImageService;
    use Illuminate\Support\Facades\Storage;
    use Illuminate\Support\Facades\DB;

    class ImageController extends Controller
    {
        protected ImageService $imageService;

        public function __construct(ImageService $imageService)
        {
            $this->imageService = $imageService;
        }

        public function index(Request $request)
        {
            try {
                $query = Image::query();

                if ($request->filled('keyword')) {
                    $keyword = trim($request->input('keyword'));
                    $query->where('original_name', 'like', "%{$keyword}%");
                }

                if ($request->filled('folder_id')) {
                    $folderId = $request->input('folder_id');
                    if ($folderId === 'orphans') {
                        try {
                            $usedIds = DB::table('imageables')->pluck('image_id')->toArray();
                            $query->whereNotIn('id', $usedIds);
                        } catch (\Throwable $th) {}
                    } else {
                        $query->where('folder_id', $folderId);
                    }
                }

                $sortBy = $request->input('sort_by', 'date');
                $sortDirection = strtolower($request->input('sort_direction', 'desc')) === 'asc' ? 'asc' : 'desc';

                if ($sortBy === 'name') {
                    $query->orderBy('original_name', $sortDirection);
                } elseif ($sortBy === 'size') {
                    $query->orderBy('size', $sortDirection);
                } elseif ($sortBy === 'extension' || $sortBy === 'type') {
                    $query->orderByRaw("SUBSTRING_INDEX(original_name, '.', -1) {$sortDirection}");
                } else {
                    $query->orderBy('created_at', $sortDirection);
                }

                $perPage = (int)$request->input('per_page', 50);
                $paginator = $query->paginate($perPage);

                $pageIds = $paginator->pluck('id')->toArray();
                $usedIdMap = [];
                if (!empty($pageIds)) {
                    try {
                        $usedIdMap = DB::table('imageables')
                            ->whereIn('image_id', $pageIds)
                            ->pluck('image_id')
                            ->flip()
                            ->toArray();
                    } catch (\Throwable $th) {
                        $usedIdMap = [];
                    }
                }

                $transformedItems = [];
                foreach ($paginator->items() as $item) {
                    $rawSize = $item->size ?? $item->file_size ?? 0;
                    $sizeText = '-';
                    if (!empty($rawSize) && is_numeric($rawSize) && (float)$rawSize > 0) {
                        $bytes = (float)$rawSize;
                        $units = ['B', 'KB', 'MB', 'GB'];
                        $pow = min((int)floor(log($bytes, 1024)), count($units) - 1);
                        $pow = max(0, $pow);
                        $sizeText = round($bytes / pow(1024, $pow), 1) . ' ' . $units[$pow];
                    }

                    $transformedItems[] = [
                        'id'             => (int)$item->id,
                        'path'           => (string)$item->path,
                        'original_name'  => (string)($item->original_name ?? 'image'),
                        'size'           => (int)$rawSize,
                        'formatted_size' => $sizeText,
                        'formatted_date' => $item->created_at ? $item->created_at->format('Y-m-d H:i') : '-',
                        'extension'      => strtoupper(pathinfo($item->original_name ?? $item->path ?? '', PATHINFO_EXTENSION) ?: 'IMG'),
                        'is_used'        => isset($usedIdMap[$item->id]),
                        'is_protected'   => (bool)($item->is_protected ?? false),
                    ];
                }

                if ($request->ajax() || $request->wantsJson()) {
                    return response()->json([
                        'success' => true,
                        'images'  => [
                            'current_page' => $paginator->currentPage(),
                            'data'         => $transformedItems,
                            'last_page'    => $paginator->lastPage(),
                            'total'        => $paginator->total(),
                            'per_page'     => $paginator->perPage(),
                        ]
                    ], 200);
                }

                return view('admin.images.index', compact('paginator'));

            } catch (\Throwable $e) {
                return response()->json([
                    'success' => false,
                    'message' => '서버 내부 오류: ' . $e->getMessage(),
                    'images'  => ['data' => []]
                ], 200);
            }
        }

        public function toggleProtection($id)
        {
            try {
                $image = Image::findOrFail($id);

                $isUsed = false;
                try {
                    $isUsed = DB::table('imageables')->where('image_id', $id)->exists();
                } catch (\Throwable $th) {}

                if ($isUsed) {
                    return response()->json([
                        'success' => false,
                        'message' => '실제 서비스에서 사용 중인 이미지는 상시 보호되며 잠금을 변경할 수 없습니다.'
                    ], 422);
                }

                $image->is_protected = !($image->is_protected ?? false);
                $image->save();

                return response()->json([
                    'success'      => true,
                    'is_protected' => (bool)$image->is_protected,
                    'message'      => $image->is_protected ? '보호 잠금이 설정되었습니다.' : '보호 잠금이 해제되었습니다.'
                ]);
            } catch (\Throwable $e) {
                return response()->json(['success' => false, 'message' => $e->getMessage()], 200);
            }
        }

        public function destroy(Request $request, $id)
        {
            try {
                $image = Image::findOrFail($id);

                $isUsed = false;
                try {
                    $isUsed = DB::table('imageables')->where('image_id', $id)->exists();
                } catch (\Throwable $th) {}

                if ($isUsed) {
                    return response()->json([
                        'success' => false,
                        'message' => '실제 서비스에서 사용 중인 이미지는 삭제할 수 없습니다.'
                    ], 422);
                }

                if (!empty($image->is_protected)) {
                    return response()->json([
                        'success' => false,
                        'message' => '보호 잠금(🔒) 상태의 파일입니다. 잠금을 해제한 후 삭제해 주세요.'
                    ], 422);
                }

                $disk = $image->disk ?? 'public';
                if (!empty($image->path) && Storage::disk($disk)->exists($image->path)) {
                    Storage::disk($disk)->delete($image->path);
                }

                $image->delete();

                return response()->json([
                    'success' => true,
                    'message' => '이미지가 안전하게 삭제되었습니다.'
                ]);
            } catch (\Throwable $e) {
                return response()->json(['success' => false, 'message' => $e->getMessage()], 200);
            }
        }

        public function store(Request $request)
        {
            $request->validate([
                'image' => 'required|image|mimes:jpeg,png,jpg,webp,gif|max:10240',
            ]);

            $file = $request->file('image');
            $originalName = $file->getClientOriginalName();

            if ($this->hasInvalidFileName($originalName)) {
                return response()->json([
                    'success' => false,
                    'message' => '사용할 수 없는 특수문자나 예약어가 포함된 파일명입니다.'
                ], 422);
            }

            $type = $request->input('type', 'general');
            $folderId = $request->input('folder_id');

            $result = $this->imageService->uploadToTemp($file, $type);
            $image = $result['image'];

            if ($folderId && $folderId !== 'orphans') {
                $image->update(['folder_id' => $folderId]);
            }

            return response()->json([
                'success' => true,
                'message' => $result['is_shared'] 
                    ? '이미 보관함에 존재하는 동일한 이미지입니다. 기존 파일을 재사용합니다.' 
                    : '이미지가 성공적으로 업로드되었습니다.',
                'data'    => $image
            ]);
        }

        public function rename(Request $request, $id)
        {
            $request->validate([
                'name' => 'required|string|max:255',
            ]);

            $newName = trim($request->input('name'));
            $image = Image::findOrFail($id);

            if ($this->hasInvalidFileName($newName)) {
                return response()->json([
                    'success' => false,
                    'message' => '사용할 수 없는 특수문자나 예약어가 포함된 파일명입니다.'
                ], 422);
            }

            $image->original_name = $newName;
            $image->save();

            return response()->json([
                'success' => true,
                'message' => '파일명이 변경되었습니다.'
            ]);
        }

        public function bulkDelete(Request $request)
        {
            $request->validate([
                'ids'   => 'required|array',
                'ids.*' => 'exists:images,id'
            ]);

            $images = Image::whereIn('id', $request->input('ids'))->get();
            $deletedCount = 0;
            $skippedCount = 0;

            foreach ($images as $image) {
                $isUsed = false;
                try {
                    $isUsed = DB::table('imageables')->where('image_id', $image->id)->exists();
                } catch (\Throwable $th) {}

                if ($isUsed || !empty($image->is_protected)) {
                    $skippedCount++;
                    continue;
                }

                $disk = $image->disk ?? 'public';
                if (Storage::disk($disk)->exists($image->path)) {
                    Storage::disk($disk)->delete($image->path);
                }

                $image->delete();
                $deletedCount++;
            }

            return response()->json([
                'success' => true,
                'message' => "{$deletedCount}개의 파일이 삭제되었습니다." . ($skippedCount > 0 ? " ({$skippedCount}개 보호 파일 제외)" : "")
            ]);
        }

        public function orphans(Request $request)
        {
            try {
                $usedIds = DB::table('imageables')->pluck('image_id')->toArray();
                $orphanImages = Image::whereNotIn('id', $usedIds)->latest()->paginate(30);
            } catch (\Throwable $th) {
                $orphanImages = Image::latest()->paginate(30);
            }
            return view('admin.images.orphans', compact('orphanImages'));
        }

        public function destroyOrphans(Request $request)
        {
            $request->validate([
                'ids'   => 'required|array',
                'ids.*' => 'exists:images,id'
            ]);

            $deletedCount = $this->imageService->deleteOrphans($request->input('ids'));

            return response()->json([
                'success' => true,
                'message' => "{$deletedCount}개의 고아 이미지가 완전히 삭제되었습니다."
            ]);
        }

        private function hasInvalidFileName(string $filename): bool
        {
            if (trim($filename) === '') return true;

            if (preg_match('/[\\/:*?"<>|#]/', $filename)) {
                return true;
            }

            $reservedNames = [
                'CON', 'PRN', 'AUX', 'NUL',
                'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
                'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
            ];
            $nameWithoutExt = strtoupper(pathinfo($filename, PATHINFO_FILENAME));
            return in_array($nameWithoutExt, $reservedNames);
        }
    }

---

### 라. 백엔드 폴더 컨트롤러: `app/Http/Controllers/Admin/ImageFolderController.php`

    namespace App\Http\Controllers\Admin;

    use App\Http\Controllers\Controller;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\DB;
    use Illuminate\Support\Facades\Schema;

    class ImageFolderController extends Controller
    {
        public function index()
        {
            try {
                if (!Schema::hasTable('image_folders')) {
                    return response()->json(['success' => true, 'folders' => []]);
                }

                $hasParentCol = Schema::hasColumn('image_folders', 'parent_id');
                $hasPivot = Schema::hasTable('image_image_folder');$hasFolderIdCol = Schema::hasColumn('images', 'folder_id');

                $rawFolders = DB::table('image_folders')->orderBy('name', 'asc')->get();

                $countMap = [];
                if ($hasPivot) {$countMap = DB::table('image_image_folder')
                        ->select('image_folder_id', DB::raw('count(*) as aggregate'))
                        ->groupBy('image_folder_id')
                        ->pluck('aggregate', 'image_folder_id')
                        ->toArray();
                } elseif ($hasFolderIdCol) {$countMap = DB::table('images')
                        ->whereNotNull('folder_id')
                        ->select('folder_id', DB::raw('count(*) as aggregate'))
                        ->groupBy('folder_id')
                        ->pluck('aggregate', 'folder_id')
                        ->toArray();
                }

                $folderMap = [];
                foreach ($rawFolders as$f) {
                    $folderMap[$f->id] = [
                        'id'           => (int)$f->id,
                        'parent_id'    => $hasParentCol ? ($f->parent_id ? (int)$f->parent_id : null) : null,
                        'name'         => (string)$f->name,
                        'images_count' => (int)($countMap[$f->id] ?? 0),
                        'has_children' => false,
                        'depth'        => 0,
                    ];
                }

                $childrenMap = [];
                foreach ($folderMap as $id =>$item) {
                    $pid =$item['parent_id'] ?? 0;
                    $childrenMap[$pid][] =$id;
                    if ($pid && isset($folderMap[$pid])) {
                        $folderMap[$pid]['has_children'] = true;
                    }
                }

                $flatTree = [];$traverse = function ($parentId,$depth) use (&$traverse, &$flatTree, $childrenMap,$folderMap) {
                    if (!isset($childrenMap[$parentId])) return;
                    foreach ($childrenMap[$parentId] as $childId) {$node = $folderMap[$childId];
                        $node['depth'] =$depth;
                        $flatTree[] =$node;
                        $traverse($childId, $depth + 1);
                    }
                };$traverse(0, 0);

                if (empty($flatTree) && !empty($folderMap)) {
                    $flatTree = array_values($folderMap);
                }

                return response()->json([
                    'success' => true,
                    'folders' => $flatTree
                ]);

            } catch (\Throwable $e) {
                \Log::error('ImageFolder index error: ' .$e->getMessage());
                return response()->json([
                    'success' => false,
                    'message' => '폴더 목록 조회 중 오류: ' . $e->getMessage(),
                    'folders' => []
                ], 200);
            }
        }

        public function store(Request $request)
        {
            $request->validate([
                'name'      => 'required|string|max:50',
                'parent_id' => 'nullable|integer',
            ]);

            $name = trim($request->input('name'));$parentId = $request->input('parent_id') ? (int)$request->input('parent_id') : null;

            try {
                $hasParentCol = Schema::hasColumn('image_folders', 'parent_id');

                $query = DB::table('image_folders')->where('name',$name);
                if ($hasParentCol) {
                    $query->where('parent_id',$parentId);
                }
                if ($query->exists()) {
                    return response()->json([
                        'success' => false,
                        'message' => '동일한 위치에 같은 이름의 폴더가 이미 존재합니다.'
                    ], 422);
                }

                $data = [
                    'name'       => $name,
                    'created_at' => now(),
                    'updated_at' => now(),
                ];
                if ($hasParentCol) {
                    $data['parent_id'] =$parentId;
                }

                $id = DB::table('image_folders')->insertGetId($data);

                return response()->json([
                    'success' => true,
                    'message' => '폴더가 생성되었습니다.',
                    'folder'  => [
                        'id'           => $id,
                        'name'         => $name,
                        'parent_id'    => $parentId,
                        'images_count' => 0
                    ]
                ]);

            } catch (\Throwable $e) {
                return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
            }
        }

        public function destroy($id)
        {
            try {
                $folder = DB::table('image_folders')->where('id',$id)->first();
                if (!$folder) {
                    return response()->json(['success' => false, 'message' => '존재하지 않는 폴더입니다.'], 404);
                }

                $hasParentCol = Schema::hasColumn('image_folders', 'parent_id');

                if ($hasParentCol) {
                    $childFolderCount = DB::table('image_folders')->where('parent_id',$id)->count();
                    if ($childFolderCount > 0) {
                        return response()->json([
                            'success' => false,
                            'message' => "하위 폴더({$childFolderCount}개)가 존재하므로 삭제할 수 없습니다. 하위 폴더를 먼저 정리해 주세요."
                        ], 422);
                    }
                }

                $allDescendantFolderIds = [$id];
                if ($hasParentCol) {$allDescendantFolderIds = $this->getAllDescendantFolderIds((int)$id);
                }

                $totalImages = 0;
                if (Schema::hasTable('image_image_folder')) {
                    $totalImages = DB::table('image_image_folder')
                        ->whereIn('image_folder_id', $allDescendantFolderIds)
                        ->count();
                } elseif (Schema::hasColumn('images', 'folder_id')) {
                    $totalImages = DB::table('images')
                        ->whereIn('folder_id', $allDescendantFolderIds)
                        ->count();
                }

                if ($totalImages > 0) {
                    return response()->json([
                        'success' => false,
                        'message' => "폴더 또는 하위 폴더에 {$totalImages}개의 이미지가 보관되어 있습니다. 파일을 모두 비운 후 삭제해 주세요."
                    ], 422);
                }

                DB::table('image_folders')->where('id', $id)->delete();

                return response()->json([
                    'success' => true,
                    'message' => '폴더가 안전하게 삭제되었습니다.'
                ]);

            } catch (\Throwable $e) {
                return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
            }
        }

        private function getAllDescendantFolderIds(int $folderId): array
        {
            $ids = [$folderId];
            $children = DB::table('image_folders')->where('parent_id',$folderId)->pluck('id')->toArray();
            foreach ($children as$cid) {
                $ids = array_merge($ids, $this->getAllDescendantFolderIds((int)$cid));
            }
            return array_unique($ids);
        }

        public function moveFiles(Request $request)
        {
            $request->validate([
                'image_ids'        => 'required|array',
                'target_folder_id' => 'required',
            ]);

            $imageIds =$request->input('image_ids');
            $targetFolderId =$request->input('target_folder_id');

            try {
                if (Schema::hasTable('image_image_folder')) {
                    foreach ($imageIds as$imgId) {
                        DB::table('image_image_folder')->where('image_id', $imgId)->delete();
                        if ($targetFolderId !== 'null' && $targetFolderId !== null &&$targetFolderId !== 'orphans') {
                            DB::table('image_image_folder')->insert([
                                'image_id'        => $imgId,
                                'image_folder_id' => $targetFolderId,
                                'created_at'      => now(),
                                'updated_at'      => now(),
                            ]);
                        }
                    }
                } elseif (Schema::hasColumn('images', 'folder_id')) {
                    $newFolderId = ($targetFolderId === 'null' \vert{}\vert{}$targetFolderId === null || $targetFolderId === 'orphans') ? null : $targetFolderId;
                    DB::table('images')->whereIn('id', $imageIds)->update([
                        'folder_id' => $newFolderId
                    ]);
                }

                return response()->json([
                    'success' => true,
                    'message' => '파일이 성공적으로 이동되었습니다.'
                ]);

            } catch (\Throwable $e) {
                return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
            }
        }
    }