초기 커밋 - EV AS 관리 시스템
This commit is contained in:
174
frontend/static/js/imageCompress.js
Executable file
174
frontend/static/js/imageCompress.js
Executable file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* imageCompress.js
|
||||
* 업로드 전 브라우저에서 이미지를 리사이즈 + JPEG 압축
|
||||
* Canvas API 사용 — 외부 라이브러리 불필요
|
||||
*/
|
||||
const ImageCompressor = (() => {
|
||||
|
||||
// 서버에서 가져온 설정 캐시
|
||||
let _cfg = null;
|
||||
|
||||
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출) */
|
||||
async function loadConfig() {
|
||||
if (_cfg) return _cfg;
|
||||
try {
|
||||
const res = await fetch('/api/settings/public');
|
||||
_cfg = await res.json();
|
||||
} catch {
|
||||
_cfg = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
|
||||
}
|
||||
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((resolve) => {
|
||||
// 압축 비활성 or 이미지가 아닌 파일은 그대로 반환
|
||||
if (!cfg.image_compress_enabled || !file.type.startsWith('image/')) {
|
||||
return resolve(file);
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const maxPx = cfg.image_max_px;
|
||||
let { width, height } = img;
|
||||
|
||||
// 긴 변이 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;
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const compressed = new File(
|
||||
[blob],
|
||||
file.name.replace(/\.[^.]+$/, '') + '.jpg',
|
||||
{ type: 'image/jpeg', lastModified: Date.now() }
|
||||
);
|
||||
resolve(compressed);
|
||||
},
|
||||
'image/jpeg',
|
||||
cfg.image_quality / 100 // 0~1 범위
|
||||
);
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FileList / File[] 전체를 압축
|
||||
* @param {FileList|File[]} files
|
||||
* @returns {Promise<File[]>}
|
||||
*/
|
||||
async function compressAll(files) {
|
||||
const cfg = await loadConfig();
|
||||
return Promise.all(Array.from(files).map(f => compressOne(f, cfg)));
|
||||
}
|
||||
|
||||
/**
|
||||
* input[type=file] + 미리보기 div 에 압축 프리뷰 설정
|
||||
* @param {string} inputId - file input 요소 id
|
||||
* @param {string} previewId - 미리보기 컨테이너 id
|
||||
* @param {string} infoId - 용량 정보 표시 span id (선택)
|
||||
*/
|
||||
function setupPreview(inputId, previewId, infoId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const preview = document.getElementById(previewId);
|
||||
const info = infoId ? document.getElementById(infoId) : null;
|
||||
|
||||
if (!input || !preview) return;
|
||||
|
||||
input.addEventListener('change', async function () {
|
||||
preview.innerHTML = '';
|
||||
if (info) info.textContent = '압축 중...';
|
||||
|
||||
const cfg = await loadConfig();
|
||||
const origBytes = Array.from(this.files).reduce((s, f) => s + f.size, 0);
|
||||
|
||||
const compressed = await compressAll(this.files);
|
||||
const compBytes = compressed.reduce((s, f) => s + f.size, 0);
|
||||
|
||||
// DataTransfer 로 input.files 교체 (압축된 파일로)
|
||||
const dt = new DataTransfer();
|
||||
compressed.forEach(f => dt.items.add(f));
|
||||
this.files = dt.files;
|
||||
|
||||
// 미리보기 렌더링
|
||||
compressed.forEach((f, i) => {
|
||||
const url = URL.createObjectURL(f);
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:relative;display:inline-block;';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.style.cssText = 'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid #C5CFE0;';
|
||||
img.onload = () => URL.revokeObjectURL(url);
|
||||
|
||||
// 삭제 버튼
|
||||
const 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;';
|
||||
del.onclick = () => {
|
||||
// 해당 파일 제거
|
||||
const cur = Array.from(input.files);
|
||||
cur.splice(i, 1);
|
||||
const dt2 = new DataTransfer();
|
||||
cur.forEach(f2 => dt2.items.add(f2));
|
||||
input.files = dt2.files;
|
||||
wrap.remove();
|
||||
updateInfo(input, info);
|
||||
};
|
||||
|
||||
wrap.appendChild(img);
|
||||
wrap.appendChild(del);
|
||||
preview.appendChild(wrap);
|
||||
});
|
||||
|
||||
// 용량 정보
|
||||
if (info) {
|
||||
const 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;
|
||||
const bytes = Array.from(input.files).reduce((s, f) => s + f.size, 0);
|
||||
info.textContent = `${input.files.length}장 | ${fmt(bytes)}`;
|
||||
}
|
||||
|
||||
function fmt(bytes) {
|
||||
return bytes < 1024 * 1024
|
||||
? (bytes / 1024).toFixed(0) + ' KB'
|
||||
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
return { compressAll, setupPreview, loadConfig };
|
||||
})();
|
||||
Reference in New Issue
Block a user