├── .eslintrc.json
├── styles
└── globals.css
├── app
├── favicon.ico
├── api
│ └── todo
│ │ └── route.ts
├── layout.tsx
├── playground
│ └── page.tsx
└── page.tsx
├── postcss.config.js
├── next.config.js
├── components
├── SuspenseComponent.tsx
├── Counter.tsx
├── CompWithFetch.tsx
├── ErrorBoundary.tsx
├── VirtualizedList.tsx
├── ui
│ ├── label.tsx
│ ├── textarea.tsx
│ ├── input.tsx
│ ├── card.tsx
│ ├── button.tsx
│ ├── form.tsx
│ └── dropdown-menu.tsx
├── UseCallbackExample.tsx
├── Column.tsx
├── SearchComponent.tsx
├── PostsComponent.tsx
├── UseMemoExample.tsx
├── Dashboard.tsx
├── TaskList.tsx
├── TaskItem.tsx
└── CreateTaskForm.tsx
├── components.json
├── hooks
├── useDebounce.tsx
├── useFetch.tsx
├── useLocalStorage.tsx
└── useWindowSize.ts
├── .gitignore
├── tailwind.config.ts
├── public
├── vercel.svg
└── next.svg
├── README.md
├── __tests__
├── button.test.tsx
└── counter.test.tsx
├── tsconfig.json
├── types
└── types.ts
├── jest.config.ts
├── tailwind.config.js
├── dommyData.ts
├── package.json
├── lib
└── utils.ts
└── context
└── BoardContext
└── BoardContext.tsx
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/Porter-Task-Manager/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: false,
4 | };
5 |
6 | module.exports = nextConfig;
7 |
--------------------------------------------------------------------------------
/components/SuspenseComponent.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { fetcher } from "@/lib/utils";
3 | import useSWR from "swr";
4 |
5 | export default function SuspenseComponent() {
6 | const { data } = useSWR("/api/todo", fetcher, { suspense: true });
7 |
8 | return
{data.message} ;
9 | }
10 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": false
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/hooks/useDebounce.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export function useDebounce(value: T, delay: number): T {
4 | const [debounceValue, setDebounceValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => {
8 | setDebounceValue(value);
9 | }, delay);
10 |
11 | return () => {
12 | clearTimeout(handler);
13 | };
14 | }, [value, delay]);
15 |
16 | return debounceValue;
17 | }
18 |
--------------------------------------------------------------------------------
/components/Counter.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Button } from "./ui/button";
3 |
4 | export function Counter() {
5 | const [count, setCount] = useState(0);
6 |
7 | const increment = () => {
8 | setCount(count + 1);
9 | };
10 |
11 | return (
12 |
13 |
Counter: {count}
14 |
15 | Increment
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
--------------------------------------------------------------------------------
/app/api/todo/route.ts:
--------------------------------------------------------------------------------
1 | export async function GET(request: Request) {
2 | // validate user
3 | // auth
4 | console.log("This is running on the server");
5 | // Wait for one second
6 |
7 | await new Promise((resolve) => setTimeout(resolve, 3000));
8 |
9 | // throw new Error("error");
10 | return Response.json({ message: "Success again" });
11 | }
12 |
13 | export async function POST(request: Request) {
14 | const body = await request.json();
15 | console.log(body);
16 | return Response.json({ message: "Success POST", body });
17 | }
18 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config: Config = {
4 | content: [
5 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
13 | 'gradient-conic':
14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
15 | },
16 | },
17 | },
18 | plugins: [],
19 | }
20 | export default config
21 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { BoardProvider } from "@/context/BoardContext/BoardContext";
2 | import "../styles/globals.css";
3 | import { Analytics } from "@vercel/analytics/react";
4 |
5 | export default function RootLayout({
6 | children,
7 | }: {
8 | children: React.ReactNode;
9 | }) {
10 | return (
11 |
12 |
13 |
14 | {children}
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/CompWithFetch.tsx:
--------------------------------------------------------------------------------
1 | import { getReq } from "@/lib/utils";
2 |
3 | export default async function CompWithFetch() {
4 | const data = await getReq();
5 | console.log("Server", data);
6 | return {JSON.stringify(data)} ;
7 | }
8 |
9 | // "use client";
10 | // import { fetcher } from "@/lib/utils";
11 | // import useSWR from "swr";
12 |
13 | // export default function CompWithFetch() {
14 | // const { data, error, isLoading } = useSWR("/api/todo", fetcher);
15 |
16 | // if (error) return Error ;
17 | // if (isLoading) return Loading... ;
18 | // return {data.message} ;
19 | // }
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## About
2 |
3 | Live Demo! 👉🏼 [porter-demo.vercel.app](https://porter-demo.vercel.app/)
4 |
5 | Lear to build this app at [codewithbeto.dev](https://codewithbeto.dev)
6 |
7 | Design provided by [Eco Studios](https://www.ecostudios.dev)
8 |
9 | ## Getting Started
10 |
11 | Install dependencies:
12 |
13 | ```bash
14 | npm install
15 | ```
16 |
17 | Then run the server:
18 |
19 | ```bash
20 | npm run dev
21 | ```
22 |
23 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
24 |
25 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
26 |
--------------------------------------------------------------------------------
/__tests__/button.test.tsx:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom";
2 | import { render, screen } from "@testing-library/react";
3 | import { Button } from "@/components/ui/button";
4 |
5 | describe("Button Component", () => {
6 | test("renders with correct text", () => {
7 | render(Click Me! );
8 | const buttonElement = screen.getByText("Click Me!");
9 | expect(buttonElement).toBeInTheDocument();
10 | });
11 |
12 | test("renders correctly", () => {
13 | render(a );
14 | const buttonElement = screen.getByTestId("button");
15 | expect(buttonElement).toBeInTheDocument();
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/components/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Component, ReactNode } from "react";
3 |
4 | interface ErrorBoundaryState {
5 | hasError: boolean;
6 | }
7 |
8 | export class ErrorBoundary extends Component<
9 | React.PropsWithChildren<{}>,
10 | ErrorBoundaryState
11 | > {
12 | constructor(props: React.PropsWithChildren) {
13 | super(props);
14 | this.state = { hasError: false };
15 | }
16 |
17 | static getDerivedStateFromError(error: Error): ErrorBoundaryState {
18 | return { hasError: true };
19 | }
20 |
21 | render(): ReactNode {
22 | if (this.state.hasError) {
23 | return Something went wrong ;
24 | }
25 |
26 | return this.props.children;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/__tests__/counter.test.tsx:
--------------------------------------------------------------------------------
1 | import { Counter } from "@/components/Counter";
2 | import { fireEvent, render } from "@testing-library/react";
3 |
4 | describe("Counter Component", () => {
5 | test("Initial count is 0", () => {
6 | const { getByTestId } = render( );
7 | const countElement = getByTestId("count");
8 | expect(countElement.textContent).toBe("Counter: 0");
9 | });
10 |
11 | test("Increments count by 1 when increment button is clicked", () => {
12 | const { getByTestId } = render( );
13 | const incrementButton = getByTestId("increment-button");
14 | fireEvent.click(incrementButton);
15 | const countElement = getByTestId("count");
16 | expect(countElement.textContent).toBe("Counter: 1");
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/components/VirtualizedList.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Product } from "@/lib/utils";
3 | import * as React from "react";
4 | import { List, ListRowProps } from "react-virtualized";
5 |
6 | export default function VirtualizedList({
7 | products,
8 | }: {
9 | products: Product[];
10 | }): JSX.Element {
11 | function rowRenderer({ key, index, style }: ListRowProps) {
12 | return (
13 |
14 | {products[index].name}
15 |
16 | );
17 | }
18 |
19 | return (
20 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/types/types.ts:
--------------------------------------------------------------------------------
1 | import { DraggableLocation } from "react-beautiful-dnd";
2 |
3 | export type Task = {
4 | id: string;
5 | task: string;
6 | tag: string;
7 | date: string;
8 | description: string;
9 | };
10 |
11 | export type TaskMap = {
12 | [key: string]: Task[];
13 | };
14 |
15 | export type Board = {
16 | columns: TaskMap;
17 | ordered: string[];
18 | };
19 |
20 | export type BoardAction =
21 | | { type: "SET_TASKS"; payload: Board }
22 | | { type: "ADD_TASK"; payload: Task }
23 | | { type: "REMOVE_TASK"; payload: RemoveTaskPayload }
24 | | { type: "MOVE_TASK"; payload: OnDragPayload }
25 | | { type: "MOVE_COLUMN"; payload: OnDragPayload };
26 |
27 | type OnDragPayload = {
28 | source: DraggableLocation;
29 | destination: DraggableLocation;
30 | };
31 |
32 | type RemoveTaskPayload = {
33 | id: string;
34 | };
35 |
--------------------------------------------------------------------------------
/hooks/useFetch.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | interface FetchState {
4 | data: T | null;
5 | loading: boolean;
6 | error: Error | null;
7 | }
8 |
9 | export function useFetch(url: string, options?: RequestInit): FetchState {
10 | const [state, setState] = useState>({
11 | data: null,
12 | loading: true,
13 | error: null,
14 | });
15 |
16 | useEffect(() => {
17 | const fetchData = async () => {
18 | try {
19 | const response = await fetch(url, options);
20 | const data: T = await response.json();
21 | setState({ data, loading: false, error: null });
22 | } catch (error) {
23 | setState({ data: null, loading: false, error: error as Error });
24 | }
25 | };
26 |
27 | fetchData();
28 | }, [url, options]);
29 |
30 | return state;
31 | }
32 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * For a detailed explanation regarding each configuration property, visit:
3 | * https://jestjs.io/docs/configuration
4 | */
5 |
6 | import type { Config } from "jest";
7 | import nextJest from "next/jest";
8 |
9 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
10 | const createJestConfig = nextJest({
11 | dir: "./",
12 | });
13 |
14 | const config: Config = {
15 | // Automatically clear mock calls, instances, contexts and results before every test
16 | clearMocks: true,
17 |
18 | // Indicates which provider should be used to instrument code for coverage
19 | coverageProvider: "v8",
20 | testEnvironment: "jsdom",
21 | };
22 |
23 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
24 | export default createJestConfig(config);
25 |
--------------------------------------------------------------------------------
/hooks/useLocalStorage.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | function useLocalStorage(
4 | key: string,
5 | initialValue: T
6 | ): [T, (value: T) => void] {
7 | const [storedValue, setStoredValue] = useState(() => {
8 | try {
9 | const item = window.localStorage.getItem(key);
10 | return item ? JSON.parse(item) : initialValue;
11 | } catch (error) {
12 | console.log(error);
13 | return initialValue;
14 | }
15 | });
16 |
17 | const setValue = (value: T) => {
18 | try {
19 | const valueToStore =
20 | value instanceof Function ? value(storedValue) : value;
21 | setStoredValue(valueToStore);
22 | window.localStorage.setItem(key, JSON.stringify(valueToStore));
23 | } catch (error) {
24 | console.log(error);
25 | }
26 | };
27 |
28 | return [storedValue, setValue];
29 | }
30 |
31 | export default useLocalStorage;
32 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | keyframes: {
20 | "accordion-down": {
21 | from: { height: 0 },
22 | to: { height: "var(--radix-accordion-content-height)" },
23 | },
24 | "accordion-up": {
25 | from: { height: "var(--radix-accordion-content-height)" },
26 | to: { height: 0 },
27 | },
28 | },
29 | animation: {
30 | "accordion-down": "accordion-down 0.2s ease-out",
31 | "accordion-up": "accordion-up 0.2s ease-out",
32 | },
33 | },
34 | },
35 | plugins: [require("tailwindcss-animate")],
36 | };
37 |
--------------------------------------------------------------------------------
/components/UseCallbackExample.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useCallback, useState, memo } from "react";
3 | import { Button } from "./ui/button";
4 |
5 | export default function UseCallbackExample(): JSX.Element {
6 | const [count, setCount] = useState(0);
7 |
8 | const increment = useCallback(() => {
9 | setCount((prev) => prev + 1);
10 | }, []);
11 |
12 | return (
13 |
14 |
useCallback example
15 | {count}
16 |
17 |
18 |
19 | );
20 | }
21 |
22 | const Other = memo(function Other({ count }: { count: number }) {
23 | console.log("Other component rendered");
24 | // fetching
25 | // filtering
26 | // sorting
27 | return Other component {count}
;
28 | });
29 |
30 | const CounterButton = memo(function CounterButton({
31 | onIncrement,
32 | }: {
33 | onIncrement: () => void;
34 | }): JSX.Element {
35 | console.log("CounterButton rendered");
36 | return Increment ;
37 | });
38 |
--------------------------------------------------------------------------------
/app/playground/page.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorBoundary } from "@/components/ErrorBoundary";
2 | import PostsComponent from "@/components/PostsComponent";
3 | import SearchComponent from "@/components/SearchComponent";
4 | import SuspenseComponent from "@/components/SuspenseComponent";
5 | import UseCallbackExample from "@/components/UseCallbackExample";
6 | import UseMemoExample from "@/components/UseMemoExample";
7 | import VirtualizedList from "@/components/VirtualizedList";
8 | import { createProducts } from "@/lib/utils";
9 | import { Suspense } from "react";
10 |
11 | export default function Playground() {
12 | return (
13 |
14 | Playground
15 | {/* */}
16 | {/*
17 |
18 | Loading with suspense}>
19 |
20 |
21 | */}
22 | {/* */}
23 | {/* */}
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/components/Column.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This component contains a Column e.g. Todo, In Progress, Complete, etc.
3 | */
4 |
5 | import { Task } from "@/types/types";
6 | import {
7 | Draggable,
8 | DraggableProvided,
9 | DraggableStateSnapshot,
10 | } from "react-beautiful-dnd";
11 | import TaskList from "./TaskList";
12 |
13 | type ColumnProps = {
14 | listTitle: string;
15 | listOfTasks: Task[];
16 | index: number;
17 | };
18 |
19 | export default function Column({ index, listOfTasks, listTitle }: ColumnProps) {
20 | return (
21 |
22 | {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (
23 |
28 |
35 |
36 | )}
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/hooks/useWindowSize.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export default function useWindowSize() {
4 | const [windowSize, setWindowSize] = useState<{
5 | width: number | undefined;
6 | height: number | undefined;
7 | }>({
8 | width: undefined,
9 | height: undefined,
10 | });
11 |
12 | useEffect(() => {
13 | // Handler to call on window resize
14 | function handleResize() {
15 | // Set window width/height to state
16 | setWindowSize({
17 | width: window.innerWidth,
18 | height: window.innerHeight,
19 | });
20 | }
21 |
22 | // Add event listener
23 | window.addEventListener("resize", handleResize);
24 |
25 | // Call handler right away so state gets updated with initial window size
26 | handleResize();
27 |
28 | // Remove event listener on cleanup
29 | return () => window.removeEventListener("resize", handleResize);
30 | }, []); // Empty array ensures that effect is only run on mount
31 |
32 | return {
33 | windowSize,
34 | isMobile: typeof windowSize?.width === "number" && windowSize?.width < 768,
35 | isDesktop:
36 | typeof windowSize?.width === "number" && windowSize?.width >= 768,
37 | };
38 | }
39 |
--------------------------------------------------------------------------------
/components/SearchComponent.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useDebounce } from "@/hooks/useDebounce";
3 | import { ChangeEvent, Suspense, lazy, useEffect, useState } from "react";
4 | const LazyPostsComponent = lazy(() => import("./PostsComponent"));
5 |
6 | export default function SearchComponent(): JSX.Element {
7 | const [searchTerm, setSearchTerm] = useState("");
8 | const debouncedSearchTerm = useDebounce(searchTerm, 1000);
9 |
10 | const hanleSearch = (searchValue: string) => {
11 | console.log("Searching for: ", searchValue);
12 | };
13 |
14 | useEffect(() => {
15 | if (debouncedSearchTerm) {
16 | hanleSearch(debouncedSearchTerm);
17 | }
18 | }, [debouncedSearchTerm]);
19 |
20 | return (
21 |
22 |
Debounce example
23 | ) =>
29 | setSearchTerm(e.target.value)
30 | }
31 | />
32 | Lazy loading...}>
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/PostsComponent.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useFetch } from "@/hooks/useFetch";
4 | import useLocalStorage from "@/hooks/useLocalStorage";
5 | import { Button } from "./ui/button";
6 | import { cn } from "@/lib/utils";
7 |
8 | interface Post {
9 | userId: number;
10 | id: number;
11 | title: string;
12 | body: string;
13 | }
14 |
15 | export default function PostsComponent(): JSX.Element {
16 | const [theme, setTheme] = useLocalStorage("@theme", "light");
17 | const { data, error, loading } = useFetch(
18 | "https://jsonplaceholder.typicode.com/posts"
19 | );
20 | const style = theme === "dark" ? "bg-black text-white" : "bg-white";
21 |
22 | if (loading) return Loading...
;
23 | if (error) return Error...
;
24 | return (
25 |
26 | {
28 | setTheme(theme === "light" ? "dark" : "light");
29 | }}
30 | >
31 | Toggle Theme
32 |
33 | Theme value: {theme}
34 | Posts fetched using useFetch advanced hook
35 | {data &&
36 | data.slice(0, 5).map((post) => (
37 |
41 | {post.title}
42 |
43 | ))}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/dommyData.ts:
--------------------------------------------------------------------------------
1 | import { Task, TaskMap } from "./types/types";
2 |
3 | export const todos: Task[] = [
4 | {
5 | id: "1",
6 | task: "Learn React",
7 | description: "Description with some more text here",
8 | tag: "Low",
9 | date: "Sun Oct 22 2023",
10 | },
11 | {
12 | id: "2",
13 | task: "Build a To-Do App",
14 | description: "Description with some more text here",
15 | tag: "Low",
16 | date: "Sun Oct 22 2023",
17 | },
18 | {
19 | id: "3",
20 | task: "Review CSS Modules",
21 | description: "Description with some more text here",
22 | tag: "Low",
23 | date: "Sun Oct 22 2023",
24 | },
25 | ];
26 |
27 | export const initialState: TaskMap = {
28 | Pending: [
29 | {
30 | id: "1",
31 | task: "Learn React",
32 | description: "Description with some more text here",
33 | tag: "Low",
34 | date: "Sun Oct 22 2023",
35 | },
36 | {
37 | id: "2",
38 | task: "Build a To-Do App",
39 | description: "Description with some more text here",
40 | tag: "Low",
41 | date: "Sun Oct 22 2023",
42 | },
43 | {
44 | id: "3",
45 | task: "Review CSS Modules",
46 | description: "Description with some more text here",
47 | tag: "Low",
48 | date: "Sun Oct 22 2023",
49 | },
50 | ],
51 | Ongoing: [],
52 | Done: [
53 | {
54 | id: "4",
55 | task: "Get a Job",
56 | description: "Description with some more text here",
57 | tag: "High",
58 | date: "2023-10-23",
59 | },
60 | ],
61 | };
62 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "porter-task-manager",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "test": "jest"
11 | },
12 | "dependencies": {
13 | "@hookform/resolvers": "^3.3.2",
14 | "@radix-ui/react-dropdown-menu": "^2.0.6",
15 | "@radix-ui/react-icons": "^1.3.0",
16 | "@radix-ui/react-label": "^2.0.2",
17 | "@radix-ui/react-slot": "^1.0.2",
18 | "@vercel/analytics": "^1.1.1",
19 | "class-variance-authority": "^0.7.0",
20 | "clsx": "^2.0.0",
21 | "next": "13.5.6",
22 | "react": "^18",
23 | "react-beautiful-dnd": "^13.1.1",
24 | "react-dom": "^18",
25 | "react-hook-form": "^7.47.0",
26 | "react-virtualized": "^9.22.5",
27 | "swr": "^2.2.4",
28 | "tailwind-merge": "^1.14.0",
29 | "tailwindcss-animate": "^1.0.7",
30 | "zod": "^3.22.4"
31 | },
32 | "devDependencies": {
33 | "@testing-library/jest-dom": "^6.1.5",
34 | "@testing-library/react": "^14.1.2",
35 | "@types/jest": "^29.5.11",
36 | "@types/node": "^20",
37 | "@types/react": "^18",
38 | "@types/react-beautiful-dnd": "^13.1.6",
39 | "@types/react-dom": "^18",
40 | "@types/react-virtualized": "^9.21.29",
41 | "autoprefixer": "^10",
42 | "eslint": "^8",
43 | "eslint-config-next": "13.5.6",
44 | "jest": "^29.7.0",
45 | "jest-environment-jsdom": "^29.7.0",
46 | "postcss": "^8",
47 | "tailwindcss": "^3",
48 | "ts-node": "^10.9.2",
49 | "typescript": "^5"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/components/UseMemoExample.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Product, createProducts, filterProducts } from "@/lib/utils";
3 | import { fchown } from "fs";
4 | import { ChangeEvent, useMemo, useState } from "react";
5 | import { Button } from "./ui/button";
6 |
7 | const products = createProducts();
8 |
9 | export default function UseMemoExample(): JSX.Element {
10 | const [dummyVariable, setDummyVariable] = useState(false);
11 | const [filterBy, setFilterBy] = useState<"all" | "expensive" | "cheap">(
12 | "all"
13 | );
14 |
15 | return (
16 |
17 |
setDummyVariable(!dummyVariable)}>
18 | Toggle Dummy
19 |
20 |
) => {
22 | setFilterBy(e.target.value as "all" | "expensive" | "cheap");
23 | }}
24 | >
25 | All
26 | Expensive
27 | Cheap
28 |
29 |
useMemo example
30 |
31 |
32 | );
33 | }
34 |
35 | function ProductsList({
36 | products,
37 | filterBy,
38 | }: {
39 | products: Product[];
40 | filterBy: "all" | "expensive" | "cheap";
41 | }): JSX.Element {
42 | const visibleProducts = useMemo(
43 | () => filterProducts(products, filterBy),
44 | [filterBy, products]
45 | );
46 | return (
47 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export async function fetcher(url: string): Promise {
9 | const response = await fetch(url);
10 | if (!response.ok) {
11 | throw new Error("Error");
12 | }
13 | return response.json();
14 | }
15 |
16 | export async function getReq() {
17 | const response = await fetch("http://localhost:3000/api/todo", {
18 | // body: JSON.stringify({
19 | // user: "beto",
20 | // pass: "xxx",
21 | // }),
22 | // method: "POST",
23 | headers: {
24 | "Content-Type": "application/json",
25 | Authorization: `Bearer ${"beto"}`,
26 | },
27 | });
28 | return await response.json();
29 | }
30 |
31 | export interface Product {
32 | id: number;
33 | name: string;
34 | isExpensive: boolean;
35 | price: number;
36 | }
37 |
38 | export function createProducts(): Product[] {
39 | console.log("Creating Products");
40 | const products: Product[] = [];
41 |
42 | for (let i = 0; i < 10000; i++) {
43 | const price = Math.floor(Math.random() * 1000);
44 | products.push({
45 | id: i + 1,
46 | price: price,
47 | isExpensive: price > 500 ? true : false,
48 | name: `${i + 1} Product Name`,
49 | });
50 | }
51 |
52 | return products;
53 | }
54 |
55 | export function filterProducts(
56 | products: Product[],
57 | filterBy: "all" | "expensive" | "cheap"
58 | ): Product[] {
59 | console.log("Filtering Products");
60 | return products.filter((product) => {
61 | if (filterBy === "all") {
62 | return true;
63 | } else if (filterBy === "expensive") {
64 | return product.isExpensive;
65 | } else if (filterBy === "cheap") {
66 | return !product.isExpensive;
67 | }
68 | });
69 | }
70 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import CompWithFetch from "@/components/CompWithFetch";
2 | import Dashboard from "@/components/Dashboard";
3 | import { Button } from "@/components/ui/button";
4 | import { GitHubLogoIcon, PlusIcon } from "@radix-ui/react-icons";
5 |
6 | export default function Home() {
7 | return (
8 |
9 |
10 |
11 | Porter - Lightweight Task Manager
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {/*
20 | Column
21 | */}
22 |
23 |
24 |
25 | Want to build this app yourself? Head over to{" "}
26 |
27 | codewithbeto.dev
28 | {" "}
29 | and check out our React with TypeScript course.
30 | Oh, and grab our Early Bird deal while youre at it! 🐤
31 |
32 |
33 |
34 |
Shortcuts
35 |
36 | - New Task
37 |
38 | ⌘
39 |
40 |
41 | K
42 |
43 |
44 |
45 | - Close Task Form
46 |
47 | Esc
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-slate-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-slate-900 text-slate-50 shadow hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
14 | destructive:
15 | "bg-red-500 text-slate-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-slate-200 bg-transparent shadow-sm hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:hover:bg-slate-800 dark:hover:text-slate-50",
18 | secondary:
19 | "bg-slate-100 text-slate-900 shadow-sm hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
20 | ghost:
21 | "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
22 | link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2",
26 | sm: "h-8 rounded-md px-3 text-xs",
27 | lg: "h-10 rounded-md px-8",
28 | icon: "h-9 w-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | );
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean;
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button";
47 | return (
48 |
54 | );
55 | }
56 | );
57 | Button.displayName = "Button";
58 |
59 | export { Button, buttonVariants };
60 |
--------------------------------------------------------------------------------
/components/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useCallback } from "react";
3 | import {
4 | DragDropContext,
5 | Droppable,
6 | DropResult,
7 | DraggableLocation,
8 | } from "react-beautiful-dnd";
9 | import Column from "./Column";
10 | import { useBoard } from "@/context/BoardContext/BoardContext";
11 | import useWindowSize from "@/hooks/useWindowSize";
12 |
13 | export default function Dashboard() {
14 | const { boardState, dispatch } = useBoard();
15 | const { isMobile } = useWindowSize();
16 |
17 | // using useCallback is optional
18 | const onBeforeCapture = useCallback(() => {
19 | /*...*/
20 | }, []);
21 | const onBeforeDragStart = useCallback(() => {
22 | /*...*/
23 | }, []);
24 | const onDragStart = useCallback(() => {
25 | /*...*/
26 | }, []);
27 | const onDragUpdate = useCallback(() => {
28 | /*...*/
29 | }, []);
30 | const onDragEnd = useCallback(
31 | (result: DropResult) => {
32 | // console.log(result);
33 | if (!result.destination) return; // dropped nowhere
34 |
35 | const source: DraggableLocation = result.source;
36 | const destination: DraggableLocation = result.destination;
37 |
38 | // Reordering column
39 | if (result.type === "COLUMN") {
40 | dispatch({ type: "MOVE_COLUMN", payload: { source, destination } });
41 | return;
42 | }
43 | // Reordering or moving tasks
44 | if (result.type === "TASK") {
45 | dispatch({ type: "MOVE_TASK", payload: { source, destination } });
46 | }
47 | },
48 | [dispatch]
49 | );
50 |
51 | return (
52 |
59 |
64 | {(provided, snapshot) => (
65 |
70 | {boardState.ordered.map((key, index) => (
71 |
77 | ))}
78 |
79 | )}
80 |
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/components/TaskList.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Task } from "@/types/types";
3 | import {
4 | Draggable,
5 | DraggableProvided,
6 | DraggableStateSnapshot,
7 | Droppable,
8 | DroppableProvided,
9 | DroppableStateSnapshot,
10 | } from "react-beautiful-dnd";
11 | import { Card, CardContent, CardHeader } from "./ui/card";
12 | import TaskItem from "./TaskItem";
13 | import { Button } from "./ui/button";
14 | import { PlusIcon } from "@radix-ui/react-icons";
15 | import CreateTaskForm from "./CreateTaskForm";
16 | import { cn } from "@/lib/utils";
17 |
18 | type TaskListProps = {
19 | listTitle?: string;
20 | listId?: string;
21 | listType?: string;
22 | listOfTasks: Task[];
23 | isDropDisabled?: boolean;
24 | };
25 |
26 | /**
27 | *
28 | * This component supports dropping items
29 | * It also renders a list of draggables
30 | *
31 | */
32 |
33 | export default function TaskList({
34 | listTitle,
35 | listOfTasks,
36 | isDropDisabled,
37 | listId = "LIST",
38 | listType,
39 | }: TaskListProps) {
40 | const [showForm, setShowForm] = React.useState(false);
41 |
42 | React.useEffect(() => {
43 | const down = (e: KeyboardEvent) => {
44 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
45 | e.preventDefault();
46 | setShowForm((open) => !open);
47 | }
48 | if (e.key === "Escape") {
49 | e.preventDefault();
50 | setShowForm(false);
51 | }
52 | };
53 |
54 | document.addEventListener("keydown", down);
55 | return () => document.removeEventListener("keydown", down);
56 | }, []);
57 |
58 | return (
59 |
64 | {(
65 | dropProvided: DroppableProvided,
66 | dropSnapshot: DroppableStateSnapshot
67 | ) => (
68 |
69 |
70 | {listTitle}
71 | {listTitle === "Pending" && (
72 | setShowForm(!showForm)}
76 | >
77 |
83 |
84 | )}
85 |
86 |
87 | {listTitle === "Pending" && showForm && (
88 |
89 |
90 |
91 | )}
92 |
97 |
98 |
99 | )}
100 |
101 | );
102 | }
103 |
104 | type InnerListProps = {
105 | dropProvided: DroppableProvided;
106 | listOfTasks: Task[];
107 | title?: string;
108 | };
109 |
110 | function InnerList({ title, listOfTasks, dropProvided }: InnerListProps) {
111 | return (
112 |
113 | {listOfTasks.map((task, index) => {
114 | return (
115 |
116 | {(
117 | dragProvided: DraggableProvided,
118 | dragSnapshot: DraggableStateSnapshot
119 | ) => (
120 |
126 | )}
127 |
128 | );
129 | })}
130 | {dropProvided.placeholder}
131 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/components/TaskItem.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardFooter,
7 | CardHeader,
8 | CardTitle,
9 | } from "@/components/ui/card";
10 | import { Task } from "@/types/types";
11 | import { DraggableProvided } from "react-beautiful-dnd";
12 | import { cn } from "@/lib/utils";
13 | import { Button } from "./ui/button";
14 | import {
15 | CalendarIcon,
16 | DotsHorizontalIcon,
17 | Pencil1Icon,
18 | StarIcon,
19 | TrashIcon,
20 | } from "@radix-ui/react-icons";
21 | import {
22 | DropdownMenu,
23 | DropdownMenuContent,
24 | DropdownMenuLabel,
25 | DropdownMenuSeparator,
26 | DropdownMenuTrigger,
27 | } from "./ui/dropdown-menu";
28 | import { useBoard } from "@/context/BoardContext/BoardContext";
29 |
30 | type TaskItemProps = {
31 | task: Task;
32 | isDragging: boolean;
33 | provided: DraggableProvided;
34 | };
35 |
36 | function TaskItem({ isDragging, provided, task }: TaskItemProps) {
37 | const { dispatch } = useBoard();
38 | return (
39 |
45 |
46 |
47 |
48 |
{task.task}
49 |
50 |
51 |
56 |
57 |
58 |
59 |
60 | Options
61 |
62 | {
66 | dispatch({ type: "REMOVE_TASK", payload: { id: task.id } });
67 | }}
68 | >
69 | Delete
70 |
71 |
75 | Update
76 |
77 |
78 |
82 | Favorites
83 |
84 |
85 |
86 |
87 |
88 |
89 | {task.description && (
90 |
91 | {task.description}
92 |
93 | )}
94 |
95 |
96 |
97 | {task.date}
98 |
99 |
108 | {task.tag}
109 |
110 |
111 |
112 |
113 | );
114 | }
115 |
116 | export default React.memo(TaskItem);
117 |
--------------------------------------------------------------------------------
/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/context/BoardContext/BoardContext.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import { initialState } from "@/dommyData";
4 | import { Board, BoardAction } from "@/types/types";
5 |
6 | const boardInitialState: Board = {
7 | columns: initialState,
8 | ordered: Object.keys(initialState),
9 | };
10 |
11 | const BoardContext = React.createContext({
12 | boardState: boardInitialState,
13 | dispatch: (action: BoardAction) => {},
14 | });
15 |
16 | export const BoardProvider = ({ children }: React.PropsWithChildren) => {
17 | const [boardState, dispatch] = React.useReducer(
18 | boardReducer,
19 | boardInitialState
20 | );
21 | const [loading, setLoading] = React.useState(true);
22 |
23 | React.useEffect(() => {
24 | loadData();
25 | }, []);
26 |
27 | function loadData() {
28 | const localBoardData = localStorage.getItem("@Board");
29 |
30 | if (localBoardData === null) {
31 | localStorage.setItem("@Board", JSON.stringify(boardInitialState));
32 | } else {
33 | const dataObject = JSON.parse(localBoardData);
34 | dispatch({ type: "SET_TASKS", payload: dataObject });
35 | }
36 | setLoading(false);
37 | }
38 |
39 | if (loading) return;
40 | return (
41 |
42 | {children}
43 |
44 | );
45 | };
46 |
47 | /**
48 | * custom hook for easy context usage :)
49 | */
50 | export function useBoard() {
51 | return React.useContext(BoardContext);
52 | }
53 |
54 | /**
55 | * Tasks reducer
56 | * This function handles actions on the state
57 | */
58 |
59 | function boardReducer(state: Board, action: BoardAction): Board {
60 | switch (action.type) {
61 | case "SET_TASKS": {
62 | return action.payload;
63 | }
64 | case "MOVE_COLUMN": {
65 | const result = [...state.ordered];
66 | const [removed] = result.splice(action.payload.source.index, 1);
67 | result.splice(action.payload.destination.index, 0, removed);
68 |
69 | const newState = { ...state, ordered: result };
70 | // save locally
71 | localStorage.setItem("@Board", JSON.stringify(newState));
72 | return newState;
73 | }
74 | case "MOVE_TASK": {
75 | if (
76 | action.payload.source.droppableId ===
77 | action.payload.destination.droppableId
78 | ) {
79 | // Reordering within the same column
80 | const reorderedTasks = [
81 | ...state.columns[action.payload.source.droppableId],
82 | ];
83 | const [movedTask] = reorderedTasks.splice(
84 | action.payload.source.index,
85 | 1
86 | );
87 | reorderedTasks.splice(action.payload.destination.index, 0, movedTask);
88 |
89 | const newState = {
90 | ...state,
91 | columns: {
92 | ...state.columns,
93 | [action.payload.source.droppableId]: reorderedTasks,
94 | },
95 | };
96 | // save locally
97 | localStorage.setItem("@Board", JSON.stringify(newState));
98 | // Exit after handling reordering within the same column
99 | return newState;
100 | }
101 |
102 | // Handling movement between different columns
103 | const startTasks = [...state.columns[action.payload.source.droppableId]];
104 | const finishTasks = [
105 | ...state.columns[action.payload.destination.droppableId],
106 | ];
107 | const [removedTask] = startTasks.splice(action.payload.source.index, 1);
108 | finishTasks.splice(action.payload.destination.index, 0, removedTask);
109 |
110 | const newState = {
111 | ...state,
112 | columns: {
113 | ...state.columns,
114 | [action.payload.source.droppableId]: startTasks,
115 | [action.payload.destination.droppableId]: finishTasks,
116 | },
117 | };
118 |
119 | // save locally
120 | localStorage.setItem("@Board", JSON.stringify(newState));
121 |
122 | return newState;
123 | }
124 | case "ADD_TASK": {
125 | const newState = {
126 | ...state,
127 | columns: {
128 | ...state.columns,
129 | ["Pending"]: [action.payload, ...state.columns.Pending],
130 | },
131 | };
132 |
133 | // save
134 | localStorage.setItem("@Board", JSON.stringify(newState));
135 |
136 | return newState;
137 | }
138 | case "REMOVE_TASK": {
139 | // I'm sure there is a better way to do this :')
140 | const taskToRemoveId = action.payload.id;
141 | const newState = { ...state };
142 |
143 | for (const column of Object.keys(state.columns)) {
144 | newState.columns[column] = newState.columns[column].filter(
145 | (task) => task.id !== taskToRemoveId
146 | );
147 | }
148 | // save
149 | localStorage.setItem("@Board", JSON.stringify(newState));
150 |
151 | return newState;
152 | }
153 | default: {
154 | return boardInitialState;
155 | }
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/components/CreateTaskForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { TrashIcon, StarIcon, MixerVerticalIcon } from "@radix-ui/react-icons";
3 | import { Button } from "./ui/button";
4 | import { Card, CardContent, CardHeader } from "./ui/card";
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuLabel,
10 | DropdownMenuSeparator,
11 | DropdownMenuTrigger,
12 | } from "./ui/dropdown-menu";
13 | import { Input } from "./ui/input";
14 | import * as z from "zod";
15 | import { useForm } from "react-hook-form";
16 | import { zodResolver } from "@hookform/resolvers/zod";
17 | import {
18 | Form,
19 | FormControl,
20 | FormDescription,
21 | FormField,
22 | FormItem,
23 | FormLabel,
24 | FormMessage,
25 | } from "./ui/form";
26 | import { Dispatch, SetStateAction, useState } from "react";
27 | import { useBoard } from "@/context/BoardContext/BoardContext";
28 | import { cn } from "@/lib/utils";
29 | import { Textarea } from "./ui/textarea";
30 |
31 | const formSchema = z.object({
32 | task: z.string().min(2, { message: "Enter at least 2 characters" }).max(100),
33 | description: z.string().max(500),
34 | });
35 |
36 | export default function CreateTaskForm({
37 | setShowForm,
38 | }: {
39 | setShowForm: Dispatch>;
40 | }) {
41 | const form = useForm>({
42 | resolver: zodResolver(formSchema),
43 | defaultValues: {
44 | task: "",
45 | description: "",
46 | },
47 | });
48 | const { dispatch } = useBoard();
49 | const [tag, setTag] = useState("Low");
50 |
51 | function onSubmit({ task, description }: z.infer) {
52 | dispatch({
53 | type: "ADD_TASK",
54 | payload: {
55 | id: Math.random().toString(), // YOLO
56 | date: new Date().toDateString(),
57 | tag,
58 | description,
59 | task,
60 | },
61 | });
62 | setShowForm(false);
63 | }
64 | return (
65 |
153 |
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import {
6 | CheckIcon,
7 | ChevronRightIcon,
8 | DotFilledIcon,
9 | } from "@radix-ui/react-icons"
10 |
11 | import { cn } from "@/lib/utils"
12 |
13 | const DropdownMenu = DropdownMenuPrimitive.Root
14 |
15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
16 |
17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
18 |
19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
20 |
21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
22 |
23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
24 |
25 | const DropdownMenuSubTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef & {
28 | inset?: boolean
29 | }
30 | >(({ className, inset, children, ...props }, ref) => (
31 |
40 | {children}
41 |
42 |
43 | ))
44 | DropdownMenuSubTrigger.displayName =
45 | DropdownMenuPrimitive.SubTrigger.displayName
46 |
47 | const DropdownMenuSubContent = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, ...props }, ref) => (
51 |
59 | ))
60 | DropdownMenuSubContent.displayName =
61 | DropdownMenuPrimitive.SubContent.displayName
62 |
63 | const DropdownMenuContent = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, sideOffset = 4, ...props }, ref) => (
67 |
68 |
78 |
79 | ))
80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
81 |
82 | const DropdownMenuItem = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef & {
85 | inset?: boolean
86 | }
87 | >(({ className, inset, ...props }, ref) => (
88 |
97 | ))
98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
99 |
100 | const DropdownMenuCheckboxItem = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, children, checked, ...props }, ref) => (
104 |
113 |
114 |
115 |
116 |
117 |
118 | {children}
119 |
120 | ))
121 | DropdownMenuCheckboxItem.displayName =
122 | DropdownMenuPrimitive.CheckboxItem.displayName
123 |
124 | const DropdownMenuRadioItem = React.forwardRef<
125 | React.ElementRef,
126 | React.ComponentPropsWithoutRef
127 | >(({ className, children, ...props }, ref) => (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | ))
144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
145 |
146 | const DropdownMenuLabel = React.forwardRef<
147 | React.ElementRef,
148 | React.ComponentPropsWithoutRef & {
149 | inset?: boolean
150 | }
151 | >(({ className, inset, ...props }, ref) => (
152 |
161 | ))
162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
163 |
164 | const DropdownMenuSeparator = React.forwardRef<
165 | React.ElementRef,
166 | React.ComponentPropsWithoutRef
167 | >(({ className, ...props }, ref) => (
168 |
173 | ))
174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
175 |
176 | const DropdownMenuShortcut = ({
177 | className,
178 | ...props
179 | }: React.HTMLAttributes) => {
180 | return (
181 |
185 | )
186 | }
187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
188 |
189 | export {
190 | DropdownMenu,
191 | DropdownMenuTrigger,
192 | DropdownMenuContent,
193 | DropdownMenuItem,
194 | DropdownMenuCheckboxItem,
195 | DropdownMenuRadioItem,
196 | DropdownMenuLabel,
197 | DropdownMenuSeparator,
198 | DropdownMenuShortcut,
199 | DropdownMenuGroup,
200 | DropdownMenuPortal,
201 | DropdownMenuSub,
202 | DropdownMenuSubContent,
203 | DropdownMenuSubTrigger,
204 | DropdownMenuRadioGroup,
205 | }
206 |
--------------------------------------------------------------------------------