diff --git a/src/plays/simple-todo-app/Readme.md b/src/plays/simple-todo-app/Readme.md new file mode 100644 index 000000000..6d8b5faf7 --- /dev/null +++ b/src/plays/simple-todo-app/Readme.md @@ -0,0 +1,33 @@ +# Simple Todo App + +A productivity-focused Todo app built with React + TypeScript. Capture tasks, keep them synced with local storage, edit inline, filter by status, and use bulk actions to stay tidy. + +## Play Demographic + +- Language: ts +- Level: Beginner + +## Creator Information + +- User: efebaslilar +- Gihub Link: https://github.com/efebaslilar +- Blog: +- Video: + +## Implementation Details + +- Core state is handled with `useState`, fed by a lazy initializer that restores the last session from `localStorage`, falling back to seed data for new visitors. +- `useMemo` derives the filtered + sorted list, overall stats, and completion ratios without recomputing on every render. +- Inline editing keeps focus management tight via refs, keyboard shortcuts (Enter to save, Esc to cancel), and blur handling to commit changes. +- Bulk controls (mark all, clear completed, clear all) provide quick-maintenance options that stay disabled until relevant, keeping the UI calm. +- Styling focuses on approachability with card-like elevation, responsive grid tooling, and subtle affordances for hover/focus states. + +## Consideration + +- Because todos persist locally, clearing the browser’s storage (or the “Clear all” action) is the quickest way to start fresh. +- The seed data auto-populates for newcomers; subsequent visits will hydrate from the saved list instead. + +## Resources + +- [React Docs – Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects) +- [MDN – KeyboardEvent reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent) diff --git a/src/plays/simple-todo-app/SimpleTodoApp.tsx b/src/plays/simple-todo-app/SimpleTodoApp.tsx new file mode 100644 index 000000000..f56351bff --- /dev/null +++ b/src/plays/simple-todo-app/SimpleTodoApp.tsx @@ -0,0 +1,415 @@ +import React, { FormEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'; +import PlayHeader from 'common/playlists/PlayHeader'; +import './styles.css'; + +type TodoFilter = 'all' | 'active' | 'completed'; + +interface Todo { + id: number; + text: string; + completed: boolean; + createdAt: number; +} + +const STORAGE_KEY = 'react-play:simple-todo-app'; +const FILTER_KEY = `${STORAGE_KEY}:filter`; + +const seedData = [ + { id: 1, text: 'Learn TypeScript utility types', completed: false }, + { id: 2, text: 'Review pending pull requests', completed: true }, + { id: 3, text: 'Follow efebaslilar in github', completed: false } +]; + +const seededTodos: Todo[] = seedData.map((item, index) => ({ + ...item, + createdAt: Date.now() - (seedData.length - index) * 1000 +})); + +function SimpleTodoApp(props: any) { + const play = props.play ? props.play : props; + const [todos, setTodos] = useState(() => { + if (typeof window === 'undefined') { + return seededTodos; + } + + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (!stored) { + return seededTodos; + } + + const parsed = JSON.parse(stored) as Array>; + if (!Array.isArray(parsed)) { + return seededTodos; + } + + const fallbackTimestamp = Date.now(); + const sanitized = parsed + .filter( + (todo): todo is Partial & Required> => + typeof todo === 'object' && todo !== null && typeof todo.text === 'string' + ) + .map((todo, index) => ({ + id: typeof todo.id === 'number' ? todo.id : fallbackTimestamp + index, + text: todo.text.trim(), + completed: Boolean(todo.completed), + createdAt: typeof todo.createdAt === 'number' ? todo.createdAt : fallbackTimestamp + index + })) + .filter((todo) => todo.text.length > 0); + + return sanitized.length > 0 ? sanitized : seededTodos; + } catch { + return seededTodos; + } + }); + const [filter, setFilter] = useState(() => { + if (typeof window === 'undefined') { + return 'all'; + } + + const stored = window.localStorage.getItem(FILTER_KEY); + if (stored === 'active' || stored === 'completed' || stored === 'all') { + return stored; + } + + return 'all'; + }); + const [text, setText] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editingText, setEditingText] = useState(''); + const [editingOriginalText, setEditingOriginalText] = useState(''); + const editInputRef = useRef(null); + const cancellingEditRef = useRef(false); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); + }, [todos]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(FILTER_KEY, filter); + }, [filter]); + + useEffect(() => { + if (editingId !== null) { + editInputRef.current?.focus(); + editInputRef.current?.select(); + } + }, [editingId]); + + const filteredTodos = useMemo(() => { + switch (filter) { + case 'active': + return todos.filter((todo) => !todo.completed); + case 'completed': + return todos.filter((todo) => todo.completed); + default: + return todos; + } + }, [todos, filter]); + + const visibleTodos = useMemo(() => { + const sorted = filteredTodos.slice().sort((a, b) => { + if (a.completed !== b.completed && filter === 'all') { + return a.completed ? 1 : -1; + } + + return b.createdAt - a.createdAt; + }); + + return sorted; + }, [filteredTodos, filter]); + + const remainingCount = useMemo(() => todos.filter((todo) => !todo.completed).length, [todos]); + + const completedCount = todos.length - remainingCount; + const completionRatio = todos.length ? Math.round((completedCount / todos.length) * 100) : 0; + const hasTodos = todos.length > 0; + const hasCompleted = completedCount > 0; + const allCompleted = hasTodos && remainingCount === 0; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const trimmed = text.trim(); + if (!trimmed) return; + + setTodos((prev) => [ + { + id: Date.now(), + text: trimmed, + completed: false, + createdAt: Date.now() + }, + ...prev + ]); + setText(''); + }; + + const toggleTodo = (id: number) => { + setTodos((prev) => + prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo)) + ); + }; + + const removeTodo = (id: number) => { + setTodos((prev) => prev.filter((todo) => todo.id !== id)); + if (editingId === id) { + setEditingId(null); + setEditingText(''); + setEditingOriginalText(''); + } + }; + + const clearCompleted = () => { + setTodos((prev) => prev.filter((todo) => !todo.completed)); + }; + + const toggleAll = () => { + if (!hasTodos) return; + + setTodos((prev) => + prev.map((todo) => ({ + ...todo, + completed: !allCompleted + })) + ); + }; + + const clearAll = () => { + setTodos([]); + setEditingId(null); + setEditingText(''); + setEditingOriginalText(''); + }; + + const startEditing = (id: number, value: string) => { + setEditingId(id); + setEditingText(value); + setEditingOriginalText(value); + cancellingEditRef.current = false; + }; + + const resetEditingState = () => { + setEditingId(null); + setEditingText(''); + setEditingOriginalText(''); + }; + + const commitEdit = () => { + if (editingId === null) return; + + const trimmed = editingText.trim(); + const textToPersist = trimmed.length > 0 ? trimmed : editingOriginalText; + + setTodos((prev) => + prev.map((todo) => (todo.id === editingId ? { ...todo, text: textToPersist } : todo)) + ); + resetEditingState(); + }; + + const cancelEdit = () => { + cancellingEditRef.current = true; + resetEditingState(); + setTimeout(() => { + cancellingEditRef.current = false; + }, 0); + }; + + const handleEditBlur = () => { + if (cancellingEditRef.current) { + cancellingEditRef.current = false; + + return; + } + + commitEdit(); + }; + + const handleEditKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitEdit(); + } + + if (event.key === 'Escape') { + event.preventDefault(); + cancelEdit(); + } + }; + + return ( + <> +
+ +
+
+

Simple Todo App

+

+ Track what needs to be tackled next. Add todos, toggle their completion state, edit + existing items, and quickly clear the finished work. +

+
+
+
+ setText(event.target.value)} + /> + +
+ +
+
+ + {remainingCount} {remainingCount === 1 ? 'task' : 'tasks'} left + + {hasTodos && {completionRatio}% complete} +
+
+ + + +
+
+ + + +
+
+ +
    + {visibleTodos.length === 0 && ( +
  • Nothing to show here yet.
  • + )} + {visibleTodos.map((todo) => { + const isEditing = editingId === todo.id; + + return ( +
  • + +
    + {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
    +
  • + ); + })} +
+
+
+
+ + ); +} + +export default SimpleTodoApp; diff --git a/src/plays/simple-todo-app/styles.css b/src/plays/simple-todo-app/styles.css new file mode 100644 index 000000000..91b86dbdf --- /dev/null +++ b/src/plays/simple-todo-app/styles.css @@ -0,0 +1,271 @@ +.todo-app { + max-width: 520px; + margin: 1.5rem auto 0; + padding: 1.75rem; + border-radius: 16px; + background: linear-gradient(145deg, #f8fafc, #e2e8f0); + box-shadow: 0 12px 28px -18px rgba(15, 23, 42, 0.4); + color: #0f172a; +} + +.todo-form { + display: flex; + gap: 0.75rem; + margin-bottom: 1.25rem; +} +.play-description{ + text-align: center; +} + +.todo-form input { + flex: 1; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 1px solid #cbd5f5; + border-radius: 12px; + background: #ffffff; + transition: border 0.2s ease, box-shadow 0.2s ease; +} + +.todo-form input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); +} + +.todo-form button { + padding: 0.75rem 1.25rem; + font-weight: 600; + border: none; + border-radius: 12px; + background: #6366f1; + color: #f8fafc; + cursor: pointer; + transition: background 0.2s ease, transform 0.1s ease; +} + +.todo-form button:hover { + background: #4f46e5; +} + +.todo-form button:active { + transform: translateY(1px); +} + +.todo-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.85rem; + align-items: start; + margin-bottom: 1.25rem; +} + +.todo-status { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; +} + +.todo-count { + font-size: 0.9rem; + color: #475569; +} + +.todo-progress { + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #4c1d95; + background: rgba(99, 102, 241, 0.12); + padding: 0.2rem 0.5rem; + border-radius: 999px; +} + +.todo-filters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.todo-filters button, +.todo-actions button { + padding: 0.45rem 0.95rem; + border-radius: 999px; + border: 1px solid transparent; + background: #e2e8f0; + color: #0f172a; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, border 0.2s ease; +} + +.todo-filters button.active { + background: #312e81; + color: #f8fafc; +} + +.todo-filters button:hover:not(.active), +.todo-actions button:hover:not(:disabled) { + background: #cbd5f5; +} + +.todo-actions button:disabled, +.todo-actions button[aria-disabled='true'] { + background: #e2e8f0; + color: #94a3b8; + cursor: not-allowed; +} + +.todo-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; + grid-column: 1 / -1; +} + +.todo-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.75rem; +} + +.todo-item { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1rem; + border-radius: 12px; + background: #fff; + box-shadow: 0 4px 15px -12px rgba(15, 23, 42, 0.5); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.todo-item:hover { + transform: translateY(-2px); + box-shadow: 0 12px 24px -18px rgba(15, 23, 42, 0.45); +} + +.todo-item-main { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + width: 100%; +} + +.todo-item input[type='checkbox'] { + width: 20px; + height: 20px; + accent-color: #4f46e5; +} + +.todo-item span { + flex: 1; + font-size: 0.98rem; + word-break: break-word; +} + +.todo-item.completed span { + color: #94a3b8; + text-decoration: line-through; +} + +.todo-item-actions { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.todo-item-actions button { + border: none; + background: rgba(226, 232, 240, 0.7); + color: #475569; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + padding: 0.35rem 0.65rem; + border-radius: 8px; + transition: background 0.2s ease, color 0.2s ease, transform 0.1s ease; +} + +.todo-item-actions button:hover { + background: #cbd5f5; + color: #312e81; +} + +.todo-item-actions button:active { + transform: translateY(1px); +} + +.todo-edit-input { + flex: 1; + padding: 0.35rem 0.6rem; + border-radius: 8px; + border: 1px solid #cbd5f5; + background: #ffffff; + font-size: 0.95rem; + transition: border 0.2s ease, box-shadow 0.2s ease; +} + +.todo-edit-input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); +} + +.todo-item.editing { + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.18), + 0 18px 30px -22px rgba(15, 23, 42, 0.55); +} + +.todo-item.editing .todo-item-main { + cursor: text; +} + +.todo-delete { + border: none; + background: rgba(248, 113, 113, 0.22); + color: #b91c1c; + font-weight: 700; +} + +.todo-delete:hover { + background: rgba(248, 113, 113, 0.36); + color: #7f1d1d; +} + +.todo-delete:active { + transform: translateY(1px); +} + +.todo-empty { + padding: 0.75rem 1rem; + border-radius: 12px; + background: #e2e8f0; + color: #475569; + font-size: 0.95rem; + text-align: center; +} + +@media (min-width: 640px) { + .todo-toolbar { + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: center; + } + + .todo-filters { + justify-self: start; + } + + .todo-item { + grid-template-columns: minmax(0, 1fr) auto; + } +}