dashboard 형태의 UI 수정 및 고도화
This commit is contained in:
271
src/pages/todo/Todo.css
Normal file
271
src/pages/todo/Todo.css
Normal file
@@ -0,0 +1,271 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Todo Page — Cyberpunk Kanban Board
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.todo-page {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.todo-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Add Form ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.todo-form {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
animation: fadeIn 0.2s ease both;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.todo-form__field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.todo-form__field input,
|
||||
.todo-form__field textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text-bright);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.todo-form__field input:focus,
|
||||
.todo-form__field textarea:focus {
|
||||
border-color: rgba(244, 114, 182, 0.5);
|
||||
box-shadow: 0 0 0 2px rgba(244, 114, 182, 0.1);
|
||||
}
|
||||
|
||||
.todo-form__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Error / Loading ─────────────────────────────────────────────────── */
|
||||
|
||||
.todo-error {
|
||||
margin: 0;
|
||||
color: #f9b6b1;
|
||||
border: 1px solid rgba(249, 182, 177, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(249, 182, 177, 0.08);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.todo-loading {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Board ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.todo-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ── Column ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.todo-col {
|
||||
background: rgba(10, 18, 45, 0.6);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-height: 200px;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.todo-col.is-drag-over {
|
||||
border-color: rgba(244, 114, 182, 0.4);
|
||||
background: rgba(244, 114, 182, 0.04);
|
||||
}
|
||||
|
||||
.todo-col__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.todo-col__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.todo-col__count {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(244, 114, 182, 0.12);
|
||||
border: 1px solid rgba(244, 114, 182, 0.25);
|
||||
color: #f472b6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.todo-col__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.todo-col__empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Card ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.todo-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
cursor: grab;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.todo-card:hover {
|
||||
border-color: rgba(244, 114, 182, 0.25);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.todo-card.is-dragging {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.todo-card__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.todo-card__desc {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.todo-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.todo-card__date {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.todo-card__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.todo-card__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.todo-card__btn:hover {
|
||||
background: rgba(244, 114, 182, 0.15);
|
||||
border-color: rgba(244, 114, 182, 0.4);
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
.todo-card__btn--danger:hover {
|
||||
background: rgba(249, 182, 177, 0.15);
|
||||
border-color: rgba(249, 182, 177, 0.4);
|
||||
color: #f9b6b1;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.todo-board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.todo-col {
|
||||
min-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.todo-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.todo-toolbar .button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
240
src/pages/todo/Todo.jsx
Normal file
240
src/pages/todo/Todo.jsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getTodos, addTodo, updateTodo, deleteTodo, clearTodos } from '../../api';
|
||||
import './Todo.css';
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'todo', label: '할 일' },
|
||||
{ id: 'in_progress', label: '진행 중' },
|
||||
{ id: 'done', label: '완료' },
|
||||
];
|
||||
|
||||
const emptyForm = { title: '', description: '' };
|
||||
|
||||
const Todo = () => {
|
||||
const [todos, setTodos] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(null);
|
||||
const dragItem = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await getTodos();
|
||||
setTodos(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
setError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.title.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await addTodo({ ...form, status: 'todo' });
|
||||
setTodos((prev) => [created, ...prev]);
|
||||
setForm(emptyForm);
|
||||
setFormOpen(false);
|
||||
} catch (err) {
|
||||
setError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMove = async (id, newStatus) => {
|
||||
setTodos((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, status: newStatus } : t))
|
||||
);
|
||||
try {
|
||||
await updateTodo(id, { status: newStatus, updated_at: new Date().toISOString() });
|
||||
} catch {
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
setTodos((prev) => prev.filter((t) => t.id !== id));
|
||||
try {
|
||||
await deleteTodo(id);
|
||||
} catch {
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await clearTodos();
|
||||
setTodos((prev) => prev.filter((t) => t.status !== 'done'));
|
||||
} catch (err) {
|
||||
setError(err?.message ?? String(err));
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Drag & Drop ─────────────────────────────────────────────── */
|
||||
const onDragStart = (e, todo) => {
|
||||
dragItem.current = todo;
|
||||
setDragging(todo.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
setDragging(null);
|
||||
setDragOver(null);
|
||||
dragItem.current = null;
|
||||
};
|
||||
|
||||
const onDragOver = (e, colId) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOver(colId);
|
||||
};
|
||||
|
||||
const onDrop = (e, colId) => {
|
||||
e.preventDefault();
|
||||
if (dragItem.current && dragItem.current.status !== colId) {
|
||||
handleMove(dragItem.current.id, colId);
|
||||
}
|
||||
setDragOver(null);
|
||||
};
|
||||
|
||||
const byStatus = (status) => todos.filter((t) => t.status === status);
|
||||
|
||||
return (
|
||||
<div className="todo-page">
|
||||
{/* 추가 버튼 & 완료 비우기 */}
|
||||
<div className="todo-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="button primary"
|
||||
onClick={() => setFormOpen((v) => !v)}
|
||||
>
|
||||
{formOpen ? '취소' : '+ 태스크 추가'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button ghost"
|
||||
onClick={handleClear}
|
||||
>
|
||||
완료 비우기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 추가 폼 */}
|
||||
{formOpen && (
|
||||
<form className="todo-form" onSubmit={handleAdd}>
|
||||
<label className="todo-form__field">
|
||||
<span>제목 *</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="태스크 제목을 입력하세요"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((v) => ({ ...v, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="todo-form__field">
|
||||
<span>설명</span>
|
||||
<textarea
|
||||
placeholder="설명 (선택)"
|
||||
value={form.description}
|
||||
rows={3}
|
||||
onChange={(e) => setForm((v) => ({ ...v, description: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="todo-form__actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="button primary"
|
||||
disabled={saving || !form.title.trim()}
|
||||
>
|
||||
{saving ? '저장 중...' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <p className="todo-error">{error}</p>}
|
||||
{loading && todos.length === 0 && <p className="todo-loading">불러오는 중...</p>}
|
||||
|
||||
{/* 보드 */}
|
||||
<div className="todo-board">
|
||||
{COLUMNS.map((col) => {
|
||||
const items = byStatus(col.id);
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`}
|
||||
onDragOver={(e) => onDragOver(e, col.id)}
|
||||
onDrop={(e) => onDrop(e, col.id)}
|
||||
>
|
||||
<div className="todo-col__head">
|
||||
<span className="todo-col__title">{col.label}</span>
|
||||
<span className="todo-col__count">{items.length}</span>
|
||||
</div>
|
||||
<div className="todo-col__body">
|
||||
{items.length === 0 && (
|
||||
<p className="todo-col__empty">드래그하여 이동</p>
|
||||
)}
|
||||
{items.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`todo-card${dragging === todo.id ? ' is-dragging' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, todo)}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<p className="todo-card__title">{todo.title}</p>
|
||||
{todo.description && (
|
||||
<p className="todo-card__desc">{todo.description}</p>
|
||||
)}
|
||||
<div className="todo-card__footer">
|
||||
<span className="todo-card__date">
|
||||
{todo.created_at
|
||||
? new Date(todo.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||
: ''}
|
||||
</span>
|
||||
<div className="todo-card__actions">
|
||||
{COLUMNS.filter((c) => c.id !== col.id).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="todo-card__btn"
|
||||
title={`${c.label}으로 이동`}
|
||||
onClick={() => handleMove(todo.id, c.id)}
|
||||
>
|
||||
{c.id === 'todo' ? '↩' : c.id === 'in_progress' ? '▶' : '✓'}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="todo-card__btn todo-card__btn--danger"
|
||||
title="삭제"
|
||||
onClick={() => handleDelete(todo.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Todo;
|
||||
Reference in New Issue
Block a user