diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 4b23e3d..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": "./tsconfig.json" - }, - "plugins": ["@typescript-eslint"], - "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "@it-incubator/eslint-config"], - "rules": { - "@typescript-eslint/consistent-type-imports": "warn" - } -} diff --git a/.idea/biome.xml b/.idea/biome.xml new file mode 100644 index 0000000..cb2a0dc --- /dev/null +++ b/.idea/biome.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000..d23208f --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..3876841 --- /dev/null +++ b/biome.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "nursery": { + "useSortedClasses": "warn" + } + } + }, + "javascript": { + "formatter": { + "semicolons": "asNeeded" + } + }, + "formatter": { "indentStyle": "space", "indentWidth": 2 } +} diff --git a/bun.lockb b/bun.lockb index f52c32c..6d62a4b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..af7b002 --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/package.json b/package.json index e4a7554..6407f00 100644 --- a/package.json +++ b/package.json @@ -1,37 +1,43 @@ { - "name": "todolist_next", + "name": "todolist", "version": "0.1.0", "private": true, "scripts": { "build": "next build", "dev": "next dev", - "lint": "next lint", - "start": "next start" + "lint": "biome lint --write --unsafe ./src", + "start": "next start", + "format": "prettier --write ." }, "dependencies": { - "@tanstack/react-query": "^4.28.0", - "axios": "^1.3.4", - "next": "13.2.4", - "react": "18.2.0", - "react-dom": "18.2.0", - "zod": "^3.21.4" + "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", + "@tanstack/react-query": "^5.51.23", + "async-mutex": "^0.5.0", + "axios": "^1.7.4", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.427.0", + "next": "^14.2.5", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.2", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8" }, "devDependencies": { - "@it-incubator/eslint-config": "^0.1.0", - "@it-incubator/prettier-config": "^0.1.0", - "@types/node": "^18.15.11", - "@types/react": "^18.0.33", - "@types/react-dom": "^18.0.11", - "@typescript-eslint/eslint-plugin": "^5.57.1", - "@typescript-eslint/parser": "^5.57.1", - "autoprefixer": "^10.4.14", - "eslint": "^8.37.0", - "eslint-config-next": "13.2.4", - "postcss": "^8.4.21", - "prettier": "^2.8.7", - "prettier-plugin-tailwindcss": "^0.2.6", - "tailwindcss": "^3.3.1", - "typescript": "^5.0.3" + "@biomejs/biome": "1.8.3", + "@types/node": "^22.3.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4" }, "ct3aMetadata": { "initVersion": "6.10.1" diff --git a/prettier.config.cjs b/prettier.config.cjs deleted file mode 100644 index accc933..0000000 --- a/prettier.config.cjs +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import("prettier").Config} */ -module.exports = { - plugins: [require.resolve("prettier-plugin-tailwindcss"), require.resolve("@it-incubator/prettier-config")], -}; diff --git a/src/components/auth-redirect/index.tsx b/src/components/auth-redirect/index.tsx index 1ac843d..280cb02 100644 --- a/src/components/auth-redirect/index.tsx +++ b/src/components/auth-redirect/index.tsx @@ -1,30 +1,31 @@ -import type { FC, ReactNode } from "react"; -import { useEffect } from "react"; +import type { FC, ReactNode } from "react" +import { useEffect } from "react" -import { useRouter } from "next/router"; +import { useRouter } from "next/router" -import { Loader } from "../loader"; +import { Loader } from "../ui/loader" -import { useMeQuery } from "@/services"; +import { useMeQuery } from "@/services" export const AuthRedirect: FC<{ children: ReactNode }> = ({ children }) => { - const router = useRouter(); - const { data: user, isLoading, isError } = useMeQuery(); - const isAuthPage = router.pathname === "/login"; + const router = useRouter() + const { data: user, isLoading } = useMeQuery() + const isAuthPage = + router.pathname === "/login" || router.pathname === "/sign-up" useEffect(() => { if (!isLoading && !user && !isAuthPage) { - router.push("/login"); + router.push("/login") } - }, [user, isError, isLoading, isAuthPage, router]); + }, [user, isLoading, isAuthPage, router]) if (isLoading || (!user && !isAuthPage)) { return (
- ); + ) } - return <>{children}; -}; + return <>{children} +} diff --git a/src/components/button/index.tsx b/src/components/button/index.tsx deleted file mode 100644 index f67bf8f..0000000 --- a/src/components/button/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { ButtonHTMLAttributes, DetailedHTMLProps, FC } from "react"; - -type Props = DetailedHTMLProps< - ButtonHTMLAttributes, - HTMLButtonElement ->; - -export const Button: FC = ({ className, ...rest }) => { - return ( -
-
- ); -}; diff --git a/src/components/fullscreen-loader/index.tsx b/src/components/fullscreen-loader/index.tsx index c6a6a64..cb3c722 100644 --- a/src/components/fullscreen-loader/index.tsx +++ b/src/components/fullscreen-loader/index.tsx @@ -1,9 +1,9 @@ -import { Loader } from "../loader"; +import { Loader } from "../ui/loader" export const FullscreenLoader = () => { return (
- ); -}; + ) +} diff --git a/src/components/index.ts b/src/components/index.ts index bb544b5..766c5c7 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,6 @@ -export * from "./todolist"; -export * from "./auth-redirect"; -export * from "./button"; -export * from "./input"; -export * from "./loader"; -export * from "./fullscreen-loader"; +export * from "./todolist" +export * from "./auth-redirect" +export * from "./ui/button" +export * from "./ui/input" +export * from "./ui/loader" +export * from "./fullscreen-loader" diff --git a/src/components/input/index.tsx b/src/components/input/index.tsx deleted file mode 100644 index f63056d..0000000 --- a/src/components/input/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { DetailedHTMLProps, FC, InputHTMLAttributes } from "react"; - -type Props = DetailedHTMLProps< - InputHTMLAttributes, - HTMLInputElement ->; - -export const Input: FC = ({ className, ...rest }) => { - return ( -
- -
- ); -}; diff --git a/src/components/mode-toggle.tsx b/src/components/mode-toggle.tsx new file mode 100644 index 0000000..7e613c4 --- /dev/null +++ b/src/components/mode-toggle.tsx @@ -0,0 +1,40 @@ +"use client" + +import { Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" +import * as React from "react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export function ModeToggle() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ) +} diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..30d1451 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,9 @@ +"use client" + +import { ThemeProvider as NextThemesProvider } from "next-themes" +import type { ThemeProviderProps } from "next-themes/dist/types" +import * as React from "react" + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/src/components/todolist/index.tsx b/src/components/todolist/index.tsx index bdaec5e..ec4d0fd 100644 --- a/src/components/todolist/index.tsx +++ b/src/components/todolist/index.tsx @@ -1,107 +1,170 @@ -import type { ChangeEvent, FC, MouseEventHandler } from "react"; -import { memo, useState } from "react"; - -import type { Task, Todolist as TodolistType } from "../../services/todolists"; -import { Button } from "../button"; -import { Input } from "../input"; +import { + type ChangeEvent, + type FC, + type FormEvent, + memo, + useCallback, + useMemo, + useState, +} from "react" import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Plus, Trash } from "lucide-react" +import type { + Task, + Todolist as TodolistType, +} from "../../services/todolist-api/todolists" +import { Button } from "../ui/button" +import { Input } from "../ui/input" + +import { Checkbox } from "@/components/ui/checkbox" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + TaskStatus, useCreateTaskMutation, useDeleteTaskMutation, useDeleteTodolistMutation, useGetTasksQuery, useUpdateTaskMutation, -} from "@/services"; +} from "@/services" type Props = { - todolist: TodolistType; -}; + todolist: TodolistType +} -type Filter = "all" | "active" | "completed"; +type Filter = "all" | "active" | "completed" export const Todolist: FC = memo(({ todolist }) => { - const { data: tasks, isLoading } = useGetTasksQuery(todolist.id); - const { mutate: putTask } = useUpdateTaskMutation(); - const { mutate: deleteTask } = useDeleteTaskMutation(); - const { mutate: deleteTodolist } = useDeleteTodolistMutation(); - const { mutate: createTask } = useCreateTaskMutation(); - const [newTaskTitle, setNewTaskTitle] = useState(""); - const [filter, setFilter] = useState("all"); + const { data: tasks, isLoading } = useGetTasksQuery(todolist.id) + const { mutate: updateTask } = useUpdateTaskMutation() + const { mutate: deleteTask } = useDeleteTaskMutation() + const { mutate: deleteTodolist } = useDeleteTodolistMutation() + const { mutate: createTask } = useCreateTaskMutation() + const [newTaskTitle, setNewTaskTitle] = useState("") + const [filter, setFilter] = useState("all") - const handleChangeStatus = (todolistId: string, task: Task) => { - const newTask = { ...task, status: task.status === 0 ? 2 : 0 }; + const handleChangeStatus = useCallback( + (todolistId: string, task: Task) => { + updateTask({ + todolistId, + id: task.id, + status: + task.status === TaskStatus.DONE ? TaskStatus.OPEN : TaskStatus.DONE, + }) + }, + [updateTask], + ) + + const handleDeleteTask = useCallback( + (todolistId: string, taskId: string) => { + deleteTask({ todolistId, taskId }) + }, + [deleteTask], + ) - putTask({ todolistId, task: newTask }); - }; - const handleDeleteTask = (todolistId: string, taskId: string) => { - deleteTask({ todolistId, taskId }); - }; const handleDeleteTodolist = (todolistId: string) => { - deleteTodolist({ todolistId }); - }; - const handleAddTask = () => { - createTask({ todolistId: todolist.id, title: newTaskTitle }); - setNewTaskTitle(""); - }; + deleteTodolist({ todolistId }) + } + + const handleAddTask = (e: FormEvent) => { + e.preventDefault() + createTask({ todolistId: todolist.id, title: newTaskTitle }) + setNewTaskTitle("") + } + const handleNewTaskTitleChange = (e: ChangeEvent) => { - setNewTaskTitle(e.target.value); - }; + setNewTaskTitle(e.target.value) + } - const handleFilterChange: MouseEventHandler = (e) => { - setFilter(e.currentTarget.value as Filter); - }; + const handleFilterChange = (value: string) => { + setFilter(value as Filter) + } - if (isLoading) return
loading...
; - const filteredTasks = tasks?.items?.filter((task) => { + const filteredTasks = tasks?.filter((task) => { switch (filter) { case "active": - return task.status === 0; + return task.status === TaskStatus.OPEN case "completed": - return task.status === 2; + return task.status === TaskStatus.DONE default: - return true; + return true } - }); + }) + + const renderTasks = useMemo(() => { + return filteredTasks?.map((task) => { + return ( +
  • + + +
  • + ) + }) + }, [filteredTasks, todolist.id, handleChangeStatus, handleDeleteTask]) return ( -
    -
    -

    {todolist.title}

    - + +
    + + {todolist.title} + + + +
    + + +
    +
      {isLoading ? null : renderTasks}
    +
    -
    - - -
    - {filteredTasks?.map((task) => ( -
    - handleChangeStatus(todolist.id, task)} - type={"checkbox"} - checked={[0, 1, 3].includes(task.status)} - /> -
    {task.title}
    - -
    - ))} -
    - - - -
    -
    - ); -}); + + + + ALL + + + TODO + + + DONE + + + + + ) +}) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..1620e13 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import { Slot } from "@radix-ui/react-slot" +import { type VariantProps, cva } from "class-variance-authority" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10 shrink-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + }, +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..0a9e625 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..ddbdd01 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..5ac50f0 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/components/ui/input/index.tsx b/src/components/ui/input/index.tsx new file mode 100644 index 0000000..1086b7e --- /dev/null +++ b/src/components/ui/input/index.tsx @@ -0,0 +1,35 @@ +import { Label } from "@/components/ui/label" +import { cn } from "@/helpers" +import * as React from "react" +import { useId } from "react" + +export interface InputProps + extends React.InputHTMLAttributes { + label?: string +} + +const Input = React.forwardRef( + ({ className, type, label, id, ...props }, ref) => { + const generatedId = useId() + const idToUse = id || generatedId + + return ( +
    + {label && } + +
    + ) + }, +) +Input.displayName = "Input" + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..683faa7 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/loader/index.tsx b/src/components/ui/loader/index.tsx similarity index 95% rename from src/components/loader/index.tsx rename to src/components/ui/loader/index.tsx index 0e6b3e1..b2e8265 100644 --- a/src/components/loader/index.tsx +++ b/src/components/ui/loader/index.tsx @@ -3,5 +3,5 @@ export const Loader = () => {
    - ); -}; + ) +} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..01b8b6d --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
    + ) +} + +export { Skeleton } diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..8bfe044 --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,59 @@ +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import type { VariantProps } from "class-variance-authority" +import * as React from "react" + +import { toggleVariants } from "@/components/ui/toggle" +import { cn } from "@/lib/utils" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 0000000..9ecac28 --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3", + sm: "h-9 px-2.5", + lg: "h-11 px-5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/src/constants/index.ts b/src/constants/index.ts index bbe7f52..7b27518 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,3 +1,3 @@ -export * from "./routes"; -export * from "./query-keys"; -export * from "./result-code"; +export * from "./routes" +export * from "./query-keys" +export * from "./result-code" diff --git a/src/constants/query-keys/index.ts b/src/constants/query-keys/index.ts index 417b25b..6e9343c 100644 --- a/src/constants/query-keys/index.ts +++ b/src/constants/query-keys/index.ts @@ -2,4 +2,4 @@ export const QUERY_KEYS = { TODOLISTS: "todolists", TASKS: "tasks", ME: "me", -} as const; +} as const diff --git a/src/constants/routes/index.ts b/src/constants/routes/index.ts index 956fa1a..9f5ae9c 100644 --- a/src/constants/routes/index.ts +++ b/src/constants/routes/index.ts @@ -1,4 +1,4 @@ export const ROUTES = { HOME: "/", LOGIN: "/login", -} as const; +} as const diff --git a/src/env/client.mjs b/src/env/client.mjs index 107c6d3..5d17abd 100644 --- a/src/env/client.mjs +++ b/src/env/client.mjs @@ -1,35 +1,35 @@ // @ts-check -import { clientEnv, clientSchema } from "./schema.mjs"; +import { clientEnv, clientSchema } from "./schema.mjs" -const _clientEnv = clientSchema.safeParse(clientEnv); +const _clientEnv = clientSchema.safeParse(clientEnv) export const formatErrors = ( /** @type {import('zod').ZodFormattedError,string>} */ - errors + errors, ) => Object.entries(errors) .map(([name, value]) => { if (value && "_errors" in value) - return `${name}: ${value._errors.join(", ")}\n`; + return `${name}: ${value._errors.join(", ")}\n` }) - .filter(Boolean); + .filter(Boolean) if (!_clientEnv.success) { console.error( "❌ Invalid environment variables:\n", - ...formatErrors(_clientEnv.error.format()) - ); - throw new Error("Invalid environment variables"); + ...formatErrors(_clientEnv.error.format()), + ) + throw new Error("Invalid environment variables") } -for (let key of Object.keys(_clientEnv.data)) { +for (const key of Object.keys(_clientEnv.data)) { if (!key.startsWith("NEXT_PUBLIC_")) { console.warn( - `❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'` - ); + `❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`, + ) - throw new Error("Invalid public environment variable name"); + throw new Error("Invalid public environment variable name") } } -export const env = _clientEnv.data; +export const env = _clientEnv.data diff --git a/src/env/schema.mjs b/src/env/schema.mjs index c621d13..6d4eef8 100644 --- a/src/env/schema.mjs +++ b/src/env/schema.mjs @@ -1,5 +1,5 @@ // @ts-check -import { z } from "zod"; +import { z } from "zod" /** * Specify your server-side environment variables schema here. @@ -7,7 +7,7 @@ import { z } from "zod"; */ export const serverSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]), -}); +}) /** * Specify your client-side environment variables schema here. @@ -16,7 +16,7 @@ export const serverSchema = z.object({ */ export const clientSchema = z.object({ // NEXT_PUBLIC_CLIENTVAR: z.string(), -}); +}) /** * You can't destruct `process.env` as a regular object, so you have to do @@ -26,4 +26,4 @@ export const clientSchema = z.object({ */ export const clientEnv = { // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, -}; +} diff --git a/src/env/server.mjs b/src/env/server.mjs index 9e69ae4..03b6fe0 100644 --- a/src/env/server.mjs +++ b/src/env/server.mjs @@ -3,25 +3,25 @@ * This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars. * It has to be a `.mjs`-file to be imported there. */ -import { env as clientEnv, formatErrors } from "./client.mjs"; -import { serverSchema } from "./schema.mjs"; +import { serverSchema } from "./schema.mjs" +import { env as clientEnv, formatErrors } from "./client.mjs" -const _serverEnv = serverSchema.safeParse(process.env); +const _serverEnv = serverSchema.safeParse(process.env) if (!_serverEnv.success) { console.error( "❌ Invalid environment variables:\n", - ...formatErrors(_serverEnv.error.format()) - ); - throw new Error("Invalid environment variables"); + ...formatErrors(_serverEnv.error.format()), + ) + throw new Error("Invalid environment variables") } -for (let key of Object.keys(_serverEnv.data)) { +for (const key of Object.keys(_serverEnv.data)) { if (key.startsWith("NEXT_PUBLIC_")) { - console.warn("❌ You are exposing a server-side env-variable:", key); + console.warn("❌ You are exposing a server-side env-variable:", key) - throw new Error("You are exposing a server-side env-variable"); + throw new Error("You are exposing a server-side env-variable") } } -export const env = { ..._serverEnv.data, ...clientEnv }; +export const env = { ..._serverEnv.data, ...clientEnv } diff --git a/src/helpers/api-response/index.ts b/src/helpers/api-response/index.ts index c30b670..bff2302 100644 --- a/src/helpers/api-response/index.ts +++ b/src/helpers/api-response/index.ts @@ -1,17 +1,17 @@ -import type { ResultCode } from "@/constants"; +import type { ResultCode } from "@/constants" type ApiResponseSuccess = { - data: T; - messages?: never; - fieldsErrors?: never; - resultCode: ResultCode.Success; -}; + data: T + messages?: never + fieldsErrors?: never + resultCode: ResultCode.Success +} type ApiResponseError = { - data: T; - messages: string[]; - fieldsErrors?: string[]; - resultCode: ResultCode.InvalidRequest | ResultCode.InvalidRequestWithCaptcha; -}; + data: T + messages: string[] + fieldsErrors?: string[] + resultCode: ResultCode.InvalidRequest | ResultCode.InvalidRequestWithCaptcha +} -export type ApiResponse = ApiResponseSuccess | ApiResponseError; +export type ApiResponse = ApiResponseSuccess | ApiResponseError diff --git a/src/helpers/classnames/classnames.ts b/src/helpers/classnames/classnames.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/src/helpers/classnames/classnames.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/helpers/classnames/index.ts b/src/helpers/classnames/index.ts new file mode 100644 index 0000000..8cd8f33 --- /dev/null +++ b/src/helpers/classnames/index.ts @@ -0,0 +1 @@ +export * from "./classnames" diff --git a/src/helpers/handle-error/index.ts b/src/helpers/handle-error/index.ts index 27796d9..7e0e43b 100644 --- a/src/helpers/handle-error/index.ts +++ b/src/helpers/handle-error/index.ts @@ -1,11 +1,11 @@ -import type { ApiResponse } from "@/helpers"; +import type { ApiResponse } from "@/helpers" export const handleError = (data: ApiResponse): ApiResponse => { - if (data.resultCode !== 0) { - const error = data?.messages?.[0] || "An error has occurred"; + // if (data.resultCode !== 0) { + // const error = data?.messages?.[0] || "An error has occurred"; + // + // throw new Error(error); + // } - throw new Error(error); - } - - return data; -}; + return data +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 2c8ccc8..b7ea556 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,3 +1,4 @@ -export * from "./handle-error"; -export * from "./no-refetch"; -export * from "./api-response"; +export * from "./handle-error" +export * from "./no-refetch" +export * from "./api-response" +export * from "./classnames" diff --git a/src/helpers/no-refetch/index.ts b/src/helpers/no-refetch/index.ts index 904079a..9f267c9 100644 --- a/src/helpers/no-refetch/index.ts +++ b/src/helpers/no-refetch/index.ts @@ -5,4 +5,4 @@ export const noRefetch = { refetchOnMount: false, refetchIntervalInBackground: false, retry: false, -} as const; +} as const diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..d084cca --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index d14b253..33a2aee 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,20 +1,49 @@ -import "../styles/globals.css"; +import "../styles/globals.css" -import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; -import { type AppType } from "next/dist/shared/lib/utils"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { AppType } from "next/dist/shared/lib/utils" -import { AuthRedirect } from "@/components"; +import { AuthRedirect, Button } from "@/components" +import { ModeToggle } from "@/components/mode-toggle" +import { ThemeProvider } from "@/components/theme-provider" +import { useLogoutMutation } from "@/services" -const queryClient = new QueryClient(); +const queryClient = new QueryClient() const MyApp: AppType = ({ Component, pageProps }) => { return ( - + +
    + + - ); -}; + ) +} -export default MyApp; +function Header() { + const { mutate: logout } = useLogoutMutation() + const handleLogout = () => { + logout() + } + return ( +
    +

    Tasks

    +
    + + +
    +
    + ) +} + +export default MyApp diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx new file mode 100644 index 0000000..985d849 --- /dev/null +++ b/src/pages/_document.tsx @@ -0,0 +1,13 @@ +import { Head, Html, Main, NextScript } from "next/document" + +export default function Document() { + return ( + + + +
    + + + + ) +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 45764f3..59ae411 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,38 +1,36 @@ -import type { ChangeEvent } from "react"; -import { useState } from "react"; +import { type ChangeEvent, type FormEvent, useMemo } from "react" +import { useState } from "react" -import { type NextPage } from "next"; -import Head from "next/head"; +import type { NextPage } from "next" +import Head from "next/head" -import { Todolist, Button, FullscreenLoader, Input } from "@/components"; -import { - useCreateTodolistMutation, - useLogoutMutation, - useTodolistsQuery, -} from "@/services"; +import { Button, FullscreenLoader, Input, Todolist } from "@/components" +import { useCreateTodolistMutation, useTodolistsQuery } from "@/services" +import { Plus } from "lucide-react" const Home: NextPage = () => { - const [newTodolistTitle, setNewTodolistTitle] = useState(""); - const { mutate: logout } = useLogoutMutation(); - const { data: todolists, isLoading: isTodolistsLoading } = - useTodolistsQuery(); + const [newTodolistTitle, setNewTodolistTitle] = useState("") + const { data: todolists, isLoading: isTodolistsLoading } = useTodolistsQuery() - const handleLogout = () => { - logout(); - }; + const { mutate: createTodolist } = useCreateTodolistMutation() - const { mutate: createTodolist } = useCreateTodolistMutation(); - - const handleAddTodolist = () => { - createTodolist({ title: newTodolistTitle }); - setNewTodolistTitle(""); - }; + const handleAddTodolist = (e: FormEvent) => { + e.preventDefault() + createTodolist({ title: newTodolistTitle }) + setNewTodolistTitle("") + } const handleNewTodolistTitleChange = (e: ChangeEvent) => { - setNewTodolistTitle(e.target.value); - }; + setNewTodolistTitle(e.target.value) + } - if (isTodolistsLoading) return ; + const renderTodolists = useMemo(() => { + return todolists?.map((todolist) => { + return + }) + }, [todolists]) + + if (isTodolistsLoading) return return ( <> @@ -41,29 +39,22 @@ const Home: NextPage = () => { -
    -

    Todolist

    - -
    -
    - - -
    -
    - {todolists?.map((todolist) => { - return ; - })} -
    +
    + + +
    +
    {renderTodolists}
    - ); -}; + ) +} -export default Home; +export default Home diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 629bdac..d95c73d 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -1,69 +1,70 @@ -import type { ChangeEvent } from "react"; -import React, { useState } from "react"; +import type { ChangeEvent } from "react" +import React, { useState } from "react" -import type { NextPage } from "next"; +import type { NextPage } from "next" -import { Button, Input } from "@/components"; -import { useLoginMutation } from "@/services"; +import { Button, Input } from "@/components" +import { Label } from "@/components/ui/label" +import { useLoginMutation } from "@/services" const Login: NextPage = () => { - const { mutate: login } = useLoginMutation(); + const { mutate: login } = useLoginMutation() - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [remember, setRemember] = useState(true); + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [remember, setRemember] = useState(true) const handlePasswordChange = (e: ChangeEvent) => { - setPassword(e.target.value); - }; + setPassword(e.target.value) + } const handleEmailChange = (e: ChangeEvent) => { - setEmail(e.target.value); - }; + setEmail(e.target.value) + } const handleRememberChange = (e: ChangeEvent) => { - setRemember(e.target.checked); - }; + setRemember(e.target.checked) + } const handleSubmit = () => { login({ email, password, rememberMe: remember, - }); - }; + }) + } return ( -
    +
    - + - + -
    - ); -}; + ) +} -export default Login; +export default Login diff --git a/src/pages/sign-up.tsx b/src/pages/sign-up.tsx new file mode 100644 index 0000000..5cb1071 --- /dev/null +++ b/src/pages/sign-up.tsx @@ -0,0 +1,50 @@ +import type { FormEvent } from "react" +import React from "react" + +import type { NextPage } from "next" + +import { Button, Input } from "@/components" +import { useSignUpMutation } from "@/services" + +const Login: NextPage = () => { + const { mutate: signUp } = useSignUpMutation() + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + const formData = new FormData(e.currentTarget) + + // biome-ignore lint/suspicious/noExplicitAny: + const values = Object.fromEntries(formData) as any + signUp(values) + } + + return ( +
    +
    +

    Sign up

    + + + + + + +
    +
    + ) +} + +export default Login diff --git a/src/services/auth/auth.api.ts b/src/services/auth/auth.api.ts deleted file mode 100644 index a0df1fb..0000000 --- a/src/services/auth/auth.api.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { handleError } from "@/helpers"; -import { authInstance } from "@/services/auth/auth.instance"; -import type { - LoginResponse, - LogoutResponse, - MeResponse, - PostLoginArgs, -} from "@/services/todolists"; - -export const AuthApi = { - async login(args: PostLoginArgs) { - const res = await authInstance.post("/login", args); - - return handleError(res.data); - }, - - async logout() { - const res = await authInstance.delete("/login"); - - return handleError(res.data); - }, - - async me() { - const res = await authInstance.get("/me"); - - return handleError(res.data); - }, -}; diff --git a/src/services/auth/auth.hooks.ts b/src/services/auth/auth.hooks.ts deleted file mode 100644 index 2934d70..0000000 --- a/src/services/auth/auth.hooks.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "next/router"; - -import { QUERY_KEYS, ROUTES } from "@/constants"; -import { noRefetch } from "@/helpers"; -import { AuthApi } from "@/services/auth/auth.api"; - -export const useMeQuery = () => { - return useQuery({ - queryFn: AuthApi.me, - queryKey: [QUERY_KEYS.ME], - ...noRefetch, - }); -}; - -export const useLoginMutation = () => { - const router = useRouter(); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: AuthApi.login, - onSuccess: async () => { - await queryClient.invalidateQueries([QUERY_KEYS.ME]); - await router.push(ROUTES.HOME); - }, - }); -}; - -export const useLogoutMutation = () => { - const queryClient = useQueryClient(); - const router = useRouter(); - - return useMutation({ - mutationFn: AuthApi.logout, - onSuccess: async () => { - await queryClient.invalidateQueries([QUERY_KEYS.ME]); - await router.push(ROUTES.LOGIN); - }, - }); -}; diff --git a/src/services/auth/auth.instance.ts b/src/services/auth/auth.instance.ts deleted file mode 100644 index 424e2ba..0000000 --- a/src/services/auth/auth.instance.ts +++ /dev/null @@ -1,9 +0,0 @@ -import axios from "axios"; - -export const authInstance = axios.create({ - baseURL: "https://social-network.samuraijs.com/api/1.1/auth/", - withCredentials: true, - headers: { - "API-KEY": "94b16f71-bc2c-42a4-8dd2-7c0e953caae2", - }, -}); diff --git a/src/services/auth/index.ts b/src/services/auth/index.ts deleted file mode 100644 index f13aaf0..0000000 --- a/src/services/auth/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./auth.api"; -export * from "./auth.hooks"; diff --git a/src/services/index.ts b/src/services/index.ts index fd43fc7..55f5072 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,2 +1,2 @@ -export * from "./auth"; -export * from "./todolists"; +export * from "./todolist-api/auth" +export * from "./todolist-api/todolists" diff --git a/src/services/todolist-api/auth/auth.api.ts b/src/services/todolist-api/auth/auth.api.ts new file mode 100644 index 0000000..030be27 --- /dev/null +++ b/src/services/todolist-api/auth/auth.api.ts @@ -0,0 +1,40 @@ +import type { + LoginResponse, + LogoutResponse, + MeResponse, + PostLoginArgs, + PostSignUpArgs, +} from "../todolists" + +import { handleError } from "@/helpers" +import { todolistApiInstance } from "@/services/todolist-api/todolist-api.instance" + +export const AuthApi = { + async login(args: PostLoginArgs) { + const res = await todolistApiInstance.post( + "/auth/login", + args, + ) + + localStorage.setItem("accessToken", res.data.accessToken) + localStorage.setItem("refreshToken", res.data.refreshToken) + + return res.data + }, + async signUp(args: PostSignUpArgs) { + const res = await todolistApiInstance.post("/auth/sign-up", args) + + return res.data + }, + async logout() { + const res = await todolistApiInstance.delete("/auth/login") + + return handleError(res.data) + }, + + async me() { + const res = await todolistApiInstance.get("/auth/me") + + return handleError(res.data) + }, +} diff --git a/src/services/todolist-api/auth/auth.hooks.ts b/src/services/todolist-api/auth/auth.hooks.ts new file mode 100644 index 0000000..01a7f7f --- /dev/null +++ b/src/services/todolist-api/auth/auth.hooks.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useRouter } from "next/router" + +import { QUERY_KEYS, ROUTES } from "@/constants" +import { noRefetch } from "@/helpers" +import { AuthApi } from "@/services/todolist-api/auth/auth.api" + +export const useMeQuery = () => { + return useQuery({ + queryFn: AuthApi.me, + queryKey: [QUERY_KEYS.ME], + ...noRefetch, + }) +} + +export const useLoginMutation = () => { + const router = useRouter() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: AuthApi.login, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ME] }) + await router.push(ROUTES.HOME) + }, + }) +} + +export const useLogoutMutation = () => { + const queryClient = useQueryClient() + const router = useRouter() + + return useMutation({ + mutationFn: AuthApi.logout, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.ME] }) + await router.push(ROUTES.LOGIN) + }, + }) +} + +export const useSignUpMutation = () => { + return useMutation({ + mutationFn: AuthApi.signUp, + }) +} diff --git a/src/services/todolist-api/auth/index.ts b/src/services/todolist-api/auth/index.ts new file mode 100644 index 0000000..f8be71d --- /dev/null +++ b/src/services/todolist-api/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./auth.api" +export * from "./auth.hooks" diff --git a/src/services/todolist-api/todolist-api.instance.ts b/src/services/todolist-api/todolist-api.instance.ts new file mode 100644 index 0000000..ad66b0e --- /dev/null +++ b/src/services/todolist-api/todolist-api.instance.ts @@ -0,0 +1,96 @@ +import { Mutex } from "async-mutex" +import axios from "axios" +import router from "next/router" + +const mutex = new Mutex() + +let refreshedAt: number | null = null + +export const todolistApiInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000", +}) + +async function refreshAccessToken(): Promise { + const res = await todolistApiInstance.post<{ + refreshToken: string + accessToken: string + }>( + "/auth/refresh-token", + {}, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("refreshToken")}`, + }, + }, + ) + + localStorage.setItem("accessToken", res.data.accessToken) + localStorage.setItem("refreshToken", res.data.refreshToken) + + return res.data.accessToken +} + +todolistApiInstance.interceptors.request.use( + async (config) => { + if (!config?.url?.includes("refresh-token")) { + await mutex.waitForUnlock() + } + + config.headers.Authorization = + config.headers.Authorization ?? + `Bearer ${localStorage.getItem("accessToken")}` + + return config + }, + (error) => Promise.reject(error), +) + +todolistApiInstance.interceptors.response.use( + (response) => response, + async (error) => { + if (error?.response?.request?.responseURL?.includes("refresh-token")) { + return + } + await mutex.waitForUnlock() + const originalRequest = error.config + // Check for a 401 response and if this request hasn't been retried yet + if (error.response?.status === 401 && !originalRequest._retry) { + if (!mutex.isLocked()) { + originalRequest._retry = true + // Use a mutex to ensure that token refresh logic is not run multiple times in parallel + const release = await mutex.acquire() + + if (refreshedAt && refreshedAt + 60000 > new Date().getTime()) { + // If the token has been refreshed within the last minute, use the refreshed token + originalRequest.headers.Authorization = `Bearer ${localStorage.getItem("accessToken")}` + release() + + return todolistApiInstance(originalRequest) + } + refreshedAt = new Date().getTime() + try { + const newAccessToken = await refreshAccessToken() + + originalRequest.headers.Authorization = `Bearer ${newAccessToken}` + + return todolistApiInstance(originalRequest) // Retry the original request with the new token + } catch (error) { + if (!["/login", "/sign-up"].includes(window.location.pathname)) { + router.push("/login") + } + } finally { + release() + } + } else { + await mutex.waitForUnlock() + const newAccessToken = localStorage.getItem("accessToken") + + originalRequest.headers.Authorization = `Bearer ${newAccessToken}` + + return todolistApiInstance(originalRequest) // Retry the original request with the new token + } + } + + return Promise.reject(error) + }, +) diff --git a/src/services/todolist-api/todolists/index.ts b/src/services/todolist-api/todolists/index.ts new file mode 100644 index 0000000..982e747 --- /dev/null +++ b/src/services/todolist-api/todolists/index.ts @@ -0,0 +1,3 @@ +export * from "./todolists.hooks" +export * from "./todolists.api" +export * from "./todolists.types" diff --git a/src/services/todolist-api/todolists/todolists.api.ts b/src/services/todolist-api/todolists/todolists.api.ts new file mode 100644 index 0000000..613603e --- /dev/null +++ b/src/services/todolist-api/todolists/todolists.api.ts @@ -0,0 +1,89 @@ +import { handleError } from "@/helpers" +import type { + CreateTaskResponse, + CreateTodolistResponse, + DeleteTaskResponse, + DeleteTodolistResponse, + Task, + TasksResponse, + Todolist, + UpdateTaskResponse, +} from "@/services" +import { todolistApiInstance } from "@/services/todolist-api/todolist-api.instance" + +export const TodolistAPI = { + async getTodolists() { + const res = await todolistApiInstance.get("/todolists") + + return res.data + }, + + async createTodolist({ title }: { title: string }) { + const res = await todolistApiInstance.post( + "/todolists", + { + title, + }, + ) + + return res.data + }, + + async deleteTodolist({ todolistId }: { todolistId: string }) { + const res = await todolistApiInstance.delete( + `/todolists/${todolistId}`, + ) + + return res.data + }, + + async getTodolistTasks({ todolistId }: { todolistId: string }) { + const res = await todolistApiInstance.get( + `/todolists/${todolistId}/tasks`, + ) + + return res.data + }, + + async createTask({ + todolistId, + title, + }: { + todolistId: string + title: string + }) { + const res = await todolistApiInstance.post( + `/todolists/${todolistId}/tasks`, + { title }, + ) + + return res.data + }, + + async updateTask({ + id, + todolistId, + ...rest + }: Partial & Required> & { todolistId: string }) { + const res = await todolistApiInstance.patch( + `/todolists/${todolistId}/tasks/${id}`, + rest, + ) + + return res.data + }, + + async deleteTask({ + todolistId, + taskId, + }: { + todolistId: string + taskId: string + }) { + const res = await todolistApiInstance.delete( + `/todolists/${todolistId}/tasks/${taskId}`, + ) + + return res.data + }, +} diff --git a/src/services/todolists/todolists.hooks.ts b/src/services/todolist-api/todolists/todolists.hooks.ts similarity index 52% rename from src/services/todolists/todolists.hooks.ts rename to src/services/todolist-api/todolists/todolists.hooks.ts index 298ee20..a8f8b10 100644 --- a/src/services/todolists/todolists.hooks.ts +++ b/src/services/todolist-api/todolists/todolists.hooks.ts @@ -1,82 +1,85 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { QUERY_KEYS } from "@/constants"; -import { noRefetch } from "@/helpers"; -import { TodolistAPI } from "@/services"; +import { QUERY_KEYS } from "@/constants" +import { noRefetch } from "@/helpers" +import { TodolistAPI } from "@/services" export const useTodolistsQuery = () => { return useQuery({ queryFn: TodolistAPI.getTodolists, queryKey: [QUERY_KEYS.TODOLISTS], ...noRefetch, - }); -}; + }) +} export const useCreateTodolistMutation = () => { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: TodolistAPI.createTodolist, - //todo: add onMutate - onSuccess: async () => { - await queryClient.invalidateQueries([QUERY_KEYS.TODOLISTS]); + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.TODOLISTS] }) }, - }); -}; + }) +} export const useDeleteTodolistMutation = () => { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: TodolistAPI.deleteTodolist, onSuccess: () => { - queryClient.invalidateQueries([QUERY_KEYS.TODOLISTS]); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.TODOLISTS] }) }, - }); -}; + }) +} export const useGetTasksQuery = (todolistId: string) => { return useQuery({ queryKey: [QUERY_KEYS.TASKS, todolistId], queryFn: () => TodolistAPI.getTodolistTasks({ todolistId }), - }); -}; + }) +} export const useCreateTaskMutation = () => { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: TodolistAPI.createTask, onSuccess: (res) => { - const todolistId = res.data.item.todoListId; + const todolistId = res.todoListId - queryClient.invalidateQueries([QUERY_KEYS.TASKS, todolistId]); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.TASKS, todolistId], + }) }, - }); -}; + }) +} export const useUpdateTaskMutation = () => { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: TodolistAPI.updateTask, - onSuccess: async (res) => { - const todolistId = res.data.item.todoListId; - - await queryClient.invalidateQueries([QUERY_KEYS.TASKS, todolistId]); + onSuccess: async (_, { todolistId }) => { + await queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.TASKS, todolistId], + }) }, - }); -}; + }) +} export const useDeleteTaskMutation = () => { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: TodolistAPI.deleteTask, onSuccess: (_, variables) => { - const todolistId = variables.todolistId; + const todolistId = variables.todolistId - queryClient.invalidateQueries([QUERY_KEYS.TASKS, todolistId]); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.TASKS, todolistId], + }) }, - }); -}; + }) +} diff --git a/src/services/todolist-api/todolists/todolists.types.ts b/src/services/todolist-api/todolists/todolists.types.ts new file mode 100644 index 0000000..d7f3162 --- /dev/null +++ b/src/services/todolist-api/todolists/todolists.types.ts @@ -0,0 +1,73 @@ +import type { ApiResponse } from "@/helpers" + +export type TasksResponse = Task[] + +export type Task = { + order: string + title: string + description?: string | null + deadline?: string | null + status: TaskStatus + priority: TaskPriority + ownerId: string + todoListId: string + id: string + createdAt: string + updatedAt: string +} + +export type Todolist = { + id: string + title: string + addedDate: Date + order: number +} + +export type PostLoginArgs = { + email: string + password: string + rememberMe: boolean +} + +export type PostSignUpArgs = { + email: string + password: string + username?: string +} + +export type LoginResponseData = { + accessToken: string + refreshToken: string +} + +export type MeResponseData = { + id: number + login: string + email: string +} + +export type LoginResponse = LoginResponseData +export type LogoutResponse = ApiResponse +export type MeResponse = ApiResponse + +export type DeleteTodolistResponse = ApiResponse +export type CreateTodolistResponse = ApiResponse +export type CreateTodolistResponseData = { + item: Todolist +} + +export type CreateTaskResponse = Task +export type DeleteTaskResponse = undefined +export type UpdateTaskResponse = undefined + +export enum TaskStatus { + OPEN = "OPEN", + IN_PROGRESS = "IN_PROGRESS", + DONE = "DONE", +} + +export enum TaskPriority { + LOW = "LOW", + MEDIUM = "MEDIUM", + HIGH = "HIGH", +} diff --git a/src/services/todolists/index.ts b/src/services/todolists/index.ts deleted file mode 100644 index 4ee7e85..0000000 --- a/src/services/todolists/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./todolists.hooks"; -export * from "./todolists.api"; -export * from "./todolists.types"; diff --git a/src/services/todolists/todolists.api.ts b/src/services/todolists/todolists.api.ts deleted file mode 100644 index 0681567..0000000 --- a/src/services/todolists/todolists.api.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { todolistsInstance } from "./todolists.instance"; - -import { handleError } from "@/helpers"; -import type { - CreateTaskResponse, - CreateTodolistResponse, - DeleteTaskResponse, - DeleteTodolistResponse, - Task, - TasksResponse, - Todolist, - UpdateTaskResponse, -} from "@/services"; - -export const TodolistAPI = { - async getTodolists() { - const res = await todolistsInstance.get("/"); - - return res.data; - }, - - async createTodolist({ title }: { title: string }) { - const res = await todolistsInstance.post("/", { - title, - }); - - return handleError(res.data); - }, - - async deleteTodolist({ todolistId }: { todolistId: string }) { - const res = await todolistsInstance.delete( - `/${todolistId}` - ); - - return res.data; - }, - - async getTodolistTasks({ todolistId }: { todolistId: string }) { - const res = await todolistsInstance.get( - `/${todolistId}/tasks` - ); - - return res.data; - }, - - async createTask({ - todolistId, - title, - }: { - todolistId: string; - title: string; - }) { - const res = await todolistsInstance.post( - `/${todolistId}/tasks`, - { title } - ); - - return handleError(res.data); - }, - - async updateTask({ todolistId, task }: { todolistId: string; task: Task }) { - const { id, ...rest } = task; - const res = await todolistsInstance.put( - `/${todolistId}/tasks/${id}`, - rest - ); - - return res.data; - }, - - async deleteTask({ - todolistId, - taskId, - }: { - todolistId: string; - taskId: string; - }) { - const res = await todolistsInstance.delete( - `/${todolistId}/tasks/${taskId}` - ); - - return res.data; - }, -}; diff --git a/src/services/todolists/todolists.instance.ts b/src/services/todolists/todolists.instance.ts deleted file mode 100644 index ab7844c..0000000 --- a/src/services/todolists/todolists.instance.ts +++ /dev/null @@ -1,9 +0,0 @@ -import axios from "axios"; - -export const todolistsInstance = axios.create({ - baseURL: "https://social-network.samuraijs.com/api/1.1/todo-lists", - withCredentials: true, - headers: { - "API-KEY": "94b16f71-bc2c-42a4-8dd2-7c0e953caae2", - }, -}); diff --git a/src/services/todolists/todolists.types.ts b/src/services/todolists/todolists.types.ts deleted file mode 100644 index 5492719..0000000 --- a/src/services/todolists/todolists.types.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ApiResponse } from "@/helpers"; - -export type UpdateTaskResponseData = { - item: Task; -}; - -export type TasksResponse = { - items: Task[]; - totalCount: number; - error?: string; -}; - -export type Task = { - id: string; - title: string; - description?: string; - todoListId: string; - order: number; - status: number; - priority: number; - startDate?: Date; - deadline?: Date; - addedDate: Date; -}; - -export type Todolist = { - id: string; - title: string; - addedDate: Date; - order: number; -}; - -export type PostLoginArgs = { - email: string; - password: string; - rememberMe: boolean; -}; - -export type LoginResponseData = { - userId: number; -}; - -export type MeResponseData = { - id: number; - login: string; - email: string; -}; - -export type LoginResponse = ApiResponse; -export type LogoutResponse = ApiResponse; -export type MeResponse = ApiResponse; - -export type DeleteTodolistResponse = ApiResponse; -export type CreateTodolistResponse = ApiResponse; -export type CreateTodolistResponseData = { - item: Todolist; -}; - -export type CreateTaskResponse = ApiResponse<{ item: Task }>; -export type DeleteTaskResponse = ApiResponse; -export type UpdateTaskResponse = ApiResponse; diff --git a/src/styles/globals.css b/src/styles/globals.css index 75c6d65..e8e4f13 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,48 +1,72 @@ @tailwind base; @tailwind components; @tailwind utilities; -.loader { - width: 48px; - height: 48px; - border: 3px dotted #000; - border-style: solid solid dotted dotted; - border-radius: 50%; - display: inline-block; - position: relative; - box-sizing: border-box; - animation: rotation 2s linear infinite; -} -.loader::after { - content: ''; - box-sizing: border-box; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - margin: auto; - border: 3px dotted #FF3D00; - border-style: solid solid dotted; - width: 24px; - height: 24px; - border-radius: 50%; - animation: rotationBack 1s linear infinite; - transform-origin: center center; + +@layer base { + #__next{ + height: 100%; + } + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } } -@keyframes rotation { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} -@keyframes rotationBack { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(-360deg); - } +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } \ No newline at end of file diff --git a/tailwind.config.cjs b/tailwind.config.cjs deleted file mode 100644 index 54331dc..0000000 --- a/tailwind.config.cjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ["./src/**/*.{js,ts,jsx,tsx}"], - theme: { - extend: {}, - }, - plugins: [], -}; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..84287e8 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,80 @@ +import type { Config } from "tailwindcss" + +const config = { + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} satisfies Config + +export default config \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 0541f38..633f468 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,6 @@ "paths": { "@/*": ["./src/*"] } - }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"], "exclude": ["node_modules"]