기능 개선 — 사진 업로드, 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>
This commit is contained in:
@@ -16,7 +16,12 @@ const API = (() => {
|
||||
}
|
||||
}
|
||||
const res = await fetch(BASE + path, { method, headers, body: fetchBody });
|
||||
if (res.status === 401) { Auth.logout(); return; }
|
||||
if (res.status === 401) {
|
||||
if (location.pathname !== '/pages/login.html') {
|
||||
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
|
||||
}
|
||||
Auth.logout(); return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '오류가 발생했습니다.' }));
|
||||
throw new Error(err.detail || '오류');
|
||||
@@ -36,7 +41,12 @@ const API = (() => {
|
||||
|
||||
const res = await fetch(BASE + path, { method: 'GET', headers });
|
||||
|
||||
if (res.status === 401) { Auth.logout(); return; }
|
||||
if (res.status === 401) {
|
||||
if (location.pathname !== '/pages/login.html') {
|
||||
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
|
||||
}
|
||||
Auth.logout(); return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: '다운로드 오류' }));
|
||||
throw new Error(err.detail || '다운로드 오류');
|
||||
@@ -66,7 +76,7 @@ const API = (() => {
|
||||
post: (path, body) => req('POST', path, body, body instanceof FormData),
|
||||
put: (path, body) => req('PUT', path, body, body instanceof FormData),
|
||||
patch: (path, body) => req('PATCH', path, body, body instanceof FormData),
|
||||
delete: (path, body) => req('DELETE', path, body ?? null),
|
||||
delete: (path, body) => req('DELETE', path, body !== undefined ? body : null),
|
||||
download,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -21,10 +21,16 @@ const Auth = (() => {
|
||||
}
|
||||
|
||||
function require(allowedRoles) {
|
||||
if (!token()) { logout(); return false; }
|
||||
if (!token()) {
|
||||
// 로그인 후 원래 페이지로 돌아올 수 있도록 현재 URL 저장
|
||||
if (location.pathname !== '/pages/login.html') {
|
||||
sessionStorage.setItem('ev_redirect', location.pathname + location.search);
|
||||
}
|
||||
logout(); return false;
|
||||
}
|
||||
if (allowedRoles && !allowedRoles.includes(role())) {
|
||||
alert('접근 권한이 없습니다.');
|
||||
history.back();
|
||||
alert('접근 권한이 없습니다. (현재 역할: ' + (role() || '없음') + ')');
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -72,12 +78,14 @@ const Auth = (() => {
|
||||
if (!sidebar) return;
|
||||
const opening = !sidebar.classList.contains('mobile-open');
|
||||
sidebar.classList.toggle('mobile-open', opening);
|
||||
overlay?.classList.toggle('show', opening);
|
||||
if (overlay) overlay.classList.toggle('show', opening);
|
||||
}
|
||||
|
||||
function closeMobileNav() {
|
||||
document.querySelector('.sidebar')?.classList.remove('mobile-open');
|
||||
document.getElementById('mobileNavOverlay')?.classList.remove('show');
|
||||
var sb = document.querySelector('.sidebar');
|
||||
if (sb) sb.classList.remove('mobile-open');
|
||||
var ov = document.getElementById('mobileNavOverlay');
|
||||
if (ov) ov.classList.remove('show');
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
|
||||
@@ -6,16 +6,21 @@
|
||||
const ImageCompressor = (() => {
|
||||
|
||||
// 서버에서 가져온 설정 캐시
|
||||
let _cfg = null;
|
||||
var _cfg = null;
|
||||
|
||||
/** 관리자가 저장한 이미지 설정 로드 (최초 1회만 API 호출) */
|
||||
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 {
|
||||
const res = await fetch('/api/settings/public');
|
||||
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 {
|
||||
_cfg = { image_compress_enabled: true, image_max_px: 1024, image_quality: 85 };
|
||||
} catch (e) {
|
||||
_cfg = DEFAULT_CFG;
|
||||
}
|
||||
return _cfg;
|
||||
}
|
||||
@@ -27,62 +32,101 @@ const ImageCompressor = (() => {
|
||||
* @returns {Promise<File>}
|
||||
*/
|
||||
function compressOne(file, cfg) {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(function(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;
|
||||
// createObjectURL 사용: readAsDataURL 대비 메모리 1/4 이하
|
||||
// (base64 변환 없이 브라우저가 파일을 직접 디코딩)
|
||||
var objUrl = URL.createObjectURL(file);
|
||||
var img = new Image();
|
||||
|
||||
// 긴 변이 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const compressed = new File(
|
||||
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() }
|
||||
);
|
||||
resolve(compressed);
|
||||
},
|
||||
'image/jpeg',
|
||||
cfg.image_quality / 100 // 0~1 범위
|
||||
);
|
||||
};
|
||||
img.src = e.target.result;
|
||||
));
|
||||
};
|
||||
check.src = blobUrl;
|
||||
},
|
||||
'image/jpeg',
|
||||
cfg.image_quality / 100 // 0~1 범위
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
img.src = objUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FileList / 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)));
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,52 +136,59 @@ const ImageCompressor = (() => {
|
||||
* @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;
|
||||
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 () {
|
||||
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);
|
||||
var cfg = await loadConfig();
|
||||
var origBytes = Array.from(this.files).reduce(function(s, f) { return s + f.size; }, 0);
|
||||
|
||||
const compressed = await compressAll(this.files);
|
||||
const compBytes = compressed.reduce((s, f) => 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 교체 (압축된 파일로)
|
||||
const dt = new DataTransfer();
|
||||
compressed.forEach(f => dt.items.add(f));
|
||||
var dt = new DataTransfer();
|
||||
compressed.forEach(function(f) { dt.items.add(f); });
|
||||
this.files = dt.files;
|
||||
|
||||
// 미리보기 렌더링
|
||||
compressed.forEach((f, i) => {
|
||||
const url = URL.createObjectURL(f);
|
||||
const wrap = document.createElement('div');
|
||||
compressed.forEach(function(f, i) {
|
||||
var url = URL.createObjectURL(f);
|
||||
var 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);
|
||||
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;
|
||||
|
||||
// 삭제 버튼
|
||||
const del = document.createElement('button');
|
||||
// 삭제 버튼 — 클로저로 이 파일(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;';
|
||||
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);
|
||||
};
|
||||
(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);
|
||||
@@ -146,12 +197,12 @@ const ImageCompressor = (() => {
|
||||
|
||||
// 용량 정보
|
||||
if (info) {
|
||||
const pct = origBytes > 0 ? Math.round((1 - compBytes / origBytes) * 100) : 0;
|
||||
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.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.textContent = compressed.length + '장 | ' + fmt(compBytes) + ' (압축 비활성)';
|
||||
info.style.color = '#8899BB';
|
||||
}
|
||||
}
|
||||
@@ -160,13 +211,13 @@ const ImageCompressor = (() => {
|
||||
|
||||
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)}`;
|
||||
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
|
||||
? (bytes / 1024).toFixed(0) + ' KB'
|
||||
? Math.round(bytes / 1024) + ' KB'
|
||||
: (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
@@ -177,16 +228,16 @@ const ImageCompressor = (() => {
|
||||
* @param {string} galleryId - 기존 multiple input id (setupPreview 대상)
|
||||
*/
|
||||
function setupCameraAppend(cameraId, galleryId) {
|
||||
const cam = document.getElementById(cameraId);
|
||||
const main = document.getElementById(galleryId);
|
||||
var cam = document.getElementById(cameraId);
|
||||
var main = document.getElementById(galleryId);
|
||||
if (!cam || !main) return;
|
||||
cam.addEventListener('change', function () {
|
||||
cam.addEventListener('change', function() {
|
||||
if (!this.files.length) return;
|
||||
const dt = new DataTransfer();
|
||||
Array.from(main.files).forEach(f => dt.items.add(f)); // 기존 파일 유지
|
||||
Array.from(this.files).forEach(f => dt.items.add(f)); // 새 사진 추가
|
||||
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 재실행
|
||||
main.dispatchEvent(new Event('change')); // setupPreview 재실행
|
||||
this.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user