Files
ev-charger-as/frontend/static/js/imageCompress.js
byun 9f0f4326fe 기능 개선 — 사진 업로드, HEIC 지원, 재조치 흐름, 신고 순번, 모바일 UI
- 이미지 압축: 삼성/네이버 브라우저 호환, 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>
2026-06-02 05:38:33 +09:00

247 lines
9.3 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };
})();