Add todo 리스트 관리 기능 구현
All checks were successful
Build And Test / build-and-push (push) Successful in 1m36s
All checks were successful
Build And Test / build-and-push (push) Successful in 1m36s
This commit is contained in:
@ -1,2 +1,3 @@
|
|||||||
REACT_APP_API_AUTH=http://127.0.0.1:8000
|
REACT_APP_API_AUTH=http://127.0.0.1:8000
|
||||||
REACT_APP_API_BLOG=http://127.0.0.1:8800
|
REACT_APP_API_BLOG=http://127.0.0.1:8800
|
||||||
|
REACT_APP_API_TODO=http://127.0.0.1:8880
|
@ -1,2 +1,3 @@
|
|||||||
REACT_APP_API_AUTH=https://www.demo.test
|
REACT_APP_API_AUTH=https://www.demo.test
|
||||||
REACT_APP_API_BLOG=https://www.demo.test
|
REACT_APP_API_BLOG=https://www.demo.test
|
||||||
|
REACT_APP_API_TODO=https://www.demo.test
|
@ -12,6 +12,8 @@ import PostCategory from "./pages/PostCategory";
|
|||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import Profile from "./pages/Profile";
|
import Profile from "./pages/Profile";
|
||||||
|
import TaskPage from "./pages/TaskPage";
|
||||||
|
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -32,6 +34,7 @@ function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
<Route path="/tasks" element={<TaskPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
20
src/api/todoApi.js
Normal file
20
src/api/todoApi.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// src/api/todoApi.js
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const todoApi = axios.create({
|
||||||
|
baseURL: process.env.REACT_APP_API_TODO,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// JWT 자동 첨부
|
||||||
|
todoApi.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('access');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default todoApi;
|
@ -6,7 +6,7 @@ const Navbar = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isLoggedIn, logout, user } = useAuth();
|
const { isLoggedIn, logout, user } = useAuth();
|
||||||
|
|
||||||
const menuItems = ["home", "about", "posts", "paas", "infra"];
|
const menuItems = ["home", "about", "posts", "paas", "infra", "tasks"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed w-full z-50 bg-white/90 backdrop-blur-md shadow-sm">
|
<nav className="fixed w-full z-50 bg-white/90 backdrop-blur-md shadow-sm">
|
||||||
|
112
src/components/Todo/TodoDetail.js
Normal file
112
src/components/Todo/TodoDetail.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import todoApi from "../../api/todoApi";
|
||||||
|
|
||||||
|
const TodoDetail = ({ task, onUpdated }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
due_date: "",
|
||||||
|
tags: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (task) {
|
||||||
|
setFormData({
|
||||||
|
title: task.title,
|
||||||
|
description: task.description || "",
|
||||||
|
due_date: task.due_date || "",
|
||||||
|
tags: (task.tags || []).join(", "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [task]);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
try {
|
||||||
|
const tagList =
|
||||||
|
formData.tags.trim() !== ""
|
||||||
|
? formData.tags.split(",").map((tag) => tag.trim())
|
||||||
|
: [];
|
||||||
|
|
||||||
|
await todoApi.patch(`/api/todo/tasks/${task.id}/`, {
|
||||||
|
title: formData.title,
|
||||||
|
description: formData.description,
|
||||||
|
due_date: formData.due_date === "" ? null : formData.due_date,
|
||||||
|
tags: tagList,
|
||||||
|
});
|
||||||
|
|
||||||
|
alert("수정되었습니다.");
|
||||||
|
onUpdated?.(); // ✅ 부모에게 갱신 요청
|
||||||
|
} catch (err) {
|
||||||
|
console.error("수정 실패:", err);
|
||||||
|
alert("수정 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold mb-4">🛠 작업 수정</h2>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">제목</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">설명</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows="3"
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">마감일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="due_date"
|
||||||
|
value={formData.due_date || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">태그 (쉼표로 구분)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="tags"
|
||||||
|
value={formData.tags}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdate}
|
||||||
|
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
💾 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TodoDetail;
|
124
src/components/Todo/TodoForm.js
Normal file
124
src/components/Todo/TodoForm.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import todoApi from "../../api/todoApi";
|
||||||
|
|
||||||
|
const TodoForm = ({ onTaskCreated }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
due_date: "",
|
||||||
|
tags: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const tagList =
|
||||||
|
formData.tags.trim() !== ""
|
||||||
|
? formData.tags.split(",").map((tag) => tag.trim())
|
||||||
|
: [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await todoApi.post("/api/todo/tasks/", {
|
||||||
|
title: formData.title,
|
||||||
|
description: formData.description,
|
||||||
|
due_date: formData.due_date === "" ? null : formData.due_date,
|
||||||
|
tags: tagList,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormData({ title: "", description: "", due_date: "", tags: "" });
|
||||||
|
setExpanded(false);
|
||||||
|
onTaskCreated();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("등록 실패:", err.response?.data || err.message);
|
||||||
|
alert("등록 실패: " + JSON.stringify(err.response?.data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setFormData({ title: "", description: "", due_date: "", tags: "" });
|
||||||
|
setExpanded(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-white p-4 rounded-lg shadow mb-6 border border-gray-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value={formData.title}
|
||||||
|
onFocus={() => setExpanded(true)}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="➕ 작업 추가하기..."
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300 mb-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="설명 (선택)"
|
||||||
|
className="w-full px-3 py-2 border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-blue-300 mb-2"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">📅 마감일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="due_date"
|
||||||
|
value={formData.due_date}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">🏷 태그</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="tags"
|
||||||
|
value={formData.tags}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="예: 중요, 오늘, 업무"
|
||||||
|
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-600 hover:bg-gray-100 transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition"
|
||||||
|
>
|
||||||
|
등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TodoForm;
|
65
src/components/Todo/TodoItem.js
Normal file
65
src/components/Todo/TodoItem.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// ✅ src/components/Todo/TodoItem.js
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const TodoItem = ({ task, onToggleComplete, onClick, onTagClick, onDelete }) => {
|
||||||
|
const handleCheckboxClick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleComplete(task.id, task.is_completed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(task.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className="bg-white rounded-xl shadow-sm p-4 mb-4 border border-gray-200 hover:shadow-md transition cursor-pointer relative"
|
||||||
|
>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<button
|
||||||
|
onClick={handleCheckboxClick}
|
||||||
|
className={`w-5 h-5 mr-3 rounded-full border-2 mt-1 ${
|
||||||
|
task.is_completed ? "bg-blue-500 border-blue-500" : "border-gray-300"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className={`text-lg font-semibold ${task.is_completed ? "line-through text-gray-400" : ""}`}>
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.due_date && (
|
||||||
|
<div className="text-sm text-gray-500 mt-1">📅 마감일: {task.due_date}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 space-x-2">
|
||||||
|
{task.tags?.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTagClick(tag);
|
||||||
|
}}
|
||||||
|
className="text-xs bg-gray-100 hover:bg-blue-100 text-gray-700 px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
className="text-red-400 hover:text-red-600 ml-4 text-sm"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TodoItem;
|
22
src/components/Todo/TodoList.js
Normal file
22
src/components/Todo/TodoList.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// ✅ src/components/Todo/TodoList.js
|
||||||
|
import React from "react";
|
||||||
|
import TodoItem from "./TodoItem";
|
||||||
|
|
||||||
|
const TodoList = ({ tasks, onSelectTask, onToggleComplete, onTagClick, onDelete }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<TodoItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onToggleComplete={onToggleComplete}
|
||||||
|
onClick={() => onSelectTask(task)}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TodoList;
|
180
src/pages/TaskPage.js
Normal file
180
src/pages/TaskPage.js
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
// ✅ src/pages/TaskPage.js
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import TodoForm from "../components/Todo/TodoForm";
|
||||||
|
import TodoList from "../components/Todo/TodoList";
|
||||||
|
import TodoDetail from "../components/Todo/TodoDetail";
|
||||||
|
import todoApi from "../api/todoApi";
|
||||||
|
|
||||||
|
const FILTERS = [
|
||||||
|
{ label: "오늘 할 일", value: "today" },
|
||||||
|
{ label: "중요", value: "important" },
|
||||||
|
{ label: "계획된 일정", value: "scheduled" },
|
||||||
|
{ label: "전체", value: "all" },
|
||||||
|
{ label: "완료된 작업", value: "completed" }, // ✅ 추가됨
|
||||||
|
];
|
||||||
|
|
||||||
|
const TaskPage = () => {
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState("all");
|
||||||
|
const [selectedTag, setSelectedTag] = useState(null);
|
||||||
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await todoApi.get("/api/todo/tasks/");
|
||||||
|
setTasks(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("목록 불러오기 실패:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
}, [fetchTasks]);
|
||||||
|
|
||||||
|
const closeDetail = () => setSelectedTask(null);
|
||||||
|
|
||||||
|
const filteredTasks = tasks.filter((task) => {
|
||||||
|
if (selectedTag) return task.tags?.includes(selectedTag);
|
||||||
|
if (selectedFilter === "today") {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
return task.due_date === today;
|
||||||
|
} else if (selectedFilter === "important") {
|
||||||
|
return task.tags?.includes("중요");
|
||||||
|
} else if (selectedFilter === "scheduled") {
|
||||||
|
return !!task.due_date;
|
||||||
|
} else if (selectedFilter === "completed") {
|
||||||
|
return task.is_completed;
|
||||||
|
}
|
||||||
|
return true; // 전체
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTags = [...new Set(tasks.flatMap((task) => task.tags || []))];
|
||||||
|
|
||||||
|
const toggleComplete = async (taskId, is_completed) => {
|
||||||
|
try {
|
||||||
|
await todoApi.patch(`/api/todo/tasks/${taskId}/`, {
|
||||||
|
is_completed: !is_completed,
|
||||||
|
});
|
||||||
|
fetchTasks();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("완료 상태 변경 실패:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTask = async (taskId) => {
|
||||||
|
if (!window.confirm("정말로 이 작업을 삭제하시겠습니까?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await todoApi.delete(`/api/todo/tasks/${taskId}/`);
|
||||||
|
if (selectedTask?.id === taskId) setSelectedTask(null);
|
||||||
|
fetchTasks();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("삭제 실패:", err);
|
||||||
|
alert("삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-50">
|
||||||
|
{/* ✅ 왼쪽 사이드바 - 목록 + 태그 */}
|
||||||
|
<aside className="w-60 border-r border-gray-200 p-4 space-y-4 bg-white overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold mb-2">📂 목록</h2>
|
||||||
|
{FILTERS.map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.value}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFilter(filter.value);
|
||||||
|
setSelectedTag(null);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-2 py-1 rounded hover:bg-gray-100 ${
|
||||||
|
selectedFilter === filter.value && !selectedTag
|
||||||
|
? "bg-blue-100 font-semibold"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-md font-semibold mt-6 mb-2">🏷 태그</h2>
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTag(tag);
|
||||||
|
setSelectedFilter(null);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-2 py-1 rounded hover:bg-gray-100 ${
|
||||||
|
selectedTag === tag ? "bg-blue-100 font-semibold" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 가운데 메인 영역 */}
|
||||||
|
<main className="flex-1 border-r border-gray-200 p-4 overflow-y-auto">
|
||||||
|
<TodoForm onTaskCreated={fetchTasks} />
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold">
|
||||||
|
📋{" "}
|
||||||
|
{(selectedTag && `태그: ${selectedTag}`) ||
|
||||||
|
FILTERS.find((f) => f.value === selectedFilter)?.label ||
|
||||||
|
"할 일"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TodoList
|
||||||
|
tasks={filteredTasks}
|
||||||
|
onSelectTask={setSelectedTask}
|
||||||
|
onToggleComplete={toggleComplete}
|
||||||
|
onTagClick={(tag) => {
|
||||||
|
setSelectedTag(tag);
|
||||||
|
setSelectedFilter(null);
|
||||||
|
}}
|
||||||
|
onDelete={deleteTask}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 오른쪽 상세 Drawer */}
|
||||||
|
<section
|
||||||
|
className={`fixed top-0 right-0 h-full w-full md:w-1/2 lg:w-2/5 xl:w-1/3 bg-white shadow-lg border-l border-gray-300 transform transition-transform duration-300 ease-in-out z-50 ${
|
||||||
|
selectedTask ? "translate-x-0" : "translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={closeDetail}
|
||||||
|
className="absolute top-4 right-4 text-gray-600 hover:text-red-500 text-2xl font-bold"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{selectedTask && (
|
||||||
|
<div className="p-6">
|
||||||
|
<TodoDetail
|
||||||
|
task={selectedTask}
|
||||||
|
onUpdated={() => {
|
||||||
|
fetchTasks(); // 목록 갱신
|
||||||
|
setSelectedTask(null); // 상세 창 닫기 (필요에 따라 유지 가능)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{selectedTask && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-30 z-40"
|
||||||
|
onClick={closeDetail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskPage;
|
Reference in New Issue
Block a user