🚀 시놀로지 배포 준비 완료

 주요 변경사항:
- 단일 docker-compose.yml로 통합 (로컬/시놀로지 환경 지원)
- 시놀로지 볼륨 매핑 설정 (volume1: 이미지, volume3: 데이터)
- 통합 배포 가이드 및 자동 배포 스크립트 추가
- 완전한 Memos 스타일 워크플로우 구현

🎯 새로운 기능:
- 📝 메모 작성 (upload.html) - 이미지 업로드 지원
- 📥 수신함 (inbox.html) - 메모 편집 및 Todo/보드 변환
-  Todo 목록 (todo-list.html) - 오늘 할 일 관리
- 📋 보드 (board.html) - 프로젝트 관리, 접기/펼치기, 이미지 지원
- 📚 아카이브 (archive.html) - 완료된 보드 보관
- 🔐 초기 설정 화면 - 관리자 계정 생성

🔧 기술적 개선:
- 이미지 업로드/편집 완전 지원
- 반응형 디자인 및 모바일 최적화
- 보드 완료 후 자동 숨김 처리
- 메모 편집 시 제목 필드 제거
- 테스트 로그인 버튼 제거 (프로덕션 준비)
- 과거 코드 정리 (TodoService, CalendarSyncService 등)

📦 배포 관련:
- env.synology.example - 시놀로지 환경 설정 템플릿
- SYNOLOGY_DEPLOYMENT_GUIDE.md - 상세한 배포 가이드
- deploy-synology.sh - 원클릭 자동 배포 스크립트
- Nginx 정적 파일 서빙 및 이미지 프록시 설정

🗑️ 정리된 파일:
- 사용하지 않는 HTML 페이지들 (dashboard, calendar, checklist 등)
- 복잡한 통합 서비스들 (integrations 폴더)
- 중복된 시놀로지 설정 파일들
This commit is contained in:
Hyungi Ahn
2025-09-24 09:12:39 +09:00
parent 4c7d2d8290
commit 0b967a84fa
42 changed files with 5467 additions and 6691 deletions

View File

@@ -4,8 +4,8 @@ FROM nginx:alpine
# 정적 파일들을 nginx 웹 루트로 복사
COPY . /usr/share/nginx/html/
# nginx 설정 파일 복사 (있는 경우)
# COPY nginx.conf /etc/nginx/nginx.conf
# nginx 설정 파일 복사
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 포트 80 노출
EXPOSE 80

574
frontend/archive.html Normal file
View File

@@ -0,0 +1,574 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>아카이브 - Todo Project</title>
<link rel="icon" type="image/x-icon" href="static/icons/favicon.ico">
<link rel="apple-touch-icon" href="static/icons/apple-touch-icon.png">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--parchment: #f7f3e9;
--parchment-dark: #f0ead6;
--ink: #2c1810;
--ink-light: #5d4e37;
--sepia: #8b7355;
--gold: #d4af37;
--shadow: rgba(139, 115, 85, 0.2);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
}
body {
font-family: 'Noto Serif KR', serif;
background: linear-gradient(135deg, #f7f3e9 0%, #f0ead6 100%);
background-attachment: fixed;
color: var(--ink);
}
.parchment-container {
background: var(--parchment);
background-image:
radial-gradient(circle at 25% 25%, rgba(139, 115, 85, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(139, 115, 85, 0.05) 0%, transparent 50%);
border: 2px solid var(--sepia);
border-radius: 8px;
box-shadow:
0 8px 32px var(--shadow),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
position: relative;
}
.parchment-container::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg, var(--gold), var(--sepia), var(--gold));
border-radius: 10px;
z-index: -1;
opacity: 0.3;
}
.header-vintage {
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
border-bottom: 3px solid var(--gold);
box-shadow: 0 2px 10px var(--shadow);
}
.search-container {
background: var(--parchment-dark);
border: 2px solid var(--sepia);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.search-input {
background: var(--parchment);
border: 2px solid var(--sepia);
border-radius: 8px;
padding: 0.75rem 1rem;
width: 100%;
font-family: 'Noto Serif KR', serif;
color: var(--ink);
font-size: 1rem;
}
.search-input:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.2);
}
.archive-board {
background: var(--parchment-dark);
border: 2px solid var(--sepia);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
position: relative;
opacity: 0.85;
}
.archive-board:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px var(--shadow);
border-color: var(--gold);
opacity: 1;
}
.archive-board.expanded {
border-color: var(--gold);
box-shadow: 0 0 15px rgba(212, 175, 55, 0.3);
opacity: 1;
}
.board-header-archive {
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
color: var(--parchment);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
font-weight: 600;
text-align: center;
box-shadow: 0 4px 15px var(--shadow);
cursor: pointer;
}
.board-header-archive:hover {
background: linear-gradient(135deg, var(--ink-light), var(--ink));
}
.completed-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
background: var(--success);
color: white;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
margin-left: 0.5rem;
}
.memo-item-archive {
background: var(--parchment);
border: 1px solid var(--sepia);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
opacity: 0.9;
}
.action-button {
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
color: var(--parchment);
border: 2px solid var(--gold);
border-radius: 20px;
padding: 0.5rem 1rem;
font-family: 'Noto Serif KR', serif;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 8px var(--shadow);
font-size: 0.875rem;
}
.action-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--shadow);
}
.filter-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.filter-button {
background: var(--parchment);
border: 1px solid var(--sepia);
border-radius: 20px;
padding: 0.5rem 1rem;
font-family: 'Noto Serif KR', serif;
font-size: 0.875rem;
color: var(--ink);
cursor: pointer;
transition: all 0.3s ease;
}
.filter-button:hover, .filter-button.active {
background: var(--gold);
border-color: var(--gold);
color: var(--ink);
box-shadow: 0 2px 8px var(--shadow);
}
.date-info {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.75rem;
color: var(--sepia);
margin-top: 0.5rem;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(40px, 1fr));
gap: 0.25rem;
max-width: 160px;
margin-top: 0.5rem;
}
.image-item {
aspect-ratio: 1;
border-radius: 3px;
overflow: hidden;
border: 1px solid var(--sepia);
}
.image-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.modal {
background: rgba(44, 24, 16, 0.8);
backdrop-filter: blur(5px);
}
.memos-container {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.memos-container.expanded {
max-height: 500px;
}
@media (max-width: 768px) {
.filter-buttons {
justify-content: center;
}
.date-info {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}
</style>
</head>
<body>
<!-- 헤더 -->
<header class="header-vintage">
<div class="max-w-6xl mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h1 class="text-2xl font-semibold" style="color: var(--ink);">
<i class="fas fa-archive mr-3" style="color: var(--gold);"></i>
아카이브
</h1>
<span id="archiveCount" class="ml-4 px-3 py-1 bg-gold text-white rounded-full text-sm font-medium"></span>
</div>
<div class="flex space-x-2">
<a href="upload.html" class="action-button">
<i class="fas fa-feather-alt mr-2"></i>메모
</a>
<a href="inbox.html" class="action-button">
<i class="fas fa-inbox mr-2"></i>수신함
</a>
<a href="todo-list.html" class="action-button">
<i class="fas fa-tasks mr-2"></i>Todo 목록
</a>
<a href="board.html" class="action-button">
<i class="fas fa-clipboard mr-2"></i>보드
</a>
<a href="archive.html" class="action-button" style="background: var(--gold); border-color: var(--gold); color: var(--ink);">
<i class="fas fa-archive mr-2"></i>아카이브
</a>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-6xl mx-auto px-4 py-8">
<!-- 검색 및 필터 -->
<div class="search-container">
<div class="mb-4">
<div class="flex items-center gap-3 mb-3">
<i class="fas fa-search" style="color: var(--gold);"></i>
<input type="text" id="searchInput" class="search-input" placeholder="보드 제목이나 내용으로 검색하세요..." onkeyup="handleSearch()">
</div>
<div class="filter-buttons">
<button class="filter-button active" onclick="setDateFilter('all')">전체</button>
<button class="filter-button" onclick="setDateFilter('week')">최근 1주일</button>
<button class="filter-button" onclick="setDateFilter('month')">최근 1개월</button>
<button class="filter-button" onclick="setDateFilter('quarter')">최근 3개월</button>
<button class="filter-button" onclick="setDateFilter('year')">최근 1년</button>
</div>
</div>
</div>
<!-- 아카이브 목록 -->
<div id="archiveContainer">
<!-- 완료된 보드들이 여기에 표시됩니다 -->
</div>
<div id="emptyState" class="hidden text-center py-12">
<div class="parchment-container p-8">
<i class="fas fa-archive text-6xl mb-4" style="color: var(--sepia); opacity: 0.5;"></i>
<h3 class="text-xl font-medium mb-2" style="color: var(--ink-light);">완료된 보드가 없습니다</h3>
<p class="text-sm mb-6" style="color: var(--sepia);">보드를 완료하면 여기에서 확인할 수 있습니다</p>
<a href="board.html" class="action-button">
<i class="fas fa-clipboard mr-2"></i>보드로 이동
</a>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/auth.js"></script>
<script>
let allArchivedBoards = [];
let filteredBoards = [];
let currentDateFilter = 'all';
let currentSearchTerm = '';
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
// 인증 확인
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
if (!token) {
window.location.href = 'index.html';
return;
}
loadArchivedBoards();
});
// 완료된 보드 목록 로드
async function loadArchivedBoards() {
try {
const boards = await TodoAPI.getTodos(null, 'board');
// 완료된 보드만 필터링하고 그룹화
const completedBoards = boards.filter(item => item.status === 'completed');
const boardGroups = {};
completedBoards.forEach(item => {
const boardId = item.board_id || item.id;
if (!boardGroups[boardId]) {
boardGroups[boardId] = {
header: null,
memos: []
};
}
if (item.is_board_header) {
boardGroups[boardId].header = item;
} else {
boardGroups[boardId].memos.push(item);
}
});
allArchivedBoards = Object.values(boardGroups)
.filter(board => board.header)
.sort((a, b) => new Date(b.header.completed_at) - new Date(a.header.completed_at));
applyFilters();
} catch (error) {
console.error('아카이브 로드 실패:', error);
showEmptyState();
}
}
// 필터 적용
function applyFilters() {
let filtered = [...allArchivedBoards];
// 날짜 필터 적용
if (currentDateFilter !== 'all') {
const now = new Date();
const filterDate = new Date();
switch (currentDateFilter) {
case 'week':
filterDate.setDate(now.getDate() - 7);
break;
case 'month':
filterDate.setMonth(now.getMonth() - 1);
break;
case 'quarter':
filterDate.setMonth(now.getMonth() - 3);
break;
case 'year':
filterDate.setFullYear(now.getFullYear() - 1);
break;
}
filtered = filtered.filter(board =>
new Date(board.header.completed_at) >= filterDate
);
}
// 검색어 필터 적용
if (currentSearchTerm) {
const searchLower = currentSearchTerm.toLowerCase();
filtered = filtered.filter(board => {
const headerMatch = (board.header.title || board.header.description || '').toLowerCase().includes(searchLower);
const memoMatch = board.memos.some(memo =>
memo.description.toLowerCase().includes(searchLower)
);
return headerMatch || memoMatch;
});
}
filteredBoards = filtered;
renderArchivedBoards();
}
// 아카이브 보드 렌더링
function renderArchivedBoards() {
const archiveContainer = document.getElementById('archiveContainer');
const emptyState = document.getElementById('emptyState');
const archiveCount = document.getElementById('archiveCount');
archiveCount.textContent = `${filteredBoards.length}`;
if (!filteredBoards || filteredBoards.length === 0) {
showEmptyState();
return;
}
emptyState.classList.add('hidden');
archiveContainer.innerHTML = filteredBoards.map(board => {
const header = board.header;
const memos = board.memos.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const completedDate = new Date(header.completed_at);
const createdDate = new Date(header.created_at);
const duration = Math.ceil((completedDate - createdDate) / (1000 * 60 * 60 * 24));
return `
<div class="archive-board" id="board_${header.id}">
<div class="board-header-archive" onclick="toggleBoardExpansion('${header.id}')">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">
${header.title || header.description}
<span class="completed-badge">
<i class="fas fa-check mr-1"></i>완료됨
</span>
</h3>
<i id="expandIcon_${header.id}" class="fas fa-chevron-down transition-transform"></i>
</div>
<div class="date-info">
<span><i class="fas fa-calendar-plus mr-1"></i>시작: ${createdDate.toLocaleDateString('ko-KR')}</span>
<span><i class="fas fa-calendar-check mr-1"></i>완료: ${completedDate.toLocaleDateString('ko-KR')}</span>
<span><i class="fas fa-clock mr-1"></i>기간: ${duration}일</span>
<span><i class="fas fa-sticky-note mr-1"></i>메모: ${memos.length}개</span>
</div>
</div>
<div id="memos_${header.id}" class="memos-container">
<div class="p-2">
${memos.length > 0 ? memos.map(memo => {
const memoDate = new Date(memo.created_at);
const hasImages = memo.image_urls && memo.image_urls.length > 0;
return `
<div class="memo-item-archive">
<div class="flex justify-between items-start">
<div class="flex-1">
<p class="mb-2">${memo.description}</p>
<div class="flex items-center text-xs" style="color: var(--sepia);">
<i class="fas fa-clock mr-1"></i>
<span>${memoDate.toLocaleDateString('ko-KR')} ${memoDate.toLocaleTimeString('ko-KR', {hour: '2-digit', minute: '2-digit'})}</span>
${hasImages ? `<i class="fas fa-images ml-3 mr-1"></i><span>${memo.image_urls.length}장</span>` : ''}
</div>
</div>
</div>
${hasImages ? `
<div class="image-grid mt-2">
${memo.image_urls.slice(0, 6).map(url => `
<div class="image-item">
<img src="${url}" alt="첨부 이미지" onclick="showImageModal('${url}')">
</div>
`).join('')}
${memo.image_urls.length > 6 ? `
<div class="image-item flex items-center justify-center" style="background: var(--parchment); border: 2px dashed var(--sepia);">
<span class="text-xs" style="color: var(--sepia);">+${memo.image_urls.length - 6}</span>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}).join('') : '<p class="text-center text-sm" style="color: var(--sepia);">메모가 없습니다</p>'}
</div>
</div>
</div>
`;
}).join('');
}
// 빈 상태 표시
function showEmptyState() {
document.getElementById('archiveContainer').innerHTML = '';
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('archiveCount').textContent = '0개';
}
// 보드 확장/축소
function toggleBoardExpansion(boardId) {
const memosContainer = document.getElementById(`memos_${boardId}`);
const expandIcon = document.getElementById(`expandIcon_${boardId}`);
const boardElement = document.getElementById(`board_${boardId}`);
if (memosContainer.classList.contains('expanded')) {
memosContainer.classList.remove('expanded');
expandIcon.style.transform = 'rotate(0deg)';
boardElement.classList.remove('expanded');
} else {
memosContainer.classList.add('expanded');
expandIcon.style.transform = 'rotate(180deg)';
boardElement.classList.add('expanded');
}
}
// 검색 처리
function handleSearch() {
currentSearchTerm = document.getElementById('searchInput').value.trim();
applyFilters();
}
// 날짜 필터 설정
function setDateFilter(filter) {
currentDateFilter = filter;
// 필터 버튼 활성화 상태 업데이트
document.querySelectorAll('.filter-button').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
applyFilters();
}
// 이미지 모달 표시
function showImageModal(imageUrl) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 modal flex items-center justify-center z-50';
modal.innerHTML = `
<div class="max-w-4xl max-h-4xl p-4">
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg">
</div>
`;
modal.onclick = () => modal.remove();
document.body.appendChild(modal);
}
// 전역 함수 등록
window.toggleBoardExpansion = toggleBoardExpansion;
window.handleSearch = handleSearch;
window.setDateFilter = setDateFilter;
window.showImageModal = showImageModal;
</script>
</body>
</html>

1141
frontend/board.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,398 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>캘린더 - 마감 기한이 있는 일들</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #3b82f6; /* 하늘색 */
--primary-dark: #2563eb; /* 진한 하늘색 */
--success: #10b981; /* 초록색 */
--warning: #f59e0b; /* 주황색 */
--danger: #ef4444; /* 빨간색 */
--gray-50: #f9fafb; /* 연한 회색 */
--gray-100: #f3f4f6; /* 회색 */
--gray-200: #e5e7eb; /* 중간 회색 */
--gray-300: #d1d5db; /* 진한 회색 */
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-warning {
background-color: var(--warning);
color: white;
transition: all 0.2s;
}
.btn-warning:hover {
background-color: #d97706;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
}
.calendar-item {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.calendar-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.deadline-urgent {
border-left: 4px solid #ef4444;
}
.deadline-warning {
border-left: 4px solid #f59e0b;
}
.deadline-normal {
border-left: 4px solid #3b82f6;
}
</style>
</head>
<body>
<div class="min-h-screen">
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<button onclick="goBack()" class="mr-4 text-gray-500 hover:text-gray-700">
<i class="fas fa-arrow-left text-xl"></i>
</button>
<i class="fas fa-calendar-times text-2xl text-orange-500 mr-3"></i>
<h1 class="text-xl font-semibold text-gray-800">캘린더</h1>
<span class="ml-3 text-sm text-gray-500">마감 기한이 있는 일들</span>
</div>
<div class="flex items-center space-x-4">
<button onclick="goToDashboard()" class="text-blue-600 hover:text-blue-800 font-medium">
<i class="fas fa-chart-line mr-1"></i>대시보드
</button>
<span class="text-sm text-gray-600" id="currentUser"></span>
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 페이지 설명 -->
<div class="bg-orange-50 rounded-xl p-6 mb-8">
<div class="flex items-center mb-4">
<i class="fas fa-calendar-times text-2xl text-orange-600 mr-3"></i>
<h2 class="text-xl font-semibold text-orange-900">캘린더 관리</h2>
</div>
<p class="text-orange-800 mb-4">
마감 기한이 있는 일들을 관리합니다. 우선순위에 따라 계획적으로 진행해보세요.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="bg-white rounded-lg p-3">
<div class="font-medium text-red-900 mb-1">🚨 긴급</div>
<div class="text-red-700">3일 이내 마감</div>
</div>
<div class="bg-white rounded-lg p-3">
<div class="font-medium text-orange-900 mb-1">⚠️ 주의</div>
<div class="text-orange-700">1주일 이내 마감</div>
</div>
<div class="bg-white rounded-lg p-3">
<div class="font-medium text-blue-900 mb-1">📅 여유</div>
<div class="text-blue-700">1주일 이상 남음</div>
</div>
</div>
</div>
<!-- 필터 및 정렬 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex space-x-1 bg-gray-100 rounded-lg p-1">
<button onclick="filterCalendar('all')" class="filter-tab active px-4 py-2 rounded text-sm font-medium">전체</button>
<button onclick="filterCalendar('urgent')" class="filter-tab px-4 py-2 rounded text-sm font-medium">긴급</button>
<button onclick="filterCalendar('warning')" class="filter-tab px-4 py-2 rounded text-sm font-medium">주의</button>
<button onclick="filterCalendar('normal')" class="filter-tab px-4 py-2 rounded text-sm font-medium">여유</button>
<button onclick="filterCalendar('completed')" class="filter-tab px-4 py-2 rounded text-sm font-medium">완료</button>
</div>
<div class="flex items-center space-x-3">
<label class="text-sm text-gray-600">정렬:</label>
<select id="sortBy" class="border border-gray-300 rounded-lg px-3 py-1 text-sm">
<option value="due_date">마감일 순</option>
<option value="priority">우선순위 순</option>
<option value="created_at">등록일 순</option>
</select>
</div>
</div>
</div>
<!-- 캘린더 목록 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b">
<h3 class="text-lg font-semibold text-gray-800">
<i class="fas fa-list text-orange-500 mr-2"></i>마감 기한별 목록
</h3>
</div>
<div id="calendarList" class="divide-y divide-gray-100">
<!-- 캘린더 항목들이 여기에 동적으로 추가됩니다 -->
</div>
<div id="emptyState" class="p-12 text-center text-gray-500">
<i class="fas fa-calendar-times text-4xl mb-4 opacity-50"></i>
<p>아직 마감 기한이 설정된 일이 없습니다.</p>
<p class="text-sm">메인 페이지에서 항목을 등록하고 마감 기한을 설정해보세요!</p>
<button onclick="goBack()" class="mt-4 btn-warning px-6 py-2 rounded-lg">
<i class="fas fa-arrow-left mr-2"></i>메인으로 돌아가기
</button>
</div>
</div>
</main>
</div>
<!-- JavaScript -->
<script src="static/js/auth.js"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
checkAuthStatus();
loadCalendarItems();
});
// 뒤로 가기
function goBack() {
window.location.href = 'index.html';
}
// 캘린더 항목 로드
async function loadCalendarItems() {
try {
// API에서 캘린더 카테고리 항목들만 가져오기
const items = await TodoAPI.getTodos(null, 'calendar');
renderCalendarItems(items);
} catch (error) {
console.error('캘린더 항목 로드 실패:', error);
renderCalendarItems([]);
}
}
// 캘린더 항목 렌더링
function renderCalendarItems(items) {
const calendarList = document.getElementById('calendarList');
const emptyState = document.getElementById('emptyState');
if (items.length === 0) {
calendarList.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
calendarList.innerHTML = items.map(item => `
<div class="calendar-item p-6 ${getDeadlineClass(item.priority)}">
<div class="flex items-start space-x-4">
<!-- 우선순위 아이콘 -->
<div class="flex-shrink-0 mt-1">
<div class="w-8 h-8 rounded-full flex items-center justify-center ${getPriorityColor(item.priority)}">
<i class="fas ${getPriorityIcon(item.priority)} text-sm"></i>
</div>
</div>
<!-- 사진 (있는 경우) -->
${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0">
<div class="flex space-x-2">
${item.image_urls.slice(0, 3).map(url => `
<img src="${url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
`).join('')}
${item.image_urls.length > 3 ? `
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
<span class="text-xs text-gray-500">+${item.image_urls.length - 3}</span>
</div>
` : ''}
</div>
</div>
` : ''}
<!-- 내용 -->
<div class="flex-1 min-w-0">
<h4 class="text-gray-900 font-medium mb-2">${item.title}</h4>
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-2">
<span class="${getDueDateColor(item.due_date)}">
<i class="fas fa-calendar-times mr-1"></i>마감: ${formatDate(item.due_date)}
</span>
<span>
<i class="fas fa-clock mr-1"></i>등록: ${formatDate(item.created_at)}
</span>
</div>
<div class="text-sm">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getPriorityBadgeColor(item.priority)}">
${getPriorityText(item.priority)}
</span>
<span class="ml-2 text-gray-500">
${getDaysRemaining(item.due_date)}
</span>
</div>
</div>
<!-- 액션 버튼 -->
<div class="flex-shrink-0 flex space-x-2">
${item.status !== 'completed' ? `
<button onclick="completeCalendar('${item.id}')" class="text-green-500 hover:text-green-700" title="완료하기">
<i class="fas fa-check"></i>
</button>
<button onclick="extendDeadline('${item.id}')" class="text-orange-500 hover:text-orange-700" title="기한 연장">
<i class="fas fa-calendar-plus"></i>
</button>
` : ''}
<button onclick="editCalendar('${item.id}')" class="text-gray-400 hover:text-blue-500" title="수정하기">
<i class="fas fa-edit"></i>
</button>
</div>
</div>
</div>
`).join('');
}
// 마감 기한별 클래스
function getDeadlineClass(priority) {
const classes = {
urgent: 'deadline-urgent',
warning: 'deadline-warning',
normal: 'deadline-normal'
};
return classes[priority] || 'deadline-normal';
}
// 우선순위별 색상
function getPriorityColor(priority) {
const colors = {
urgent: 'bg-red-100 text-red-600',
warning: 'bg-orange-100 text-orange-600',
normal: 'bg-blue-100 text-blue-600'
};
return colors[priority] || 'bg-gray-100 text-gray-600';
}
// 우선순위별 아이콘
function getPriorityIcon(priority) {
const icons = {
urgent: 'fa-exclamation-triangle',
warning: 'fa-exclamation',
normal: 'fa-calendar'
};
return icons[priority] || 'fa-circle';
}
// 우선순위별 배지 색상
function getPriorityBadgeColor(priority) {
const colors = {
urgent: 'bg-red-100 text-red-800',
warning: 'bg-orange-100 text-orange-800',
normal: 'bg-blue-100 text-blue-800'
};
return colors[priority] || 'bg-gray-100 text-gray-800';
}
// 우선순위 텍스트
function getPriorityText(priority) {
const texts = {
urgent: '긴급',
warning: '주의',
normal: '여유'
};
return texts[priority] || '일반';
}
// 마감일 색상
function getDueDateColor(dueDate) {
const days = getDaysUntilDeadline(dueDate);
if (days <= 3) return 'text-red-600 font-medium';
if (days <= 7) return 'text-orange-600 font-medium';
return 'text-gray-600';
}
// 남은 일수 계산
function getDaysUntilDeadline(dueDate) {
const today = new Date();
const deadline = new Date(dueDate);
const diffTime = deadline - today;
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
// 남은 일수 텍스트
function getDaysRemaining(dueDate) {
const days = getDaysUntilDeadline(dueDate);
if (days < 0) return '기한 초과';
if (days === 0) return '오늘 마감';
if (days === 1) return '내일 마감';
return `${days}일 남음`;
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '날짜 없음';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '날짜 없음';
return date.toLocaleDateString('ko-KR');
}
// 캘린더 완료
function completeCalendar(id) {
console.log('캘린더 완료:', id);
// TODO: API 호출하여 상태를 'completed'로 변경
}
// 기한 연장
function extendDeadline(id) {
console.log('기한 연장:', id);
// TODO: 기한 연장 모달 표시
}
// 캘린더 편집
function editCalendar(id) {
console.log('캘린더 편집:', id);
// TODO: 편집 모달 또는 페이지로 이동
}
// 필터링
function filterCalendar(filter) {
console.log('필터:', filter);
// TODO: 필터에 따라 목록 재로드
}
// 대시보드로 이동
function goToDashboard() {
window.location.href = 'dashboard.html';
}
// 전역 함수 등록
window.goToDashboard = goToDashboard;
</script>
<script src="static/js/api.js?v=20250921110800"></script>
<script src="static/js/auth.js?v=20250921110800"></script>
</body>
</html>

View File

@@ -1,604 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>체크리스트 - 기한 없는 일들</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #3b82f6; /* 하늘색 */
--primary-dark: #2563eb; /* 진한 하늘색 */
--success: #10b981; /* 초록색 */
--warning: #f59e0b; /* 주황색 */
--danger: #ef4444; /* 빨간색 */
--gray-50: #f9fafb; /* 연한 회색 */
--gray-100: #f3f4f6; /* 회색 */
--gray-200: #e5e7eb; /* 중간 회색 */
--gray-300: #d1d5db; /* 진한 회색 */
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-success {
background-color: var(--success);
color: white;
transition: all 0.2s;
}
.btn-success:hover {
background-color: #059669;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.checklist-item {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.checklist-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.checklist-item.completed {
opacity: 0.7;
background-color: #f9fafb;
}
.checklist-item.completed .item-content {
text-decoration: line-through;
color: #6b7280;
}
.checkbox-custom {
width: 20px;
height: 20px;
border: 2px solid #d1d5db;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.checkbox-custom.checked {
background-color: #10b981;
border-color: #10b981;
color: white;
}
.checkbox-custom:hover {
border-color: #10b981;
}
</style>
</head>
<body>
<div class="min-h-screen">
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<button onclick="goBack()" class="mr-4 text-gray-500 hover:text-gray-700">
<i class="fas fa-arrow-left text-xl"></i>
</button>
<i class="fas fa-check-square text-2xl text-green-500 mr-3"></i>
<h1 class="text-xl font-semibold text-gray-800">체크리스트</h1>
<span class="ml-3 text-sm text-gray-500">기한 없는 일들</span>
</div>
<div class="flex items-center space-x-4">
<button onclick="goToDashboard()" class="text-blue-600 hover:text-blue-800 font-medium">
<i class="fas fa-chart-line mr-1"></i>대시보드
</button>
<span class="text-sm text-gray-600" id="currentUser"></span>
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 페이지 설명 -->
<div class="bg-green-50 rounded-xl p-6 mb-8">
<div class="flex items-center mb-4">
<i class="fas fa-check-square text-2xl text-green-600 mr-3"></i>
<h2 class="text-xl font-semibold text-green-900">체크리스트 관리</h2>
</div>
<p class="text-green-800 mb-4">
기한이 없는 일들을 관리합니다. 언제든 할 수 있는 일들을 체크해나가세요.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="bg-white rounded-lg p-3">
<div class="font-medium text-green-900 mb-1">📝 할 일</div>
<div class="text-green-700">아직 완료하지 않은 일들</div>
</div>
<div class="bg-white rounded-lg p-3">
<div class="font-medium text-green-900 mb-1">✅ 완료</div>
<div class="text-green-700">완료한 일들</div>
</div>
<div class="bg-white rounded-lg p-3">
<div class="font-medium text-green-900 mb-1">📊 진행률</div>
<div class="text-green-700" id="progressText">0% 완료</div>
</div>
</div>
</div>
<!-- 진행률 표시 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800">
<i class="fas fa-chart-line text-green-500 mr-2"></i>전체 진행률
</h3>
<div class="text-sm text-gray-600">
<span id="completedCount">0</span> / <span id="totalCount">0</span> 완료
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-3">
<div id="progressBar" class="bg-green-500 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- 필터 및 정렬 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex space-x-1 bg-gray-100 rounded-lg p-1">
<button onclick="filterChecklist('all')" class="filter-tab active px-4 py-2 rounded text-sm font-medium">전체</button>
<button onclick="filterChecklist('active')" class="filter-tab px-4 py-2 rounded text-sm font-medium">할 일</button>
<button onclick="filterChecklist('completed')" class="filter-tab px-4 py-2 rounded text-sm font-medium">완료</button>
</div>
<div class="flex items-center space-x-3">
<label class="text-sm text-gray-600">정렬:</label>
<select id="sortBy" class="border border-gray-300 rounded-lg px-3 py-1 text-sm">
<option value="created_at">등록일 순</option>
<option value="completed_at">완료일 순</option>
<option value="alphabetical">가나다 순</option>
</select>
<button onclick="clearCompleted()" class="text-sm text-red-600 hover:text-red-800">
<i class="fas fa-trash mr-1"></i>완료된 항목 삭제
</button>
</div>
</div>
</div>
<!-- 체크리스트 목록 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b">
<h3 class="text-lg font-semibold text-gray-800">
<i class="fas fa-list text-green-500 mr-2"></i>체크리스트 목록
</h3>
</div>
<div id="checklistList" class="divide-y divide-gray-100">
<!-- 체크리스트 항목들이 여기에 동적으로 추가됩니다 -->
</div>
<div id="emptyState" class="p-12 text-center text-gray-500">
<i class="fas fa-check-square text-4xl mb-4 opacity-50"></i>
<p>아직 체크리스트 항목이 없습니다.</p>
<p class="text-sm">메인 페이지에서 기한 없는 항목을 등록해보세요!</p>
<button onclick="goBack()" class="mt-4 btn-success px-6 py-2 rounded-lg">
<i class="fas fa-arrow-left mr-2"></i>메인으로 돌아가기
</button>
</div>
</div>
</main>
</div>
<!-- JavaScript -->
<script src="static/js/auth.js"></script>
<script>
let checklistItems = [];
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
checkAuthStatus();
loadChecklistItems();
});
// 뒤로 가기
function goBack() {
window.location.href = 'index.html';
}
// 체크리스트 항목 로드
async function loadChecklistItems() {
try {
// API에서 체크리스트 카테고리 항목들만 가져오기
const items = await TodoAPI.getTodos(null, 'checklist');
console.log('🔍 체크리스트 API 응답 데이터:', items);
// 각 항목의 이미지 데이터 확인
items.forEach((item, index) => {
console.log(`📋 체크리스트 항목 ${index + 1}:`, {
id: item.id,
title: item.title,
image_urls: item.image_urls,
image_urls_type: typeof item.image_urls,
image_urls_length: item.image_urls ? item.image_urls.length : 'null'
});
});
checklistItems = items;
} catch (error) {
console.error('체크리스트 항목 로드 실패:', error);
checklistItems = [];
}
renderChecklistItems(checklistItems);
updateProgress();
}
// 체크리스트 항목 렌더링
function renderChecklistItems(items) {
const checklistList = document.getElementById('checklistList');
const emptyState = document.getElementById('emptyState');
if (items.length === 0) {
checklistList.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
checklistList.innerHTML = items.map(item => `
<div class="checklist-item p-6 ${item.completed ? 'completed' : ''}">
<div class="flex items-start space-x-4">
<!-- 체크박스 -->
<div class="flex-shrink-0 mt-1">
<div class="checkbox-custom ${item.completed ? 'checked' : ''}" onclick="toggleComplete('${item.id}')">
${item.completed ? '<i class="fas fa-check text-xs"></i>' : ''}
</div>
</div>
<!-- 사진 (있는 경우) -->
${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0">
<div class="flex space-x-2">
${item.image_urls.slice(0, 3).map(url => `
<img src="${url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
`).join('')}
${item.image_urls.length > 3 ? `
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
<span class="text-xs text-gray-500">+${item.image_urls.length - 3}</span>
</div>
` : ''}
</div>
</div>
` : ''}
<!-- 내용 -->
<div class="flex-1 min-w-0">
<h4 class="item-content text-gray-900 font-medium mb-2">${item.title}</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.completed && item.completed_at ? `
<span class="text-green-600">
<i class="fas fa-check mr-1"></i>완료: ${formatDate(item.completed_at)}
</span>
` : ''}
</div>
</div>
<!-- 액션 버튼 -->
<div class="flex-shrink-0 flex space-x-2">
<!-- 카테고리 변경 버튼 -->
<div class="relative">
<button onclick="showCategoryMenu('${item.id}')" class="text-gray-400 hover:text-purple-500" title="카테고리 변경">
<i class="fas fa-exchange-alt"></i>
</button>
<div id="categoryMenu-${item.id}" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border z-10">
<div class="py-2">
<button onclick="changeCategory('${item.id}', 'todo')" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">
<i class="fas fa-calendar-day mr-2 text-blue-500"></i>Todo로 변경
</button>
<button onclick="changeCategory('${item.id}', 'calendar')" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-orange-50 hover:text-orange-600">
<i class="fas fa-calendar-times mr-2 text-orange-500"></i>캘린더로 변경
</button>
</div>
</div>
</div>
<button onclick="editChecklist('${item.id}')" class="text-gray-400 hover:text-blue-500" title="수정하기">
<i class="fas fa-edit"></i>
</button>
<button onclick="deleteChecklist('${item.id}')" class="text-gray-400 hover:text-red-500" title="삭제하기">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
`).join('');
}
// 완료 상태 토글
function toggleComplete(id) {
const item = checklistItems.find(item => item.id === id);
if (item) {
if (!item.completed) {
// 완료 처리 - 애니메이션 후 제거
item.completed = true;
item.completed_at = new Date().toISOString().split('T')[0];
// 즉시 완료 상태로 렌더링
renderChecklistItems(checklistItems);
updateProgress();
// 1.5초 후 항목 제거
setTimeout(() => {
const itemIndex = checklistItems.findIndex(i => i.id === id);
if (itemIndex !== -1) {
checklistItems.splice(itemIndex, 1);
renderChecklistItems(checklistItems);
updateProgress();
// 완료 메시지 표시
showCompletionToast('항목이 완료되었습니다! 🎉');
}
}, 1500);
// API 호출하여 상태 업데이트
updateTodoStatus(id, 'completed');
console.log('체크리스트 완료 처리:', id);
} else {
// 완료 취소는 허용하지 않음 (이미 완료된 항목은 제거되므로)
console.log('완료된 항목은 취소할 수 없습니다.');
}
}
}
// Todo 상태 업데이트 (API 호출)
async function updateTodoStatus(id, status) {
try {
const token = localStorage.getItem('authToken');
if (!token) {
console.error('인증 토큰이 없습니다!');
return;
}
const response = await fetch(`http://localhost:9000/api/todos/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ status: status })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
console.log(`Todo ${id} 상태가 ${status}로 업데이트되었습니다.`);
} catch (error) {
console.error('Todo 상태 업데이트 실패:', error);
}
}
// 완료 토스트 메시지 표시
function showCompletionToast(message) {
// 기존 토스트가 있으면 제거
const existingToast = document.getElementById('completionToast');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.id = 'completionToast';
toast.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-x-full';
toast.innerHTML = `
<div class="flex items-center space-x-2">
<i class="fas fa-check-circle"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(toast);
// 애니메이션으로 나타나기
setTimeout(() => {
toast.classList.remove('translate-x-full');
}, 100);
// 3초 후 사라지기
setTimeout(() => {
toast.classList.add('translate-x-full');
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, 3000);
}
// 진행률 업데이트
function updateProgress() {
const total = checklistItems.length;
const completed = checklistItems.filter(item => item.completed).length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
document.getElementById('totalCount').textContent = total;
document.getElementById('completedCount').textContent = completed;
document.getElementById('progressText').textContent = `${percentage}% 완료`;
document.getElementById('progressBar').style.width = `${percentage}%`;
}
// 완료된 항목 삭제
function clearCompleted() {
if (confirm('완료된 모든 항목을 삭제하시겠습니까?')) {
checklistItems = checklistItems.filter(item => !item.completed);
renderChecklistItems(checklistItems);
updateProgress();
// TODO: API 호출하여 완료된 항목들 삭제
console.log('완료된 항목들 삭제');
}
}
// 체크리스트 편집
function editChecklist(id) {
console.log('체크리스트 편집:', id);
// TODO: 편집 모달 또는 페이지로 이동
}
// 체크리스트 삭제
function deleteChecklist(id) {
if (confirm('이 항목을 삭제하시겠습니까?')) {
checklistItems = checklistItems.filter(item => item.id !== id);
renderChecklistItems(checklistItems);
updateProgress();
// TODO: API 호출하여 항목 삭제
console.log('체크리스트 삭제:', id);
}
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '날짜 없음';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '날짜 없음';
return date.toLocaleDateString('ko-KR');
}
// 필터링
function filterChecklist(filter) {
let filteredItems = checklistItems;
if (filter === 'active') {
filteredItems = checklistItems.filter(item => !item.completed);
} else if (filter === 'completed') {
filteredItems = checklistItems.filter(item => item.completed);
}
renderChecklistItems(filteredItems);
// 필터 탭 활성화 상태 업데이트
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active');
});
event.target.classList.add('active');
console.log('필터:', filter);
}
// 카테고리 메뉴 표시/숨김
function showCategoryMenu(itemId) {
// 다른 메뉴들 숨기기
document.querySelectorAll('[id^="categoryMenu-"]').forEach(menu => {
if (menu.id !== `categoryMenu-${itemId}`) {
menu.classList.add('hidden');
}
});
// 해당 메뉴 토글
const menu = document.getElementById(`categoryMenu-${itemId}`);
if (menu) {
menu.classList.toggle('hidden');
}
}
// 카테고리 변경
async function changeCategory(itemId, newCategory) {
try {
// 날짜 입력 받기 (todo나 calendar인 경우)
let dueDate = null;
if (newCategory === 'todo' || newCategory === 'calendar') {
const dateInput = prompt(
newCategory === 'todo' ?
'시작 날짜를 입력하세요 (YYYY-MM-DD):' :
'마감 날짜를 입력하세요 (YYYY-MM-DD):'
);
if (!dateInput) {
return; // 취소
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateInput)) {
alert('올바른 날짜 형식을 입력해주세요 (YYYY-MM-DD)');
return;
}
dueDate = dateInput + 'T09:00:00Z';
}
// API 호출하여 카테고리 변경
const updateData = {
category: newCategory
};
if (dueDate) {
updateData.due_date = dueDate;
}
await TodoAPI.updateTodo(itemId, updateData);
// 성공 메시지
const categoryNames = {
'todo': 'Todo',
'calendar': '캘린더'
};
alert(`항목이 ${categoryNames[newCategory]}로 이동되었습니다!`);
// 메뉴 숨기기
document.getElementById(`categoryMenu-${itemId}`).classList.add('hidden');
// 페이지 새로고침하여 변경된 항목 제거
await loadChecklistItems();
} catch (error) {
console.error('카테고리 변경 실패:', error);
alert('카테고리 변경에 실패했습니다.');
}
}
// 대시보드로 이동
function goToDashboard() {
window.location.href = 'dashboard.html';
}
// 전역 함수 등록
window.goToDashboard = goToDashboard;
window.showCategoryMenu = showCategoryMenu;
window.changeCategory = changeCategory;
// 문서 클릭 시 메뉴 숨기기
document.addEventListener('click', (e) => {
if (!e.target.closest('.relative')) {
document.querySelectorAll('[id^="categoryMenu-"]').forEach(menu => {
menu.classList.add('hidden');
});
}
});
</script>
<script src="static/js/api.js?v=20250921110800"></script>
<script src="static/js/auth.js?v=20250921110800"></script>
</body>
</html>

View File

@@ -1,623 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INDEX - Todo Project</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
/* 분류 카드 스타일 */
.classify-card {
background: white;
border-radius: 1rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
border: 2px solid transparent;
}
.classify-card:hover {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.classify-card.selected {
border-color: var(--primary);
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.2);
}
/* 분류 버튼 스타일 */
.classify-btn {
padding: 12px 24px;
border-radius: 12px;
font-weight: 600;
transition: all 0.2s;
border: 2px solid transparent;
}
.classify-btn.todo {
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
color: #1e40af;
border-color: #3b82f6;
}
.classify-btn.todo:hover {
background: linear-gradient(135deg, #bfdbfe, #93c5fd);
transform: scale(1.05);
}
.classify-btn.calendar {
background: linear-gradient(135deg, #fef3c7, #fde68a);
color: #92400e;
border-color: #f59e0b;
}
.classify-btn.calendar:hover {
background: linear-gradient(135deg, #fde68a, #fcd34d);
transform: scale(1.05);
}
.classify-btn.checklist {
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #065f46;
border-color: #10b981;
}
.classify-btn.checklist:hover {
background: linear-gradient(135deg, #a7f3d0, #6ee7b7);
transform: scale(1.05);
}
/* 스마트 제안 스타일 */
.smart-suggestion {
background: linear-gradient(135deg, #f3e8ff, #e9d5ff);
border: 2px solid #8b5cf6;
border-radius: 12px;
padding: 12px;
margin: 12px 0;
}
/* 태그 스타일 */
.tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
background: #f1f5f9;
color: #475569;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
margin: 2px;
cursor: pointer;
transition: all 0.2s;
}
.tag:hover {
background: #e2e8f0;
}
.tag.selected {
background: var(--primary);
color: white;
}
/* 애니메이션 */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.slide-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* 모바일 최적화 */
@media (max-width: 768px) {
.classify-btn {
padding: 10px 16px;
font-size: 14px;
}
.classify-card {
margin: 8px 0;
}
}
</style>
</head>
<body>
<div class="min-h-screen">
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<button onclick="goBack()" class="mr-4 text-gray-500 hover:text-gray-700">
<i class="fas fa-arrow-left text-xl"></i>
</button>
<i class="fas fa-inbox text-2xl text-purple-500 mr-3"></i>
<h1 class="text-xl font-semibold text-gray-800">INDEX</h1>
<span class="ml-3 px-2 py-1 bg-red-100 text-red-800 text-sm rounded-full" id="pendingCount">0</span>
</div>
<div class="flex items-center space-x-4">
<button onclick="goToDashboard()" class="text-blue-600 hover:text-blue-800 font-medium">
<i class="fas fa-chart-line mr-1"></i>대시보드
</button>
<button onclick="selectAll()" class="text-gray-600 hover:text-gray-800 text-sm">
<i class="fas fa-check-square mr-1"></i>전체선택
</button>
<span class="text-sm text-gray-600" id="currentUser"></span>
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 상단 통계 및 필터 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<!-- 통계 카드들 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-inbox text-purple-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-600">분류 대기</p>
<p class="text-2xl font-bold text-gray-900" id="totalPending">0</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-calendar-day text-blue-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-600">Todo 이동</p>
<p class="text-2xl font-bold text-gray-900" id="todoMoved">0</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
<i class="fas fa-calendar-times text-orange-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-600">캘린더 이동</p>
<p class="text-2xl font-bold text-gray-900" id="calendarMoved">0</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-check-square text-green-600 text-xl"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-600">체크리스트 이동</p>
<p class="text-2xl font-bold text-gray-900" id="checklistMoved">0</p>
</div>
</div>
</div>
</div>
<!-- 필터 및 정렬 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex flex-wrap gap-2">
<button onclick="filterItems('all')" class="filter-btn active px-4 py-2 rounded-lg text-sm font-medium">전체</button>
<button onclick="filterItems('upload')" class="filter-btn px-4 py-2 rounded-lg text-sm font-medium">업로드</button>
<button onclick="filterItems('mail')" class="filter-btn px-4 py-2 rounded-lg text-sm font-medium">메일</button>
<button onclick="filterItems('suggested')" class="filter-btn px-4 py-2 rounded-lg text-sm font-medium">제안 있음</button>
</div>
<div class="flex items-center space-x-4">
<select id="sortBy" class="border border-gray-300 rounded-lg px-3 py-2 text-sm">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
<option value="suggested">제안순</option>
</select>
<button onclick="batchClassify()" class="btn-primary px-4 py-2 rounded-lg text-sm" disabled id="batchBtn">
<i class="fas fa-layer-group mr-1"></i>일괄 분류
</button>
</div>
</div>
</div>
<!-- 분류 대기 항목들 -->
<div class="space-y-4" id="classifyItems">
<!-- 항목들이 여기에 동적으로 추가됩니다 -->
</div>
<!-- 빈 상태 -->
<div id="emptyState" class="hidden text-center py-16">
<i class="fas fa-inbox text-6xl text-gray-300 mb-4"></i>
<h3 class="text-xl font-semibold text-gray-600 mb-2">분류할 항목이 없습니다</h3>
<p class="text-gray-500 mb-6">새로운 항목을 업로드하거나 메일을 받으면 여기에 표시됩니다.</p>
<button onclick="goToDashboard()" class="btn-primary px-6 py-3 rounded-lg">
<i class="fas fa-plus mr-2"></i>새 항목 추가
</button>
</div>
</main>
</div>
<!-- JavaScript -->
<script src="static/js/api.js?v=2"></script>
<script src="static/js/auth.js?v=2"></script>
<script>
let pendingItems = [];
let selectedItems = [];
let currentFilter = 'all';
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
checkAuthStatus();
loadPendingItems();
updateStats();
});
// 분류 대기 항목 로드
function loadPendingItems() {
// 분류되지 않은 항목들을 API에서 가져와야 함
// 현재는 빈 배열로 설정 (분류 기능 미구현)
pendingItems = [];
renderItems();
}
// 항목들 렌더링
function renderItems() {
const container = document.getElementById('classifyItems');
const emptyState = document.getElementById('emptyState');
// 필터링
let filteredItems = pendingItems;
if (currentFilter !== 'all') {
filteredItems = pendingItems.filter(item => {
if (currentFilter === 'suggested') return item.suggested;
return item.type === currentFilter;
});
}
if (filteredItems.length === 0) {
container.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
container.innerHTML = filteredItems.map(item => `
<div class="classify-card p-6 ${selectedItems.includes(item.id) ? 'selected' : ''}" data-id="${item.id}">
<div class="flex items-start space-x-4">
<!-- 선택 체크박스 -->
<div class="flex-shrink-0 mt-1">
<input type="checkbox" class="w-5 h-5 text-blue-600 rounded"
${selectedItems.includes(item.id) ? 'checked' : ''}
onchange="toggleSelection(${item.id})">
</div>
<!-- 타입 아이콘 -->
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-lg flex items-center justify-center ${item.type === 'upload' ? 'bg-blue-100' : 'bg-green-100'}">
<i class="fas ${item.type === 'upload' ? 'fa-camera text-blue-600' : 'fa-envelope text-green-600'} text-xl"></i>
</div>
</div>
<!-- 사진 (있는 경우) -->
${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0">
<div class="flex space-x-2">
${item.image_urls.slice(0, 2).map(url => `
<img src="${url}" class="w-20 h-20 object-cover rounded-lg" alt="첨부 사진">
`).join('')}
${item.image_urls.length > 2 ? `
<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center">
<span class="text-sm text-gray-500">+${item.image_urls.length - 2}</span>
</div>
` : ''}
</div>
</div>
` : ''}
<!-- 내용 -->
<div class="flex-1 min-w-0">
<h4 class="text-lg font-medium text-gray-900 mb-2">${item.content}</h4>
<!-- 메타 정보 -->
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-3">
<span>
<i class="fas fa-clock mr-1"></i>${formatDate(item.created_at)}
</span>
<span>
<i class="fas fa-source mr-1"></i>${item.source}
</span>
${item.sender ? `
<span>
<i class="fas fa-user mr-1"></i>${item.sender}
</span>
` : ''}
</div>
<!-- 태그 -->
<div class="flex flex-wrap gap-1 mb-3">
${item.tags.map(tag => `<span class="tag">#${tag}</span>`).join('')}
</div>
<!-- 스마트 제안 -->
${item.suggested ? `
<div class="smart-suggestion">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-magic text-purple-600 mr-2"></i>
<span class="text-sm font-medium text-purple-800">
AI 제안: <strong>${getSuggestionText(item.suggested)}</strong>
</span>
<span class="ml-2 text-xs text-purple-600">(${Math.round(item.confidence * 100)}% 확신)</span>
</div>
<button onclick="acceptSuggestion('${item.id}', '${item.suggested}')"
class="text-xs bg-purple-600 text-white px-3 py-1 rounded-full hover:bg-purple-700">
적용
</button>
</div>
</div>
` : ''}
</div>
</div>
<!-- 분류 버튼들 -->
<div class="mt-6 flex flex-wrap gap-3 justify-center">
<button onclick="classifyItem('${item.id}', 'todo')" class="classify-btn todo">
<i class="fas fa-calendar-day mr-2"></i>Todo
<div class="text-xs opacity-75">시작 날짜</div>
</button>
<button onclick="classifyItem('${item.id}', 'calendar')" class="classify-btn calendar">
<i class="fas fa-calendar-times mr-2"></i>캘린더
<div class="text-xs opacity-75">마감 기한</div>
</button>
<button onclick="classifyItem('${item.id}', 'checklist')" class="classify-btn checklist">
<i class="fas fa-check-square mr-2"></i>체크리스트
<div class="text-xs opacity-75">기한 없음</div>
</button>
</div>
</div>
`).join('');
// 애니메이션 적용
container.querySelectorAll('.classify-card').forEach((card, index) => {
card.style.animationDelay = `${index * 0.1}s`;
card.classList.add('fade-in');
});
}
// 항목 선택 토글
function toggleSelection(id) {
const index = selectedItems.indexOf(id);
if (index > -1) {
selectedItems.splice(index, 1);
} else {
selectedItems.push(id);
}
updateBatchButton();
renderItems();
}
// 전체 선택
function selectAll() {
if (selectedItems.length === pendingItems.length) {
selectedItems = [];
} else {
selectedItems = pendingItems.map(item => item.id);
}
updateBatchButton();
renderItems();
}
// 일괄 분류 버튼 업데이트
function updateBatchButton() {
const batchBtn = document.getElementById('batchBtn');
if (selectedItems.length > 0) {
batchBtn.disabled = false;
batchBtn.textContent = `${selectedItems.length}개 일괄 분류`;
} else {
batchBtn.disabled = true;
batchBtn.innerHTML = '<i class="fas fa-layer-group mr-1"></i>일괄 분류';
}
}
// 개별 항목 분류
function classifyItem(id, category) {
const item = pendingItems.find(item => item.id === id);
if (!item) return;
// 애니메이션 효과
const card = document.querySelector(`[data-id="${id}"]`);
card.style.transform = 'scale(0.95)';
card.style.opacity = '0.7';
setTimeout(() => {
// 항목 제거
pendingItems = pendingItems.filter(item => item.id !== id);
selectedItems = selectedItems.filter(itemId => itemId !== id);
// UI 업데이트
renderItems();
updateStats();
updateBatchButton();
// 성공 메시지
showToast(`"${item.content}"이(가) ${getSuggestionText(category)}(으)로 이동되었습니다.`, 'success');
// TODO: API 호출하여 실제 분류 처리
console.log(`항목 ${id}을(를) ${category}로 분류`);
}, 300);
}
// 제안 수락
function acceptSuggestion(id, category) {
classifyItem(id, category);
}
// 일괄 분류
function batchClassify() {
if (selectedItems.length === 0) return;
// 일괄 분류 모달 또는 드롭다운 표시
const category = prompt(`선택된 ${selectedItems.length}개 항목을 어디로 분류하시겠습니까?\n1. Todo\n2. 캘린더\n3. 체크리스트\n\n번호를 입력하세요:`);
const categories = { '1': 'todo', '2': 'calendar', '3': 'checklist' };
const selectedCategory = categories[category];
if (selectedCategory) {
selectedItems.forEach(id => {
setTimeout(() => classifyItem(id, selectedCategory), Math.random() * 500);
});
}
}
// 필터링
function filterItems(filter) {
currentFilter = filter;
// 필터 버튼 활성화 상태 업데이트
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active', 'bg-blue-600', 'text-white');
btn.classList.add('text-gray-600', 'bg-gray-100');
});
event.target.classList.add('active', 'bg-blue-600', 'text-white');
event.target.classList.remove('text-gray-600', 'bg-gray-100');
renderItems();
}
// 통계 업데이트
function updateStats() {
document.getElementById('totalPending').textContent = pendingItems.length;
document.getElementById('pendingCount').textContent = pendingItems.length;
// TODO: 실제 이동된 항목 수 계산
document.getElementById('todoMoved').textContent = '5';
document.getElementById('calendarMoved').textContent = '3';
document.getElementById('checklistMoved').textContent = '7';
}
// 유틸리티 함수들
function getSuggestionText(category) {
const texts = {
'todo': 'Todo',
'calendar': '캘린더',
'checklist': '체크리스트'
};
return texts[category] || '미분류';
}
function formatDate(dateString) {
if (!dateString) return '날짜 없음';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '날짜 없음';
const now = new Date();
const diffTime = now - date;
const diffHours = Math.floor(diffTime / (1000 * 60 * 60));
if (diffHours < 1) return '방금 전';
if (diffHours < 24) return `${diffHours}시간 전`;
return date.toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function showToast(message, type = 'info') {
// 간단한 토스트 메시지 (실제로는 더 예쁜 토스트 UI 구현)
console.log(`[${type.toUpperCase()}] ${message}`);
// 임시 알림
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
type === 'success' ? 'bg-green-500' : 'bg-blue-500'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// 네비게이션 함수들
function goBack() {
window.location.href = 'index.html';
}
function goToDashboard() {
window.location.href = 'dashboard.html';
}
// 전역 함수 등록
window.toggleSelection = toggleSelection;
window.selectAll = selectAll;
window.classifyItem = classifyItem;
window.acceptSuggestion = acceptSuggestion;
window.batchClassify = batchClassify;
window.filterItems = filterItems;
window.goBack = goBack;
window.goToDashboard = goToDashboard;
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

798
frontend/inbox.html Normal file
View File

@@ -0,0 +1,798 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>수신함 - Todo Project</title>
<link rel="icon" type="image/x-icon" href="static/icons/favicon.ico">
<link rel="apple-touch-icon" href="static/icons/apple-touch-icon.png">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--parchment: #f7f3e9;
--parchment-dark: #f0ead6;
--ink: #2c1810;
--ink-light: #5d4e37;
--sepia: #8b7355;
--gold: #d4af37;
--shadow: rgba(139, 115, 85, 0.2);
}
body {
font-family: 'Noto Serif KR', serif;
background: linear-gradient(135deg, #f7f3e9 0%, #f0ead6 100%);
background-attachment: fixed;
color: var(--ink);
}
.parchment-container {
background: var(--parchment);
background-image:
radial-gradient(circle at 25% 25%, rgba(139, 115, 85, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(139, 115, 85, 0.05) 0%, transparent 50%);
border: 2px solid var(--sepia);
border-radius: 8px;
box-shadow:
0 8px 32px var(--shadow),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
position: relative;
}
.parchment-container::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg, var(--gold), var(--sepia), var(--gold));
border-radius: 10px;
z-index: -1;
opacity: 0.3;
}
.header-vintage {
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
border-bottom: 3px solid var(--gold);
box-shadow: 0 2px 10px var(--shadow);
}
.memo-item {
background: var(--parchment-dark);
border: 1px solid var(--sepia);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.memo-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px var(--shadow);
}
.todo-button {
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
color: var(--parchment);
border: 2px solid var(--gold);
border-radius: 20px;
padding: 0.5rem 1rem;
font-family: 'Noto Serif KR', serif;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 8px var(--shadow);
}
.todo-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--shadow);
background: linear-gradient(135deg, var(--ink-light), var(--ink));
}
.edit-button {
background: var(--gold);
color: var(--ink);
border: 1px solid var(--gold);
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
}
.edit-button:hover {
background: var(--sepia);
border-color: var(--sepia);
color: white;
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--shadow);
}
.cancel-button {
background: transparent;
color: var(--ink-light);
border: 1px solid var(--sepia);
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-button:hover {
background: var(--sepia);
color: white;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 0.5rem;
max-width: 300px;
}
.image-item {
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--sepia);
}
.image-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.date-modal {
background: rgba(44, 24, 16, 0.8);
backdrop-filter: blur(5px);
}
.date-input {
background: var(--parchment);
border: 2px solid var(--sepia);
border-radius: 8px;
padding: 0.75rem;
font-family: 'Noto Serif KR', serif;
color: var(--ink);
}
.date-input:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.2);
}
</style>
</head>
<body>
<!-- 헤더 -->
<header class="header-vintage">
<div class="max-w-4xl mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h1 class="text-2xl font-semibold" style="color: var(--ink);">
<i class="fas fa-inbox mr-3" style="color: var(--gold);"></i>
수신함
</h1>
</div>
<div class="flex space-x-2">
<a href="upload.html" class="todo-button">
<i class="fas fa-feather-alt mr-2"></i>메모
</a>
<a href="inbox.html" class="todo-button" style="background: var(--gold); border-color: var(--gold); color: var(--ink);">
<i class="fas fa-inbox mr-2"></i>수신함
</a>
<a href="todo-list.html" class="todo-button">
<i class="fas fa-tasks mr-2"></i>Todo 목록
</a>
<a href="board.html" class="todo-button">
<i class="fas fa-clipboard mr-2"></i>보드
</a>
<a href="archive.html" class="todo-button">
<i class="fas fa-archive mr-2"></i>아카이브
</a>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="parchment-container p-6">
<div id="memoList">
<!-- 메모 목록이 여기에 표시됩니다 -->
</div>
<div id="emptyState" class="hidden text-center py-12">
<i class="fas fa-inbox text-6xl mb-4" style="color: var(--sepia); opacity: 0.5;"></i>
<h3 class="text-xl font-medium mb-2" style="color: var(--ink-light);">수신함이 비어있습니다</h3>
<p class="text-sm mb-6" style="color: var(--sepia);">새로운 메모를 작성해보세요</p>
<a href="upload.html" class="todo-button">
<i class="fas fa-feather-alt mr-2"></i>첫 메모 작성하기
</a>
</div>
</div>
</main>
<!-- 변환 모달 -->
<div id="conversionModal" class="hidden fixed inset-0 date-modal flex items-center justify-center z-50">
<div class="parchment-container p-6 max-w-md w-full mx-4">
<h3 id="modalTitle" class="text-lg font-semibold mb-4 text-center" style="color: var(--ink);">
<i id="modalIcon" class="mr-2" style="color: var(--gold);"></i>
<span id="modalText"></span>
</h3>
<!-- Todo 변환 시 날짜 선택 -->
<div id="todoDateSection" class="mb-4 hidden">
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">시작 날짜</label>
<input type="date" id="todoStartDate" class="date-input w-full">
</div>
<!-- 보드 변환 시 제목 입력 -->
<div id="boardTitleSection" class="mb-4 hidden">
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">보드 제목</label>
<input type="text" id="boardTitle" class="date-input w-full" placeholder="예: 9월 선일정밀 가공품">
</div>
<div class="flex space-x-3">
<button onclick="confirmConversion()" class="todo-button flex-1">
<i class="fas fa-check mr-2"></i>확인
</button>
<button onclick="closeConversionModal()" class="todo-button flex-1" style="background: var(--sepia);">
<i class="fas fa-times mr-2"></i>취소
</button>
</div>
</div>
</div>
<!-- 편집 모달 -->
<div id="editModal" class="hidden fixed inset-0 date-modal flex items-center justify-center z-50">
<div class="parchment-container p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 class="text-lg font-semibold mb-4 text-center" style="color: var(--ink);">
<i class="fas fa-edit mr-2" style="color: var(--gold);"></i>
메모 편집
</h3>
<form id="editForm" class="space-y-4">
<!-- 내용 입력 -->
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">내용</label>
<textarea id="editDescription" rows="6" class="w-full px-3 py-2 border rounded-lg resize-none"
style="border-color: var(--sepia); background: white; color: var(--ink);"
placeholder="내용을 입력하세요" required></textarea>
</div>
<!-- 기존 이미지 -->
<div id="existingImages" class="hidden">
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">기존 이미지</label>
<div id="existingImageGrid" class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-3"></div>
</div>
<!-- 새 이미지 추가 -->
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">새 이미지 추가</label>
<div class="space-y-3">
<!-- 데스크톱: 파일 선택 -->
<div class="desktop-upload">
<input type="file" id="editImageInput" multiple accept="image/*" class="hidden">
<button type="button" onclick="document.getElementById('editImageInput').click()"
class="w-full py-3 px-4 border-2 border-dashed rounded-lg transition-colors"
style="border-color: var(--sepia); color: var(--ink-light);"
onmouseover="this.style.borderColor='var(--gold)'; this.style.backgroundColor='var(--parchment-dark)'"
onmouseout="this.style.borderColor='var(--sepia)'; this.style.backgroundColor='transparent'">
<i class="fas fa-images mr-2"></i>이미지 선택 (최대 5장)
</button>
</div>
<!-- 모바일: 카메라/갤러리 -->
<div class="mobile-upload hidden">
<div class="grid grid-cols-2 gap-3">
<button type="button" onclick="captureEditImage()" class="photo-button">
<i class="fas fa-camera mr-2"></i>카메라
</button>
<button type="button" onclick="document.getElementById('editImageInput').click()" class="photo-button">
<i class="fas fa-images mr-2"></i>갤러리
</button>
</div>
</div>
</div>
<!-- 새 이미지 미리보기 -->
<div id="newImagePreview" class="hidden mt-3">
<div id="newImageGrid" class="grid grid-cols-2 md:grid-cols-3 gap-3"></div>
</div>
</div>
<!-- 버튼 -->
<div class="flex justify-end space-x-3 pt-4">
<button type="button" onclick="closeEditModal()" class="cancel-button">
취소
</button>
<button type="submit" class="todo-button">
<i class="fas fa-save mr-2"></i>저장
</button>
</div>
</form>
</div>
</div>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/auth.js"></script>
<script>
let currentMemoId = null;
let currentConversionType = null;
let currentEditMemo = null;
let newEditImages = [];
let existingImages = [];
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
// 인증 확인
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
if (!token) {
window.location.href = 'index.html';
return;
}
loadMemos();
});
// 메모 목록 로드
async function loadMemos() {
try {
const memos = await TodoAPI.getTodos(null, 'memo');
renderMemos(memos);
} catch (error) {
console.error('메모 로드 실패:', error);
showEmptyState();
}
}
// 메모 목록 렌더링
function renderMemos(memos) {
const memoList = document.getElementById('memoList');
const emptyState = document.getElementById('emptyState');
if (!memos || memos.length === 0) {
showEmptyState();
return;
}
emptyState.classList.add('hidden');
memoList.innerHTML = memos.map(memo => {
const createdAt = new Date(memo.created_at);
const timeAgo = getTimeAgo(createdAt);
const hasImages = memo.image_urls && memo.image_urls.length > 0;
return `
<div class="memo-item">
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
${memo.title ? `<h3 class="font-medium text-lg mb-2" style="color: var(--ink);">${memo.title}</h3>` : ''}
<p class="text-sm mb-2" style="color: var(--ink-light);">
${memo.description || '내용 없음'}
</p>
<div class="flex items-center text-xs" style="color: var(--sepia);">
<i class="fas fa-clock mr-1"></i>
<span>${timeAgo}</span>
${hasImages ? `<i class="fas fa-images ml-3 mr-1"></i><span>${memo.image_urls.length}장</span>` : ''}
</div>
</div>
<div class="flex space-x-2 ml-4">
<button onclick="openEditModal('${memo.id}')" class="edit-button" title="편집">
<i class="fas fa-edit"></i>
</button>
<button onclick="openConversionModal('${memo.id}', 'todo')" class="todo-button">
<i class="fas fa-tasks mr-2"></i>Todo로
</button>
<button onclick="openConversionModal('${memo.id}', 'board')" class="todo-button">
<i class="fas fa-clipboard mr-2"></i>보드로
</button>
</div>
</div>
${hasImages ? `
<div class="image-grid mt-3">
${memo.image_urls.slice(0, 4).map(url => `
<div class="image-item">
<img src="${url}" alt="첨부 이미지" onclick="showImageModal('${url}')">
</div>
`).join('')}
${memo.image_urls.length > 4 ? `
<div class="image-item flex items-center justify-center" style="background: var(--parchment); border: 2px dashed var(--sepia);">
<span class="text-xs" style="color: var(--sepia);">+${memo.image_urls.length - 4}</span>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}).join('');
}
// 빈 상태 표시
function showEmptyState() {
document.getElementById('memoList').innerHTML = '';
document.getElementById('emptyState').classList.remove('hidden');
}
// 변환 모달 열기
function openConversionModal(memoId, type) {
currentMemoId = memoId;
currentConversionType = type;
const modalIcon = document.getElementById('modalIcon');
const modalText = document.getElementById('modalText');
const todoDateSection = document.getElementById('todoDateSection');
const boardTitleSection = document.getElementById('boardTitleSection');
if (type === 'todo') {
modalIcon.className = 'fas fa-tasks mr-2';
modalText.textContent = 'Todo로 변환';
todoDateSection.classList.remove('hidden');
boardTitleSection.classList.add('hidden');
// 기본값을 오늘 날짜로 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('todoStartDate').value = today;
} else if (type === 'board') {
modalIcon.className = 'fas fa-clipboard mr-2';
modalText.textContent = '보드로 변환';
todoDateSection.classList.add('hidden');
boardTitleSection.classList.remove('hidden');
// 기존 메모 내용을 기본 제목으로 설정
const memo = document.querySelector(`[onclick*="${memoId}"]`);
if (memo) {
const h3Element = memo.querySelector('h3');
const pElement = memo.querySelector('p');
let description = '';
if (h3Element) {
description = h3Element.textContent.trim();
} else if (pElement) {
description = pElement.textContent.trim();
// "내용 없음"이면 빈 문자열로 설정
if (description === '내용 없음') {
description = '';
}
}
document.getElementById('boardTitle').value = description;
}
}
document.getElementById('conversionModal').classList.remove('hidden');
}
// 변환 모달 닫기
function closeConversionModal() {
currentMemoId = null;
currentConversionType = null;
document.getElementById('conversionModal').classList.add('hidden');
}
// 변환 확인
async function confirmConversion() {
if (!currentMemoId || !currentConversionType) return;
try {
if (currentConversionType === 'todo') {
const startDate = document.getElementById('todoStartDate').value;
if (!startDate) {
alert('시작 날짜를 선택해주세요.');
return;
}
// 메모를 Todo로 변환
await TodoAPI.updateTodo(currentMemoId, {
category: 'todo',
start_date: startDate,
status: 'pending'
});
alert('Todo로 변환되었습니다!');
} else if (currentConversionType === 'board') {
const boardTitle = document.getElementById('boardTitle').value.trim();
if (!boardTitle) {
alert('보드 제목을 입력해주세요.');
return;
}
// 메모를 보드로 변환 (헤더로 설정)
await TodoAPI.updateTodo(currentMemoId, {
category: 'board',
title: boardTitle,
board_id: currentMemoId, // 자기 자신을 board_id로 설정
is_board_header: true,
status: 'pending'
});
alert('보드로 변환되었습니다!');
}
closeConversionModal();
loadMemos(); // 목록 새로고침
} catch (error) {
console.error('변환 실패:', error);
alert('변환에 실패했습니다.');
}
}
// 시간 경과 표시
function getTimeAgo(date) {
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric'
});
}
// 이미지 모달 표시 (간단한 구현)
function showImageModal(imageUrl) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 date-modal flex items-center justify-center z-50';
modal.innerHTML = `
<div class="max-w-4xl max-h-4xl p-4">
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg">
</div>
`;
modal.onclick = () => modal.remove();
document.body.appendChild(modal);
}
// 편집 모달 열기
async function openEditModal(memoId) {
try {
// 메모 정보 가져오기
const memo = await TodoAPI.getTodoById(memoId);
currentEditMemo = memo;
// 폼 채우기
document.getElementById('editDescription').value = memo.description || '';
// 기존 이미지 처리
existingImages = memo.image_urls || [];
if (existingImages.length > 0) {
document.getElementById('existingImages').classList.remove('hidden');
renderExistingImages();
} else {
document.getElementById('existingImages').classList.add('hidden');
}
// 새 이미지 초기화
newEditImages = [];
document.getElementById('newImagePreview').classList.add('hidden');
// 모바일/데스크톱 구분
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile) {
document.querySelector('.desktop-upload').classList.add('hidden');
document.querySelector('.mobile-upload').classList.remove('hidden');
} else {
document.querySelector('.desktop-upload').classList.remove('hidden');
document.querySelector('.mobile-upload').classList.add('hidden');
}
// 파일 입력 이벤트 리스너
const fileInput = document.getElementById('editImageInput');
fileInput.onchange = handleEditImageSelect;
// 편집 폼 이벤트 리스너
const editForm = document.getElementById('editForm');
editForm.onsubmit = handleEditSubmit;
document.getElementById('editModal').classList.remove('hidden');
} catch (error) {
console.error('메모 로드 실패:', error);
alert('메모를 불러올 수 없습니다.');
}
}
// 편집 모달 닫기
function closeEditModal() {
currentEditMemo = null;
newEditImages = [];
existingImages = [];
document.getElementById('editModal').classList.add('hidden');
}
// 기존 이미지 렌더링
function renderExistingImages() {
const grid = document.getElementById('existingImageGrid');
grid.innerHTML = existingImages.map((url, index) => `
<div class="relative">
<img src="${url}" alt="기존 이미지" class="w-full h-24 object-cover rounded-lg">
<button type="button" onclick="removeExistingImage(${index})"
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600">
×
</button>
</div>
`).join('');
}
// 기존 이미지 제거
function removeExistingImage(index) {
existingImages.splice(index, 1);
if (existingImages.length === 0) {
document.getElementById('existingImages').classList.add('hidden');
} else {
renderExistingImages();
}
}
// 새 이미지 선택 처리
async function handleEditImageSelect(event) {
const files = Array.from(event.target.files);
const totalImages = existingImages.length + newEditImages.length + files.length;
if (totalImages > 5) {
alert('최대 5장까지만 업로드할 수 있습니다.');
return;
}
for (const file of files) {
try {
// 이미지 압축
const compressedFile = await compressImageSimple(file, 0.7, 1920);
const base64 = await convertFileToBase64(compressedFile);
newEditImages.push({
file: compressedFile,
preview: base64
});
} catch (error) {
console.error('이미지 처리 실패:', error);
}
}
renderNewImages();
event.target.value = '';
}
// 새 이미지 렌더링
function renderNewImages() {
if (newEditImages.length === 0) {
document.getElementById('newImagePreview').classList.add('hidden');
return;
}
document.getElementById('newImagePreview').classList.remove('hidden');
const grid = document.getElementById('newImageGrid');
grid.innerHTML = newEditImages.map((img, index) => `
<div class="relative">
<img src="${img.preview}" alt="새 이미지" class="w-full h-24 object-cover rounded-lg">
<button type="button" onclick="removeNewImage(${index})"
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600">
×
</button>
</div>
`).join('');
}
// 새 이미지 제거
function removeNewImage(index) {
newEditImages.splice(index, 1);
renderNewImages();
}
// 모바일 카메라 캡처
async function captureEditImage() {
try {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.onchange = handleEditImageSelect;
input.click();
} catch (error) {
console.error('카메라 접근 실패:', error);
alert('카메라에 접근할 수 없습니다.');
}
}
// 편집 폼 제출
async function handleEditSubmit(event) {
event.preventDefault();
const description = document.getElementById('editDescription').value.trim();
if (!description) {
alert('내용을 입력해주세요.');
return;
}
try {
// 새 이미지 업로드
const newImageUrls = [];
for (const img of newEditImages) {
try {
const uploadResult = await TodoAPI.uploadImage(img.file);
newImageUrls.push(uploadResult.file_url);
} catch (error) {
console.error('이미지 업로드 실패:', error);
}
}
// 모든 이미지 URL 합치기
const allImageUrls = [...existingImages, ...newImageUrls];
// 메모 업데이트
const updateData = {
description: description,
image_urls: allImageUrls
};
await TodoAPI.updateTodo(currentEditMemo.id, updateData);
alert('메모가 성공적으로 수정되었습니다!');
closeEditModal();
loadMemos(); // 목록 새로고침
} catch (error) {
console.error('메모 수정 실패:', error);
alert('메모 수정에 실패했습니다.');
}
}
// 이미지 압축 함수 (upload.html에서 복사)
async function compressImageSimple(file, quality = 0.7, maxWidth = 1920) {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
const ratio = Math.min(maxWidth / img.width, maxWidth / img.height);
canvas.width = img.width * ratio;
canvas.height = img.height * ratio;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(resolve, 'image/jpeg', quality);
};
img.src = URL.createObjectURL(file);
});
}
// Base64 변환 함수
function convertFileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 전역 함수 등록
window.openConversionModal = openConversionModal;
window.closeConversionModal = closeConversionModal;
window.confirmConversion = confirmConversion;
window.showImageModal = showImageModal;
window.openEditModal = openEditModal;
window.closeEditModal = closeEditModal;
window.removeExistingImage = removeExistingImage;
window.removeNewImage = removeNewImage;
window.captureEditImage = captureEditImage;
</script>
</body>
</html>

View File

@@ -33,57 +33,77 @@
<!-- 외부 라이브러리 -->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--primary: #3b82f6; /* 하늘색 */
--primary-dark: #2563eb; /* 진한 하늘색 */
--success: #10b981; /* 초록색 (유지) */
--warning: #f59e0b; /* 주황색 */
--danger: #ef4444; /* 빨간색 (유지) */
--gray-50: #f9fafb; /* 연한 회색 */
--gray-100: #f3f4f6; /* 회색 */
--gray-200: #e5e7eb; /* 중간 회색 */
--gray-300: #d1d5db; /* 진한 회색 */
--parchment: #f7f3e9;
--parchment-dark: #f0ead6;
--ink: #2c1810;
--ink-light: #5d4e37;
--sepia: #8b7355;
--gold: #d4af37;
--shadow: rgba(139, 115, 85, 0.2);
--success: #10b981;
--danger: #ef4444;
}
body {
background-color: var(--gray-50);
font-family: 'Noto Serif KR', serif;
background: linear-gradient(to bottom, var(--parchment) 0%, #e0d8c7 100%);
color: var(--ink);
min-height: 100vh;
}
.parchment-container {
background-color: var(--parchment);
border: 1px solid var(--sepia);
box-shadow: 3px 3px 8px var(--shadow);
position: relative;
border-radius: 8px;
}
.parchment-container::before {
content: '';
position: absolute;
top: -3px; left: -3px; right: -3px; bottom: -3px;
border: 1px dashed var(--gold);
z-index: -1;
border-radius: 8px;
}
.btn-primary {
background-color: var(--primary);
color: white;
background-color: var(--gold);
color: var(--ink);
border: 1px solid var(--gold);
transition: all 0.2s;
font-weight: 500;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-warning {
background-color: var(--warning);
background-color: var(--sepia);
border-color: var(--sepia);
color: white;
transition: all 0.2s;
}
.btn-warning:hover {
background-color: #d97706;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
box-shadow: 0 4px 12px var(--shadow);
}
.input-field {
border: 1px solid var(--gray-300);
border: 1px solid var(--sepia);
background: white;
transition: all 0.2s;
color: var(--ink);
}
.input-field:focus {
border-color: var(--primary);
border-color: var(--gold);
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.2);
}
.login-header {
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
border-bottom: 3px solid var(--gold);
box-shadow: 0 2px 10px var(--shadow);
}
.todo-item {
@@ -107,53 +127,102 @@
<body>
<!-- 로그인 화면 -->
<div id="loginScreen" class="min-h-screen flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
<div class="text-center mb-6">
<i class="fas fa-tasks text-4xl text-blue-500 mb-4"></i>
<h1 class="text-2xl font-bold text-gray-800">Todo Project</h1>
<p class="text-gray-500 text-sm">간결한 할일 관리</p>
<div class="parchment-container p-8 w-full max-w-md">
<div class="text-center mb-8">
<i class="fas fa-feather-alt text-5xl mb-4" style="color: var(--gold);"></i>
<h1 class="text-3xl font-semibold mb-2" style="color: var(--ink);">Todo Project</h1>
<p class="text-sm" style="color: var(--ink-light);">메모 중심의 간결한 할일 관리</p>
</div>
<form id="loginForm" class="space-y-4">
<form id="loginForm" class="space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">사용자명</label>
<input type="text" id="username" class="input-field w-full px-3 py-2 rounded-lg" placeholder="사용자명을 입력하세요" required>
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">사용자명</label>
<input type="text" id="username" class="input-field w-full px-4 py-3 rounded-lg" placeholder="사용자명을 입력하세요" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
<input type="password" id="password" class="input-field w-full px-3 py-2 rounded-lg" placeholder="비밀번호를 입력하세요" required>
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">비밀번호</label>
<input type="password" id="password" class="input-field w-full px-4 py-3 rounded-lg" placeholder="비밀번호를 입력하세요" required>
</div>
<button type="submit" class="btn-primary w-full py-2 px-4 rounded-lg font-medium">
로그인
<button type="submit" class="btn-primary w-full py-3 px-4 rounded-lg font-medium text-lg">
<i class="fas fa-sign-in-alt mr-2"></i>로그인
</button>
</form>
<div class="mt-4 space-y-2">
<button onclick="testLogin()" class="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors text-sm">
🚀 테스트 로그인 (관리자)
</button>
</div>
<div class="mt-4 text-xs text-gray-500 text-center">
<p>관리자: hyungi / admin123</p>
</div>
<!-- 이미 로그인된 상태 표시 -->
<div id="alreadyLoggedIn" class="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg hidden">
<div id="alreadyLoggedIn" class="mt-6 p-4 rounded-lg hidden" style="background-color: #f0fdf4; border: 1px solid #bbf7d0;">
<div class="text-center">
<i class="fas fa-check-circle text-green-500 text-xl mb-2"></i>
<p class="text-green-800 font-medium mb-2">이미 로그인되어 있습니다!</p>
<button onclick="window.location.href='dashboard.html'"
class="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors">
대시보드로 이동
<i class="fas fa-check-circle text-xl mb-3" style="color: var(--success);"></i>
<p class="font-medium mb-3" style="color: #166534;">이미 로그인되어 있습니다!</p>
<button onclick="window.location.href='upload.html'"
class="w-full py-2 px-4 rounded-lg font-medium transition-all"
style="background-color: var(--success); color: white; border: 1px solid var(--success);"
onmouseover="this.style.backgroundColor='#059669'"
onmouseout="this.style.backgroundColor='var(--success)'">
<i class="fas fa-feather-alt mr-2"></i>메모 작성하기
</button>
</div>
</div>
</div>
</div>
<!-- 초기 설정 화면 -->
<div id="setupScreen" class="hidden min-h-screen flex items-center justify-center p-4">
<div class="parchment-container p-8 w-full max-w-lg">
<div class="text-center mb-8">
<i class="fas fa-cog text-5xl mb-4" style="color: var(--gold);"></i>
<h1 class="text-3xl font-semibold mb-2" style="color: var(--ink);">시스템 초기 설정</h1>
<p class="text-sm" style="color: var(--ink-light);">관리자 계정을 설정하여 Todo Project를 시작하세요</p>
</div>
<form id="setupForm" class="space-y-5">
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">관리자 사용자명</label>
<input type="text" id="setupUsername" class="input-field w-full px-4 py-3 rounded-lg"
placeholder="관리자 사용자명 (3자 이상)" required minlength="3">
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">관리자 이메일</label>
<input type="email" id="setupEmail" class="input-field w-full px-4 py-3 rounded-lg"
placeholder="admin@example.com" required>
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">관리자 비밀번호</label>
<input type="password" id="setupPassword" class="input-field w-full px-4 py-3 rounded-lg"
placeholder="비밀번호 (6자 이상)" required minlength="6">
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">관리자 이름</label>
<input type="text" id="setupFullName" class="input-field w-full px-4 py-3 rounded-lg"
placeholder="Administrator" value="Administrator">
</div>
<button type="submit" class="btn-primary w-full py-3 px-4 rounded-lg font-medium text-lg">
<i class="fas fa-rocket mr-2"></i>시스템 초기화
</button>
</form>
<div class="mt-6 p-4 rounded-lg" style="background-color: #fef3c7; border: 1px solid #f59e0b;">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle mr-3 mt-1" style="color: #d97706;"></i>
<div class="text-sm" style="color: #92400e;">
<p class="font-medium mb-1">주의사항</p>
<ul class="list-disc list-inside space-y-1">
<li>이 설정은 최초 1회만 가능합니다</li>
<li>관리자 계정 정보를 안전하게 보관하세요</li>
<li>설정 완료 후 즉시 로그인됩니다</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- 메인 애플리케이션 -->
<div id="mainApp" class="hidden min-h-screen">
<!-- 헤더 -->
@@ -293,19 +362,54 @@
<!-- JavaScript -->
<script>
// 토큰 상태 확인
console.log('=== 토큰 상태 확인 ===');
const existingToken = localStorage.getItem('authToken');
const existingUser = localStorage.getItem('currentUser');
console.log('기존 토큰 존재:', existingToken ? '있음' : '없음');
console.log('기존 사용자 정보:', existingUser);
// 토큰이 있으면 대시보드로 리다이렉트 (무한 루프 방지)
if (existingToken && existingUser) {
console.log('유효한 토큰이 있습니다. 대시보드로 이동합니다.');
window.location.href = 'dashboard.html';
// 초기 설정 상태 확인
async function checkSetupStatus() {
try {
const response = await fetch('/api/setup/status');
const data = await response.json();
if (data.is_setup_required) {
// 초기 설정이 필요한 경우
document.getElementById('loginScreen').classList.add('hidden');
document.getElementById('setupScreen').classList.remove('hidden');
return false;
} else {
// 이미 설정된 경우 로그인 화면 표시
document.getElementById('setupScreen').classList.add('hidden');
document.getElementById('loginScreen').classList.remove('hidden');
return true;
}
} catch (error) {
console.error('설정 상태 확인 실패:', error);
// 오류 시 로그인 화면 표시
document.getElementById('setupScreen').classList.add('hidden');
document.getElementById('loginScreen').classList.remove('hidden');
return true;
}
}
// 토큰 상태 확인
async function checkAuthStatus() {
console.log('=== 토큰 상태 확인 ===');
const existingToken = localStorage.getItem('authToken');
const existingUser = localStorage.getItem('currentUser');
console.log('기존 토큰 존재:', existingToken ? '있음' : '없음');
console.log('기존 사용자 정보:', existingUser);
// 토큰이 있으면 대시보드로 리다이렉트
if (existingToken && existingUser) {
console.log('유효한 토큰이 있습니다. 대시보드로 이동합니다.');
window.location.href = 'upload.html';
return;
}
// 토큰이 없으면 설정 상태 확인
await checkSetupStatus();
}
// 페이지 로드 시 상태 확인
checkAuthStatus();
</script>
<script src="static/js/api.js?v=20250921110800"></script>
@@ -320,6 +424,58 @@
console.log('AuthAPI 존재:', typeof AuthAPI !== 'undefined');
console.log('window.currentUser:', window.currentUser);
// 초기 설정 폼 이벤트 리스너
const setupForm = document.getElementById('setupForm');
if (setupForm) {
setupForm.addEventListener('submit', async (event) => {
event.preventDefault();
const username = document.getElementById('setupUsername').value;
const email = document.getElementById('setupEmail').value;
const password = document.getElementById('setupPassword').value;
const fullName = document.getElementById('setupFullName').value;
if (!username || !email || !password) {
alert('모든 필수 필드를 입력해주세요.');
return;
}
try {
console.log('시스템 초기화 시도...');
const response = await fetch('/api/setup/initialize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
admin_username: username,
admin_email: email,
admin_password: password,
admin_full_name: fullName
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || '초기화 실패');
}
console.log('초기화 성공:', result);
alert('시스템이 성공적으로 초기화되었습니다!\\n생성된 계정으로 자동 로그인합니다.');
// 자동 로그인
const loginResult = await AuthAPI.login(username, password);
console.log('자동 로그인 성공:', loginResult);
window.location.href = 'upload.html';
} catch (error) {
console.error('초기화 실패:', error);
alert('초기화 실패: ' + error.message);
}
});
}
// 로그인 폼 이벤트 리스너 수동 추가 (백업)
const loginForm = document.getElementById('loginForm');
if (loginForm && !loginForm.hasAttribute('data-listener-added')) {
@@ -340,7 +496,7 @@
console.log('로그인 시도:', username);
const result = await AuthAPI.login(username, password);
console.log('로그인 성공:', result);
window.location.href = 'dashboard.html';
window.location.href = 'upload.html';
} catch (error) {
console.error('로그인 실패:', error);
alert('로그인 실패: ' + error.message);
@@ -349,30 +505,6 @@
}
});
// 테스트 로그인 함수
async function testLogin() {
try {
console.log('테스트 로그인 시작...');
const result = await AuthAPI.login('hyungi', 'admin123');
console.log('로그인 성공:', result);
// 토큰 확인
const token = localStorage.getItem('authToken');
console.log('저장된 토큰:', token ? '있음' : '없음');
// 사용자 정보 확인
const user = localStorage.getItem('currentUser');
console.log('저장된 사용자 정보:', user);
// 대시보드로 이동
setTimeout(() => {
window.location.href = 'dashboard.html';
}, 1000);
} catch (error) {
console.error('테스트 로그인 실패:', error);
alert('로그인 실패: ' + error.message);
}
}
</script>
</body>
</html>

60
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,60 @@
server {
listen 80;
server_name localhost;
# 파일 업로드 크기 제한 (50MB)
client_max_body_size 50M;
# 프론트엔드 파일 서빙
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# API 프록시 설정
location /api/ {
proxy_pass http://backend:9000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS 헤더 추가
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
# OPTIONS 요청 처리
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
}
# 업로드된 이미지 서빙 (백엔드 프록시)
location /uploads/ {
proxy_pass http://backend:9000/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 캐시 설정 (이미지 파일용)
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 루트 경로 프록시
location = /api {
proxy_pass http://backend:9000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -2,7 +2,10 @@
* API 통신 유틸리티
*/
const API_BASE_URL = 'http://localhost:9000/api';
// 환경에 따른 API URL 설정
const API_BASE_URL = window.location.hostname === 'localhost'
? 'http://localhost:9000/api' // 로컬 개발 환경
: `${window.location.protocol}//${window.location.hostname}:9000/api`; // 시놀로지 배포 환경
class ApiClient {
constructor() {

View File

@@ -90,7 +90,7 @@ async function handleLogin(event) {
window.currentUser = response.user;
// 대시보드로 리다이렉트
window.location.href = 'dashboard.html';
window.location.href = 'inbox.html';
} catch (error) {
console.error('로그인 실패:', error);
@@ -169,7 +169,7 @@ function showMainApp() {
// index.html에서는 대시보드로 리다이렉트
if (!mainApp && loginScreen) {
window.location.href = 'dashboard.html';
window.location.href = 'inbox.html';
return;
}

View File

@@ -396,7 +396,7 @@ function goToPage(pageType) {
// 대시보드로 이동
function goToDashboard() {
window.location.href = 'dashboard.html';
window.location.href = 'upload.html';
}
// 분류 센터로 이동

643
frontend/todo-list.html Normal file
View File

@@ -0,0 +1,643 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo 목록 - Todo Project</title>
<link rel="icon" type="image/x-icon" href="static/icons/favicon.ico">
<link rel="apple-touch-icon" href="static/icons/apple-touch-icon.png">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--parchment: #f7f3e9;
--parchment-dark: #f0ead6;
--ink: #2c1810;
--ink-light: #5d4e37;
--sepia: #8b7355;
--gold: #d4af37;
--shadow: rgba(139, 115, 85, 0.2);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
}
body {
font-family: 'Noto Serif KR', serif;
background: linear-gradient(135deg, #f7f3e9 0%, #f0ead6 100%);
background-attachment: fixed;
color: var(--ink);
}
.parchment-container {
background: var(--parchment);
background-image:
radial-gradient(circle at 25% 25%, rgba(139, 115, 85, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(139, 115, 85, 0.05) 0%, transparent 50%);
border: 2px solid var(--sepia);
border-radius: 8px;
box-shadow:
0 8px 32px var(--shadow),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
position: relative;
}
.parchment-container::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg, var(--gold), var(--sepia), var(--gold));
border-radius: 10px;
z-index: -1;
opacity: 0.3;
}
.header-vintage {
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
border-bottom: 3px solid var(--gold);
box-shadow: 0 2px 10px var(--shadow);
}
.todo-item {
background: var(--parchment-dark);
border: 1px solid var(--sepia);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.todo-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px var(--shadow);
}
.todo-item.overdue {
border-left: 4px solid var(--danger);
background: linear-gradient(135deg, var(--parchment-dark), #fef2f2);
}
.todo-item.today {
border-left: 4px solid var(--warning);
background: linear-gradient(135deg, var(--parchment-dark), #fffbeb);
}
.action-button {
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
color: var(--parchment);
border: 2px solid var(--gold);
border-radius: 20px;
padding: 0.5rem 1rem;
font-family: 'Noto Serif KR', serif;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 8px var(--shadow);
font-size: 0.875rem;
}
.action-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--shadow);
}
.action-button.complete {
background: linear-gradient(135deg, var(--success), #16a34a);
}
.action-button.delay {
background: linear-gradient(135deg, var(--warning), #d97706);
}
.action-button.extend {
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 0.5rem;
max-width: 240px;
}
.image-item {
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--sepia);
}
.image-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.date-modal {
background: rgba(44, 24, 16, 0.8);
backdrop-filter: blur(5px);
}
.date-input {
background: var(--parchment);
border: 2px solid var(--sepia);
border-radius: 8px;
padding: 0.75rem;
font-family: 'Noto Serif KR', serif;
color: var(--ink);
}
.date-input:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.2);
}
.priority-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.priority-high {
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.priority-medium {
background: rgba(245, 158, 11, 0.1);
color: var(--warning);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.priority-low {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
border: 1px solid rgba(34, 197, 94, 0.3);
}
</style>
</head>
<body>
<!-- 헤더 -->
<header class="header-vintage">
<div class="max-w-6xl mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h1 class="text-2xl font-semibold" style="color: var(--ink);">
<i class="fas fa-tasks mr-3" style="color: var(--gold);"></i>
Todo 목록
</h1>
<span id="todoCount" class="ml-4 px-3 py-1 bg-gold text-white rounded-full text-sm font-medium"></span>
</div>
<div class="flex space-x-2">
<a href="upload.html" class="action-button">
<i class="fas fa-feather-alt mr-2"></i>메모
</a>
<a href="inbox.html" class="action-button">
<i class="fas fa-inbox mr-2"></i>수신함
</a>
<a href="todo-list.html" class="action-button" style="background: var(--gold); border-color: var(--gold); color: var(--ink);">
<i class="fas fa-tasks mr-2"></i>Todo 목록
</a>
<a href="board.html" class="action-button">
<i class="fas fa-clipboard mr-2"></i>보드
</a>
<a href="archive.html" class="action-button">
<i class="fas fa-archive mr-2"></i>아카이브
</a>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-6xl mx-auto px-4 py-8">
<!-- 오늘 날짜 표시 -->
<div class="parchment-container p-4 mb-6">
<div class="flex items-center justify-center">
<div class="text-center">
<h2 class="text-xl font-medium" style="color: var(--ink);">
<i class="fas fa-calendar-day mr-3" style="color: var(--gold);"></i>
<span id="todayDate"></span>
</h2>
<p class="text-sm mt-1" style="color: var(--ink-light);">오늘 해야 할 일들</p>
</div>
</div>
</div>
<!-- Todo 목록 -->
<div class="parchment-container p-6">
<div id="todoList">
<!-- Todo 목록이 여기에 표시됩니다 -->
</div>
<div id="emptyState" class="hidden text-center py-12">
<i class="fas fa-clipboard-list text-6xl mb-4" style="color: var(--sepia); opacity: 0.5;"></i>
<h3 class="text-xl font-medium mb-2" style="color: var(--ink-light);">진행할 Todo가 없습니다</h3>
<p class="text-sm mb-6" style="color: var(--sepia);">수신함에서 새로운 Todo를 추가해보세요</p>
<a href="inbox.html" class="action-button">
<i class="fas fa-inbox mr-2"></i>수신함으로 이동
</a>
</div>
</div>
</main>
<!-- 날짜 선택 모달 -->
<div id="dateModal" class="hidden fixed inset-0 date-modal flex items-center justify-center z-50">
<div class="parchment-container p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4 text-center" style="color: var(--ink);">
<i class="fas fa-calendar-alt mr-2" style="color: var(--gold);"></i>
새로운 날짜 선택
</h3>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">지연할 날짜</label>
<input type="date" id="newDueDate" class="date-input w-full">
</div>
<div class="flex space-x-3">
<button onclick="confirmDateChange()" class="action-button flex-1">
<i class="fas fa-check mr-2"></i>확인
</button>
<button onclick="closeDateModal()" class="action-button flex-1" style="background: var(--sepia);">
<i class="fas fa-times mr-2"></i>취소
</button>
</div>
</div>
</div>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/auth.js"></script>
<script>
let allTodos = [];
let currentTodoId = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
// 인증 확인
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
if (!token) {
window.location.href = 'index.html';
return;
}
displayTodayDate();
loadTodos();
});
// Todo 목록 로드
async function loadTodos() {
try {
const todos = await TodoAPI.getTodos(null, 'todo');
// 서울 시간 기준 오늘 날짜 계산
const seoulTime = new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"});
const today = new Date(seoulTime);
today.setHours(0, 0, 0, 0);
allTodos = todos.filter(todo => {
if (todo.status === 'completed') return false;
// 시작 날짜가 오늘 이전이거나 오늘인 Todo들 표시 (해야 할 시점이 된 것들)
const startDate = new Date(todo.start_date);
startDate.setHours(0, 0, 0, 0);
return startDate.getTime() <= today.getTime();
});
filterTodos();
} catch (error) {
console.error('Todo 로드 실패:', error);
showEmptyState();
}
}
// Todo 정렬 및 렌더링
function filterTodos() {
let sortedTodos = [...allTodos];
// 상태별 우선순위 정렬 (오늘 시작 > 진행 중) + 생성일 최신순
sortedTodos.sort((a, b) => {
const seoulTime = new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"});
const today = new Date(seoulTime);
today.setHours(0, 0, 0, 0);
const startDateA = new Date(a.start_date);
const startDateB = new Date(b.start_date);
startDateA.setHours(0, 0, 0, 0);
startDateB.setHours(0, 0, 0, 0);
// 오늘 시작하는 것을 우선으로
const isATodayStart = startDateA.getTime() === today.getTime();
const isBTodayStart = startDateB.getTime() === today.getTime();
if (isATodayStart && !isBTodayStart) return -1;
if (!isATodayStart && isBTodayStart) return 1;
// 같은 상태면 생성일 최신순
const createdA = new Date(a.created_at);
const createdB = new Date(b.created_at);
return createdB - createdA;
});
renderTodos(sortedTodos);
}
// 오늘 날짜 표시
function displayTodayDate() {
const seoulTime = new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"});
const today = new Date(seoulTime);
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
timeZone: 'Asia/Seoul'
};
const todayString = today.toLocaleDateString('ko-KR', options);
document.getElementById('todayDate').textContent = todayString;
}
// Todo 목록 렌더링
function renderTodos(todos) {
const todoList = document.getElementById('todoList');
const emptyState = document.getElementById('emptyState');
const todoCount = document.getElementById('todoCount');
todoCount.textContent = `${todos.length}`;
if (!todos || todos.length === 0) {
showEmptyState();
return;
}
emptyState.classList.add('hidden');
// 서울 시간 기준 오늘 날짜 계산
const seoulTime = new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"});
const today = new Date(seoulTime);
today.setHours(0, 0, 0, 0);
todoList.innerHTML = todos.map(todo => {
const hasImages = todo.image_urls && todo.image_urls.length > 0;
const createdAt = new Date(todo.created_at);
const timeAgo = getTimeAgo(createdAt);
// 시작 날짜 상태 계산
const startDate = new Date(todo.start_date);
startDate.setHours(0, 0, 0, 0);
let statusClass = '';
let statusText = '';
let statusIcon = '';
if (startDate.getTime() < today.getTime()) {
// 시작 날짜가 지났지만 아직 완료하지 않은 경우
statusClass = 'overdue';
statusText = '진행 중';
statusIcon = 'fas fa-play';
} else if (startDate.getTime() === today.getTime()) {
// 오늘 시작하는 Todo
statusClass = 'today';
statusText = '오늘 시작';
statusIcon = 'fas fa-clock';
}
const formattedDate = startDate.toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric',
weekday: 'short'
});
return `
<div class="todo-item ${statusClass}">
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<div class="flex items-center mb-2">
<span class="priority-badge priority-medium">
<i class="${statusIcon} mr-1"></i>
${statusText}
</span>
</div>
<p class="text-base mb-2 font-medium" style="color: var(--ink);">
${todo.description || '내용 없음'}
</p>
<div class="flex items-center text-xs" style="color: var(--sepia);">
<i class="fas fa-calendar mr-1"></i>
<span>시작일: ${formattedDate}</span>
${hasImages ? `<i class="fas fa-images ml-3 mr-1"></i><span>${todo.image_urls.length}장</span>` : ''}
</div>
</div>
</div>
${hasImages ? `
<div class="image-grid mb-4">
${todo.image_urls.slice(0, 4).map(url => `
<div class="image-item">
<img src="${url}" alt="첨부 이미지" onclick="showImageModal('${url}')">
</div>
`).join('')}
${todo.image_urls.length > 4 ? `
<div class="image-item flex items-center justify-center" style="background: var(--parchment); border: 2px dashed var(--sepia);">
<span class="text-xs" style="color: var(--sepia);">+${todo.image_urls.length - 4}</span>
</div>
` : ''}
</div>
` : ''}
<div class="flex flex-wrap gap-2">
<button onclick="completeTodo('${todo.id}')" class="action-button complete">
<i class="fas fa-check mr-1"></i>완료
</button>
<button onclick="extendTodo('${todo.id}', 3)" class="action-button extend">
<i class="fas fa-plus mr-1"></i>+3일
</button>
<button onclick="extendTodo('${todo.id}', 5)" class="action-button extend">
<i class="fas fa-plus mr-1"></i>+5일
</button>
<button onclick="openDateModal('${todo.id}')" class="action-button delay">
<i class="fas fa-calendar-alt mr-1"></i>지연
</button>
</div>
</div>
`;
}).join('');
}
// 빈 상태 표시
function showEmptyState() {
document.getElementById('todoList').innerHTML = '';
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('todoCount').textContent = '0개';
}
// Todo 완료 처리
async function completeTodo(todoId) {
try {
await TodoAPI.updateTodo(todoId, {
status: 'completed',
completed_at: new Date().toISOString()
});
// 완료된 Todo를 목록에서 제거
allTodos = allTodos.filter(todo => todo.id !== todoId);
filterTodos();
// 성공 메시지
showToast('Todo가 완료되었습니다!', 'success');
} catch (error) {
console.error('Todo 완료 실패:', error);
showToast('Todo 완료에 실패했습니다.', 'error');
}
}
// Todo 기간 연장
async function extendTodo(todoId, days) {
try {
const todo = allTodos.find(t => t.id === todoId);
if (!todo) return;
const currentDate = new Date(todo.start_date);
const newDate = new Date(currentDate.getTime() + (days * 24 * 60 * 60 * 1000));
await TodoAPI.updateTodo(todoId, {
start_date: newDate.toISOString().split('T')[0]
});
// 로컬 데이터 업데이트
todo.start_date = newDate.toISOString().split('T')[0];
filterTodos();
showToast(`${days}일 연장되었습니다!`, 'success');
} catch (error) {
console.error('Todo 연장 실패:', error);
showToast('Todo 연장에 실패했습니다.', 'error');
}
}
// 날짜 선택 모달 열기
function openDateModal(todoId) {
currentTodoId = todoId;
const todo = allTodos.find(t => t.id === todoId);
if (todo) {
// 현재 날짜를 기본값으로 설정
const currentDate = new Date(todo.start_date);
document.getElementById('newDueDate').value = currentDate.toISOString().split('T')[0];
}
document.getElementById('dateModal').classList.remove('hidden');
}
// 날짜 선택 모달 닫기
function closeDateModal() {
currentTodoId = null;
document.getElementById('dateModal').classList.add('hidden');
}
// 날짜 변경 확인
async function confirmDateChange() {
if (!currentTodoId) return;
const newDate = document.getElementById('newDueDate').value;
if (!newDate) {
alert('새로운 날짜를 선택해주세요.');
return;
}
try {
await TodoAPI.updateTodo(currentTodoId, {
start_date: newDate
});
// 로컬 데이터 업데이트
const todo = allTodos.find(t => t.id === currentTodoId);
if (todo) {
todo.start_date = newDate;
}
filterTodos();
closeDateModal();
showToast('날짜가 변경되었습니다!', 'success');
} catch (error) {
console.error('날짜 변경 실패:', error);
showToast('날짜 변경에 실패했습니다.', 'error');
}
}
// 토스트 메시지 표시
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white font-medium z-50 transform transition-all duration-300`;
switch (type) {
case 'success':
toast.style.background = 'var(--success)';
break;
case 'error':
toast.style.background = 'var(--danger)';
break;
default:
toast.style.background = 'var(--sepia)';
}
toast.textContent = message;
document.body.appendChild(toast);
// 애니메이션
setTimeout(() => toast.style.transform = 'translateX(0)', 100);
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// 시간 경과 표시
function getTimeAgo(date) {
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric'
});
}
// 이미지 모달 표시
function showImageModal(imageUrl) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 date-modal flex items-center justify-center z-50';
modal.innerHTML = `
<div class="max-w-4xl max-h-4xl p-4">
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg">
</div>
`;
modal.onclick = () => modal.remove();
document.body.appendChild(modal);
}
// 전역 함수 등록
window.filterTodos = filterTodos;
window.completeTodo = completeTodo;
window.extendTodo = extendTodo;
window.openDateModal = openDateModal;
window.closeDateModal = closeDateModal;
window.confirmDateChange = confirmDateChange;
window.showImageModal = showImageModal;
</script>
</body>
</html>

View File

@@ -1,353 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo - 시작 날짜가 있는 일들</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #3b82f6; /* 하늘색 */
--primary-dark: #2563eb; /* 진한 하늘색 */
--success: #10b981; /* 초록색 */
--warning: #f59e0b; /* 주황색 */
--danger: #ef4444; /* 빨간색 */
--gray-50: #f9fafb; /* 연한 회색 */
--gray-100: #f3f4f6; /* 회색 */
--gray-200: #e5e7eb; /* 중간 회색 */
--gray-300: #d1d5db; /* 진한 회색 */
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.todo-item {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.todo-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>
</head>
<body>
<div class="min-h-screen">
<!-- 헤더 -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<button onclick="goBack()" class="mr-4 text-gray-500 hover:text-gray-700">
<i class="fas fa-arrow-left text-xl"></i>
</button>
<i class="fas fa-calendar-day text-2xl text-blue-500 mr-3"></i>
<h1 class="text-xl font-semibold text-gray-800">Todo</h1>
<span class="ml-3 text-sm text-gray-500">시작 날짜가 있는 일들</span>
</div>
<div class="flex items-center space-x-4">
<button onclick="goToDashboard()" class="text-blue-600 hover:text-blue-800 font-medium">
<i class="fas fa-chart-line mr-1"></i>대시보드
</button>
<span class="text-sm text-gray-600" id="currentUser"></span>
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 페이지 설명 -->
<div class="bg-blue-50 rounded-xl p-6 mb-8">
<div class="flex items-center mb-4">
<i class="fas fa-calendar-day text-2xl text-blue-600 mr-3"></i>
<h2 class="text-xl font-semibold text-blue-900">Todo 관리</h2>
</div>
<p class="text-blue-800 mb-4">
시작 날짜가 정해진 일들을 관리합니다. 언제 시작할지 계획을 세우고 실행해보세요.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="bg-white rounded-lg p-3">
<div class="font-medium text-blue-900 mb-1">📅 시작 예정</div>
<div class="text-blue-700">아직 시작하지 않은 일들</div>
</div>
<div class="bg-white rounded-lg p-3">
<div class="font-medium text-blue-900 mb-1">🔥 진행 중</div>
<div class="text-blue-700">현재 작업 중인 일들</div>
</div>
<div class="bg-white rounded-lg p-3">
<div class="font-medium text-blue-900 mb-1">✅ 완료</div>
<div class="text-blue-700">완료된 일들</div>
</div>
</div>
</div>
<!-- 필터 및 정렬 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex space-x-1 bg-gray-100 rounded-lg p-1">
<button onclick="filterTodos('all')" class="filter-tab active px-4 py-2 rounded text-sm font-medium">전체</button>
<button onclick="filterTodos('scheduled')" class="filter-tab px-4 py-2 rounded text-sm font-medium">시작 예정</button>
<button onclick="filterTodos('active')" class="filter-tab px-4 py-2 rounded text-sm font-medium">진행 중</button>
<button onclick="filterTodos('completed')" class="filter-tab px-4 py-2 rounded text-sm font-medium">완료</button>
</div>
<div class="flex items-center space-x-3">
<label class="text-sm text-gray-600">정렬:</label>
<select id="sortBy" class="border border-gray-300 rounded-lg px-3 py-1 text-sm">
<option value="start_date">시작일 순</option>
<option value="created_at">등록일 순</option>
<option value="priority">우선순위 순</option>
</select>
</div>
</div>
</div>
<!-- Todo 목록 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b">
<h3 class="text-lg font-semibold text-gray-800">
<i class="fas fa-list text-blue-500 mr-2"></i>Todo 목록
</h3>
</div>
<div id="todoList" class="divide-y divide-gray-100">
<!-- Todo 항목들이 여기에 동적으로 추가됩니다 -->
</div>
<div id="emptyState" class="p-12 text-center text-gray-500">
<i class="fas fa-calendar-day text-4xl mb-4 opacity-50"></i>
<p>아직 시작 날짜가 설정된 일이 없습니다.</p>
<p class="text-sm">메인 페이지에서 항목을 등록하고 시작 날짜를 설정해보세요!</p>
<button onclick="goBack()" class="mt-4 btn-primary px-6 py-2 rounded-lg">
<i class="fas fa-arrow-left mr-2"></i>메인으로 돌아가기
</button>
</div>
</div>
</main>
</div>
<!-- JavaScript -->
<script src="static/js/auth.js"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
checkAuthStatus();
loadTodoItems();
});
// 뒤로 가기
function goBack() {
window.location.href = 'index.html';
}
// Todo 항목 로드
async function loadTodoItems() {
try {
// API에서 Todo 카테고리 항목들만 가져오기
const items = await TodoAPI.getTodos(null, 'todo');
renderTodoItems(items);
} catch (error) {
console.error('Todo 항목 로드 실패:', error);
renderTodoItems([]);
}
}
// Todo 항목 렌더링
function renderTodoItems(items) {
const todoList = document.getElementById('todoList');
const emptyState = document.getElementById('emptyState');
if (items.length === 0) {
todoList.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
todoList.innerHTML = items.map(item => `
<div class="todo-item p-6">
<div class="flex items-start space-x-4">
<!-- 상태 아이콘 -->
<div class="flex-shrink-0 mt-1">
<div class="w-8 h-8 rounded-full flex items-center justify-center ${getStatusColor(item.status)}">
<i class="fas ${getStatusIcon(item.status)} text-sm"></i>
</div>
</div>
<!-- 사진 (있는 경우) -->
${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0">
<div class="flex space-x-2">
${item.image_urls.slice(0, 3).map(url => `
<img src="${url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
`).join('')}
${item.image_urls.length > 3 ? `
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
<span class="text-xs text-gray-500">+${item.image_urls.length - 3}</span>
</div>
` : ''}
</div>
</div>
` : ''}
<!-- 내용 -->
<div class="flex-1 min-w-0">
<h4 class="text-gray-900 font-medium mb-2">${item.title}</h4>
<div class="flex items-center space-x-4 text-sm text-gray-500">
<span>
<i class="fas fa-calendar mr-1"></i>마감일: ${formatDate(item.due_date)}
</span>
<span>
<i class="fas fa-clock mr-1"></i>등록: ${formatDate(item.created_at)}
</span>
</div>
</div>
<!-- 액션 버튼 -->
<div class="flex-shrink-0 flex space-x-2">
${item.status !== 'completed' ? `
<button onclick="delayTodo('${item.id}')" class="text-orange-500 hover:text-orange-700" title="지연하기">
<i class="fas fa-clock"></i>
</button>
<button onclick="completeTodo('${item.id}')" class="text-green-500 hover:text-green-700" title="완료하기">
<i class="fas fa-check"></i>
</button>
<button onclick="splitTodo('${item.id}')" class="text-purple-500 hover:text-purple-700" title="분할하기">
<i class="fas fa-cut"></i>
</button>
` : ''}
<button onclick="editTodo('${item.id}')" class="text-gray-400 hover:text-blue-500" title="수정하기">
<i class="fas fa-edit"></i>
</button>
</div>
</div>
</div>
`).join('');
}
// 상태별 색상
function getStatusColor(status) {
const colors = {
scheduled: 'bg-blue-100 text-blue-600',
active: 'bg-orange-100 text-orange-600',
completed: 'bg-green-100 text-green-600'
};
return colors[status] || 'bg-gray-100 text-gray-600';
}
// 상태별 아이콘
function getStatusIcon(status) {
const icons = {
scheduled: 'fa-calendar',
active: 'fa-play',
completed: 'fa-check'
};
return icons[status] || 'fa-circle';
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '날짜 없음';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '날짜 없음';
return date.toLocaleDateString('ko-KR');
}
// Todo 지연
function delayTodo(id) {
const newDate = prompt('새로운 시작 날짜를 입력하세요 (YYYY-MM-DD):');
if (newDate && /^\d{4}-\d{2}-\d{2}$/.test(newDate)) {
// TODO: API 호출하여 due_date 업데이트
console.log('Todo 지연:', id, '새 날짜:', newDate);
alert(`할 일이 ${newDate}로 지연되었습니다.`);
loadTodoItems(); // 목록 새로고침
} else if (newDate) {
alert('올바른 날짜 형식을 입력해주세요 (YYYY-MM-DD)');
}
}
// Todo 완료
async function completeTodo(id) {
try {
// TODO: API 호출하여 상태를 'completed'로 변경
console.log('Todo 완료:', id);
alert('할 일이 완료되었습니다!');
loadTodoItems(); // 목록 새로고침
} catch (error) {
console.error('완료 처리 실패:', error);
alert('완료 처리에 실패했습니다.');
}
}
// Todo 분할
function splitTodo(id) {
const splitCount = prompt('몇 개로 분할하시겠습니까? (2-5개):');
const count = parseInt(splitCount);
if (count >= 2 && count <= 5) {
const subtasks = [];
for (let i = 1; i <= count; i++) {
const subtask = prompt(`${i}번째 세부 작업을 입력하세요:`);
if (subtask) {
subtasks.push(subtask.trim());
}
}
if (subtasks.length > 0) {
// TODO: API 호출하여 원본 삭제 후 세부 작업들 생성
console.log('Todo 분할:', id, '세부 작업들:', subtasks);
alert(`할 일이 ${subtasks.length}개의 세부 작업으로 분할되었습니다.`);
loadTodoItems(); // 목록 새로고침
}
} else if (splitCount) {
alert('2-5개 사이의 숫자를 입력해주세요.');
}
}
// Todo 편집
function editTodo(id) {
console.log('Todo 편집:', id);
// TODO: 편집 모달 또는 페이지로 이동
alert('편집 기능은 준비 중입니다.');
}
// 필터링
function filterTodos(filter) {
console.log('필터:', filter);
// TODO: 필터에 따라 목록 재로드
}
// 대시보드로 이동
function goToDashboard() {
window.location.href = 'dashboard.html';
}
// 전역 함수 등록
window.goToDashboard = goToDashboard;
</script>
<script src="static/js/api.js?v=20250921110800"></script>
<script src="static/js/auth.js?v=20250921110800"></script>
</body>
</html>

853
frontend/upload.html Normal file
View File

@@ -0,0 +1,853 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>새 메모 - Todo Project</title>
<link rel="icon" type="image/x-icon" href="static/icons/favicon.ico">
<link rel="apple-touch-icon" href="static/icons/apple-touch-icon.png">
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#d4af37">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Todo Project">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--parchment: #f7f3e9;
--parchment-dark: #f0ead6;
--ink: #2c1810;
--ink-light: #5d4e37;
--sepia: #8b7355;
--gold: #d4af37;
--shadow: rgba(139, 115, 85, 0.2);
}
body {
font-family: 'Noto Serif KR', serif;
background: linear-gradient(135deg, #f7f3e9 0%, #f0ead6 100%);
background-attachment: fixed;
color: var(--ink);
}
.parchment-container {
background: var(--parchment);
background-image:
radial-gradient(circle at 25% 25%, rgba(139, 115, 85, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(139, 115, 85, 0.05) 0%, transparent 50%);
border: 2px solid var(--sepia);
border-radius: 8px;
box-shadow:
0 8px 32px var(--shadow),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
position: relative;
}
.parchment-container::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg, var(--gold), var(--sepia), var(--gold));
border-radius: 10px;
z-index: -1;
opacity: 0.3;
}
.memo-textarea {
background: transparent;
border: none;
outline: none;
resize: none;
font-family: 'Noto Serif KR', serif;
font-size: 1.1rem;
line-height: 1.8;
color: var(--ink);
width: 100%;
min-height: 200px;
padding: 2rem;
background-image: repeating-linear-gradient(
transparent,
transparent 1.7rem,
rgba(139, 115, 85, 0.1) 1.7rem,
rgba(139, 115, 85, 0.1) 1.8rem
);
}
.memo-textarea::placeholder {
color: var(--ink-light);
opacity: 0.6;
font-style: italic;
}
.memo-textarea:focus {
background-image: repeating-linear-gradient(
transparent,
transparent 1.7rem,
rgba(139, 115, 85, 0.2) 1.7rem,
rgba(139, 115, 85, 0.2) 1.8rem
);
}
.action-button {
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
color: var(--parchment);
border: 2px solid var(--gold);
border-radius: 25px;
padding: 0.75rem 2rem;
font-family: 'Noto Serif KR', serif;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--shadow);
}
.action-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px var(--shadow);
background: linear-gradient(135deg, var(--ink-light), var(--ink));
}
.photo-button {
background: var(--parchment-dark);
border: 2px dashed var(--sepia);
color: var(--ink-light);
border-radius: 12px;
padding: 1rem;
transition: all 0.3s ease;
font-family: 'Noto Serif KR', serif;
}
.photo-button:hover {
background: var(--parchment);
border-color: var(--gold);
color: var(--ink);
transform: translateY(-1px);
}
.mobile-upload {
display: none;
}
@media (max-width: 768px) {
.desktop-upload {
display: none;
}
.mobile-upload {
display: block;
}
.memo-textarea {
min-height: 180px;
padding: 1.5rem;
font-size: 1rem;
}
/* 모바일에서 최근 메모 섹션 간소화 */
.parchment-container {
margin-bottom: 1rem;
}
/* 모바일에서 헤더 패딩 줄이기 */
.header-vintage .max-w-4xl {
padding: 1rem;
}
/* 모바일에서 메인 패딩 줄이기 */
main {
padding: 1rem;
}
/* 모바일에서 키보드 대응을 위한 스타일 */
.memo-textarea:focus {
/* 포커스 시 더 부드러운 전환 */
transition: all 0.3s ease;
}
/* iOS Safari에서 키보드로 인한 뷰포트 변화 대응 */
body {
/* 키보드가 나타날 때 레이아웃 깨짐 방지 */
-webkit-overflow-scrolling: touch;
}
}
.photo-preview {
background: var(--parchment-dark);
border: 1px solid var(--sepia);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.photo-item {
position: relative;
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
border: 2px solid var(--sepia);
}
.photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-remove {
position: absolute;
top: 0.25rem;
right: 0.25rem;
background: var(--ink);
color: var(--parchment);
border: none;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.75rem;
}
.header-vintage {
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
border-bottom: 3px solid var(--gold);
box-shadow: 0 2px 10px var(--shadow);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.recent-item {
transition: all 0.2s ease;
}
.recent-item:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--shadow);
}
.nav-button {
background-color: var(--sepia);
color: white;
padding: 0.5rem 0.75rem;
border-radius: 6px;
transition: all 0.2s ease;
border: 1px solid var(--sepia);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 500;
text-decoration: none;
min-width: 70px;
}
.nav-button:hover {
background-color: var(--gold);
border-color: var(--gold);
color: var(--ink);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.nav-button.active {
background-color: var(--gold);
border-color: var(--gold);
color: var(--ink);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
</style>
</head>
<body>
<!-- 헤더 -->
<header class="header-vintage">
<div class="max-w-6xl mx-auto px-4 py-3">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h1 class="text-xl font-semibold" style="color: var(--ink);">
<i class="fas fa-feather-alt mr-2" style="color: var(--gold);"></i>
새 메모
</h1>
</div>
<div class="flex space-x-1">
<a href="upload.html" class="nav-button active">
<i class="fas fa-feather-alt mr-1"></i>메모
</a>
<a href="inbox.html" class="nav-button">
<i class="fas fa-inbox mr-1"></i>수신함
</a>
<a href="todo-list.html" class="nav-button">
<i class="fas fa-tasks mr-1"></i>Todo
</a>
<a href="board.html" class="nav-button">
<i class="fas fa-clipboard mr-1"></i>보드
</a>
<a href="archive.html" class="nav-button">
<i class="fas fa-archive mr-1"></i>아카이브
</a>
</div>
</div>
</div>
</header>
<!-- 메인 컨텐츠 -->
<main class="max-w-4xl mx-auto px-4 py-8">
<!-- 최근 업로드 항목 (상단으로 이동) -->
<div class="mb-6">
<div class="parchment-container">
<div class="px-6 py-4">
<h3 class="text-lg font-medium mb-4 text-center" style="color: var(--ink);">
<i class="fas fa-history mr-2" style="color: var(--gold);"></i>
최근 저장된 메모
</h3>
<div id="recentUploads" class="space-y-3">
<!-- 최근 업로드 항목들이 여기에 표시됩니다 -->
</div>
</div>
</div>
</div>
<!-- 메인 입력 폼 -->
<div class="parchment-container">
<form id="uploadForm">
<!-- 사진 버튼들 (상단으로 이동) -->
<div class="px-6 pt-6 pb-4">
<!-- 데스크톱용 -->
<div class="desktop-upload">
<button type="button" onclick="selectFile()" class="photo-button w-full text-center">
<i class="fas fa-images mr-2"></i>
사진 첨부
</button>
<input type="file" id="desktopFileInput" accept="image/*" multiple class="hidden">
</div>
<!-- 모바일용 -->
<div class="mobile-upload">
<div class="grid grid-cols-2 gap-3">
<button type="button" onclick="openCamera()" class="photo-button text-center">
<i class="fas fa-camera mb-2 block text-lg"></i>
<span class="text-sm">사진 촬영</span>
</button>
<button type="button" onclick="openGallery()" class="photo-button text-center">
<i class="fas fa-images mb-2 block text-lg"></i>
<span class="text-sm">갤러리</span>
</button>
</div>
<input type="file" id="cameraInput" accept="image/*" capture="camera" multiple class="hidden">
<input type="file" id="galleryInput" accept="image/*" multiple class="hidden">
</div>
<!-- 사진 미리보기 -->
<div id="photoPreviewGrid" class="hidden mt-4">
<div class="photo-preview">
<div class="flex justify-between items-center mb-3">
<span class="text-sm font-medium" style="color: var(--ink-light);">
첨부된 사진 (<span id="photoCount">0</span>/5)
</span>
<button type="button" onclick="removeAllPhotos()"
class="text-xs" style="color: var(--sepia);">
<i class="fas fa-trash mr-1"></i>모두 삭제
</button>
</div>
<div id="photoGrid" class="photo-grid"></div>
</div>
</div>
</div>
<!-- 메모 입력 영역 (하단으로 이동) -->
<textarea
id="uploadContent"
class="memo-textarea"
placeholder="오늘 있었던 일, 떠오른 생각, 중요한 메모... 무엇이든 자유롭게 적어보세요."
required
autofocus></textarea>
<!-- 저장 버튼 -->
<div class="px-6 pb-6">
<div class="text-center">
<button type="submit" class="action-button">
<i class="fas fa-save mr-2"></i>
메모 저장
</button>
</div>
</div>
</form>
</div>
</main>
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="hidden fixed inset-0 flex items-center justify-center z-50" style="background: rgba(44, 24, 16, 0.7);">
<div class="parchment-container p-6 flex items-center space-x-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--gold);"></div>
<span style="color: var(--ink); font-family: 'Noto Serif KR', serif;">메모 저장 중...</span>
</div>
</div>
<!-- JavaScript -->
<script src="static/js/api.js"></script>
<script src="static/js/auth.js"></script>
<script src="static/js/image-utils.js"></script>
<script>
let currentPhotos = [];
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
// 인증 확인 (여러 토큰 키 확인)
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
if (!token) {
console.log('토큰이 없습니다. 대시보드로 이동합니다.');
window.location.href = 'index.html';
return;
}
console.log('인증 토큰 확인됨:', token ? '있음' : '없음');
// 이벤트 리스너 설정
setupEventListeners();
// 최근 업로드 항목 로드
loadRecentUploads();
});
function setupEventListeners() {
// 폼 제출
document.getElementById('uploadForm').addEventListener('submit', handleUploadSubmit);
// 파일 입력 이벤트
document.getElementById('desktopFileInput').addEventListener('change', handleFileSelect);
document.getElementById('cameraInput').addEventListener('change', handleFileSelect);
document.getElementById('galleryInput').addEventListener('change', handleFileSelect);
// 드래그 앤 드롭
const uploadArea = document.querySelector('.desktop-upload .border-dashed');
if (uploadArea) {
uploadArea.addEventListener('dragover', handleDragOver);
uploadArea.addEventListener('drop', handleDrop);
}
// 모바일 키보드 대응 설정
setupMobileKeyboardHandling();
}
// 모바일 키보드 대응 설정
function setupMobileKeyboardHandling() {
const textarea = document.getElementById('uploadContent');
// iOS Safari와 Android Chrome에서 키보드 대응
textarea.addEventListener('focus', function() {
// 모바일 디바이스 감지
if (window.innerWidth <= 768) {
// 약간의 지연 후 스크롤 (키보드 애니메이션 완료 대기)
setTimeout(() => {
// textarea를 화면 중앙으로 부드럽게 스크롤
textarea.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}, 300);
}
});
// 키보드가 사라질 때 처리
textarea.addEventListener('blur', function() {
if (window.innerWidth <= 768) {
// 키보드가 사라진 후 전체 레이아웃 복원
setTimeout(() => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}, 100);
}
});
// 뷰포트 크기 변화 감지 (키보드 나타남/사라짐)
let initialViewportHeight = window.innerHeight;
window.addEventListener('resize', function() {
const currentHeight = window.innerHeight;
const heightDifference = initialViewportHeight - currentHeight;
// 키보드가 나타났을 때 (높이가 150px 이상 줄어들었을 때)
if (heightDifference > 150 && document.activeElement === textarea) {
setTimeout(() => {
textarea.scrollIntoView({
behavior: 'smooth',
block: 'end',
inline: 'nearest'
});
}, 100);
}
});
// 터치 시작 시 현재 뷰포트 높이 업데이트
document.addEventListener('touchstart', function() {
initialViewportHeight = window.innerHeight;
});
}
// 파일 선택
function selectFile() {
document.getElementById('desktopFileInput').click();
}
// 카메라 열기
function openCamera() {
document.getElementById('cameraInput').click();
}
// 갤러리 열기
function openGallery() {
document.getElementById('galleryInput').click();
}
// 드래그 오버
function handleDragOver(e) {
e.preventDefault();
e.currentTarget.classList.add('border-purple-300', 'bg-purple-50');
}
// 드롭
function handleDrop(e) {
e.preventDefault();
e.currentTarget.classList.remove('border-purple-300', 'bg-purple-50');
const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/'));
processFiles(files);
}
// 파일 선택 처리
function handleFileSelect(e) {
const files = Array.from(e.target.files);
processFiles(files);
}
// 파일 처리
async function processFiles(files) {
if (currentPhotos.length + files.length > 5) {
alert('최대 5장까지만 업로드할 수 있습니다.');
return;
}
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
let processedFile = file;
// 간단한 이미지 압축 시도
try {
// 파일 크기가 2MB 이상인 경우 압축 시도
if (file.size > 2 * 1024 * 1024) {
console.log(`큰 파일 감지 (${(file.size / 1024 / 1024).toFixed(2)}MB), 압축 시도:`, file.name);
const compressed = await compressImageSimple(file, 0.7, 1920);
if (compressed && compressed.size < file.size) {
processedFile = compressed;
console.log(`압축 성공: ${(file.size / 1024 / 1024).toFixed(2)}MB → ${(compressed.size / 1024 / 1024).toFixed(2)}MB`);
} else {
console.warn('압축 효과 없음, 원본 사용:', file.name);
}
} else {
console.log('작은 파일, 압축 생략:', file.name);
}
} catch (compressionError) {
console.warn('이미지 압축 실패, 원본 사용:', compressionError);
}
const base64 = await convertFileToBase64(processedFile);
currentPhotos.push({
file: processedFile,
base64: base64,
name: file.name
});
console.log('파일 처리 완료:', file.name);
} catch (error) {
console.error('이미지 처리 실패:', error);
alert(`이미지 처리에 실패했습니다: ${file.name}`);
}
}
}
updatePhotoPreview();
}
// 간단한 이미지 압축 함수
function compressImageSimple(file, quality = 0.7, maxWidth = 1920) {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = function() {
// 비율 유지하면서 크기 조정
let { width, height } = img;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
// 이미지 그리기
ctx.drawImage(img, 0, 0, width, height);
// Blob으로 변환
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('압축 실패'));
}
},
'image/jpeg',
quality
);
};
img.onerror = () => reject(new Error('이미지 로드 실패'));
img.src = URL.createObjectURL(file);
});
}
// Base64 변환
function convertFileToBase64(file) {
return new Promise((resolve, reject) => {
// 파일이 유효한 Blob/File인지 확인
if (!file || !(file instanceof Blob || file instanceof File)) {
reject(new Error('유효하지 않은 파일 객체입니다.'));
return;
}
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => {
console.error('FileReader 오류:', error);
reject(error);
};
try {
reader.readAsDataURL(file);
} catch (error) {
console.error('readAsDataURL 호출 오류:', error);
reject(error);
}
});
}
// 사진 미리보기 업데이트
function updatePhotoPreview() {
const previewGrid = document.getElementById('photoPreviewGrid');
const photoGrid = document.getElementById('photoGrid');
const photoCount = document.getElementById('photoCount');
if (currentPhotos.length === 0) {
previewGrid.classList.add('hidden');
return;
}
previewGrid.classList.remove('hidden');
photoCount.textContent = currentPhotos.length;
photoGrid.innerHTML = currentPhotos.map((photo, index) => `
<div class="photo-item">
<img src="${photo.base64}" alt="${photo.name}">
<button type="button" class="photo-remove" onclick="removePhoto(${index})">
<i class="fas fa-times text-sm"></i>
</button>
</div>
`).join('');
}
// 사진 제거
function removePhoto(index) {
currentPhotos.splice(index, 1);
updatePhotoPreview();
}
// 모든 사진 제거
function removeAllPhotos() {
currentPhotos = [];
updatePhotoPreview();
}
// 업로드 제출
async function handleUploadSubmit(e) {
e.preventDefault();
const content = document.getElementById('uploadContent').value.trim();
if (!content) {
alert('메모를 입력해주세요.');
return;
}
try {
document.getElementById('loadingOverlay').classList.remove('hidden');
const todoData = {
title: null, // 메모는 제목 없음
description: content,
category: 'memo',
image_urls: currentPhotos.map(photo => photo.base64)
};
console.log('업로드 데이터:', todoData);
const response = await TodoAPI.createTodo(todoData);
console.log('업로드 성공:', response);
// 성공 시 폼 초기화 및 메시지 표시
showSuccessMessage('메모가 성공적으로 저장되었습니다!');
clearForm();
} catch (error) {
console.error('업로드 실패:', error);
alert('업로드에 실패했습니다. 다시 시도해주세요.');
} finally {
document.getElementById('loadingOverlay').classList.add('hidden');
}
}
// 성공 메시지 표시
function showSuccessMessage(message) {
// 기존 메시지 제거
const existingMessage = document.querySelector('.success-message');
if (existingMessage) {
existingMessage.remove();
}
// 새 메시지 생성
const messageDiv = document.createElement('div');
messageDiv.className = 'success-message fixed top-4 left-1/2 transform -translate-x-1/2 z-50 px-6 py-3 rounded-lg shadow-lg';
messageDiv.style.background = 'var(--gold)';
messageDiv.style.color = 'var(--ink)';
messageDiv.style.fontFamily = 'Noto Serif KR, serif';
messageDiv.innerHTML = `<i class="fas fa-check-circle mr-2"></i>${message}`;
document.body.appendChild(messageDiv);
// 3초 후 제거
setTimeout(() => {
messageDiv.remove();
}, 3000);
}
// 폼 초기화
function clearForm() {
document.getElementById('uploadContent').value = '';
removeAllPhotos();
document.getElementById('uploadContent').focus();
// 최근 업로드 목록 새로고침
loadRecentUploads();
}
// 최근 업로드 항목 로드
async function loadRecentUploads() {
try {
const response = await TodoAPI.getTodos(null, 'memo');
const recentItems = response.slice(0, 3); // 최근 3개만
renderRecentUploads(recentItems);
} catch (error) {
console.error('최근 업로드 로드 실패:', error);
document.getElementById('recentUploads').innerHTML = `
<div class="text-center py-4" style="color: var(--ink-light);">
<i class="fas fa-exclamation-triangle mr-2"></i>
최근 메모를 불러올 수 없습니다.
</div>
`;
}
}
// 최근 업로드 항목 렌더링
function renderRecentUploads(items) {
const container = document.getElementById('recentUploads');
if (!items || items.length === 0) {
container.innerHTML = `
<div class="text-center py-8" style="color: var(--ink-light);">
<i class="fas fa-inbox mr-2"></i>
아직 저장된 메모가 없습니다.
</div>
`;
return;
}
container.innerHTML = items.map(item => {
const createdAt = new Date(item.created_at);
const timeAgo = getTimeAgo(createdAt);
const hasImages = item.image_urls && item.image_urls.length > 0;
const content = item.description || item.title || '내용 없음';
return `
<div class="recent-item p-3 rounded-lg border" style="background: var(--parchment-dark); border-color: var(--sepia);">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm line-clamp-2 mb-2" style="color: var(--ink);">
${content.length > 80 ? content.substring(0, 80) + '...' : content}
</p>
<div class="flex items-center text-xs" style="color: var(--ink-light);">
<i class="fas fa-clock mr-1"></i>
<span>${timeAgo}</span>
${hasImages ? `<i class="fas fa-images ml-3 mr-1"></i><span>${item.image_urls.length}장</span>` : ''}
</div>
</div>
${hasImages && item.image_urls[0] ? `
<div class="ml-3 flex-shrink-0">
<img src="${item.image_urls[0]}" alt="첨부 이미지"
class="w-12 h-12 object-cover rounded border"
style="border-color: var(--sepia);">
</div>
` : ''}
</div>
</div>
`;
}).join('');
}
// 시간 경과 표시
function getTimeAgo(date) {
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return '방금 전';
if (diffMins < 60) return `${diffMins}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric'
});
}
// 전역 함수 등록
window.selectFile = selectFile;
window.openCamera = openCamera;
window.openGallery = openGallery;
window.removePhoto = removePhoto;
window.removeAllPhotos = removeAllPhotos;
</script>
</body>
</html>