diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92f06f0..0a9f26d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,12 +70,25 @@ Jokes & Quotes: Pets Dashboard: - Persist favorites - Download image helper - + COVID Dashboard: + - Daily trends line charts - Country comparison - + +Task Flow Board: + +- Add color themes for nodes +- Implement keyboard shortcuts (Delete, Undo) +- Add task search and filtering +- Create board templates (Kanban, Sprint Planning) +- Export/import boards as JSON +- Add task due dates and calendar view +- Implement drag-to-connect nodes feature +- Add task dependencies validation + Global Enhancements: + - Extract API calls into services folder - Add custom hooks (useFetch, useLocalStorage) - Add tests with Vitest @@ -83,18 +96,21 @@ Global Enhancements: - CI workflow (lint + build) ## Code Style + - Keep components small & focused - Use semantic HTML where practical - Prefer descriptive variable names - Add `// TODO:` comments for follow-up ideas ## Submitting a PR + 1. Ensure build passes: `npm run build` 2. Provide a clear title & description (include issue # if applicable) 3. Screenshots / GIFs for UI changes encouraged 4. One feature/fix per PR when possible ## License + By contributing you agree your work is licensed under the project’s MIT License. Happy hacking! ✨ diff --git a/README.md b/README.md index 88cdbbd..49f8424 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A collection of interactive dashboards that fetch and display data from **free, | πŸ˜‚ Jokes & Quotes | [Joke API](https://official-joke-api.appspot.com/) + [Quotable](https://github.com/lukePeavey/quotable) | Daily dose of humor and motivation | | 🐢🐱 Pets | [Dog CEO](https://dog.ceo/dog-api/) + [Cataas](https://cataas.com/#/) | Random cute dog and cat images | | 🦠 COVID-19 Tracker | [COVID19 API](https://covid19api.com/) | Track pandemic stats and trends globally | +| πŸ“‹ Task Flow Board | [React Flow](https://reactflow.dev/) | Visual task management with draggable nodes | --- @@ -51,6 +52,9 @@ Then open [http://localhost:5173](http://localhost:5173) in your browser. * 🧭 React Router v6 * 🌐 Fetch API (no external client) * 🎨 Custom Light/Dark CSS Theme +* πŸ”„ React Flow (for Task Board visualization) +* πŸ“Š Recharts (for data charts) +* πŸ—ΊοΈ Leaflet + React Leaflet (for maps) --- diff --git a/package.json b/package.json index d73f726..f30257e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "react-icons": "^5.5.0", "react-leaflet": "^4.2.1", "react-router-dom": "^6.27.0", + "reactflow": "^11.11.4", "recharts": "^3.3.0" }, "devDependencies": { diff --git a/src/App.jsx b/src/App.jsx index 87ecba6..b571290 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -43,6 +43,7 @@ import Covid from './pages/Covid.jsx'; import Navbar from './components/Navbar.jsx'; import ContributorsWall from './pages/Contributors.jsx' import Pokedex from './pages/Pokedex.jsx'; +import TaskFlowBoard from './pages/TaskFlowBoard.jsx'; // TODO: Extract theme state into context (see todo 5). import { useState, useEffect } from 'react'; @@ -82,6 +83,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 55ec1ad..86fda67 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -16,6 +16,7 @@ export default function Navbar({ theme, toggleTheme }) {
  • Jokes & Quotes
  • Pets
  • COVID-19
  • +
  • TaskFlow
  • + + {data.description && ( +

    + {data.description} +

    + )} +
    + + {data.status} + + + {data.priority} + +
    + + + ); +}; + +const nodeTypes = { + taskNode: TaskNode, +}; + +const STORAGE_KEY = 'taskflow-board-state'; + +const stripHandlers = (nodeList = []) => + nodeList.map(({ data = {}, ...rest }) => { + const { onEdit, onDelete, ...restData } = data; + return { + ...rest, + data: { ...restData }, + }; + }); + +const applyHandlers = (nodeList = [], onEdit, onDelete) => + nodeList.map((node) => ({ + ...node, + data: { + ...node.data, + onEdit: () => onEdit(node.id), + onDelete: () => onDelete(node.id), + }, + })); + +export default function TaskFlowBoard() { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingNode, setEditingNode] = useState(null); + const [formData, setFormData] = useState({ + label: '', + description: '', + status: 'todo', + priority: 'medium', + }); + const reactFlowWrapper = useRef(null); + const [reactFlowInstance, setReactFlowInstance] = useState(null); + const nodeIdCounter = useRef(1); + const nodesRef = useRef([]); + const handleDeleteNodeRef = useRef(() => {}); + + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); + + const onConnect = useCallback( + (params) => + setEdges((eds) => + addEdge( + { + ...params, + type: 'smoothstep', + animated: true, + markerEnd: { type: MarkerType.ArrowClosed }, + }, + eds + ) + ), + [setEdges] + ); + + const handleAddTask = () => { + setEditingNode(null); + setFormData({ + label: '', + description: '', + status: 'todo', + priority: 'medium', + }); + setIsModalOpen(true); + }; + + const handleEditNode = useCallback((nodeId) => { + const node = nodesRef.current.find((n) => n.id === nodeId); + if (node) { + setEditingNode(nodeId); + setFormData({ + label: node.data.label, + description: node.data.description || '', + status: node.data.status, + priority: node.data.priority, + }); + setIsModalOpen(true); + } + }, []); + + const hydrateNodes = useCallback( + (nodeList = []) => applyHandlers(stripHandlers(nodeList), handleEditNode, (id) => handleDeleteNodeRef.current(id)), + [handleEditNode] + ); + + const handleDeleteNode = useCallback( + (nodeId) => { + setNodes((nds) => hydrateNodes(nds.filter((node) => node.id !== nodeId))); + setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)); + }, + [hydrateNodes, setEdges, setNodes] + ); + + handleDeleteNodeRef.current = handleDeleteNode; + + const addInitialNodes = useCallback(() => { + const baseNodes = [ + { + id: '1', + type: 'taskNode', + position: { x: 250, y: 100 }, + data: { + label: 'Project Planning', + description: 'Define project scope and requirements', + status: 'completed', + priority: 'high', + }, + }, + { + id: '2', + type: 'taskNode', + position: { x: 250, y: 250 }, + data: { + label: 'Design UI/UX', + description: 'Create wireframes and mockups', + status: 'in-progress', + priority: 'high', + }, + }, + { + id: '3', + type: 'taskNode', + position: { x: 500, y: 250 }, + data: { + label: 'Backend Development', + description: 'Set up API endpoints', + status: 'todo', + priority: 'medium', + }, + }, + ]; + + const initialEdges = [ + { + id: 'e1-2', + source: '1', + target: '2', + type: 'smoothstep', + animated: true, + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'e1-3', + source: '1', + target: '3', + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + ]; + + setNodes(hydrateNodes(baseNodes)); + setEdges(initialEdges); + nodeIdCounter.current = 4; + }, [hydrateNodes, setEdges, setNodes]); + + useEffect(() => { + try { + const savedState = localStorage.getItem(STORAGE_KEY); + if (savedState) { + const { nodes: savedNodes, edges: savedEdges, counter } = JSON.parse(savedState); + setNodes(hydrateNodes(savedNodes || [])); + setEdges(savedEdges || []); + nodeIdCounter.current = counter || (savedNodes?.length || 1); + } else { + addInitialNodes(); + } + } catch (error) { + console.error('Failed to load board state:', error); + addInitialNodes(); + } + }, [addInitialNodes, hydrateNodes, setEdges, setNodes]); + + useEffect(() => { + if (nodes.length === 0 && edges.length === 0) return; + const state = { + nodes: stripHandlers(nodes), + edges, + counter: nodeIdCounter.current, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + }, [nodes, edges]); + + const handleSaveTask = () => { + if (!formData.label.trim()) { + alert('Task title is required!'); + return; + } + + if (editingNode) { + // Update existing node + setNodes((nds) => + hydrateNodes( + nds.map((node) => + node.id === editingNode + ? { + ...node, + data: { + ...node.data, + label: formData.label, + description: formData.description, + status: formData.status, + priority: formData.priority, + }, + } + : node + ) + ) + ); + } else { + // Add new node + const newId = `${nodeIdCounter.current}`; + const newNode = { + id: newId, + type: 'taskNode', + position: { + x: Math.random() * 400 + 100, + y: Math.random() * 300 + 100, + }, + data: { + label: formData.label, + description: formData.description, + status: formData.status, + priority: formData.priority, + }, + }; + nodeIdCounter.current += 1; + setNodes((nds) => hydrateNodes([...nds, newNode])); + } + + setIsModalOpen(false); + setFormData({ + label: '', + description: '', + status: 'todo', + priority: 'medium', + }); + }; + + const handleClearBoard = () => { + if (window.confirm('Are you sure you want to clear the entire board? This cannot be undone.')) { + setNodes([]); + setEdges([]); + nodeIdCounter.current = 1; + localStorage.removeItem(STORAGE_KEY); + } + }; + + const handleExportBoard = () => { + const state = { + nodes: stripHandlers(nodes), + edges, + counter: nodeIdCounter.current, + exportedAt: new Date().toISOString(), + }; + const dataStr = JSON.stringify(state, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `taskflow-board-${Date.now()}.json`; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleImportBoard = (event) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const imported = JSON.parse(e.target?.result); + if (imported.nodes && imported.edges) { + setNodes(hydrateNodes(imported.nodes)); + setEdges(imported.edges); + nodeIdCounter.current = imported.counter || imported.nodes.length + 1; + alert('Board imported successfully!'); + } else { + alert('Invalid board file format!'); + } + } catch (error) { + alert('Failed to import board: ' + error.message); + } + }; + reader.readAsText(file); + event.target.value = ''; + }; + + return ( +
    + + Task Flow Board + + } + subtitle="Visualize and manage your tasks with a dynamic node-based workflow" + /> + +
    + + Drag nodes to reposition β€’ Connect nodes to show dependencies β€’ Double-click background to add tasks +
    + } + > +

    + Create a Trello-style board with draggable task nodes. Connect tasks to visualize dependencies and + workflow. Your board is automatically saved to browser storage. +

    + +
    + + + + +
    + +
    + Tasks: {nodes.length} | Connections: {edges.length} +
    + +
    + +
    + { + if (reactFlowInstance) { + const position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + const newId = `${nodeIdCounter.current}`; + const newNode = { + id: newId, + type: 'taskNode', + position, + data: { + label: `Task ${newId}`, + description: '', + status: 'todo', + priority: 'medium', + }, + }; + nodeIdCounter.current += 1; + setNodes((nds) => hydrateNodes([...nds, newNode])); + } + }} + > + + { + const priorityColors = { + low: '#10b981', + medium: '#f59e0b', + high: '#ef4444', + }; + return priorityColors[node.data?.priority] || '#6b7280'; + }} + nodeStrokeWidth={3} + zoomable + pannable + /> + + +
    +
    + Legend: +
    +
    + 🟒 Low Priority | 🟠 Medium | πŸ”΄ High +
    +
    + β—† To Do |{' '} + β—† In Progress |{' '} + β—† Complete +
    +
    +
    +
    +
    + + {isModalOpen && ( + setIsModalOpen(false)}> +

    {editingNode ? 'Edit Task' : 'Add New Task'}

    +
    + + +