- 이미지 압축: 삼성/네이버 브라우저 호환, URL.createObjectURL 방식으로 메모리 절감, 대용량 PNG/HEIC 처리, blob 유효성 검증, 순차 압축으로 모바일 OOM 방지 - HEIC/HEIF 지원: pillow-heif 서버사이드 변환, Pillow 12.2.0 업그레이드 - 조치 페이지: '조치 완료 저장' 단일 버튼으로 단순화 - 재조치 흐름: 관리자 재조치 요청 시 이전 조치 이력을 번호 카드로 순차 표시 - 신고 순번: 전체 기준 ROW_NUMBER(oldest=1) 순번 표시, 삭제 gap 제거 - 모바일 탭바: position:fixed 적용으로 nav 하단 흰 여백 제거 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
9.3 KiB
JavaScript
Executable File
247 lines
9.3 KiB
JavaScript
Executable File
/**
|
||
* imageCompress.js
|
||
* 업로드 전 브라우저에서 이미지를 리사이즈 + JPEG 압축
|
||
* Canvas API 사용 — 외부 라이브러리 불필요
|
||
*/
|
||
const ImageCompressor = (() => {
|
||
|
||
// 서버에서 가져온 설정 캐시
|
||
var _cfg = null;
|
||
|
||
var DEFAULT_CFG = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
|
||
|
||
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출, 5초 타임아웃) */
|
||
async function loadConfig() {
|
||
if (_cfg) return _cfg;
|
||
try {
|
||
var controller = new AbortController();
|
||
var tid = setTimeout(function() { controller.abort(); }, 5000);
|
||
var res = await fetch('/api/settings/public', { signal: controller.signal });
|
||
clearTimeout(tid);
|
||
_cfg = await res.json();
|
||
} catch (e) {
|
||
_cfg = DEFAULT_CFG;
|
||
}
|
||
return _cfg;
|
||
}
|
||
|
||
/**
|
||
* File 객체 하나를 압축해서 새 File 로 반환
|
||
* @param {File} file - 원본 이미지 파일
|
||
* @param {Object} cfg - { image_compress_enabled, image_max_px, image_quality }
|
||
* @returns {Promise<File>}
|
||
*/
|
||
function compressOne(file, cfg) {
|
||
return new Promise(function(resolve) {
|
||
// 압축 비활성 or 이미지가 아닌 파일은 그대로 반환
|
||
if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) {
|
||
return resolve(file);
|
||
}
|
||
|
||
// createObjectURL 사용: readAsDataURL 대비 메모리 1/4 이하
|
||
// (base64 변환 없이 브라우저가 파일을 직접 디코딩)
|
||
var objUrl = URL.createObjectURL(file);
|
||
var img = new Image();
|
||
|
||
img.onerror = function() {
|
||
URL.revokeObjectURL(objUrl);
|
||
resolve(file); // 디코딩 실패(HEIF 등) → 원본 사용
|
||
};
|
||
|
||
img.onload = function() {
|
||
URL.revokeObjectURL(objUrl); // 디코딩 완료 즉시 해제
|
||
|
||
if (!img.naturalWidth || !img.naturalHeight) { resolve(file); return; }
|
||
|
||
var maxPx = cfg.image_max_px;
|
||
var width = img.naturalWidth;
|
||
var height = img.naturalHeight;
|
||
|
||
// 긴 변이 maxPx 초과하면 비율 유지하며 축소
|
||
if (width > maxPx || height > maxPx) {
|
||
if (width >= height) {
|
||
height = Math.round((height / width) * maxPx);
|
||
width = maxPx;
|
||
} else {
|
||
width = Math.round((width / height) * maxPx);
|
||
height = maxPx;
|
||
}
|
||
}
|
||
|
||
var canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
|
||
var ctx = canvas.getContext('2d');
|
||
if (!ctx) { resolve(file); return; }
|
||
img.onerror = null; // 이후 src 변경에 의한 onerror 재실행 방지
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
canvas.toBlob(
|
||
function(blob) {
|
||
canvas.width = 0; canvas.height = 0; // canvas 메모리 해제
|
||
|
||
if (!blob || blob.size < 500) { resolve(file); return; }
|
||
|
||
// blob이 실제로 렌더 가능한 이미지인지 검증
|
||
// (OOM으로 drawImage가 빈 캔버스를 만들었을 경우를 잡기 위해)
|
||
var blobUrl = URL.createObjectURL(blob);
|
||
var check = new Image();
|
||
check.onerror = function() {
|
||
URL.revokeObjectURL(blobUrl);
|
||
resolve(file); // 유효하지 않은 JPEG → 원본 사용
|
||
};
|
||
check.onload = function() {
|
||
URL.revokeObjectURL(blobUrl);
|
||
if (!check.naturalWidth || !check.naturalHeight) {
|
||
resolve(file); // 빈 이미지 → 원본 사용
|
||
return;
|
||
}
|
||
resolve(new File(
|
||
[blob],
|
||
file.name.replace(/\.[^.]+$/, '') + '.jpg',
|
||
{ type: 'image/jpeg', lastModified: Date.now() }
|
||
));
|
||
};
|
||
check.src = blobUrl;
|
||
},
|
||
'image/jpeg',
|
||
cfg.image_quality / 100 // 0~1 범위
|
||
);
|
||
};
|
||
|
||
img.src = objUrl;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* FileList / File[] 전체를 순차 압축 (병렬 처리 시 모바일 메모리 부족 방지)
|
||
* @param {FileList|File[]} files
|
||
* @returns {Promise<File[]>}
|
||
*/
|
||
async function compressAll(files) {
|
||
var cfg = await loadConfig();
|
||
var arr = Array.from(files);
|
||
var result = [];
|
||
for (var i = 0; i < arr.length; i++) {
|
||
result.push(await compressOne(arr[i], cfg));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* input[type=file] + 미리보기 div 에 압축 프리뷰 설정
|
||
* @param {string} inputId - file input 요소 id
|
||
* @param {string} previewId - 미리보기 컨테이너 id
|
||
* @param {string} infoId - 용량 정보 표시 span id (선택)
|
||
*/
|
||
function setupPreview(inputId, previewId, infoId) {
|
||
var input = document.getElementById(inputId);
|
||
var preview = document.getElementById(previewId);
|
||
var info = infoId ? document.getElementById(infoId) : null;
|
||
|
||
if (!input || !preview) return;
|
||
|
||
input.addEventListener('change', async function() {
|
||
preview.innerHTML = '';
|
||
if (info) info.textContent = '압축 중...';
|
||
|
||
var cfg = await loadConfig();
|
||
var origBytes = Array.from(this.files).reduce(function(s, f) { return s + f.size; }, 0);
|
||
|
||
var compressed = await compressAll(this.files);
|
||
var compBytes = compressed.reduce(function(s, f) { return s + f.size; }, 0);
|
||
|
||
// DataTransfer 로 input.files 교체 (압축된 파일로)
|
||
var dt = new DataTransfer();
|
||
compressed.forEach(function(f) { dt.items.add(f); });
|
||
this.files = dt.files;
|
||
|
||
// 미리보기 렌더링
|
||
compressed.forEach(function(f, i) {
|
||
var url = URL.createObjectURL(f);
|
||
var wrap = document.createElement('div');
|
||
wrap.style.cssText = 'position:relative;display:inline-block;';
|
||
|
||
var img = document.createElement('img');
|
||
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;background:#f0f0f0;';
|
||
img.onload = function() { URL.revokeObjectURL(url); };
|
||
img.onerror = function() {
|
||
// 이미지 렌더 실패 시 플레이스홀더 표시
|
||
URL.revokeObjectURL(url);
|
||
img.style.cssText = 'width:80px;height:80px;border-radius:6px;border:1px solid #C5CFE0;background:#f5f5f5;display:flex;align-items:center;justify-content:center;font-size:10px;color:#999;';
|
||
img.removeAttribute('src');
|
||
img.alt = '미리보기\n불가';
|
||
};
|
||
img.src = url;
|
||
|
||
// 삭제 버튼 — 클로저로 이 파일(f)을 직접 참조
|
||
var del = document.createElement('button');
|
||
del.textContent = '×';
|
||
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;background:#E53935;color:white;border:none;font-size:11px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1;padding:0;';
|
||
(function(targetFile) {
|
||
del.onclick = function() {
|
||
var remaining = Array.from(input.files).filter(function(f2) { return f2.name !== targetFile.name || f2.size !== targetFile.size; });
|
||
var dt2 = new DataTransfer();
|
||
remaining.forEach(function(f2) { dt2.items.add(f2); });
|
||
input.files = dt2.files;
|
||
wrap.remove();
|
||
updateInfo(input, info);
|
||
};
|
||
})(f);
|
||
|
||
wrap.appendChild(img);
|
||
wrap.appendChild(del);
|
||
preview.appendChild(wrap);
|
||
});
|
||
|
||
// 용량 정보
|
||
if (info) {
|
||
var pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0;
|
||
if (cfg.image_compress_enabled && pct > 0) {
|
||
info.textContent = compressed.length + '장 | ' + fmt(origBytes) + ' → ' + fmt(compBytes) + ' (' + pct + '% 절약) | 최대 ' + cfg.image_max_px + 'px / 품질 ' + cfg.image_quality + '%';
|
||
info.style.color = '#00875A';
|
||
} else {
|
||
info.textContent = compressed.length + '장 | ' + fmt(compBytes) + ' (압축 비활성)';
|
||
info.style.color = '#8899BB';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateInfo(input, info) {
|
||
if (!info) return;
|
||
var bytes = Array.from(input.files).reduce(function(s, f) { return s + f.size; }, 0);
|
||
info.textContent = input.files.length + '장 | ' + fmt(bytes);
|
||
}
|
||
|
||
function fmt(bytes) {
|
||
return bytes < 1024 * 1024
|
||
? Math.round(bytes / 1024) + ' KB'
|
||
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
}
|
||
|
||
/**
|
||
* 카메라 input(capture)이 찍은 사진을 갤러리 input에 병합 후 change 이벤트 발생
|
||
* → setupPreview는 갤러리 input 하나만 바라보면 됨
|
||
* @param {string} cameraId - capture="environment" input id
|
||
* @param {string} galleryId - 기존 multiple input id (setupPreview 대상)
|
||
*/
|
||
function setupCameraAppend(cameraId, galleryId) {
|
||
var cam = document.getElementById(cameraId);
|
||
var main = document.getElementById(galleryId);
|
||
if (!cam || !main) return;
|
||
cam.addEventListener('change', function() {
|
||
if (!this.files.length) return;
|
||
var dt = new DataTransfer();
|
||
Array.from(main.files).forEach(function(f) { dt.items.add(f); }); // 기존 파일 유지
|
||
Array.from(this.files).forEach(function(f) { dt.items.add(f); }); // 새 사진 추가
|
||
main.files = dt.files;
|
||
main.dispatchEvent(new Event('change')); // setupPreview 재실행
|
||
this.value = '';
|
||
});
|
||
}
|
||
|
||
return { compressAll, setupPreview, loadConfig, setupCameraAppend };
|
||
})();
|