Files
Todo-Project/frontend/board.html
Hyungi Ahn 0b967a84fa 🚀 시놀로지 배포 준비 완료
 주요 변경사항:
- 단일 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 폴더)
- 중복된 시놀로지 설정 파일들
2025-09-24 09:12:39 +09:00

1142 lines
49 KiB
HTML

<!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);
}
.board-card {
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;
}
.board-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px var(--shadow);
border-color: var(--gold);
}
.board-card.active {
border-color: var(--gold);
box-shadow: 0 0 20px rgba(212, 175, 55, 0.3);
}
.board-header {
background: linear-gradient(135deg, var(--gold), #b8941f);
color: var(--ink);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
font-weight: 600;
text-align: center;
box-shadow: 0 4px 15px var(--shadow);
}
.memo-item {
background: var(--parchment);
border: 1px solid var(--sepia);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
transition: all 0.3s ease;
cursor: pointer;
}
.memo-item:hover {
background: var(--parchment-dark);
border-color: var(--gold);
transform: translateX(5px);
}
.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.danger {
background: linear-gradient(135deg, var(--danger), #dc2626);
}
.add-memo-form {
background: var(--parchment);
border: 2px dashed var(--sepia);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.memo-textarea {
background: var(--parchment);
border: 1px solid var(--sepia);
border-radius: 6px;
padding: 0.75rem;
width: 100%;
min-height: 80px;
font-family: 'Noto Serif KR', serif;
color: var(--ink);
resize: vertical;
}
.memo-textarea:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.2);
}
.photo-button {
background: var(--gold);
color: var(--ink);
border: 2px solid var(--gold);
border-radius: 8px;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
}
.photo-button:hover {
background: #b8941f;
border-color: #b8941f;
transform: translateY(-1px);
}
.image-preview {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 2px solid var(--sepia);
}
.image-preview img {
width: 100%;
height: 120px;
object-fit: cover;
}
.image-remove {
position: absolute;
top: 5px;
right: 5px;
background: var(--danger);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
/* 모바일/데스크톱 구분 */
@media (max-width: 768px) {
.desktop-upload { display: none; }
.mobile-upload { display: block !important; }
}
@media (min-width: 769px) {
.desktop-upload { display: block; }
.mobile-upload { display: none !important; }
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 0.5rem;
max-width: 240px;
margin-top: 0.5rem;
}
.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;
}
.modal {
background: rgba(44, 24, 16, 0.8);
backdrop-filter: blur(5px);
}
.board-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
}
@media (max-width: 768px) {
.board-grid {
grid-template-columns: 1fr;
}
}
</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-clipboard mr-3" style="color: var(--gold);"></i>
보드
</h1>
<span id="boardCount" 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" style="background: var(--gold); border-color: var(--gold); color: var(--ink);">
<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 id="boardContainer" class="board-grid">
<!-- 보드들이 여기에 표시됩니다 -->
</div>
<div id="emptyState" class="hidden text-center py-12">
<div class="parchment-container p-8">
<i class="fas fa-clipboard 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="inbox.html" class="action-button">
<i class="fas fa-inbox mr-2"></i>수신함으로 이동
</a>
</div>
</div>
</main>
<!-- 메모 편집 모달 -->
<div id="editModal" class="hidden fixed inset-0 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>
<!-- 내용 편집 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">내용</label>
<textarea id="editContent" class="memo-textarea" placeholder="메모 내용을 입력하세요..."></textarea>
</div>
<!-- 기존 이미지 -->
<div id="existingEditImages" class="hidden mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">기존 이미지</label>
<div id="existingEditImageGrid" class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-3"></div>
</div>
<!-- 새 이미지 추가 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">새 이미지 추가</label>
<div class="flex space-x-2 mb-3">
<!-- 데스크톱: 파일 선택 -->
<div class="desktop-upload">
<input type="file" id="editImageInput" multiple accept="image/*" class="hidden" onchange="handleEditImageSelect()">
<button type="button" onclick="document.getElementById('editImageInput').click()" class="photo-button">
<i class="fas fa-images mr-2"></i>이미지 선택
</button>
</div>
<!-- 모바일: 카메라/갤러리 -->
<div class="mobile-upload hidden">
<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 id="newEditImagePreview" class="hidden">
<div id="newEditImageGrid" class="grid grid-cols-2 md:grid-cols-3 gap-3"></div>
</div>
</div>
<div class="flex space-x-3">
<button onclick="saveEdit()" class="action-button flex-1">
<i class="fas fa-save mr-2"></i>저장
</button>
<button onclick="closeEditModal()" 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 allBoards = [];
let currentEditId = null;
let memoImages = {}; // 보드별 이미지 저장 {boardId: [files...]}
let currentEditMemo = null;
let newEditImages = [];
let existingImages = [];
let collapsedBoards = new Set(); // 접힌 보드들 추적
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
// 인증 확인
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
if (!token) {
window.location.href = 'index.html';
return;
}
loadBoards();
});
// 보드 목록 로드
async function loadBoards() {
try {
const boards = await TodoAPI.getTodos(null, 'board');
// 완료되지 않은 보드만 필터링
const activeBoards = boards.filter(item => item.status !== 'completed');
// 보드별로 그룹화 (board_id 기준)
const boardGroups = {};
activeBoards.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);
}
});
allBoards = Object.values(boardGroups).filter(board => board.header);
renderBoards();
} catch (error) {
console.error('보드 로드 실패:', error);
showEmptyState();
}
}
// 보드 목록 렌더링
function renderBoards() {
const boardContainer = document.getElementById('boardContainer');
const emptyState = document.getElementById('emptyState');
const boardCount = document.getElementById('boardCount');
boardCount.textContent = `${allBoards.length}`;
if (!allBoards || allBoards.length === 0) {
showEmptyState();
return;
}
emptyState.classList.add('hidden');
boardContainer.innerHTML = allBoards.map(board => {
const header = board.header;
const memos = board.memos.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return `
<div class="board-card">
<div class="board-header">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold flex-1">
${header.title || header.description}
</h3>
<button onclick="toggleBoard('${header.board_id || header.id}')" class="text-sm px-2 py-1 rounded" style="background: rgba(255,255,255,0.2);" title="메모 접기/펼치기">
<i class="fas ${collapsedBoards.has(header.board_id || header.id) ? 'fa-chevron-down' : 'fa-chevron-up'}"></i>
</button>
</div>
<div class="flex justify-center space-x-2">
<button onclick="addMemoToBoard('${header.board_id || header.id}')" class="action-button">
<i class="fas fa-plus mr-1"></i>메모 추가
</button>
<button onclick="completeBoard('${header.id}')" class="action-button danger">
<i class="fas fa-check mr-1"></i>보드 완료
</button>
</div>
</div>
<div class="memos-container ${collapsedBoards.has(header.board_id || header.id) ? 'hidden' : ''}" id="memos_${header.board_id || header.id}">
${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" onclick="editMemo('${memo.id}', '${memo.description.replace(/'/g, "\\'")}')">
<div class="flex justify-between items-start">
<div class="flex-1">
<p class="text-sm mb-2" style="color: var(--ink);">
${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>
${hasImages ? `
<div class="image-grid mt-3">
${memo.image_urls.slice(0, 4).map((url, index) => `
<div class="image-item">
<img src="${url}" alt="첨부 이미지" onclick="event.stopPropagation(); showImageModal('${url}', ${JSON.stringify(memo.image_urls).replace(/"/g, '&quot;')}, ${index})">
</div>
`).join('')}
${memo.image_urls.length > 4 ? `
<div class="image-item flex items-center justify-center cursor-pointer" style="background: var(--parchment); border: 2px dashed var(--sepia);" onclick="event.stopPropagation(); showImageModal('${memo.image_urls[4]}', ${JSON.stringify(memo.image_urls).replace(/"/g, '&quot;')}, 4)">
<span class="text-xs" style="color: var(--sepia);">+${memo.image_urls.length - 4}</span>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}).join('')}
<div id="addMemoForm_${header.board_id || header.id}" class="add-memo-form hidden">
<textarea id="newMemoContent_${header.board_id || header.id}" class="memo-textarea" placeholder="새 메모를 입력하세요..."></textarea>
<!-- 이미지 업로드 섹션 -->
<div class="mt-3">
<div class="flex space-x-2 mb-3">
<!-- 데스크톱: 파일 선택 -->
<div class="desktop-upload">
<input type="file" id="memoImageInput_${header.board_id || header.id}" multiple accept="image/*" class="hidden" onchange="handleMemoImageSelect('${header.board_id || header.id}')">
<button type="button" onclick="document.getElementById('memoImageInput_${header.board_id || header.id}').click()" class="photo-button">
<i class="fas fa-images mr-2"></i>이미지 선택
</button>
</div>
<!-- 모바일: 카메라/갤러리 -->
<div class="mobile-upload hidden">
<button type="button" onclick="captureMemoImage('${header.board_id || header.id}')" class="photo-button">
<i class="fas fa-camera mr-2"></i>카메라
</button>
<button type="button" onclick="document.getElementById('memoImageInput_${header.board_id || header.id}').click()" class="photo-button">
<i class="fas fa-images mr-2"></i>갤러리
</button>
</div>
</div>
<!-- 이미지 미리보기 -->
<div id="memoImagePreview_${header.board_id || header.id}" class="hidden">
<div id="memoImageGrid_${header.board_id || header.id}" class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-3"></div>
</div>
</div>
<div class="flex space-x-2 mt-3">
<button onclick="saveMemoToBoard('${header.board_id || header.id}')" class="action-button">
<i class="fas fa-save mr-1"></i>저장
</button>
<button onclick="cancelAddMemo('${header.board_id || header.id}')" class="action-button" style="background: var(--sepia);">
<i class="fas fa-times mr-1"></i>취소
</button>
</div>
</div>
</div>
</div>
`;
}).join('');
}
// 빈 상태 표시
function showEmptyState() {
document.getElementById('boardContainer').innerHTML = '';
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('boardCount').textContent = '0개';
}
// 보드 접기/펼치기
function toggleBoard(boardId) {
const memosContainer = document.getElementById(`memos_${boardId}`);
const toggleButton = document.querySelector(`[onclick="toggleBoard('${boardId}')"] i`);
if (collapsedBoards.has(boardId)) {
// 펼치기
collapsedBoards.delete(boardId);
memosContainer.classList.remove('hidden');
toggleButton.className = 'fas fa-chevron-up';
} else {
// 접기
collapsedBoards.add(boardId);
memosContainer.classList.add('hidden');
toggleButton.className = 'fas fa-chevron-down';
}
}
// 보드에 메모 추가 폼 표시
function addMemoToBoard(boardId) {
const form = document.getElementById(`addMemoForm_${boardId}`);
form.classList.remove('hidden');
document.getElementById(`newMemoContent_${boardId}`).focus();
}
// 메모 추가 취소
function cancelAddMemo(boardId) {
const form = document.getElementById(`addMemoForm_${boardId}`);
form.classList.add('hidden');
document.getElementById(`newMemoContent_${boardId}`).value = '';
// 이미지 초기화
memoImages[boardId] = [];
const preview = document.getElementById(`memoImagePreview_${boardId}`);
if (preview) {
preview.classList.add('hidden');
document.getElementById(`memoImageGrid_${boardId}`).innerHTML = '';
}
}
// 메모 이미지 선택 처리
function handleMemoImageSelect(boardId) {
const input = document.getElementById(`memoImageInput_${boardId}`);
const files = Array.from(input.files);
if (!memoImages[boardId]) {
memoImages[boardId] = [];
}
// 최대 5개까지만 허용
const remainingSlots = 5 - memoImages[boardId].length;
const filesToAdd = files.slice(0, remainingSlots);
if (files.length > remainingSlots) {
showToast(`최대 5개의 이미지만 업로드할 수 있습니다.`, 'warning');
}
memoImages[boardId] = memoImages[boardId].concat(filesToAdd);
renderMemoImages(boardId);
input.value = ''; // 입력 초기화
}
// 메모 이미지 미리보기 렌더링
function renderMemoImages(boardId) {
const preview = document.getElementById(`memoImagePreview_${boardId}`);
const grid = document.getElementById(`memoImageGrid_${boardId}`);
if (!memoImages[boardId] || memoImages[boardId].length === 0) {
preview.classList.add('hidden');
return;
}
preview.classList.remove('hidden');
grid.innerHTML = memoImages[boardId].map((file, index) => {
const url = URL.createObjectURL(file);
return `
<div class="image-preview">
<img src="${url}" alt="이미지 ${index + 1}">
<button type="button" class="image-remove" onclick="removeMemoImage('${boardId}', ${index})">
<i class="fas fa-times"></i>
</button>
</div>
`;
}).join('');
}
// 메모 이미지 제거
function removeMemoImage(boardId, index) {
if (memoImages[boardId]) {
memoImages[boardId].splice(index, 1);
renderMemoImages(boardId);
}
}
// 카메라로 메모 이미지 촬영 (모바일)
function captureMemoImage(boardId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.onchange = function(e) {
const file = e.target.files[0];
if (file) {
if (!memoImages[boardId]) {
memoImages[boardId] = [];
}
if (memoImages[boardId].length >= 5) {
showToast('최대 5개의 이미지만 업로드할 수 있습니다.', 'warning');
return;
}
memoImages[boardId].push(file);
renderMemoImages(boardId);
}
};
input.click();
}
// 보드에 메모 저장
async function saveMemoToBoard(boardId) {
const content = document.getElementById(`newMemoContent_${boardId}`).value.trim();
if (!content) {
alert('메모 내용을 입력해주세요.');
return;
}
try {
let imageUrls = [];
// 이미지가 있으면 업로드
if (memoImages[boardId] && memoImages[boardId].length > 0) {
showToast('이미지 업로드 중...', 'info');
for (const file of memoImages[boardId]) {
try {
const compressedFile = await compressImageSimple(file, 0.7, 1920);
const response = await TodoAPI.uploadImage(compressedFile);
imageUrls.push(response.file_url);
} catch (uploadError) {
console.error('이미지 업로드 실패:', uploadError);
showToast('일부 이미지 업로드에 실패했습니다.', 'warning');
}
}
}
await TodoAPI.createTodo({
title: null,
description: content,
category: 'board',
board_id: boardId,
is_board_header: false,
image_urls: imageUrls
});
cancelAddMemo(boardId);
loadBoards(); // 목록 새로고침
showToast('메모가 추가되었습니다!', 'success');
} catch (error) {
console.error('메모 추가 실패:', error);
showToast('메모 추가에 실패했습니다.', 'error');
}
}
// 메모 편집
async function editMemo(memoId, content) {
currentEditId = memoId;
document.getElementById('editContent').value = content;
// 기존 메모 정보 가져오기
try {
currentEditMemo = await TodoAPI.getTodoById(memoId);
existingImages = currentEditMemo.image_urls || [];
newEditImages = [];
renderExistingEditImages();
renderNewEditImages();
document.getElementById('editModal').classList.remove('hidden');
} catch (error) {
console.error('메모 로드 실패:', error);
showToast('메모 로드에 실패했습니다.', 'error');
}
}
// 편집 저장
async function saveEdit() {
if (!currentEditId) return;
const content = document.getElementById('editContent').value.trim();
if (!content) {
alert('메모 내용을 입력해주세요.');
return;
}
try {
let finalImageUrls = [...existingImages];
// 새 이미지가 있으면 업로드
if (newEditImages && newEditImages.length > 0) {
showToast('이미지 업로드 중...', 'info');
for (const file of newEditImages) {
try {
const compressedFile = await compressImageSimple(file, 0.7, 1920);
const response = await TodoAPI.uploadImage(compressedFile);
finalImageUrls.push(response.file_url);
} catch (uploadError) {
console.error('이미지 업로드 실패:', uploadError);
showToast('일부 이미지 업로드에 실패했습니다.', 'warning');
}
}
}
await TodoAPI.updateTodo(currentEditId, {
description: content,
image_urls: finalImageUrls
});
closeEditModal();
loadBoards();
showToast('메모가 수정되었습니다!', 'success');
} catch (error) {
console.error('메모 수정 실패:', error);
showToast('메모 수정에 실패했습니다.', 'error');
}
}
// 편집 모달 닫기
function closeEditModal() {
currentEditId = null;
currentEditMemo = null;
existingImages = [];
newEditImages = [];
document.getElementById('editContent').value = '';
document.getElementById('editModal').classList.add('hidden');
// 이미지 미리보기 초기화
document.getElementById('existingEditImages').classList.add('hidden');
document.getElementById('newEditImagePreview').classList.add('hidden');
document.getElementById('existingEditImageGrid').innerHTML = '';
document.getElementById('newEditImageGrid').innerHTML = '';
}
// 기존 이미지 렌더링
function renderExistingEditImages() {
const container = document.getElementById('existingEditImages');
const grid = document.getElementById('existingEditImageGrid');
if (!existingImages || existingImages.length === 0) {
container.classList.add('hidden');
return;
}
container.classList.remove('hidden');
grid.innerHTML = existingImages.map((url, index) => `
<div class="image-preview">
<img src="${url}" alt="기존 이미지 ${index + 1}" onclick="showImageModal('${url}')">
<button type="button" class="image-remove" onclick="removeExistingEditImage(${index})">
<i class="fas fa-times"></i>
</button>
</div>
`).join('');
}
// 기존 이미지 제거
function removeExistingEditImage(index) {
existingImages.splice(index, 1);
renderExistingEditImages();
}
// 새 이미지 선택 처리
function handleEditImageSelect() {
const input = document.getElementById('editImageInput');
const files = Array.from(input.files);
// 최대 5개까지만 허용 (기존 + 새 이미지)
const totalImages = existingImages.length + newEditImages.length;
const remainingSlots = 5 - totalImages;
const filesToAdd = files.slice(0, remainingSlots);
if (files.length > remainingSlots) {
showToast(`최대 5개의 이미지만 업로드할 수 있습니다.`, 'warning');
}
newEditImages = newEditImages.concat(filesToAdd);
renderNewEditImages();
input.value = '';
}
// 새 이미지 미리보기 렌더링
function renderNewEditImages() {
const preview = document.getElementById('newEditImagePreview');
const grid = document.getElementById('newEditImageGrid');
if (!newEditImages || newEditImages.length === 0) {
preview.classList.add('hidden');
return;
}
preview.classList.remove('hidden');
grid.innerHTML = newEditImages.map((file, index) => {
const url = URL.createObjectURL(file);
return `
<div class="image-preview">
<img src="${url}" alt="새 이미지 ${index + 1}">
<button type="button" class="image-remove" onclick="removeNewEditImage(${index})">
<i class="fas fa-times"></i>
</button>
</div>
`;
}).join('');
}
// 새 이미지 제거
function removeNewEditImage(index) {
newEditImages.splice(index, 1);
renderNewEditImages();
}
// 카메라로 편집 이미지 촬영 (모바일)
function captureEditImage() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.onchange = function(e) {
const file = e.target.files[0];
if (file) {
const totalImages = existingImages.length + newEditImages.length;
if (totalImages >= 5) {
showToast('최대 5개의 이미지만 업로드할 수 있습니다.', 'warning');
return;
}
newEditImages.push(file);
renderNewEditImages();
}
};
input.click();
}
// 보드 완료 (종료)
async function completeBoard(boardId) {
if (!confirm('이 보드를 완료하시겠습니까? 완료된 보드는 목록에서 사라집니다.')) {
return;
}
try {
// 보드 헤더와 모든 관련 메모를 완료 상태로 변경
const boards = await TodoAPI.getTodos(null, 'board');
const boardItems = boards.filter(item => item.board_id === boardId || item.id === boardId);
for (const item of boardItems) {
await TodoAPI.updateTodo(item.id, {
status: 'completed',
completed_at: new Date().toISOString()
});
}
loadBoards();
showToast('보드가 완료되었습니다!', 'success');
// 1.5초 후 아카이브 안내 메시지 표시
setTimeout(() => {
if (confirm('완료된 보드를 아카이브에서 확인하시겠습니까?')) {
window.location.href = 'archive.html';
}
}, 1500);
} catch (error) {
console.error('보드 완료 실패:', error);
showToast('보드 완료에 실패했습니다.', 'error');
}
}
// 시간 경과 표시
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 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 showImageModal(imageUrl, allImages = null, currentIndex = 0) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 flex items-center justify-center z-50';
modal.style.cssText = `
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(5px);
`;
// 이미지가 여러 개인 경우 갤러리 모드
const isGallery = allImages && allImages.length > 1;
modal.innerHTML = `
<div class="relative max-w-[95vw] max-h-[95vh] flex flex-col items-center">
<!-- 닫기 버튼 -->
<button class="absolute -top-12 right-0 text-white text-2xl hover:text-gray-300 z-10" onclick="this.closest('.fixed').remove()">
<i class="fas fa-times"></i>
</button>
<!-- 이미지 컨테이너 -->
<div class="relative flex items-center justify-center">
${isGallery ? `
<!-- 이전 버튼 -->
<button class="absolute left-4 top-1/2 transform -translate-y-1/2 text-white text-3xl hover:text-gray-300 z-10 ${currentIndex === 0 ? 'opacity-50 cursor-not-allowed' : ''}"
onclick="showImageModal('${allImages[currentIndex - 1]}', ${JSON.stringify(allImages).replace(/"/g, '&quot;')}, ${currentIndex - 1})"
${currentIndex === 0 ? 'disabled' : ''}>
<i class="fas fa-chevron-left"></i>
</button>
<!-- 다음 버튼 -->
<button class="absolute right-4 top-1/2 transform -translate-y-1/2 text-white text-3xl hover:text-gray-300 z-10 ${currentIndex === allImages.length - 1 ? 'opacity-50 cursor-not-allowed' : ''}"
onclick="showImageModal('${allImages[currentIndex + 1]}', ${JSON.stringify(allImages).replace(/"/g, '&quot;')}, ${currentIndex + 1})"
${currentIndex === allImages.length - 1 ? 'disabled' : ''}>
<i class="fas fa-chevron-right"></i>
</button>
` : ''}
<!-- 메인 이미지 -->
<img src="${imageUrl}"
class="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl"
style="max-width: 90vw;"
onclick="event.stopPropagation()">
</div>
${isGallery ? `
<!-- 이미지 카운터 -->
<div class="mt-4 text-white text-center">
<span class="bg-black bg-opacity-50 px-3 py-1 rounded-full">
${currentIndex + 1} / ${allImages.length}
</span>
</div>
<!-- 썸네일 네비게이션 -->
<div class="mt-4 flex space-x-2 overflow-x-auto max-w-full pb-2">
${allImages.map((url, index) => `
<img src="${url}"
class="w-16 h-16 object-cover rounded cursor-pointer border-2 ${index === currentIndex ? 'border-white' : 'border-transparent opacity-70 hover:opacity-100'}"
onclick="showImageModal('${url}', ${JSON.stringify(allImages).replace(/"/g, '&quot;')}, ${index})">
`).join('')}
</div>
` : ''}
</div>
`;
// 배경 클릭 시 모달 닫기
modal.onclick = (e) => {
if (e.target === modal) {
modal.remove();
}
};
// ESC 키로 모달 닫기
const handleKeydown = (e) => {
if (e.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', handleKeydown);
} else if (isGallery) {
if (e.key === 'ArrowLeft' && currentIndex > 0) {
showImageModal(allImages[currentIndex - 1], allImages, currentIndex - 1);
} else if (e.key === 'ArrowRight' && currentIndex < allImages.length - 1) {
showImageModal(allImages[currentIndex + 1], allImages, currentIndex + 1);
}
}
};
document.addEventListener('keydown', handleKeydown);
// 모달이 제거될 때 이벤트 리스너도 제거
const originalRemove = modal.remove;
modal.remove = function() {
document.removeEventListener('keydown', handleKeydown);
originalRemove.call(this);
};
document.body.appendChild(modal);
}
// 전역 함수 등록
window.toggleBoard = toggleBoard;
window.addMemoToBoard = addMemoToBoard;
window.cancelAddMemo = cancelAddMemo;
window.saveMemoToBoard = saveMemoToBoard;
window.editMemo = editMemo;
window.saveEdit = saveEdit;
window.closeEditModal = closeEditModal;
window.completeBoard = completeBoard;
window.showImageModal = showImageModal;
// 이미지 압축 함수
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 = 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(resolve, 'image/jpeg', quality);
};
img.src = URL.createObjectURL(file);
});
}
// 파일을 Base64로 변환
async function convertFileToBase64(file) {
return new Promise((resolve, reject) => {
if (!file || !(file instanceof Blob)) {
reject(new Error('Invalid file object'));
return;
}
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
</script>
</body>
</html>