초기 커밋 - EV AS 관리 시스템

This commit is contained in:
root
2026-04-18 06:18:58 +09:00
commit 7a5c397983
52 changed files with 6044 additions and 0 deletions

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