기능 개선 — 사진 업로드, 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:
byun
2026-06-02 05:38:33 +09:00
parent 5ebd0a6ae7
commit 9f0f4326fe
18 changed files with 436 additions and 255 deletions

View File

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

View File

@@ -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) {

View File

@@ -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 = '';
});
}