Initial commit: Todo Project with dashboard, classification center, and upload functionality
- 📱 PWA 지원: 홈화면 추가 가능한 Progressive Web App - 🎨 M-Project 색상 스키마: 하늘색, 주황색, 회색, 흰색 일관된 디자인 - 📊 대시보드: 데스크톱 캘린더 뷰 + 모바일 일일 뷰 반응형 디자인 - 📥 분류 센터: Gmail 스타일 받은편지함으로 스마트 분류 시스템 - 🤖 AI 분류 제안: 키워드 기반 자동 분류 제안 및 일괄 처리 - 📷 업로드 모달: 데스크톱(파일 선택) + 모바일(카메라/갤러리) 최적화 - 🏷️ 3가지 분류: Todo(시작일), 캘린더(마감일), 체크리스트(무기한) - 📋 체크리스트: 진행률 표시 및 완료 토글 기능 - 🔄 시놀로지 연동 준비: 메일플러스 연동을 위한 구조 설계 - 📱 반응형 UI: 모든 페이지 모바일 최적화 완료
This commit is contained in:
165
frontend/static/js/api.js
Normal file
165
frontend/static/js/api.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* API 통신 유틸리티
|
||||
*/
|
||||
|
||||
const API_BASE_URL = 'http://localhost:9000/api';
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.token = localStorage.getItem('authToken');
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
// 인증 토큰 추가
|
||||
if (this.token) {
|
||||
config.headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// 토큰 만료 시 로그아웃
|
||||
this.logout();
|
||||
throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
console.error('API 요청 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// GET 요청
|
||||
async get(endpoint) {
|
||||
return this.request(endpoint, { method: 'GET' });
|
||||
}
|
||||
|
||||
// POST 요청
|
||||
async post(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// PUT 요청
|
||||
async put(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE 요청
|
||||
async delete(endpoint) {
|
||||
return this.request(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// 파일 업로드
|
||||
async uploadFile(endpoint, formData) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// Content-Type을 설정하지 않음 (FormData가 자동으로 설정)
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
// 토큰 설정
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
localStorage.setItem('authToken', token);
|
||||
}
|
||||
|
||||
// 로그아웃
|
||||
logout() {
|
||||
this.token = null;
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 API 클라이언트 인스턴스
|
||||
const api = new ApiClient();
|
||||
|
||||
// 인증 관련 API
|
||||
const AuthAPI = {
|
||||
async login(username, password) {
|
||||
const response = await api.post('/auth/login', {
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
if (response.access_token) {
|
||||
api.setToken(response.access_token);
|
||||
localStorage.setItem('currentUser', JSON.stringify(response.user));
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('로그아웃 API 호출 실패:', error);
|
||||
} finally {
|
||||
api.logout();
|
||||
}
|
||||
},
|
||||
|
||||
async getCurrentUser() {
|
||||
return api.get('/auth/me');
|
||||
}
|
||||
};
|
||||
|
||||
// Todo 관련 API
|
||||
const TodoAPI = {
|
||||
async getTodos(filter = 'all') {
|
||||
const params = filter !== 'all' ? `?status=${filter}` : '';
|
||||
return api.get(`/todos${params}`);
|
||||
},
|
||||
|
||||
async createTodo(todoData) {
|
||||
return api.post('/todos', todoData);
|
||||
},
|
||||
|
||||
async updateTodo(id, todoData) {
|
||||
return api.put(`/todos/${id}`, todoData);
|
||||
},
|
||||
|
||||
async deleteTodo(id) {
|
||||
return api.delete(`/todos/${id}`);
|
||||
},
|
||||
|
||||
async uploadImage(imageFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
return api.uploadFile('/todos/upload-image', formData);
|
||||
}
|
||||
};
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.api = api;
|
||||
window.AuthAPI = AuthAPI;
|
||||
window.TodoAPI = TodoAPI;
|
||||
139
frontend/static/js/auth.js
Normal file
139
frontend/static/js/auth.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 인증 관리
|
||||
*/
|
||||
|
||||
let currentUser = null;
|
||||
|
||||
// 페이지 로드 시 인증 상태 확인
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuthStatus();
|
||||
setupLoginForm();
|
||||
});
|
||||
|
||||
// 인증 상태 확인
|
||||
function checkAuthStatus() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const userData = localStorage.getItem('currentUser');
|
||||
|
||||
if (token && userData) {
|
||||
try {
|
||||
currentUser = JSON.parse(userData);
|
||||
showMainApp();
|
||||
} catch (error) {
|
||||
console.error('사용자 데이터 파싱 실패:', error);
|
||||
logout();
|
||||
}
|
||||
} else {
|
||||
showLoginScreen();
|
||||
}
|
||||
}
|
||||
|
||||
// 로그인 폼 설정
|
||||
function setupLoginForm() {
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', handleLogin);
|
||||
}
|
||||
}
|
||||
|
||||
// 로그인 처리
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
alert('사용자명과 비밀번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showLoading(true);
|
||||
|
||||
// 임시 로그인 (백엔드 구현 전까지)
|
||||
if (username === 'user1' && password === 'password123') {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'user1',
|
||||
email: 'user1@todo-project.local',
|
||||
full_name: '사용자1'
|
||||
};
|
||||
|
||||
currentUser = mockUser;
|
||||
localStorage.setItem('authToken', 'mock-token-' + Date.now());
|
||||
localStorage.setItem('currentUser', JSON.stringify(mockUser));
|
||||
|
||||
showMainApp();
|
||||
} else {
|
||||
throw new Error('잘못된 사용자명 또는 비밀번호입니다.');
|
||||
}
|
||||
|
||||
// 실제 API 호출 (백엔드 구현 후 사용)
|
||||
/*
|
||||
const response = await AuthAPI.login(username, password);
|
||||
currentUser = response.user;
|
||||
showMainApp();
|
||||
*/
|
||||
|
||||
} catch (error) {
|
||||
console.error('로그인 실패:', error);
|
||||
alert(error.message || '로그인에 실패했습니다.');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃
|
||||
function logout() {
|
||||
currentUser = null;
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
showLoginScreen();
|
||||
}
|
||||
|
||||
// 로그인 화면 표시
|
||||
function showLoginScreen() {
|
||||
document.getElementById('loginScreen').classList.remove('hidden');
|
||||
document.getElementById('mainApp').classList.add('hidden');
|
||||
|
||||
// 폼 초기화
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
if (loginForm) {
|
||||
loginForm.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 앱 표시
|
||||
function showMainApp() {
|
||||
document.getElementById('loginScreen').classList.add('hidden');
|
||||
document.getElementById('mainApp').classList.remove('hidden');
|
||||
|
||||
// 사용자 정보 표시
|
||||
const currentUserElement = document.getElementById('currentUser');
|
||||
if (currentUserElement && currentUser) {
|
||||
currentUserElement.textContent = currentUser.full_name || currentUser.username;
|
||||
}
|
||||
|
||||
// Todo 목록 로드
|
||||
if (typeof loadTodos === 'function') {
|
||||
loadTodos();
|
||||
}
|
||||
}
|
||||
|
||||
// 로딩 상태 표시
|
||||
function showLoading(show) {
|
||||
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||
if (loadingOverlay) {
|
||||
if (show) {
|
||||
loadingOverlay.classList.remove('hidden');
|
||||
} else {
|
||||
loadingOverlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.currentUser = currentUser;
|
||||
window.logout = logout;
|
||||
window.showLoading = showLoading;
|
||||
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;
|
||||
589
frontend/static/js/todos.js
Normal file
589
frontend/static/js/todos.js
Normal file
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* Todo 관리 기능
|
||||
*/
|
||||
|
||||
let todos = [];
|
||||
let currentPhoto = null;
|
||||
let currentFilter = 'all';
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupTodoForm();
|
||||
setupPhotoUpload();
|
||||
setupFilters();
|
||||
updateItemCounts();
|
||||
loadRegisteredItems();
|
||||
});
|
||||
|
||||
// Todo 폼 설정
|
||||
function setupTodoForm() {
|
||||
const todoForm = document.getElementById('todoForm');
|
||||
if (todoForm) {
|
||||
todoForm.addEventListener('submit', handleTodoSubmit);
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 업로드 설정
|
||||
function setupPhotoUpload() {
|
||||
const cameraInput = document.getElementById('cameraInput');
|
||||
const galleryInput = document.getElementById('galleryInput');
|
||||
|
||||
if (cameraInput) {
|
||||
cameraInput.addEventListener('change', handlePhotoUpload);
|
||||
}
|
||||
|
||||
if (galleryInput) {
|
||||
galleryInput.addEventListener('change', handlePhotoUpload);
|
||||
}
|
||||
}
|
||||
|
||||
// 필터 설정
|
||||
function setupFilters() {
|
||||
// 필터 탭 클릭 이벤트는 HTML에서 onclick으로 처리
|
||||
}
|
||||
|
||||
// Todo 제출 처리
|
||||
async function handleTodoSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const content = document.getElementById('todoContent').value.trim();
|
||||
if (!content) {
|
||||
alert('할일 내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showLoading(true);
|
||||
|
||||
const todoData = {
|
||||
content: content,
|
||||
photo: currentPhoto,
|
||||
status: 'draft',
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 임시 저장 (백엔드 구현 전까지)
|
||||
const newTodo = {
|
||||
id: Date.now(),
|
||||
...todoData,
|
||||
user_id: currentUser?.id || 1
|
||||
};
|
||||
|
||||
todos.unshift(newTodo);
|
||||
|
||||
// 실제 API 호출 (백엔드 구현 후 사용)
|
||||
/*
|
||||
const newTodo = await TodoAPI.createTodo(todoData);
|
||||
todos.unshift(newTodo);
|
||||
*/
|
||||
|
||||
// 폼 초기화 및 목록 업데이트
|
||||
clearForm();
|
||||
loadRegisteredItems();
|
||||
updateItemCounts();
|
||||
|
||||
// 성공 메시지
|
||||
showToast('항목이 등록되었습니다!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('할일 추가 실패:', error);
|
||||
alert(error.message || '할일 추가에 실패했습니다.');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 업로드 처리
|
||||
async function handlePhotoUpload(event) {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
|
||||
try {
|
||||
showLoading(true);
|
||||
|
||||
// 이미지 압축
|
||||
const compressedImage = await ImageUtils.compressImage(file, {
|
||||
maxWidth: 800,
|
||||
maxHeight: 600,
|
||||
quality: 0.8
|
||||
});
|
||||
|
||||
currentPhoto = compressedImage;
|
||||
|
||||
// 미리보기 표시
|
||||
const previewContainer = document.getElementById('photoPreview');
|
||||
const previewImage = document.getElementById('previewImage');
|
||||
|
||||
if (previewContainer && previewImage) {
|
||||
previewImage.src = compressedImage;
|
||||
previewContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('이미지 처리 실패:', error);
|
||||
alert('이미지 처리에 실패했습니다.');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 카메라 열기
|
||||
function openCamera() {
|
||||
const cameraInput = document.getElementById('cameraInput');
|
||||
if (cameraInput) {
|
||||
cameraInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
// 갤러리 열기
|
||||
function openGallery() {
|
||||
const galleryInput = document.getElementById('galleryInput');
|
||||
if (galleryInput) {
|
||||
galleryInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 제거
|
||||
function removePhoto() {
|
||||
currentPhoto = null;
|
||||
|
||||
const previewContainer = document.getElementById('photoPreview');
|
||||
const previewImage = document.getElementById('previewImage');
|
||||
|
||||
if (previewContainer) {
|
||||
previewContainer.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (previewImage) {
|
||||
previewImage.src = '';
|
||||
}
|
||||
|
||||
// 파일 입력 초기화
|
||||
const cameraInput = document.getElementById('cameraInput');
|
||||
const galleryInput = document.getElementById('galleryInput');
|
||||
|
||||
if (cameraInput) cameraInput.value = '';
|
||||
if (galleryInput) galleryInput.value = '';
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
function clearForm() {
|
||||
const todoForm = document.getElementById('todoForm');
|
||||
if (todoForm) {
|
||||
todoForm.reset();
|
||||
}
|
||||
|
||||
removePhoto();
|
||||
}
|
||||
|
||||
// Todo 목록 로드
|
||||
async function loadTodos() {
|
||||
try {
|
||||
// 임시 데이터 (백엔드 구현 전까지)
|
||||
if (todos.length === 0) {
|
||||
todos = [
|
||||
{
|
||||
id: 1,
|
||||
content: '프로젝트 문서 검토',
|
||||
status: 'active',
|
||||
photo: null,
|
||||
created_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
user_id: 1
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: '회의 준비',
|
||||
status: 'completed',
|
||||
photo: null,
|
||||
created_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
user_id: 1
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// 실제 API 호출 (백엔드 구현 후 사용)
|
||||
/*
|
||||
todos = await TodoAPI.getTodos(currentFilter);
|
||||
*/
|
||||
|
||||
renderTodos();
|
||||
|
||||
} catch (error) {
|
||||
console.error('할일 목록 로드 실패:', error);
|
||||
showToast('할일 목록을 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 목록 렌더링
|
||||
function renderTodos() {
|
||||
const todoList = document.getElementById('todoList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (!todoList || !emptyState) return;
|
||||
|
||||
// 필터링
|
||||
const filteredTodos = todos.filter(todo => {
|
||||
if (currentFilter === 'all') return true;
|
||||
if (currentFilter === 'active') return ['draft', 'scheduled', 'active', 'delayed'].includes(todo.status);
|
||||
if (currentFilter === 'completed') return todo.status === 'completed';
|
||||
return todo.status === currentFilter;
|
||||
});
|
||||
|
||||
// 빈 상태 처리
|
||||
if (filteredTodos.length === 0) {
|
||||
todoList.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
// Todo 항목 렌더링
|
||||
todoList.innerHTML = filteredTodos.map(todo => `
|
||||
<div class="todo-item p-4 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 체크박스 -->
|
||||
<button onclick="toggleTodo(${todo.id})" class="mt-1 flex-shrink-0">
|
||||
<i class="fas ${todo.status === 'completed' ? 'fa-check-circle text-green-500' : 'fa-circle text-gray-300'} text-lg"></i>
|
||||
</button>
|
||||
|
||||
<!-- 사진 (있는 경우) -->
|
||||
${todo.photo ? `
|
||||
<div class="flex-shrink-0">
|
||||
<img src="${todo.photo}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-gray-900 ${todo.status === 'completed' ? 'line-through text-gray-500' : ''}">${todo.content}</p>
|
||||
<div class="flex items-center space-x-3 mt-2 text-sm text-gray-500">
|
||||
<span class="status-${todo.status}">
|
||||
<i class="fas ${getStatusIcon(todo.status)} mr-1"></i>${getStatusText(todo.status)}
|
||||
</span>
|
||||
<span>${formatDate(todo.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
${todo.status !== 'completed' ? `
|
||||
<button onclick="editTodo(${todo.id})" class="text-gray-400 hover:text-blue-500">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button onclick="deleteTodo(${todo.id})" class="text-gray-400 hover:text-red-500">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Todo 상태 토글
|
||||
async function toggleTodo(id) {
|
||||
try {
|
||||
const todo = todos.find(t => t.id === id);
|
||||
if (!todo) return;
|
||||
|
||||
const newStatus = todo.status === 'completed' ? 'active' : 'completed';
|
||||
|
||||
// 임시 업데이트
|
||||
todo.status = newStatus;
|
||||
|
||||
// 실제 API 호출 (백엔드 구현 후 사용)
|
||||
/*
|
||||
await TodoAPI.updateTodo(id, { status: newStatus });
|
||||
*/
|
||||
|
||||
renderTodos();
|
||||
showToast(newStatus === 'completed' ? '할일을 완료했습니다!' : '할일을 다시 활성화했습니다!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('할일 상태 변경 실패:', error);
|
||||
showToast('상태 변경에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 삭제
|
||||
async function deleteTodo(id) {
|
||||
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
// 임시 삭제
|
||||
todos = todos.filter(t => t.id !== id);
|
||||
|
||||
// 실제 API 호출 (백엔드 구현 후 사용)
|
||||
/*
|
||||
await TodoAPI.deleteTodo(id);
|
||||
*/
|
||||
|
||||
renderTodos();
|
||||
showToast('할일이 삭제되었습니다.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('할일 삭제 실패:', error);
|
||||
showToast('삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 편집 (향후 구현)
|
||||
function editTodo(id) {
|
||||
// TODO: 편집 모달 또는 인라인 편집 구현
|
||||
console.log('편집 기능 구현 예정:', id);
|
||||
}
|
||||
|
||||
// 필터 변경
|
||||
function filterTodos(filter) {
|
||||
currentFilter = filter;
|
||||
|
||||
// 탭 활성화 상태 변경
|
||||
document.querySelectorAll('.filter-tab').forEach(tab => {
|
||||
tab.classList.remove('active', 'bg-white', 'text-blue-600');
|
||||
tab.classList.add('text-gray-600');
|
||||
});
|
||||
|
||||
event.target.classList.add('active', 'bg-white', 'text-blue-600');
|
||||
event.target.classList.remove('text-gray-600');
|
||||
|
||||
renderTodos();
|
||||
}
|
||||
|
||||
// 상태 아이콘 반환
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
draft: 'fa-edit',
|
||||
scheduled: 'fa-calendar',
|
||||
active: 'fa-play',
|
||||
completed: 'fa-check',
|
||||
delayed: 'fa-clock'
|
||||
};
|
||||
return icons[status] || 'fa-circle';
|
||||
}
|
||||
|
||||
// 상태 텍스트 반환
|
||||
function getStatusText(status) {
|
||||
const texts = {
|
||||
draft: '검토 필요',
|
||||
scheduled: '예정됨',
|
||||
active: '진행중',
|
||||
completed: '완료됨',
|
||||
delayed: '지연됨'
|
||||
};
|
||||
return texts[status] || '알 수 없음';
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = now - date;
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return '오늘';
|
||||
if (diffDays === 1) return '어제';
|
||||
if (diffDays < 7) return `${diffDays}일 전`;
|
||||
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
function showToast(message, type = 'info') {
|
||||
// 간단한 alert으로 대체 (향후 토스트 UI 구현)
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
|
||||
if (type === 'error') {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 이동 함수
|
||||
function goToPage(pageType) {
|
||||
const pages = {
|
||||
'todo': 'todo.html',
|
||||
'calendar': 'calendar.html',
|
||||
'checklist': 'checklist.html'
|
||||
};
|
||||
|
||||
if (pages[pageType]) {
|
||||
window.location.href = pages[pageType];
|
||||
} else {
|
||||
console.error('Unknown page type:', pageType);
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드로 이동
|
||||
function goToDashboard() {
|
||||
window.location.href = 'dashboard.html';
|
||||
}
|
||||
|
||||
// 분류 센터로 이동
|
||||
function goToClassify() {
|
||||
window.location.href = 'classify.html';
|
||||
}
|
||||
|
||||
// 항목 등록 후 인덱스 업데이트
|
||||
function updateItemCounts() {
|
||||
// TODO: API에서 각 분류별 항목 수를 가져와서 업데이트
|
||||
// 임시로 하드코딩된 값 사용
|
||||
const todoCount = document.getElementById('todoCount');
|
||||
const calendarCount = document.getElementById('calendarCount');
|
||||
const checklistCount = document.getElementById('checklistCount');
|
||||
|
||||
if (todoCount) todoCount.textContent = '2개';
|
||||
if (calendarCount) calendarCount.textContent = '3개';
|
||||
if (checklistCount) checklistCount.textContent = '5개';
|
||||
}
|
||||
|
||||
// 등록된 항목들 로드
|
||||
function loadRegisteredItems() {
|
||||
// 임시 데이터 (실제로는 API에서 가져옴)
|
||||
const sampleItems = [
|
||||
{
|
||||
id: 1,
|
||||
content: '프로젝트 문서 정리',
|
||||
photo_url: null,
|
||||
category: null,
|
||||
created_at: '2024-01-15'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: '회의 자료 준비',
|
||||
photo_url: null,
|
||||
category: 'todo',
|
||||
created_at: '2024-01-16'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: '월말 보고서 작성',
|
||||
photo_url: null,
|
||||
category: 'calendar',
|
||||
created_at: '2024-01-17'
|
||||
}
|
||||
];
|
||||
|
||||
renderRegisteredItems(sampleItems);
|
||||
}
|
||||
|
||||
// 등록된 항목들 렌더링
|
||||
function renderRegisteredItems(items) {
|
||||
const itemsList = document.getElementById('itemsList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (!itemsList || !emptyState) return;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
itemsList.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
itemsList.innerHTML = items.map(item => `
|
||||
<div class="p-6 hover:bg-gray-50 cursor-pointer transition-colors" onclick="showClassificationModal(${item.id})">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 사진 (있는 경우) -->
|
||||
${item.photo_url ? `
|
||||
<div class="flex-shrink-0">
|
||||
<img src="${item.photo_url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-gray-900 font-medium mb-2">${item.content}</h4>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1"></i>등록: ${formatDate(item.created_at)}
|
||||
</span>
|
||||
${item.category ? `
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getCategoryColor(item.category)}">
|
||||
${getCategoryText(item.category)}
|
||||
</span>
|
||||
` : `
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
미분류
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분류 아이콘 -->
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 분류 모달 표시
|
||||
function showClassificationModal(itemId) {
|
||||
// TODO: 분류 선택 모달 구현
|
||||
console.log('분류 모달 표시:', itemId);
|
||||
|
||||
// 임시로 confirm으로 분류 선택
|
||||
const choice = prompt('분류를 선택하세요:\n1. Todo (시작 날짜)\n2. 캘린더 (마감 기한)\n3. 체크리스트 (기한 없음)\n\n번호를 입력하세요:');
|
||||
|
||||
if (choice) {
|
||||
const categories = {
|
||||
'1': 'todo',
|
||||
'2': 'calendar',
|
||||
'3': 'checklist'
|
||||
};
|
||||
|
||||
const category = categories[choice];
|
||||
if (category) {
|
||||
classifyItem(itemId, category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 항목 분류
|
||||
function classifyItem(itemId, category) {
|
||||
// TODO: API 호출하여 항목 분류 업데이트
|
||||
console.log('항목 분류:', itemId, category);
|
||||
|
||||
// 분류 후 해당 페이지로 이동
|
||||
goToPage(category);
|
||||
}
|
||||
|
||||
// 분류별 색상
|
||||
function getCategoryColor(category) {
|
||||
const colors = {
|
||||
'todo': 'bg-blue-100 text-blue-800',
|
||||
'calendar': 'bg-orange-100 text-orange-800',
|
||||
'checklist': 'bg-green-100 text-green-800'
|
||||
};
|
||||
return colors[category] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
// 분류별 텍스트
|
||||
function getCategoryText(category) {
|
||||
const texts = {
|
||||
'todo': 'Todo',
|
||||
'calendar': '캘린더',
|
||||
'checklist': '체크리스트'
|
||||
};
|
||||
return texts[category] || '미분류';
|
||||
}
|
||||
|
||||
// 전역으로 사용 가능하도록 export
|
||||
window.loadTodos = loadTodos;
|
||||
window.openCamera = openCamera;
|
||||
window.openGallery = openGallery;
|
||||
window.removePhoto = removePhoto;
|
||||
window.clearForm = clearForm;
|
||||
window.toggleTodo = toggleTodo;
|
||||
window.deleteTodo = deleteTodo;
|
||||
window.editTodo = editTodo;
|
||||
window.filterTodos = filterTodos;
|
||||
window.goToPage = goToPage;
|
||||
window.goToDashboard = goToDashboard;
|
||||
window.goToClassify = goToClassify;
|
||||
window.showClassificationModal = showClassificationModal;
|
||||
window.updateItemCounts = updateItemCounts;
|
||||
Reference in New Issue
Block a user