feat(todo): 모바일 반응형 — 스와이프 칸반 + FAB + 바텀시트 입력

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 14:55:05 +09:00
parent 326d54c73f
commit 00f8e00436
2 changed files with 186 additions and 111 deletions

View File

@@ -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 {

View File

@@ -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,7 +192,66 @@ const Todo = () => {
</div> </div>
); );
/* ── 칸반 컬럼 렌더러 (재사용) ── */
const renderColumn = (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) => renderCard(todo, col.id))}
</div>
</div>
);
};
/* ── 추가 폼 (공통) ── */
const addForm = (
<form className="todo-form" onSubmit={async (e) => { await handleAdd(e); setAddSheetOpen(false); }}>
<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>
);
return ( return (
<PullToRefresh onRefresh={load}>
<div className="todo-page"> <div className="todo-page">
{/* 툴바 */} {/* 툴바 */}
<div className="todo-toolbar"> <div className="todo-toolbar">
@@ -205,127 +271,126 @@ const Todo = () => {
</button> </button>
</div> </div>
{/* 추가 폼 */} {/* 추가 폼 (데스크탑) */}
{formOpen && ( {formOpen && !isMobile && addForm}
<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>} {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={[
<div { key: 'todo', label: '할 일', content: renderColumn({ id: 'todo', label: '할 일' }) },
key={col.id} { key: 'in_progress', label: '진행 중', content: renderColumn({ id: 'in_progress', label: '진행 중' }) },
className={`todo-col${dragOver === col.id ? ' is-drag-over' : ''}`} { key: 'done', label: '완료', content: (
onDragOver={(e) => onDragOver(e, col.id)} <div
onDrop={(e) => onDrop(e, col.id)} className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
> onDragOver={(e) => onDragOver(e, 'done')}
<div className="todo-col__head"> onDrop={(e) => onDrop(e, 'done')}
<span className="todo-col__title">{col.label}</span> >
<span className="todo-col__count">{items.length}</span> <div className="todo-done-panel__head">
</div> <div className="todo-done-panel__title-row">
<div className="todo-col__body"> <span className="todo-col__title">완료</span>
{items.length === 0 && ( <span className="todo-col__count">{doneTodos.length}</span>
<p className="todo-col__empty">드래그하여 이동</p> </div>
<div className="todo-done-panel__filter">
<button type="button" className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`} onClick={() => setDoneDate('')}>전체</button>
{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'))
)}
</div>
</div>
)},
]}
/>
</div>
) : (
<>
{/* 데스크탑: 활성 보드 (할 일 + 진행 중) */}
<div className="todo-board">
{ACTIVE_COLUMNS.map((col) => renderColumn(col))}
</div>
{/* 완료 패널 */}
<div
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`}
onDragOver={(e) => onDragOver(e, 'done')}
onDrop={(e) => onDrop(e, 'done')}
>
<div className="todo-done-panel__head">
<div className="todo-done-panel__title-row">
<span className="todo-col__title">완료</span>
<span className="todo-col__count">{doneTodos.length}</span>
{doneDates.length > 0 && doneDate === '' && (
<span className="todo-done-panel__total-hint">
전체 {todos.filter(t => t.status === 'done').length}
</span>
)} )}
{items.map((todo) => renderCard(todo, col.id))} </div>
<div className="todo-done-panel__filter">
<button
type="button"
className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`}
onClick={() => setDoneDate('')}
>
전체
</button>
{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>
))}
<input
type="date"
className="todo-date-input"
value={doneDate}
onChange={(e) => setDoneDate(e.target.value)}
title="날짜 직접 선택"
/>
</div> </div>
</div> </div>
); <div className="todo-done-panel__body">
})} {doneTodos.length === 0 ? (
</div> <p className="todo-col__empty">
{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}
</p>
) : (
doneTodos.map((todo) => renderCard(todo, 'done'))
)}
</div>
</div>
</>
)}
{/* 완료 패널 */} {/* 모바일: 추가 바텀시트 */}
<div <MobileSheet
className={`todo-done-panel${dragOver === 'done' ? ' is-drag-over' : ''}`} open={addSheetOpen}
onDragOver={(e) => onDragOver(e, 'done')} onClose={() => { setAddSheetOpen(false); setForm(emptyForm); }}
onDrop={(e) => onDrop(e, 'done')} title="태스크 추가"
> >
{/* 완료 패널 헤더 */} {addForm}
<div className="todo-done-panel__head"> </MobileSheet>
<div className="todo-done-panel__title-row">
<span className="todo-col__title">완료</span>
<span className="todo-col__count">{doneTodos.length}</span>
{doneDates.length > 0 && doneDate === '' && (
<span className="todo-done-panel__total-hint">
전체 {todos.filter(t => t.status === 'done').length}
</span>
)}
</div>
{/* 날짜 필터 */}
<div className="todo-done-panel__filter">
<button
type="button"
className={`todo-date-btn${doneDate === '' ? ' is-active' : ''}`}
onClick={() => setDoneDate('')}
>
전체
</button>
{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>
))}
<input
type="date"
className="todo-date-input"
value={doneDate}
onChange={(e) => setDoneDate(e.target.value)}
title="날짜 직접 선택"
/>
</div>
</div>
{/* 완료 카드 그리드 */} <FAB onClick={() => setAddSheetOpen(true)} label="태스크 추가" />
<div className="todo-done-panel__body">
{doneTodos.length === 0 ? (
<p className="todo-col__empty">
{doneDate ? '해당 날짜에 완료된 항목이 없습니다' : '드래그하여 이동'}
</p>
) : (
doneTodos.map((todo) => renderCard(todo, 'done'))
)}
</div>
</div>
</div> </div>
</PullToRefresh>
); );
}; };