mirror of
https://github.com/ershisan99/todolist_next.git
synced 2025-12-16 12:33:57 +00:00
Merge branch 'refs/heads/refactor'
# Conflicts: # bun.lockb # src/env/client.mjs # src/env/server.mjs # src/services/auth/auth.instance.ts # src/services/todolists/todolists.instance.ts
This commit is contained in:
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
.idea/biome.xml
generated
Normal file
8
.idea/biome.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="BiomeSettings">
|
||||||
|
<option name="applySafeFixesOnSave" value="true" />
|
||||||
|
<option name="applyUnsafeFixesOnSave" value="true" />
|
||||||
|
<option name="formatOnSave" value="true" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/jsLibraryMappings.xml
generated
Normal file
6
.idea/jsLibraryMappings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JavaScriptLibraryMappings">
|
||||||
|
<includedPredefinedLibrary name="Node.js Core" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
21
biome.json
Normal file
21
biome.json
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
17
components.json
Normal file
17
components.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
54
package.json
54
package.json
@@ -1,37 +1,43 @@
|
|||||||
{
|
{
|
||||||
"name": "todolist_next",
|
"name": "todolist",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "next lint",
|
"lint": "biome lint --write --unsafe ./src",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^4.28.0",
|
"@radix-ui/react-checkbox": "^1.1.1",
|
||||||
"axios": "^1.3.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"next": "13.2.4",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"react": "18.2.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"react-dom": "18.2.0",
|
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||||
"zod": "^3.21.4"
|
"@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": {
|
"devDependencies": {
|
||||||
"@it-incubator/eslint-config": "^0.1.0",
|
"@biomejs/biome": "1.8.3",
|
||||||
"@it-incubator/prettier-config": "^0.1.0",
|
"@types/node": "^22.3.0",
|
||||||
"@types/node": "^18.15.11",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react": "^18.0.33",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-dom": "^18.0.11",
|
"autoprefixer": "^10.4.20",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
"postcss": "^8.4.41",
|
||||||
"@typescript-eslint/parser": "^5.57.1",
|
"tailwindcss": "^3.4.10",
|
||||||
"autoprefixer": "^10.4.14",
|
"typescript": "^5.5.4"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "6.10.1"
|
"initVersion": "6.10.1"
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
/** @type {import("prettier").Config} */
|
|
||||||
module.exports = {
|
|
||||||
plugins: [require.resolve("prettier-plugin-tailwindcss"), require.resolve("@it-incubator/prettier-config")],
|
|
||||||
};
|
|
||||||
@@ -1,30 +1,31 @@
|
|||||||
import type { FC, ReactNode } from "react";
|
import type { FC, ReactNode } from "react"
|
||||||
import { useEffect } 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 }) => {
|
export const AuthRedirect: FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const { data: user, isLoading, isError } = useMeQuery();
|
const { data: user, isLoading } = useMeQuery()
|
||||||
const isAuthPage = router.pathname === "/login";
|
const isAuthPage =
|
||||||
|
router.pathname === "/login" || router.pathname === "/sign-up"
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !user && !isAuthPage) {
|
if (!isLoading && !user && !isAuthPage) {
|
||||||
router.push("/login");
|
router.push("/login")
|
||||||
}
|
}
|
||||||
}, [user, isError, isLoading, isAuthPage, router]);
|
}, [user, isLoading, isAuthPage, router])
|
||||||
|
|
||||||
if (isLoading || (!user && !isAuthPage)) {
|
if (isLoading || (!user && !isAuthPage)) {
|
||||||
return (
|
return (
|
||||||
<div className={"h-screen"}>
|
<div className={"h-screen"}>
|
||||||
<Loader />
|
<Loader />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { ButtonHTMLAttributes, DetailedHTMLProps, FC } from "react";
|
|
||||||
|
|
||||||
type Props = DetailedHTMLProps<
|
|
||||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
HTMLButtonElement
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const Button: FC<Props> = ({ className, ...rest }) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
className={`rounded-md border border-gray-300 bg-sky-700 px-4 py-2 text-white focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-600 ${className}`}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Loader } from "../loader";
|
import { Loader } from "../ui/loader"
|
||||||
|
|
||||||
export const FullscreenLoader = () => {
|
export const FullscreenLoader = () => {
|
||||||
return (
|
return (
|
||||||
<div className={"flex h-screen items-center justify-center"}>
|
<div className={"flex h-screen items-center justify-center"}>
|
||||||
<Loader />
|
<Loader />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * from "./todolist";
|
export * from "./todolist"
|
||||||
export * from "./auth-redirect";
|
export * from "./auth-redirect"
|
||||||
export * from "./button";
|
export * from "./ui/button"
|
||||||
export * from "./input";
|
export * from "./ui/input"
|
||||||
export * from "./loader";
|
export * from "./ui/loader"
|
||||||
export * from "./fullscreen-loader";
|
export * from "./fullscreen-loader"
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { DetailedHTMLProps, FC, InputHTMLAttributes } from "react";
|
|
||||||
|
|
||||||
type Props = DetailedHTMLProps<
|
|
||||||
InputHTMLAttributes<HTMLInputElement>,
|
|
||||||
HTMLInputElement
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const Input: FC<Props> = ({ className, ...rest }) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
className={`w-full rounded-md border border-gray-300 px-4 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-600 ${className}`}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
40
src/components/mode-toggle.tsx
Normal file
40
src/components/mode-toggle.tsx
Normal file
@@ -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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Sun className="dark:-rotate-90 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/components/theme-provider.tsx
Normal file
9
src/components/theme-provider.tsx
Normal file
@@ -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 <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
@@ -1,107 +1,170 @@
|
|||||||
import type { ChangeEvent, FC, MouseEventHandler } from "react";
|
import {
|
||||||
import { memo, useState } from "react";
|
type ChangeEvent,
|
||||||
|
type FC,
|
||||||
import type { Task, Todolist as TodolistType } from "../../services/todolists";
|
type FormEvent,
|
||||||
import { Button } from "../button";
|
memo,
|
||||||
import { Input } from "../input";
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
import {
|
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,
|
useCreateTaskMutation,
|
||||||
useDeleteTaskMutation,
|
useDeleteTaskMutation,
|
||||||
useDeleteTodolistMutation,
|
useDeleteTodolistMutation,
|
||||||
useGetTasksQuery,
|
useGetTasksQuery,
|
||||||
useUpdateTaskMutation,
|
useUpdateTaskMutation,
|
||||||
} from "@/services";
|
} from "@/services"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
todolist: TodolistType;
|
todolist: TodolistType
|
||||||
};
|
}
|
||||||
|
|
||||||
type Filter = "all" | "active" | "completed";
|
type Filter = "all" | "active" | "completed"
|
||||||
|
|
||||||
export const Todolist: FC<Props> = memo(({ todolist }) => {
|
export const Todolist: FC<Props> = memo(({ todolist }) => {
|
||||||
const { data: tasks, isLoading } = useGetTasksQuery(todolist.id);
|
const { data: tasks, isLoading } = useGetTasksQuery(todolist.id)
|
||||||
const { mutate: putTask } = useUpdateTaskMutation();
|
const { mutate: updateTask } = useUpdateTaskMutation()
|
||||||
const { mutate: deleteTask } = useDeleteTaskMutation();
|
const { mutate: deleteTask } = useDeleteTaskMutation()
|
||||||
const { mutate: deleteTodolist } = useDeleteTodolistMutation();
|
const { mutate: deleteTodolist } = useDeleteTodolistMutation()
|
||||||
const { mutate: createTask } = useCreateTaskMutation();
|
const { mutate: createTask } = useCreateTaskMutation()
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
const [newTaskTitle, setNewTaskTitle] = useState("")
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all")
|
||||||
|
|
||||||
const handleChangeStatus = (todolistId: string, task: Task) => {
|
const handleChangeStatus = useCallback(
|
||||||
const newTask = { ...task, status: task.status === 0 ? 2 : 0 };
|
(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) => {
|
const handleDeleteTodolist = (todolistId: string) => {
|
||||||
deleteTodolist({ todolistId });
|
deleteTodolist({ todolistId })
|
||||||
};
|
}
|
||||||
const handleAddTask = () => {
|
|
||||||
createTask({ todolistId: todolist.id, title: newTaskTitle });
|
const handleAddTask = (e: FormEvent<HTMLFormElement>) => {
|
||||||
setNewTaskTitle("");
|
e.preventDefault()
|
||||||
};
|
createTask({ todolistId: todolist.id, title: newTaskTitle })
|
||||||
|
setNewTaskTitle("")
|
||||||
|
}
|
||||||
|
|
||||||
const handleNewTaskTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleNewTaskTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setNewTaskTitle(e.target.value);
|
setNewTaskTitle(e.target.value)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleFilterChange: MouseEventHandler<HTMLButtonElement> = (e) => {
|
const handleFilterChange = (value: string) => {
|
||||||
setFilter(e.currentTarget.value as Filter);
|
setFilter(value as Filter)
|
||||||
};
|
}
|
||||||
|
|
||||||
if (isLoading) return <div>loading...</div>;
|
const filteredTasks = tasks?.filter((task) => {
|
||||||
const filteredTasks = tasks?.items?.filter((task) => {
|
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case "active":
|
case "active":
|
||||||
return task.status === 0;
|
return task.status === TaskStatus.OPEN
|
||||||
case "completed":
|
case "completed":
|
||||||
return task.status === 2;
|
return task.status === TaskStatus.DONE
|
||||||
default:
|
default:
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
|
const renderTasks = useMemo(() => {
|
||||||
|
return filteredTasks?.map((task) => {
|
||||||
|
return (
|
||||||
|
<li className={"flex cursor-pointer items-center"} key={task.id}>
|
||||||
|
<label className={"flex cursor-pointer items-center gap-2"}>
|
||||||
|
<Checkbox
|
||||||
|
onCheckedChange={() => handleChangeStatus(todolist.id, task)}
|
||||||
|
checked={TaskStatus.DONE === task.status}
|
||||||
|
/>
|
||||||
|
{task.title}
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant={"ghost"}
|
||||||
|
size={"icon"}
|
||||||
|
onClick={() => handleDeleteTask(todolist.id, task.id)}
|
||||||
|
className={"ml-2"}
|
||||||
|
>
|
||||||
|
<Trash size={16} />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [filteredTasks, todolist.id, handleChangeStatus, handleDeleteTask])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card key={todolist.id} className={"flex w-72 flex-col justify-between"}>
|
||||||
key={todolist.id}
|
<div>
|
||||||
className={
|
<CardHeader className={"flex flex-row items-center justify-between"}>
|
||||||
"w- flex w-72 flex-col gap-3 rounded-md border border-gray-300 p-4"
|
<CardTitle>{todolist.title}</CardTitle>
|
||||||
}
|
<Button
|
||||||
>
|
variant={"ghost"}
|
||||||
<div className={"flex items-center justify-between "}>
|
size={"icon"}
|
||||||
<h2 className={"text-xl"}>{todolist.title}</h2>
|
onClick={() => handleDeleteTodolist(todolist.id)}
|
||||||
<button onClick={() => handleDeleteTodolist(todolist.id)}>x</button>
|
>
|
||||||
|
<Trash size={16} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={handleAddTask}
|
||||||
|
className={"relative flex w-full items-center gap-4"}
|
||||||
|
>
|
||||||
|
<Input value={newTaskTitle} onChange={handleNewTaskTitleChange} />
|
||||||
|
<Button type={"submit"} variant={"ghost"} size={"icon"}>
|
||||||
|
<Plus size={16} />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<ul className={"mt-4 space-y-2"}>{isLoading ? null : renderTasks}</ul>
|
||||||
|
</CardContent>
|
||||||
</div>
|
</div>
|
||||||
<div className={"flex w-52 gap-4"}>
|
<CardFooter className={"flex"}>
|
||||||
<Input value={newTaskTitle} onChange={handleNewTaskTitleChange} />
|
<ToggleGroup
|
||||||
<Button onClick={handleAddTask}>+</Button>
|
type="single"
|
||||||
</div>
|
onValueChange={handleFilterChange}
|
||||||
{filteredTasks?.map((task) => (
|
value={filter}
|
||||||
<div className={"flex items-center gap-2"} key={task.id}>
|
className={"w-full"}
|
||||||
<input
|
>
|
||||||
onChange={() => handleChangeStatus(todolist.id, task)}
|
<ToggleGroupItem className={"w-full"} value="all">
|
||||||
type={"checkbox"}
|
ALL
|
||||||
checked={[0, 1, 3].includes(task.status)}
|
</ToggleGroupItem>
|
||||||
/>
|
<ToggleGroupItem className={"w-full"} value="active">
|
||||||
<div key={task.id}>{task.title}</div>
|
TODO
|
||||||
<button onClick={() => handleDeleteTask(todolist.id, task.id)}>
|
</ToggleGroupItem>
|
||||||
X
|
<ToggleGroupItem className={"w-full"} value="completed">
|
||||||
</button>
|
DONE
|
||||||
</div>
|
</ToggleGroupItem>
|
||||||
))}
|
</ToggleGroup>
|
||||||
<div className={"flex"}>
|
</CardFooter>
|
||||||
<Button onClick={handleFilterChange} value={"all"}>
|
</Card>
|
||||||
All
|
)
|
||||||
</Button>
|
})
|
||||||
<Button onClick={handleFilterChange} value={"active"}>
|
|
||||||
Active
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleFilterChange} value={"completed"}>
|
|
||||||
Completed
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
@@ -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<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"font-semibold text-2xl leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal file
@@ -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<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal file
@@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 font-semibold text-sm",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
35
src/components/ui/input/index.tsx
Normal file
35
src/components/ui/input/index.tsx
Normal file
@@ -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<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, label, id, ...props }, ref) => {
|
||||||
|
const generatedId = useId()
|
||||||
|
const idToUse = id || generatedId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid items-center gap-1.5">
|
||||||
|
{label && <Label htmlFor={idToUse}>{label}</Label>}
|
||||||
|
<input
|
||||||
|
id={idToUse}
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -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<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -3,5 +3,5 @@ export const Loader = () => {
|
|||||||
<div className={"flex h-full w-full items-center justify-center"}>
|
<div className={"flex h-full w-full items-center justify-center"}>
|
||||||
<span className="loader" />
|
<span className="loader" />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
59
src/components/ui/toggle-group.tsx
Normal file
59
src/components/ui/toggle-group.tsx
Normal file
@@ -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<typeof toggleVariants>
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
const ToggleGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, children, ...props }, ref) => (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center justify-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
))
|
||||||
|
|
||||||
|
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ToggleGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, children, variant, size, ...props }, ref) => {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
43
src/components/ui/toggle.tsx
Normal file
43
src/components/ui/toggle.tsx
Normal file
@@ -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<typeof TogglePrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export * from "./routes";
|
export * from "./routes"
|
||||||
export * from "./query-keys";
|
export * from "./query-keys"
|
||||||
export * from "./result-code";
|
export * from "./result-code"
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export const QUERY_KEYS = {
|
|||||||
TODOLISTS: "todolists",
|
TODOLISTS: "todolists",
|
||||||
TASKS: "tasks",
|
TASKS: "tasks",
|
||||||
ME: "me",
|
ME: "me",
|
||||||
} as const;
|
} as const
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
HOME: "/",
|
HOME: "/",
|
||||||
LOGIN: "/login",
|
LOGIN: "/login",
|
||||||
} as const;
|
} as const
|
||||||
|
|||||||
26
src/env/client.mjs
vendored
26
src/env/client.mjs
vendored
@@ -1,35 +1,35 @@
|
|||||||
// @ts-check
|
// @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 = (
|
export const formatErrors = (
|
||||||
/** @type {import('zod').ZodFormattedError<Map<string,string>,string>} */
|
/** @type {import('zod').ZodFormattedError<Map<string,string>,string>} */
|
||||||
errors
|
errors,
|
||||||
) =>
|
) =>
|
||||||
Object.entries(errors)
|
Object.entries(errors)
|
||||||
.map(([name, value]) => {
|
.map(([name, value]) => {
|
||||||
if (value && "_errors" in 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) {
|
if (!_clientEnv.success) {
|
||||||
console.error(
|
console.error(
|
||||||
"❌ Invalid environment variables:\n",
|
"❌ Invalid environment variables:\n",
|
||||||
...formatErrors(_clientEnv.error.format())
|
...formatErrors(_clientEnv.error.format()),
|
||||||
);
|
)
|
||||||
throw new Error("Invalid environment variables");
|
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_")) {
|
if (!key.startsWith("NEXT_PUBLIC_")) {
|
||||||
console.warn(
|
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
|
||||||
|
|||||||
8
src/env/schema.mjs
vendored
8
src/env/schema.mjs
vendored
@@ -1,5 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { z } from "zod";
|
import { z } from "zod"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specify your server-side environment variables schema here.
|
* Specify your server-side environment variables schema here.
|
||||||
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|||||||
*/
|
*/
|
||||||
export const serverSchema = z.object({
|
export const serverSchema = z.object({
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||||
});
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specify your client-side environment variables schema here.
|
* Specify your client-side environment variables schema here.
|
||||||
@@ -16,7 +16,7 @@ export const serverSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const clientSchema = z.object({
|
export const clientSchema = z.object({
|
||||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||||
});
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* You can't destruct `process.env` as a regular object, so you have to do
|
* 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 = {
|
export const clientEnv = {
|
||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
};
|
}
|
||||||
|
|||||||
20
src/env/server.mjs
vendored
20
src/env/server.mjs
vendored
@@ -3,25 +3,25 @@
|
|||||||
* This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars.
|
* 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.
|
* 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) {
|
if (!_serverEnv.success) {
|
||||||
console.error(
|
console.error(
|
||||||
"❌ Invalid environment variables:\n",
|
"❌ Invalid environment variables:\n",
|
||||||
...formatErrors(_serverEnv.error.format())
|
...formatErrors(_serverEnv.error.format()),
|
||||||
);
|
)
|
||||||
throw new Error("Invalid environment variables");
|
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_")) {
|
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 }
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { ResultCode } from "@/constants";
|
import type { ResultCode } from "@/constants"
|
||||||
|
|
||||||
type ApiResponseSuccess<T> = {
|
type ApiResponseSuccess<T> = {
|
||||||
data: T;
|
data: T
|
||||||
messages?: never;
|
messages?: never
|
||||||
fieldsErrors?: never;
|
fieldsErrors?: never
|
||||||
resultCode: ResultCode.Success;
|
resultCode: ResultCode.Success
|
||||||
};
|
}
|
||||||
|
|
||||||
type ApiResponseError<T> = {
|
type ApiResponseError<T> = {
|
||||||
data: T;
|
data: T
|
||||||
messages: string[];
|
messages: string[]
|
||||||
fieldsErrors?: string[];
|
fieldsErrors?: string[]
|
||||||
resultCode: ResultCode.InvalidRequest | ResultCode.InvalidRequestWithCaptcha;
|
resultCode: ResultCode.InvalidRequest | ResultCode.InvalidRequestWithCaptcha
|
||||||
};
|
}
|
||||||
|
|
||||||
export type ApiResponse<T> = ApiResponseSuccess<T> | ApiResponseError<T>;
|
export type ApiResponse<T> = ApiResponseSuccess<T> | ApiResponseError<T>
|
||||||
|
|||||||
6
src/helpers/classnames/classnames.ts
Normal file
6
src/helpers/classnames/classnames.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
1
src/helpers/classnames/index.ts
Normal file
1
src/helpers/classnames/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./classnames"
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ApiResponse } from "@/helpers";
|
import type { ApiResponse } from "@/helpers"
|
||||||
|
|
||||||
export const handleError = <T>(data: ApiResponse<T>): ApiResponse<T> => {
|
export const handleError = <T>(data: ApiResponse<T>): ApiResponse<T> => {
|
||||||
if (data.resultCode !== 0) {
|
// if (data.resultCode !== 0) {
|
||||||
const error = data?.messages?.[0] || "An error has occurred";
|
// const error = data?.messages?.[0] || "An error has occurred";
|
||||||
|
//
|
||||||
|
// throw new Error(error);
|
||||||
|
// }
|
||||||
|
|
||||||
throw new Error(error);
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./handle-error";
|
export * from "./handle-error"
|
||||||
export * from "./no-refetch";
|
export * from "./no-refetch"
|
||||||
export * from "./api-response";
|
export * from "./api-response"
|
||||||
|
export * from "./classnames"
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ export const noRefetch = {
|
|||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
} as const;
|
} as const
|
||||||
|
|||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -1,20 +1,49 @@
|
|||||||
import "../styles/globals.css";
|
import "../styles/globals.css"
|
||||||
|
|
||||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
import { type AppType } from "next/dist/shared/lib/utils";
|
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 }) => {
|
const MyApp: AppType = ({ Component, pageProps }) => {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthRedirect>
|
<AuthRedirect>
|
||||||
<Component {...pageProps} />
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</ThemeProvider>
|
||||||
</AuthRedirect>
|
</AuthRedirect>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default MyApp;
|
function Header() {
|
||||||
|
const { mutate: logout } = useLogoutMutation()
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<header className={"flex items-center justify-between p-4"}>
|
||||||
|
<h1 className={"text-3xl"}>Tasks</h1>
|
||||||
|
<div className={"flex gap-4"}>
|
||||||
|
<ModeToggle />
|
||||||
|
<Button onClick={handleLogout} variant={"outline"}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyApp
|
||||||
|
|||||||
13
src/pages/_document.tsx
Normal file
13
src/pages/_document.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Head, Html, Main, NextScript } from "next/document"
|
||||||
|
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<Html lang="en" className={"h-screen bg-zinc-950"}>
|
||||||
|
<Head />
|
||||||
|
<body className={"grid h-full grid-rows-[auto_1fr] overflow-hidden"}>
|
||||||
|
<Main />
|
||||||
|
<NextScript />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,38 +1,36 @@
|
|||||||
import type { ChangeEvent } from "react";
|
import { type ChangeEvent, type FormEvent, useMemo } from "react"
|
||||||
import { useState } from "react";
|
import { useState } from "react"
|
||||||
|
|
||||||
import { type NextPage } from "next";
|
import type { NextPage } from "next"
|
||||||
import Head from "next/head";
|
import Head from "next/head"
|
||||||
|
|
||||||
import { Todolist, Button, FullscreenLoader, Input } from "@/components";
|
import { Button, FullscreenLoader, Input, Todolist } from "@/components"
|
||||||
import {
|
import { useCreateTodolistMutation, useTodolistsQuery } from "@/services"
|
||||||
useCreateTodolistMutation,
|
import { Plus } from "lucide-react"
|
||||||
useLogoutMutation,
|
|
||||||
useTodolistsQuery,
|
|
||||||
} from "@/services";
|
|
||||||
|
|
||||||
const Home: NextPage = () => {
|
const Home: NextPage = () => {
|
||||||
const [newTodolistTitle, setNewTodolistTitle] = useState("");
|
const [newTodolistTitle, setNewTodolistTitle] = useState("")
|
||||||
const { mutate: logout } = useLogoutMutation();
|
const { data: todolists, isLoading: isTodolistsLoading } = useTodolistsQuery()
|
||||||
const { data: todolists, isLoading: isTodolistsLoading } =
|
|
||||||
useTodolistsQuery();
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const { mutate: createTodolist } = useCreateTodolistMutation()
|
||||||
logout();
|
|
||||||
};
|
|
||||||
|
|
||||||
const { mutate: createTodolist } = useCreateTodolistMutation();
|
const handleAddTodolist = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
const handleAddTodolist = () => {
|
createTodolist({ title: newTodolistTitle })
|
||||||
createTodolist({ title: newTodolistTitle });
|
setNewTodolistTitle("")
|
||||||
setNewTodolistTitle("");
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewTodolistTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleNewTodolistTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setNewTodolistTitle(e.target.value);
|
setNewTodolistTitle(e.target.value)
|
||||||
};
|
}
|
||||||
|
|
||||||
if (isTodolistsLoading) return <FullscreenLoader />;
|
const renderTodolists = useMemo(() => {
|
||||||
|
return todolists?.map((todolist) => {
|
||||||
|
return <Todolist todolist={todolist} key={todolist.id} />
|
||||||
|
})
|
||||||
|
}, [todolists])
|
||||||
|
|
||||||
|
if (isTodolistsLoading) return <FullscreenLoader />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -41,29 +39,22 @@ const Home: NextPage = () => {
|
|||||||
<meta name="description" content="Incubator todolist" />
|
<meta name="description" content="Incubator todolist" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<header className={"flex justify-between p-4"}>
|
|
||||||
<h1 className={"text-3xl"}>Todolist</h1>
|
|
||||||
<Button onClick={handleLogout}>Logout</Button>
|
|
||||||
</header>
|
|
||||||
<main className={"flex flex-col gap-4 p-4"}>
|
<main className={"flex flex-col gap-4 p-4"}>
|
||||||
<div className={"flex items-end gap-2.5"}>
|
<form onSubmit={handleAddTodolist} className={"flex items-end gap-2.5"}>
|
||||||
<label className={"flex w-52 flex-col gap-0.5"}>
|
<Input
|
||||||
new todolist
|
className={"w-52"}
|
||||||
<Input
|
label={"New list"}
|
||||||
value={newTodolistTitle}
|
value={newTodolistTitle}
|
||||||
onChange={handleNewTodolistTitleChange}
|
onChange={handleNewTodolistTitleChange}
|
||||||
/>
|
/>
|
||||||
</label>
|
<Button type={"submit"} variant={"outline"} size={"icon"}>
|
||||||
<Button onClick={handleAddTodolist}>+</Button>
|
<Plus size={16} />
|
||||||
</div>
|
</Button>
|
||||||
<div className={"flex flex-wrap gap-4"}>
|
</form>
|
||||||
{todolists?.map((todolist) => {
|
<div className={"flex flex-wrap gap-4"}>{renderTodolists}</div>
|
||||||
return <Todolist todolist={todolist} key={todolist.id} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Home;
|
export default Home
|
||||||
|
|||||||
@@ -1,69 +1,70 @@
|
|||||||
import type { ChangeEvent } from "react";
|
import type { ChangeEvent } from "react"
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react"
|
||||||
|
|
||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next"
|
||||||
|
|
||||||
import { Button, Input } from "@/components";
|
import { Button, Input } from "@/components"
|
||||||
import { useLoginMutation } from "@/services";
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { useLoginMutation } from "@/services"
|
||||||
|
|
||||||
const Login: NextPage = () => {
|
const Login: NextPage = () => {
|
||||||
const { mutate: login } = useLoginMutation();
|
const { mutate: login } = useLoginMutation()
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("")
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("")
|
||||||
const [remember, setRemember] = useState(true);
|
const [remember, setRemember] = useState(true)
|
||||||
|
|
||||||
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setPassword(e.target.value);
|
setPassword(e.target.value)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setEmail(e.target.value);
|
setEmail(e.target.value)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleRememberChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleRememberChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setRemember(e.target.checked);
|
setRemember(e.target.checked)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
login({
|
login({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
rememberMe: remember,
|
rememberMe: remember,
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex h-screen items-center justify-center"}>
|
<div className={"flex h-full items-center justify-center"}>
|
||||||
<div className={"flex w-52 flex-col gap-3"}>
|
<div className={"flex w-52 flex-col gap-3"}>
|
||||||
<label className={"flex flex-col gap-1"}>
|
<Input
|
||||||
Email
|
value={email}
|
||||||
<Input value={email} onChange={handleEmailChange} type="email" />
|
onChange={handleEmailChange}
|
||||||
</label>
|
type="email"
|
||||||
|
label={"Email"}
|
||||||
|
/>
|
||||||
|
|
||||||
<label className={"flex flex-col gap-1"}>
|
<Input
|
||||||
Password
|
type="password"
|
||||||
<Input
|
label={"Password"}
|
||||||
type="password"
|
value={password}
|
||||||
value={password}
|
onChange={handlePasswordChange}
|
||||||
onChange={handlePasswordChange}
|
/>
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className={"flex items-center gap-2"}>
|
<Label className={"flex items-center gap-2"}>
|
||||||
<input
|
<input
|
||||||
type={"checkbox"}
|
type={"checkbox"}
|
||||||
checked={remember}
|
checked={remember}
|
||||||
onChange={handleRememberChange}
|
onChange={handleRememberChange}
|
||||||
/>
|
/>
|
||||||
Remember me
|
Remember me
|
||||||
</label>
|
</Label>
|
||||||
<Button className={"w-full"} onClick={handleSubmit}>
|
<Button className={"w-full"} onClick={handleSubmit}>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Login;
|
export default Login
|
||||||
|
|||||||
50
src/pages/sign-up.tsx
Normal file
50
src/pages/sign-up.tsx
Normal file
@@ -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<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||||
|
const values = Object.fromEntries(formData) as any
|
||||||
|
signUp(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"flex h-screen items-center justify-center"}>
|
||||||
|
<form
|
||||||
|
className={"flex w-96 flex-col gap-3 rounded-md border p-6"}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<h1 className={"font-bold text-2xl"}>Sign up</h1>
|
||||||
|
<label className={"flex flex-col gap-1"}>
|
||||||
|
Username (optional)
|
||||||
|
<Input name={"username"} type="text" />
|
||||||
|
</label>
|
||||||
|
<label className={"flex flex-col gap-1"}>
|
||||||
|
Email
|
||||||
|
<Input name={"email"} type="email" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={"flex flex-col gap-1"}>
|
||||||
|
Password
|
||||||
|
<Input type="password" name={"password"} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button className={"w-full"} type={"submit"}>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
@@ -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<LoginResponse>("/login", args);
|
|
||||||
|
|
||||||
return handleError(res.data);
|
|
||||||
},
|
|
||||||
|
|
||||||
async logout() {
|
|
||||||
const res = await authInstance.delete<LogoutResponse>("/login");
|
|
||||||
|
|
||||||
return handleError(res.data);
|
|
||||||
},
|
|
||||||
|
|
||||||
async me() {
|
|
||||||
const res = await authInstance.get<MeResponse>("/me");
|
|
||||||
|
|
||||||
return handleError(res.data);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./auth.api";
|
|
||||||
export * from "./auth.hooks";
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from "./auth";
|
export * from "./todolist-api/auth"
|
||||||
export * from "./todolists";
|
export * from "./todolist-api/todolists"
|
||||||
|
|||||||
40
src/services/todolist-api/auth/auth.api.ts
Normal file
40
src/services/todolist-api/auth/auth.api.ts
Normal file
@@ -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<LoginResponse>(
|
||||||
|
"/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<void>("/auth/sign-up", args)
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
const res = await todolistApiInstance.delete<LogoutResponse>("/auth/login")
|
||||||
|
|
||||||
|
return handleError(res.data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async me() {
|
||||||
|
const res = await todolistApiInstance.get<MeResponse>("/auth/me")
|
||||||
|
|
||||||
|
return handleError(res.data)
|
||||||
|
},
|
||||||
|
}
|
||||||
46
src/services/todolist-api/auth/auth.hooks.ts
Normal file
46
src/services/todolist-api/auth/auth.hooks.ts
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
2
src/services/todolist-api/auth/index.ts
Normal file
2
src/services/todolist-api/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./auth.api"
|
||||||
|
export * from "./auth.hooks"
|
||||||
96
src/services/todolist-api/todolist-api.instance.ts
Normal file
96
src/services/todolist-api/todolist-api.instance.ts
Normal file
@@ -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<string> {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
3
src/services/todolist-api/todolists/index.ts
Normal file
3
src/services/todolist-api/todolists/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./todolists.hooks"
|
||||||
|
export * from "./todolists.api"
|
||||||
|
export * from "./todolists.types"
|
||||||
89
src/services/todolist-api/todolists/todolists.api.ts
Normal file
89
src/services/todolist-api/todolists/todolists.api.ts
Normal file
@@ -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<Todolist[]>("/todolists")
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTodolist({ title }: { title: string }) {
|
||||||
|
const res = await todolistApiInstance.post<CreateTodolistResponse>(
|
||||||
|
"/todolists",
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTodolist({ todolistId }: { todolistId: string }) {
|
||||||
|
const res = await todolistApiInstance.delete<DeleteTodolistResponse>(
|
||||||
|
`/todolists/${todolistId}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTodolistTasks({ todolistId }: { todolistId: string }) {
|
||||||
|
const res = await todolistApiInstance.get<TasksResponse>(
|
||||||
|
`/todolists/${todolistId}/tasks`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTask({
|
||||||
|
todolistId,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
todolistId: string
|
||||||
|
title: string
|
||||||
|
}) {
|
||||||
|
const res = await todolistApiInstance.post<CreateTaskResponse>(
|
||||||
|
`/todolists/${todolistId}/tasks`,
|
||||||
|
{ title },
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTask({
|
||||||
|
id,
|
||||||
|
todolistId,
|
||||||
|
...rest
|
||||||
|
}: Partial<Task> & Required<Pick<Task, "id">> & { todolistId: string }) {
|
||||||
|
const res = await todolistApiInstance.patch<UpdateTaskResponse>(
|
||||||
|
`/todolists/${todolistId}/tasks/${id}`,
|
||||||
|
rest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTask({
|
||||||
|
todolistId,
|
||||||
|
taskId,
|
||||||
|
}: {
|
||||||
|
todolistId: string
|
||||||
|
taskId: string
|
||||||
|
}) {
|
||||||
|
const res = await todolistApiInstance.delete<DeleteTaskResponse>(
|
||||||
|
`/todolists/${todolistId}/tasks/${taskId}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 { QUERY_KEYS } from "@/constants"
|
||||||
import { noRefetch } from "@/helpers";
|
import { noRefetch } from "@/helpers"
|
||||||
import { TodolistAPI } from "@/services";
|
import { TodolistAPI } from "@/services"
|
||||||
|
|
||||||
export const useTodolistsQuery = () => {
|
export const useTodolistsQuery = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryFn: TodolistAPI.getTodolists,
|
queryFn: TodolistAPI.getTodolists,
|
||||||
queryKey: [QUERY_KEYS.TODOLISTS],
|
queryKey: [QUERY_KEYS.TODOLISTS],
|
||||||
...noRefetch,
|
...noRefetch,
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
export const useCreateTodolistMutation = () => {
|
export const useCreateTodolistMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: TodolistAPI.createTodolist,
|
mutationFn: TodolistAPI.createTodolist,
|
||||||
//todo: add onMutate
|
onSuccess: () => {
|
||||||
onSuccess: async () => {
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.TODOLISTS] })
|
||||||
await queryClient.invalidateQueries([QUERY_KEYS.TODOLISTS]);
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
export const useDeleteTodolistMutation = () => {
|
export const useDeleteTodolistMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: TodolistAPI.deleteTodolist,
|
mutationFn: TodolistAPI.deleteTodolist,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries([QUERY_KEYS.TODOLISTS]);
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.TODOLISTS] })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
export const useGetTasksQuery = (todolistId: string) => {
|
export const useGetTasksQuery = (todolistId: string) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [QUERY_KEYS.TASKS, todolistId],
|
queryKey: [QUERY_KEYS.TASKS, todolistId],
|
||||||
queryFn: () => TodolistAPI.getTodolistTasks({ todolistId }),
|
queryFn: () => TodolistAPI.getTodolistTasks({ todolistId }),
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
export const useCreateTaskMutation = () => {
|
export const useCreateTaskMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: TodolistAPI.createTask,
|
mutationFn: TodolistAPI.createTask,
|
||||||
onSuccess: (res) => {
|
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 = () => {
|
export const useUpdateTaskMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: TodolistAPI.updateTask,
|
mutationFn: TodolistAPI.updateTask,
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (_, { todolistId }) => {
|
||||||
const todolistId = res.data.item.todoListId;
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [QUERY_KEYS.TASKS, todolistId],
|
||||||
await queryClient.invalidateQueries([QUERY_KEYS.TASKS, todolistId]);
|
})
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
export const useDeleteTaskMutation = () => {
|
export const useDeleteTaskMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: TodolistAPI.deleteTask,
|
mutationFn: TodolistAPI.deleteTask,
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
const todolistId = variables.todolistId;
|
const todolistId = variables.todolistId
|
||||||
|
|
||||||
queryClient.invalidateQueries([QUERY_KEYS.TASKS, todolistId]);
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [QUERY_KEYS.TASKS, todolistId],
|
||||||
|
})
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
73
src/services/todolist-api/todolists/todolists.types.ts
Normal file
73
src/services/todolist-api/todolists/todolists.types.ts
Normal file
@@ -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<never>
|
||||||
|
export type MeResponse = ApiResponse<MeResponseData>
|
||||||
|
|
||||||
|
export type DeleteTodolistResponse = ApiResponse<never>
|
||||||
|
export type CreateTodolistResponse = ApiResponse<CreateTodolistResponseData>
|
||||||
|
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",
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./todolists.hooks";
|
|
||||||
export * from "./todolists.api";
|
|
||||||
export * from "./todolists.types";
|
|
||||||
@@ -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<Todolist[]>("/");
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createTodolist({ title }: { title: string }) {
|
|
||||||
const res = await todolistsInstance.post<CreateTodolistResponse>("/", {
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
|
|
||||||
return handleError(res.data);
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteTodolist({ todolistId }: { todolistId: string }) {
|
|
||||||
const res = await todolistsInstance.delete<DeleteTodolistResponse>(
|
|
||||||
`/${todolistId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getTodolistTasks({ todolistId }: { todolistId: string }) {
|
|
||||||
const res = await todolistsInstance.get<TasksResponse>(
|
|
||||||
`/${todolistId}/tasks`
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createTask({
|
|
||||||
todolistId,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
todolistId: string;
|
|
||||||
title: string;
|
|
||||||
}) {
|
|
||||||
const res = await todolistsInstance.post<CreateTaskResponse>(
|
|
||||||
`/${todolistId}/tasks`,
|
|
||||||
{ title }
|
|
||||||
);
|
|
||||||
|
|
||||||
return handleError(res.data);
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateTask({ todolistId, task }: { todolistId: string; task: Task }) {
|
|
||||||
const { id, ...rest } = task;
|
|
||||||
const res = await todolistsInstance.put<UpdateTaskResponse>(
|
|
||||||
`/${todolistId}/tasks/${id}`,
|
|
||||||
rest
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteTask({
|
|
||||||
todolistId,
|
|
||||||
taskId,
|
|
||||||
}: {
|
|
||||||
todolistId: string;
|
|
||||||
taskId: string;
|
|
||||||
}) {
|
|
||||||
const res = await todolistsInstance.delete<DeleteTaskResponse>(
|
|
||||||
`/${todolistId}/tasks/${taskId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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<LoginResponseData>;
|
|
||||||
export type LogoutResponse = ApiResponse<never>;
|
|
||||||
export type MeResponse = ApiResponse<MeResponseData>;
|
|
||||||
|
|
||||||
export type DeleteTodolistResponse = ApiResponse<never>;
|
|
||||||
export type CreateTodolistResponse = ApiResponse<CreateTodolistResponseData>;
|
|
||||||
export type CreateTodolistResponseData = {
|
|
||||||
item: Todolist;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateTaskResponse = ApiResponse<{ item: Task }>;
|
|
||||||
export type DeleteTaskResponse = ApiResponse<never>;
|
|
||||||
export type UpdateTaskResponse = ApiResponse<UpdateTaskResponseData>;
|
|
||||||
@@ -1,48 +1,72 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
.loader {
|
|
||||||
width: 48px;
|
@layer base {
|
||||||
height: 48px;
|
#__next{
|
||||||
border: 3px dotted #000;
|
height: 100%;
|
||||||
border-style: solid solid dotted dotted;
|
}
|
||||||
border-radius: 50%;
|
:root {
|
||||||
display: inline-block;
|
--background: 0 0% 100%;
|
||||||
position: relative;
|
--foreground: 240 10% 3.9%;
|
||||||
box-sizing: border-box;
|
--card: 0 0% 100%;
|
||||||
animation: rotation 2s linear infinite;
|
--card-foreground: 240 10% 3.9%;
|
||||||
}
|
--popover: 0 0% 100%;
|
||||||
.loader::after {
|
--popover-foreground: 240 10% 3.9%;
|
||||||
content: '';
|
--primary: 240 5.9% 10%;
|
||||||
box-sizing: border-box;
|
--primary-foreground: 0 0% 98%;
|
||||||
position: absolute;
|
--secondary: 240 4.8% 95.9%;
|
||||||
left: 0;
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
right: 0;
|
--muted: 240 4.8% 95.9%;
|
||||||
top: 0;
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
bottom: 0;
|
--accent: 240 4.8% 95.9%;
|
||||||
margin: auto;
|
--accent-foreground: 240 5.9% 10%;
|
||||||
border: 3px dotted #FF3D00;
|
--destructive: 0 84.2% 60.2%;
|
||||||
border-style: solid solid dotted;
|
--destructive-foreground: 0 0% 98%;
|
||||||
width: 24px;
|
--border: 240 5.9% 90%;
|
||||||
height: 24px;
|
--input: 240 5.9% 90%;
|
||||||
border-radius: 50%;
|
--ring: 240 10% 3.9%;
|
||||||
animation: rotationBack 1s linear infinite;
|
--radius: 0.5rem;
|
||||||
transform-origin: center center;
|
--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 {
|
@layer base {
|
||||||
0% {
|
* {
|
||||||
transform: rotate(0deg);
|
@apply border-border;
|
||||||
}
|
}
|
||||||
100% {
|
body {
|
||||||
transform: rotate(360deg);
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@keyframes rotationBack {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(-360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
80
tailwind.config.ts
Normal file
80
tailwind.config.ts
Normal file
@@ -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
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
Reference in New Issue
Block a user