feat: 사진 업로드 기능 개선 및 카테고리 업데이트
- 사진 2장까지 업로드 지원 - 카메라 촬영 + 갤러리 선택 분리 - 이미지 압축 및 최적화 (ImageUtils) - iPhone .mpo 파일 JPEG 변환 지원 - 카테고리 변경: 치수불량 → 설계미스, 검사미스 추가 - KST 시간대 설정 - URL 해시 처리로 목록관리 페이지 이동 개선 - 로그인 OAuth2 form-data 형식 수정 - 업로드 속도 개선 및 프로그레스바 추가
This commit is contained in:
@@ -48,6 +48,17 @@ async function apiRequest(endpoint, options = {}) {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('API Error Response:', error);
|
||||
console.error('Error details:', JSON.stringify(error, null, 2));
|
||||
|
||||
// 422 에러의 경우 validation 에러 메시지 추출
|
||||
if (response.status === 422 && error.detail && Array.isArray(error.detail)) {
|
||||
const validationErrors = error.detail.map(err =>
|
||||
`${err.loc ? err.loc.join('.') : 'field'}: ${err.msg}`
|
||||
).join(', ');
|
||||
throw new Error(`입력값 검증 오류: ${validationErrors}`);
|
||||
}
|
||||
|
||||
throw new Error(error.detail || 'API 요청 실패');
|
||||
}
|
||||
|
||||
@@ -61,12 +72,16 @@ async function apiRequest(endpoint, options = {}) {
|
||||
// Auth API
|
||||
const AuthAPI = {
|
||||
login: async (username, password) => {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
body: formData.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -115,10 +130,20 @@ const AuthAPI = {
|
||||
|
||||
// Issues API
|
||||
const IssuesAPI = {
|
||||
create: (issueData) => apiRequest('/issues/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(issueData)
|
||||
}),
|
||||
create: async (issueData) => {
|
||||
// photos 배열 처리 (최대 2장)
|
||||
const dataToSend = {
|
||||
category: issueData.category,
|
||||
description: issueData.description,
|
||||
photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null,
|
||||
photo2: issueData.photos && issueData.photos.length > 1 ? issueData.photos[1] : null
|
||||
};
|
||||
|
||||
return apiRequest('/issues/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dataToSend)
|
||||
});
|
||||
},
|
||||
|
||||
getAll: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
|
||||
139
frontend/static/js/date-utils.js
Normal file
139
frontend/static/js/date-utils.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 날짜 관련 유틸리티 함수들
|
||||
* 한국 표준시(KST) 기준으로 처리
|
||||
*/
|
||||
|
||||
const DateUtils = {
|
||||
/**
|
||||
* UTC 시간을 KST로 변환
|
||||
* @param {string|Date} dateInput - UTC 날짜 문자열 또는 Date 객체
|
||||
* @returns {Date} KST 시간대의 Date 객체
|
||||
*/
|
||||
toKST(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
// UTC 시간에 9시간 추가 (KST = UTC+9)
|
||||
return new Date(date.getTime() + (date.getTimezoneOffset() * 60000) + (9 * 3600000));
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 KST 시간 가져오기
|
||||
* @returns {Date} 현재 KST 시간
|
||||
*/
|
||||
nowKST() {
|
||||
const now = new Date();
|
||||
return this.toKST(now);
|
||||
},
|
||||
|
||||
/**
|
||||
* KST 날짜를 한국식 문자열로 포맷
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @param {boolean} includeTime - 시간 포함 여부
|
||||
* @returns {string} 포맷된 날짜 문자열
|
||||
*/
|
||||
formatKST(dateInput, includeTime = false) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'Asia/Seoul'
|
||||
};
|
||||
|
||||
if (includeTime) {
|
||||
options.hour = '2-digit';
|
||||
options.minute = '2-digit';
|
||||
options.hour12 = false;
|
||||
}
|
||||
|
||||
return date.toLocaleString('ko-KR', options);
|
||||
},
|
||||
|
||||
/**
|
||||
* 상대적 시간 표시 (예: 3분 전, 2시간 전)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {string} 상대적 시간 문자열
|
||||
*/
|
||||
getRelativeTime(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return '방금 전';
|
||||
if (diffMin < 60) return `${diffMin}분 전`;
|
||||
if (diffHour < 24) return `${diffHour}시간 전`;
|
||||
if (diffDay < 7) return `${diffDay}일 전`;
|
||||
|
||||
return this.formatKST(date);
|
||||
},
|
||||
|
||||
/**
|
||||
* 오늘 날짜인지 확인 (KST 기준)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isToday(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const today = new Date();
|
||||
|
||||
return date.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' }) ===
|
||||
today.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
},
|
||||
|
||||
/**
|
||||
* 이번 주인지 확인 (KST 기준, 월요일 시작)
|
||||
* @param {string|Date} dateInput - 날짜
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isThisWeek(dateInput) {
|
||||
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
|
||||
const now = new Date();
|
||||
|
||||
// 주의 시작일 (월요일) 계산
|
||||
const startOfWeek = new Date(now);
|
||||
const day = startOfWeek.getDay();
|
||||
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
|
||||
startOfWeek.setDate(diff);
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
// 주의 끝일 (일요일) 계산
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
|
||||
return date >= startOfWeek && date <= endOfWeek;
|
||||
},
|
||||
|
||||
/**
|
||||
* ISO 문자열을 로컬 date input 값으로 변환
|
||||
* @param {string} isoString - ISO 날짜 문자열
|
||||
* @returns {string} YYYY-MM-DD 형식
|
||||
*/
|
||||
toDateInputValue(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 날짜 차이 계산 (일 단위)
|
||||
* @param {string|Date} date1 - 첫 번째 날짜
|
||||
* @param {string|Date} date2 - 두 번째 날짜
|
||||
* @returns {number} 일 수 차이
|
||||
*/
|
||||
getDaysDiff(date1, date2) {
|
||||
const d1 = typeof date1 === 'string' ? new Date(date1) : date1;
|
||||
const d2 = typeof date2 === 'string' ? new Date(date2) : date2;
|
||||
const diffMs = Math.abs(d2 - d1);
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
};
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.DateUtils = DateUtils;
|
||||
134
frontend/static/js/image-utils.js
Normal file
134
frontend/static/js/image-utils.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 이미지 압축 및 최적화 유틸리티
|
||||
*/
|
||||
|
||||
const ImageUtils = {
|
||||
/**
|
||||
* 이미지를 압축하고 리사이즈
|
||||
* @param {File|Blob|String} source - 이미지 파일, Blob 또는 base64 문자열
|
||||
* @param {Object} options - 압축 옵션
|
||||
* @returns {Promise<String>} - 압축된 base64 이미지
|
||||
*/
|
||||
async compressImage(source, options = {}) {
|
||||
const {
|
||||
maxWidth = 1024, // 최대 너비
|
||||
maxHeight = 1024, // 최대 높이
|
||||
quality = 0.7, // JPEG 품질 (0-1)
|
||||
format = 'jpeg' // 출력 형식
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let img = new Image();
|
||||
|
||||
// 이미지 로드 완료 시
|
||||
img.onload = () => {
|
||||
// Canvas 생성
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 리사이즈 계산
|
||||
let { width, height } = this.calculateDimensions(
|
||||
img.width,
|
||||
img.height,
|
||||
maxWidth,
|
||||
maxHeight
|
||||
);
|
||||
|
||||
// Canvas 크기 설정
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// 압축된 이미지를 base64로 변환
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('이미지 압축 실패'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
}, `image/${format}`, quality);
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error('이미지 로드 실패'));
|
||||
|
||||
// 소스 타입에 따라 처리
|
||||
if (typeof source === 'string') {
|
||||
// Base64 문자열인 경우
|
||||
img.src = source;
|
||||
} else if (source instanceof File || source instanceof Blob) {
|
||||
// File 또는 Blob인 경우
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
img.src = reader.result;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(source);
|
||||
} else {
|
||||
reject(new Error('지원하지 않는 이미지 형식'));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 크기 계산 (비율 유지)
|
||||
*/
|
||||
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
|
||||
// 원본 크기가 제한 내에 있으면 그대로 반환
|
||||
if (originalWidth <= maxWidth && originalHeight <= maxHeight) {
|
||||
return { width: originalWidth, height: originalHeight };
|
||||
}
|
||||
|
||||
// 비율 계산
|
||||
const widthRatio = maxWidth / originalWidth;
|
||||
const heightRatio = maxHeight / originalHeight;
|
||||
const ratio = Math.min(widthRatio, heightRatio);
|
||||
|
||||
return {
|
||||
width: Math.round(originalWidth * ratio),
|
||||
height: Math.round(originalHeight * ratio)
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 파일 크기를 사람이 읽을 수 있는 형식으로 변환
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
/**
|
||||
* Base64 문자열의 크기 계산
|
||||
*/
|
||||
getBase64Size(base64String) {
|
||||
const base64Length = base64String.length - (base64String.indexOf(',') + 1);
|
||||
const padding = (base64String.charAt(base64String.length - 2) === '=') ? 2 :
|
||||
((base64String.charAt(base64String.length - 1) === '=') ? 1 : 0);
|
||||
return (base64Length * 0.75) - padding;
|
||||
},
|
||||
|
||||
/**
|
||||
* 이미지 미리보기 생성 (썸네일)
|
||||
*/
|
||||
async createThumbnail(source, size = 150) {
|
||||
return this.compressImage(source, {
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
quality: 0.8
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.ImageUtils = ImageUtils;
|
||||
Reference in New Issue
Block a user