🚀 시놀로지 배포 준비 완료
✨ 주요 변경사항: - 단일 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:
@@ -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
574
frontend/archive.html
Normal 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
1141
frontend/board.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
798
frontend/inbox.html
Normal 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>
|
||||
@@ -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
60
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
643
frontend/todo-list.html
Normal 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>
|
||||
@@ -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
853
frontend/upload.html
Normal 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>
|
||||
Reference in New Issue
Block a user