feat(todo): 모바일 반응형 — 스와이프 칸반 + FAB + 바텀시트 입력
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -370,11 +370,21 @@
|
|||||||
text-decoration-color: rgba(244, 114, 182, 0.4);
|
text-decoration-color: rgba(244, 114, 182, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 스와이프 보드 (모바일 전용) ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.todo-swipe-board {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.todo-board {
|
.todo-board {
|
||||||
grid-template-columns: 1fr;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-swipe-board {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-col {
|
.todo-col {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { getTodos, addTodo, updateTodo, deleteTodo, clearTodos } from '../../api';
|
import { getTodos, addTodo, updateTodo, deleteTodo, clearTodos } from '../../api';
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
|
import SwipeableView from '../../components/SwipeableView';
|
||||||
|
import FAB from '../../components/FAB';
|
||||||
|
import MobileSheet from '../../components/MobileSheet';
|
||||||
|
import PullToRefresh from '../../components/PullToRefresh';
|
||||||
import './Todo.css';
|
import './Todo.css';
|
||||||
|
|
||||||
const ACTIVE_COLUMNS = [
|
const ACTIVE_COLUMNS = [
|
||||||
@@ -19,11 +24,13 @@ const toDateStr = (iso) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Todo = () => {
|
const Todo = () => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [todos, setTodos] = useState([]);
|
const [todos, setTodos] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dragging, setDragging] = useState(null);
|
const [dragging, setDragging] = useState(null);
|
||||||
const [dragOver, setDragOver] = useState(null);
|
const [dragOver, setDragOver] = useState(null);
|
||||||
@@ -185,29 +192,33 @@ const Todo = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ── 칸반 컬럼 렌더러 (재사용) ── */
|
||||||
|
const renderColumn = (col) => {
|
||||||
|
const items = byStatus(col.id);
|
||||||
return (
|
return (
|
||||||
<div className="todo-page">
|
<div
|
||||||
{/* 툴바 */}
|
key={col.id}
|
||||||
<div className="todo-toolbar">
|
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`}
|
||||||
<button
|
onDragOver={(e) => onDragOver(e, col.id)}
|
||||||
type="button"
|
onDrop={(e) => onDrop(e, col.id)}
|
||||||
className="button primary"
|
|
||||||
onClick={() => setFormOpen((v) => !v)}
|
|
||||||
>
|
>
|
||||||
{formOpen ? '취소' : '+ 태스크 추가'}
|
<div className="todo-col__head">
|
||||||
</button>
|
<span className="todo-col__title">{col.label}</span>
|
||||||
<button
|
<span className="todo-col__count">{items.length}</span>
|
||||||
type="button"
|
|
||||||
className="button ghost"
|
|
||||||
onClick={handleClear}
|
|
||||||
>
|
|
||||||
완료 비우기
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="todo-col__body">
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p className="todo-col__empty">드래그하여 이동</p>
|
||||||
|
)}
|
||||||
|
{items.map((todo) => renderCard(todo, col.id))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
{/* 추가 폼 */}
|
/* ── 추가 폼 (공통) ── */
|
||||||
{formOpen && (
|
const addForm = (
|
||||||
<form className="todo-form" onSubmit={handleAdd}>
|
<form className="todo-form" onSubmit={async (e) => { await handleAdd(e); setAddSheetOpen(false); }}>
|
||||||
<label className="todo-form__field">
|
<label className="todo-form__field">
|
||||||
<span>제목 *</span>
|
<span>제목 *</span>
|
||||||
<input
|
<input
|
||||||
@@ -237,35 +248,79 @@ const Todo = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load}>
|
||||||
|
<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 && !isMobile && addForm}
|
||||||
|
|
||||||
{error && <p className="todo-error">{error}</p>}
|
{error && <p className="todo-error">{error}</p>}
|
||||||
{loading && todos.length === 0 && <p className="todo-loading">불러오는 중...</p>}
|
{loading && todos.length === 0 && <p className="todo-loading">불러오는 중...</p>}
|
||||||
|
|
||||||
{/* 활성 보드 (할 일 + 진행 중) */}
|
{/* 모바일: SwipeableView 칸반 */}
|
||||||
<div className="todo-board">
|
{isMobile ? (
|
||||||
{ACTIVE_COLUMNS.map((col) => {
|
<div className="todo-swipe-board">
|
||||||
const items = byStatus(col.id);
|
<SwipeableView
|
||||||
return (
|
tabs={[
|
||||||
|
{ key: 'todo', label: '할 일', content: renderColumn({ id: 'todo', label: '할 일' }) },
|
||||||
|
{ key: 'in_progress', label: '진행 중', content: renderColumn({ id: 'in_progress', label: '진행 중' }) },
|
||||||
|
{ key: 'done', label: '완료', content: (
|
||||||
<div
|
<div
|
||||||
key={col.id}
|
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
|
||||||
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`}
|
onDragOver={(e) => onDragOver(e, 'done')}
|
||||||
onDragOver={(e) => onDragOver(e, col.id)}
|
onDrop={(e) => onDrop(e, 'done')}
|
||||||
onDrop={(e) => onDrop(e, col.id)}
|
|
||||||
>
|
>
|
||||||
<div className="todo-col__head">
|
<div className="todo-done-panel__head">
|
||||||
<span className="todo-col__title">{col.label}</span>
|
<div className="todo-done-panel__title-row">
|
||||||
<span className="todo-col__count">{items.length}</span>
|
<span className="todo-col__title">완료</span>
|
||||||
|
<span className="todo-col__count">{doneTodos.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="todo-col__body">
|
<div className="todo-done-panel__filter">
|
||||||
{items.length === 0 && (
|
<button type="button" className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`} onClick={() => setDoneDate('')}>전체</button>
|
||||||
<p className="todo-col__empty">드래그하여 이동</p>
|
{doneDates.map((d) => (
|
||||||
|
<button key={d} type="button" className={`todo-date-btn${doneDate === d ? ' is-active' : ''}`} onClick={() => setDoneDate(d)}>
|
||||||
|
{new Date(d + 'T00:00:00').toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="todo-done-panel__body">
|
||||||
|
{doneTodos.length === 0 ? (
|
||||||
|
<p className="todo-col__empty">{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}</p>
|
||||||
|
) : (
|
||||||
|
doneTodos.map((todo) => renderCard(todo, 'done'))
|
||||||
)}
|
)}
|
||||||
{items.map((todo) => renderCard(todo, col.id))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)},
|
||||||
})}
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 데스크탑: 활성 보드 (할 일 + 진행 중) */}
|
||||||
|
<div className="todo-board">
|
||||||
|
{ACTIVE_COLUMNS.map((col) => renderColumn(col))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 완료 패널 */}
|
{/* 완료 패널 */}
|
||||||
@@ -274,7 +329,6 @@ const Todo = () => {
|
|||||||
onDragOver={(e) => onDragOver(e, 'done')}
|
onDragOver={(e) => onDragOver(e, 'done')}
|
||||||
onDrop={(e) => onDrop(e, 'done')}
|
onDrop={(e) => onDrop(e, 'done')}
|
||||||
>
|
>
|
||||||
{/* 완료 패널 헤더 */}
|
|
||||||
<div className="todo-done-panel__head">
|
<div className="todo-done-panel__head">
|
||||||
<div className="todo-done-panel__title-row">
|
<div className="todo-done-panel__title-row">
|
||||||
<span className="todo-col__title">완료</span>
|
<span className="todo-col__title">완료</span>
|
||||||
@@ -285,7 +339,6 @@ const Todo = () => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 날짜 필터 */}
|
|
||||||
<div className="todo-done-panel__filter">
|
<div className="todo-done-panel__filter">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -313,8 +366,6 @@ const Todo = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 완료 카드 그리드 */}
|
|
||||||
<div className="todo-done-panel__body">
|
<div className="todo-done-panel__body">
|
||||||
{doneTodos.length === 0 ? (
|
{doneTodos.length === 0 ? (
|
||||||
<p className="todo-col__empty">
|
<p className="todo-col__empty">
|
||||||
@@ -325,7 +376,21 @@ const Todo = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모바일: 추가 바텀시트 */}
|
||||||
|
<MobileSheet
|
||||||
|
open={addSheetOpen}
|
||||||
|
onClose={() => { setAddSheetOpen(false); setForm(emptyForm); }}
|
||||||
|
title="태스크 추가"
|
||||||
|
>
|
||||||
|
{addForm}
|
||||||
|
</MobileSheet>
|
||||||
|
|
||||||
|
<FAB onClick={() => setAddSheetOpen(true)} label="태스크 추가" />
|
||||||
</div>
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user