# 뷰티샵 미디어 라이브러리 및 탐색기 전면 고도화 종합 상세 보고서

## 1. 프로젝트 배경 및 핵심 설계 철학
* **원장님 친화적 UX 최우선**:
    * 뷰티샵 실무 환경에서는 바탕화면의 사진을 `로고.png`, `원장프로필.jpg`, `매장배너(1).png`처럼 한글, 공백, 괄호가 포함된 이름으로 올리는 것이 자연스럽습니다.
    * 시스템이 "파일명 중복"이나 "한글 불가"를 이유로 업로드를 막으면 심각한 CS 및 사용 포기로 이어지므로, **화면상 파일명은 100% 자유롭게 허용**하도록 정책을 수립했습니다.
* **백엔드 무결성 및 스토리지 절약 (바이너리 디듀플리케이션)**:
    * 파일명은 중복을 허용하되, 서버 디스크 낭비와 중복 저장을 원천 방지하기 위해 파일 실제 내용의 `SHA-256` 해시를 대조합니다.
    * 완전히 동일한 이미지인 경우 업로드를 차단하고 기존 레코드를 재사용하도록 설계했습니다.
* **프론트엔드 DOM 반응형 탐색기**:
    * 등록 폼의 현재 상태(히든 인풋 및 미리보기 영역)를 실시간으로 스캔하여 이미 등록된 이미지는 모달 탐색기 목록에서 자동으로 제외(필터링)합니다.

---

## 2. 상세 작업 내역 및 단계별 문제 해결 과정

### [백엔드] ImageController.php 고도화
1. **`hasInvalidFileName` 헬퍼 메서드 배치 및 문법 오류 해결**:
    * 컨트롤러 클래스 최하단에 `private` 헬퍼 메서드로 배치하여 업로드(`store`)와 이름 변경(`rename`)에서 공통 호출하도록 구조화했습니다.
    * `generateUniqueFileName` 내부에서 변수 선언 시 달러 기호가 누락되었던 문법 오류(`info = ...`)를 `$info = ...`로 즉시 수정했습니다.
2. **파일명 중복 검사의 범위 논의 및 최종 정책 확정**:
    * 전체 DB 기준 검사 vs 폴더(`folder_id`)별 검사를 심도 있게 검토했습니다.
    * 최종적으로 '사용자 스트레스 제로'를 목표로 삼아 파일명 중복 검사를 전면 제거하고, 오직 물리적 파일 내용 해시(`SHA-256`)로만 중복을 제어하기로 결정했습니다.
3. **한글 파일명 및 특수문자 규칙 완화**:
    * 한글, 영문, 숫자, 공백, 언더바(`_`), 하이픈(`-`), 괄호(`()`), 점(`.`)을 전면 허용했습니다.
    * 서버 보안 및 디렉터리 탐색 공격을 유발하는 시스템 위험 문자(`/\:*?"<>|#`)와 윈도우 시스템 예약어(`CON`, `PRN`, `AUX`, `NUL`, `COM1~9`, `LPT1~9`)만 정규식으로 엄격히 차단했습니다.
4. **리눅스 OS 인코딩 충돌 방어**:
    * 업로드된 파일은 라라벨의 `$file->store('shops/uploads', 'public')`을 통해 서버 디스크에 랜덤 해시 문자열로 저장됩니다.
    * 따라서 한글 파일명으로 인한 리눅스 자모 분리 현상(NFC/NFD 충돌)이나 웹 브라우저 URL 인코딩 깨짐이 원천 차단됩니다.

### [프론트엔드] modal_media.js 고도화
1. **단일/다중 모드 기등록 이미지 실시간 필터링**:
    * 모달이 열릴 때(`fetchMediaLibraryImages`) 현재 폼에 존재하는 이미지 ID를 수집하여 모달 목록에서 동적으로 숨깁니다.
    * 싱글 모드에서는 대상 필드의 히든 인풋을 조회하고, 멀티 모드에서는 `marketing_image_ids[]` 인풋과 미리보기 카드의 `data-id`를 모두 스캔합니다.
2. **`og_image` 필드 연동 에러 해결**:
    * `currentMediaType`이 `og_image`일 때 규칙 조합상 `og_image_image_id`를 탐색하여 요소를 찾지 못하던 결함을 발견했습니다.
    * `${currentMediaType}_id`, `${currentMediaType}_image_id`, `input[name="..."]` 등 다중 셀렉터로 확장하여 `og_image_id` 필드를 안정적으로 찾아내도록 수정했습니다.
3. **멀티 갤러리 이미지 초기화 결함 박멸**:
    * 모달에서 이미지를 추가 선택할 때 기존에 등록해 둔 본문 썸네일들이 통째로 사라지던 현상을 분석했습니다.
    * 원인은 `confirmMediaSelection` 내부의 재렌더링 시도와 `handleMarketingImagesCallback` 내부에 남아 있던 `previewContainer.innerHTML = ''` 때문이었습니다.
    * 기존 컨테이너를 절대 비우지 않고, 새로운 이미지만 순수하게 맨 뒤(오른쪽)에 이어 붙이는 `appendMarketingPreviewCard` 헬퍼 함수를 구축하여 완벽한 누적 추가를 구현했습니다.
4. **미리보기 및 모달 카드 UI/UX 세밀 튜닝**:
    * **비율 유지**: `object-fit: cover`로 인해 상하좌우가 잘려 나가던 문제를 해결하기 위해 `max-width: 100%`, `max-height: 100%`, `object-fit: contain`을 적용했습니다.
    * **카드 테두리 및 패딩 제거**: 모달 카드와 본문 카드의 `border`를 모두 제거하고 외곽 패딩을 0으로 밀착시켜 단정한 덩어리감을 형성했습니다.
    * **배경 투명화 및 파일명 보더 제거**: 모달 카드의 흰색 배경을 투명(`transparent`)으로 전환하고 파일명 상단의 구분선을 삭제했습니다.
    * **미세 패딩과 약한 그림자**: 모달 카드에 가장 작은 패딩(`p-1`)을 부여하고 이미지 자체에 부드러운 드롭 섀도우(`filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.08))`)를 주어 시각적 입체감을 완성했습니다.

---

## 3. 백엔드 전체 소스 코드 (`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 Illuminate\Support\Facades\Storage;

    class ImageController extends Controller
    {
        /**
         * 이미지 목록 조회 (미디어 보관함 탐색기용 JSON 응답)
         */
        public function index(Request $request)
        {
            $query = Image::query();

            // 파일명 키워드 검색
            if ($request->filled('keyword')) {
                $query->where('original_name', 'like', '\%' .$request->input('keyword') . '%');
            }

            // 폴더 필터링
            if ($request->filled('folder_id')) {
                $query->where('folder_id',$request->input('folder_id'));
            }

            // 정렬
            $sort =$request->input('sort', 'latest');
            if ($sort === 'oldest') {$query->oldest();
            } else {
                $query->latest();
            }

            $images =$query->paginate(24);

            if ($request->ajax() \vert{}\vert{}$request->wantsJson()) {
                return response()->json([
                    'success' => true,
                    'images' => $images
                ]);
            }

            return view('admin.images.index', compact('images'));
        }

        /**
         * 신규 이미지 업로드 (파일명 중복 허용, SHA-256 바이너리 해시 중복 차단)
         */
        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();
            
            // 1. 보안 위험 특수문자 및 시스템 예약어 검증
            if ($this->hasInvalidFileName($originalName)) {
                return response()->json([
                    'success' => false,
                    'message' => '사용할 수 없는 특수문자나 예약어가 포함된 파일명입니다.'
                ], 422);
            }

            // 2. 바이너리 해시 계산을 통한 실물 중복 파일 차단
            $fileHash = hash_file('sha256',$file->getRealPath());
            $existingImage = Image::where('file_hash',$fileHash)->first();
            if ($existingImage) {
                return response()->json([
                    'success' => true,
                    'message' => '이미 보관함에 존재하는 동일한 이미지입니다. 기존 파일을 재사용합니다.',
                    'data' => $existingImage
                ]);
            }

            // 3. 스토리지 저장 (public 디스크 내 해시 난수 경로)
            $path =$file->store('shops/uploads', 'public');

            // 4. DB 레코드 생성 (파일명 중복 허용)
            $image = Image::create([
                'original_name' => $originalName,
                'path' => $path,
                'file_hash' => $fileHash,
                'size' => $file->getSize(),
                'folder_id' => $request->input('folder_id'),
            ]);

            return response()->json([
                'success' => true,
                'message' => '이미지가 성공적으로 업로드되었습니다.',
                '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();

            foreach ($images as$image) {
                if (Storage::disk('public')->exists($image->path)) {
                    Storage::disk('public')->delete($image->path);
                }
                $image->delete();
            }

            return response()->json([
                'success' => true,
                'message' => '선택한 이미지들이 삭제되었습니다.'
            ]);
        }

        /**
         * 파일명 보안 및 예약어 검증 헬퍼
         */
        private function hasInvalidFileName($filename)
        {
            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));
            if (in_array($nameWithoutExt,$reservedNames)) {
                return true;
            }

            return false;
        }

        /**
         * 유니크 파일명 생성 헬퍼 (참고용 유지)
         */
        private function generateUniqueFileName($fileName,$excludeId = null)
        {
            $info = pathinfo($fileName);$name = $info['filename'];$ext = isset($info['extension']) ? '.' .$info['extension'] : '';

            $counter = 1;
            $newFileName = "{$name}({$counter}){$ext}";

            $query = Image::where('original_name',$newFileName);
            if ($excludeId !== null) {
                $query->where('id', '!=',$excludeId);
            }

            while ($query->exists()) {$counter++;
                $newFileName = "{$name}({$counter}){$ext}";
                $query = Image::where('original_name',$newFileName);
                if ($excludeId !== null) {
                    $query->where('id', '!=',$excludeId);
                }
            }

            return $newFileName;
        }
    }

---

## 4. 프론트엔드 전체 소스 코드 (`public/js/modal_media.js`)

    /**
     * 중앙 집중형 미디어 라이브러리 탐색기 공용 스크립트 (종합 완성본)
     * 경로: public/js/modal_media.js
     */

    let globeauMediaModal = null;
    let currentMediaMode = 'single';
    let currentMediaType = 'general';
    let selectedMediaCallback = null;
    let selectedMediaImagesMap = new Set();
    let currentMediaFolderId = null;
    let currentMediaViewMode = 'large';
    let contextTargetMediaImage = 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();
                
                let searchInput = document.getElementById('mediaKeywordSearch');
                if (searchInput) searchInput.value = '';
                
                let container = document.getElementById('mediaImageContainer');
                if (container) container.innerHTML = '';
            });
        }

        document.addEventListener('click', function() {
            let menu = document.getElementById('mediaFileContextMenu');
            if (menu) menu.style.display = 'none';
        });

        if (!document.getElementById('globeau-media-custom-style')) {
            let style = document.createElement('style');
            style.id = 'globeau-media-custom-style';
            style.innerHTML = `
                .media-item-card {
                    background-color: transparent !important;
                    transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease !important;
                    cursor: default;
                    border-radius: 8px !important;
                    border: none !important;
                }
                .media-item-card:hover {
                    transform: translateY(-4px) !important;
                    background-color: #e2e8f0 !important;
                    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06) !important;
                }
                .media-thumb-box {
                    background-color: transparent !important;
                    border-radius: 4px !important;
                    cursor: pointer;
                }
                .media-thumbnail-img {
                    filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.08)) !important;
                    max-width: 100%;
                    max-height: 100%;
                    object-fit: contain;
                }
                .marketing-preview-item {
                    transition: transform 0.2s ease;
                }
                .marketing-preview-item:hover {
                    transform: translateY(-2px);
                }
            `;
            document.head.appendChild(style);
        }
    });

    function openMediaModal(type, mode, callback = null, uploadRoute = '', indexRoute = '') {
        currentMediaType = type;
        currentMediaMode = mode;
        selectedMediaCallback = (typeof callback === 'function') ? callback : null;
        selectedMediaImagesMap.clear();

        window.mediaUploadRoute = uploadRoute || window.defaultMediaUploadRoute || '/admin/images';
        window.mediaIndexRoute = indexRoute || window.defaultMediaIndexRoute || '/admin/images';

        if (globeauMediaModal) {
            globeauMediaModal.show();
            fetchMediaFolders();
            fetchMediaLibraryImages();
        } else {
            console.error('미디어 모달(#globeauMediaModal) 요소를 찾을 수 없습니다.');
        }
    }

    function fetchMediaFolders() {}

    function selectMediaFolder(folderId, element) {
        currentMediaFolderId = folderId;
        
        document.querySelectorAll('#mediaFolderTreeList .list-group-item').forEach(el => {
            el.classList.remove('active', 'shadow-sm');
            el.classList.add('bg-transparent');
        });
        if (element) {
            element.classList.add('active', 'shadow-sm');
            element.classList.remove('bg-transparent');
        }

        fetchMediaLibraryImages();
    }

    function changeMediaViewMode(mode) {
        currentMediaViewMode = mode;
        let container = document.getElementById('mediaImageContainer');
        if (!container) return;
        container.className = `view-${mode}`;
        fetchMediaLibraryImages();
    }

    function handleMediaModalUpload(files) {
        if (!window.mediaUploadRoute) {
            alert('업로드 경로가 설정되지 않았습니다.');
            return;
        }

        let targetFile = files[0];
        if (!targetFile) return;

        let formData = new FormData();
        formData.append('image', targetFile);
        formData.append('type', currentMediaType);
        if (currentMediaFolderId) formData.append('folder_id', currentMediaFolderId);

        fetch(window.mediaUploadRoute, {
            method: 'POST',
            headers: {
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
                'X-Requested-With': 'XMLHttpRequest'
            },
            body: formData
        })
        .then(res => res.json())
        .then(data => {
            if (data.success) {
                let raw = data.data || data;
                let imgId = raw.id || raw.image_id;
                let rawPath = raw.path || raw.file_path || raw.url || '';
                let imgPath = rawPath.replace(/^\/?storage\//, '').replace(/^https?:\/\/[^\/]+\/storage\//, '');
                let imgName = raw.original_name || raw.name || raw.filename || 'image.png';

                if (!imgId || !imgPath) {
                    alert('업로드된 이미지의 ID 또는 경로 정보가 누락되었습니다.');
                    return;
                }

                selectedMediaImagesMap.clear();
                selectedMediaImagesMap.add({ id: imgId, path: imgPath, name: imgName });
                confirmMediaSelection();

            } else {
                alert(data.message || '업로드 실패');
            }
        })
        .catch(err => {
            console.error('Upload Error:', err);
            alert('이미지 업로드 중 오류가 발생했습니다.');
        });
    }

    function fetchMediaLibraryImages() {
        if (!window.mediaIndexRoute) return;

        let keyword = document.getElementById('mediaKeywordSearch')?.value || '';
        let sortBy = document.getElementById('mediaSortBy')?.value || 'latest';
        
        let url = `${window.mediaIndexRoute}?keyword=${encodeURIComponent(keyword)}&sort=${sortBy}&ajax=1`;
        if (currentMediaFolderId) url += `&folder_id=${currentMediaFolderId}`;

        fetch(url, {
            headers: { 
                'X-Requested-With': 'XMLHttpRequest',
                'Accept': 'application/json'
            }
        })
        .then(res => res.json())
        .then(data => {
            let images = data.images.data || data.images;

            let alreadySelectedIds = new Set();

            if (currentMediaMode === 'single') {
                let inputId = `${currentMediaType}_id`;
                let hiddenInput = document.getElementById(inputId) 
                               || 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="marketing_image_ids[]"]').forEach(input => {
                    if (input.value) alreadySelectedIds.add(String(input.value));
                });
                document.querySelectorAll('#marketing-gallery-preview [data-id], .marketing-gallery-preview [data-id]').forEach(el => {
                    if (el.dataset.id) alreadySelectedIds.add(String(el.dataset.id));
                });
            }

            let filteredImages = images.filter(img => !alreadySelectedIds.has(String(img.id)));
            renderMediaLibraryImages(filteredImages);
        })
        .catch(err => console.error('Library Fetch Error:', err));
    }

    function renderMediaLibraryImages(images) {
        let container = document.getElementById('mediaImageContainer');
        if (!container) return;
        
        container.innerHTML = '';

        if (images.length === 0) {
            container.innerHTML = '<div class="text-center py-5 text-muted small">등록 가능한 파일이 없습니다.</div>';
            return;
        }

        if (currentMediaViewMode === 'list') {
            let listGroup = document.createElement('div');
            listGroup.className = 'list-group list-group-flush shadow-sm rounded border-0 bg-transparent';

            images.forEach(img => {
                let isSelected = Array.from(selectedMediaImagesMap).some(item => item.id === img.id);
                let item = document.createElement('div');
                item.className = `list-group-item list-group-item-action d-flex align-items-center gap-3 p-1 border-0 bg-transparent ${isSelected ? 'bg-primary-subtle' : ''}`;
                
                item.innerHTML = `
                    <input type="checkbox" class="form-check-input media-checkbox ms-1" value="${img.id}" ${isSelected ? 'checked' : ''} onchange="toggleCheckboxSelection(${img.id}, '${img.path}', '${img.original_name}', this)">
                    <div class="media-thumb-box d-flex align-items-center justify-content-center overflow-hidden flex-shrink-0 p-1" style="width: 50px; height: 33px;" ondblclick="doubleClickMediaImage(${img.id}, '${img.path}', '${img.original_name}')">
                        <img src="/storage/${img.path}" class="w-100 h-100 media-thumbnail-img">
                    </div>
                    <div class="flex-grow-1 text-truncate" id="name-container-${img.id}">
                        <span class="fw-semibold text-dark small d-block text-truncate" onclick="inlineRenameMediaImage(${img.id}, '${img.original_name}')" style="cursor: pointer;" title="클릭하여 파일명 수정">${img.original_name}</span>
                        <span class="text-muted" style="font-size: 0.7rem;">용량: ${Math.round((img.size || 0)/1024)}KB</span>
                    </div>
                    <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-2 me-1" onclick="inlineRenameMediaImage(${img.id}, '${img.original_name}')" title="파일명 변경"><i class="fa-solid fa-pen"></i></button>
                `;
                listGroup.appendChild(item);
            });
            container.appendChild(listGroup);

        } else {
            let row = document.createElement('div');
            row.className = currentMediaViewMode === 'large' ? 'row g-3' : 'row g-2';

            images.forEach(img => {
                let isSelected = Array.from(selectedMediaImagesMap).some(item => item.id === img.id);
                let colClass = currentMediaViewMode === 'large' ? 'col-6 col-md-4 col-lg-3' : 'col-3 col-md-2';
                let selectedStyle = isSelected ? 'background-color: #e7f1ff !important;' : '';

                let col = document.createElement('div');
                col.className = colClass;
                col.innerHTML = `
                    <div class="card h-100 position-relative image-card p-1 text-center rounded-3 media-item-card border-0 bg-transparent" style="${selectedStyle}">
                        
                        <div class="position-absolute top-0 start-0 p-2" style="z-index: 2;" onclick="event.stopPropagation();">
                            <input type="checkbox" class="form-check-input media-checkbox shadow-sm" value="${img.id}" ${isSelected ? 'checked' : ''} onchange="toggleCheckboxSelection(${img.id}, '${img.path}', '${img.original_name}', this)">
                        </div>

                        <div class="w-100 d-flex align-items-center justify-content-center overflow-hidden p-1 media-thumb-box" 
                             style="aspect-ratio: 3 / 2; width: 100%;" 
                             ondblclick="doubleClickMediaImage(${img.id}, '${img.path}', '${img.original_name}')"
                             title="더블클릭하면 바로 선택됩니다">
                            <img src="/storage/${img.path}" class="media-thumbnail-img" style="display: block;">
                        </div>

                        <div class="card-body p-1 text-center bg-transparent border-0">
                            <div class="file-name-container" id="name-container-${img.id}">
                                <small class="text-dark text-truncate d-block fw-semibold px-1" 
                                       style="font-size: 0.75rem; cursor: pointer;" 
                                       title="클릭하여 파일명 수정"
                                       onclick="inlineRenameMediaImage(${img.id}, '${img.original_name}')">
                                    ${img.original_name}
                                </small>
                            </div>
                        </div>
                    </div>
                `;
                row.appendChild(col);
            });
            container.appendChild(row);
        }
    }

    function doubleClickMediaImage(id, path, name) {
        selectedMediaImagesMap.clear();
        selectedMediaImagesMap.add({ id, path, name });
        confirmMediaSelection();
    }

    function toggleSelectAllMedia(masterCheckbox) {
        let checkboxes = document.querySelectorAll('.media-checkbox');
        checkboxes.forEach(cb => {
            cb.checked = masterCheckbox.checked;
            cb.dispatchEvent(new Event('change'));
        });
    }

    function toggleCheckboxSelection(id, path, name, checkbox) {
        if (checkbox.checked) {
            if (currentMediaMode === 'single') {
                selectedMediaImagesMap.clear();
                document.querySelectorAll('.media-checkbox').forEach(cb => {
                    if (cb !== checkbox) cb.checked = false;
                });
                selectedMediaImagesMap.add({ id, path, name });
            } else {
                selectedMediaImagesMap.add({ id, path, name });
            }
        } else {
            selectedMediaImagesMap.forEach(item => { if (item.id === id) selectedMediaImagesMap.delete(item); });
        }

        let countText = document.getElementById('mediaSelectedCountText');
        if (countText) countText.innerText = `선택된 이미지: ${selectedMediaImagesMap.size}개`;
    }

    function deleteSelectedMediaImages() {
        if (selectedMediaImagesMap.size === 0) {
            alert('삭제할 이미지를 선택해 주세요.');
            return;
        }

        if (!confirm(`선택한 ${selectedMediaImagesMap.size}개의 이미지를 삭제하시겠습니까?`)) return;

        let ids = Array.from(selectedMediaImagesMap).map(item => item.id);

        fetch('/admin/images/bulk-delete', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
                'X-Requested-With': 'XMLHttpRequest'
            },
            body: JSON.stringify({ ids: ids })
        })
        .then(res => res.json())
        .then(data => {
            if (data.success) {
                alert('삭제되었습니다.');
                selectedMediaImagesMap.clear();
                fetchMediaLibraryImages();
            } else {
                alert(data.message || '삭제 실패');
            }
        })
        .catch(err => console.error('Delete Error:', err));
    }

    function inlineRenameMediaImage(id, currentName) {
        let container = document.getElementById(`name-container-${id}`);
        if (!container) return;

        if (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}" autofocus>
                <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>
        `;

        let input = document.getElementById(`inline-input-${id}`);
        if (!input) return;
        
        input.focus();
        input.select();

        let isProcessing = false;

        function handleBlur(e) {
            setTimeout(() => {
                if (!isProcessing && document.activeElement !== input) {
                    isProcessing = true;
                    submitInlineRename(id, e);
                }
            }, 200);
        }

        input.addEventListener('blur', handleBlur);

        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();
            }
        });
    }

    function submitInlineRename(id, e) {
        if (e && typeof e.stopPropagation === 'function') {
            e.stopPropagation();
        }
        
        let input = document.getElementById(`inline-input-${id}`);
        if (!input) return;

        let newName = input.value.trim();
        if (!newName) {
            fetchMediaLibraryImages();
            return;
        }

        fetch(`/admin/images/${id}/rename`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
                'X-Requested-With': 'XMLHttpRequest'
            },
            body: JSON.stringify({ name: newName })
        })
        .then(res => res.json())
        .then(data => {
            if (data.success) {
                fetchMediaLibraryImages();
            } else {
                alert(data.message || '파일명 변경 실패');
                fetchMediaLibraryImages();
            }
        })
        .catch(err => {
            console.error('Rename Error:', err);
            fetchMediaLibraryImages();
        });
    }

    /**
     * 본문 마케팅 미리보기 카드 순수 누적 헬퍼 (appendChild)
     */
    function appendMarketingPreviewCard(img, container, hiddenContainer) {
        let exists = container.querySelector(`[data-id="${img.id}"]`) 
                  || (hiddenContainer && hiddenContainer.querySelector(`input[value="${img.id}"]`));
        if (exists) return;

        let col = document.createElement('div');
        col.className = 'col-4 col-md-3 col-xxl-2 text-center position-relative marketing-preview-item marketing-preview-col mb-3';
        col.dataset.id = img.id;
        col.innerHTML = `
            <div class="card shadow-sm h-100 position-relative bg-light rounded-3 overflow-hidden p-0 border-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 remove-preview-btn" 
                        style="width: 20px; height: 20px; border-radius: 50%; z-index: 5; margin-top: 5px; margin-right: 5px; font-size: 0.65rem;"
                        onclick="removeMarketingPreviewItem(this, ${img.id})" title="이미지 제거">
                    <i class="fa-solid fa-xmark"></i>
                </button>

                <div class="d-flex align-items-center justify-content-center p-0 overflow-hidden" style="aspect-ratio: 1 / 1; width: 100%; background-color: #f8f9fa;">
                    <img src="/storage/${img.path}" 
                         class="media-thumbnail-img" 
                         style="display: block;">
                </div>

                <div class="card-body p-1 text-center border-0" style="background-color: #f8f9fa;">
                    <small class="text-dark text-truncate d-block fw-semibold px-1" style="font-size: 0.65rem;" title="${img.name}">${img.name}</small>
                </div>
            </div>
        `;

        container.appendChild(col);

        if (hiddenContainer) {
            let hiddenInput = document.createElement('input');
            hiddenInput.type = 'hidden';
            hiddenInput.name = 'marketing_image_ids[]';
            hiddenInput.value = img.id;
            hiddenInput.dataset.id = img.id;
            hiddenContainer.appendChild(hiddenInput);
        }
    }

    /**
     * 선택 완료 처리
     */
    function confirmMediaSelection() {
        if (selectedMediaImagesMap.size === 0) {
            alert('선택된 이미지가 없습니다.');
            return;
        }

        if (selectedMediaCallback && typeof selectedMediaCallback === 'function') {
            let imagesArray = Array.from(selectedMediaImagesMap);
            selectedMediaCallback(imagesArray);
            if (globeauMediaModal) globeauMediaModal.hide();
            return;
        }

        if (currentMediaMode === 'single') {
            let imgObj = Array.from(selectedMediaImagesMap)[0];
            if (!imgObj) return;

            let hiddenInput = document.getElementById(`${currentMediaType}_id`) 
                           || document.getElementById(`${currentMediaType}_image_id`) 
                           || document.querySelector(`input[name="${currentMediaType}_id"]`)
                           || document.querySelector(`input[name="${currentMediaType}_image_id"]`);

            let 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 {
            let previewContainer = document.getElementById('marketing-gallery-preview') 
                                || document.querySelector('.marketing-gallery-preview');
            let hiddenInputContainer = document.getElementById('marketing-hidden-inputs') 
                                    || document.querySelector('.marketing-hidden-inputs');

            if (previewContainer) {
                if (!hiddenInputContainer) {
                    let form = previewContainer.closest('form') || document.body;
                    hiddenInputContainer = document.createElement('div');
                    hiddenInputContainer.id = 'marketing-hidden-inputs';
                    form.appendChild(hiddenInputContainer);
                }

                selectedMediaImagesMap.forEach(img => {
                    appendMarketingPreviewCard(img, previewContainer, hiddenInputContainer);
                });
            }
        }

        if (globeauMediaModal) {
            globeauMediaModal.hide();
        }
    }

    function removeMarketingPreviewItem(buttonElement, imageId) {
        let previewCol = buttonElement.closest('.marketing-preview-col');
        if (previewCol) {
            previewCol.remove();
        }

        document.querySelectorAll(`input[name="marketing_image_ids[]"][value="${imageId}"]`).forEach(input => {
            input.remove();
        });
    }

    function handleMarketingImagesCallback(selectedImages) {
        let previewContainer = document.getElementById('marketing-gallery-preview') 
                            || document.querySelector('.marketing-gallery-preview');
        let hiddenInputContainer = document.getElementById('marketing-hidden-inputs') 
                                || document.querySelector('.marketing-hidden-inputs');
        
        if (!previewContainer) return;

        if (!hiddenInputContainer) {
            let form = previewContainer.closest('form') || document.body;
            hiddenInputContainer = document.createElement('div');
            hiddenInputContainer.id = 'marketing-hidden-inputs';
            form.appendChild(hiddenInputContainer);
        }

        selectedImages.forEach(img => {
            let path = img.path || (img.url ? img.url.replace(/^\/?storage\//, '') : '');
            let name = img.original_name || img.name || 'image.png';
            appendMarketingPreviewCard({ id: img.id, path: path, name: name }, previewContainer, hiddenInputContainer);
        });
    }