mirror of
https://github.com/ershisan99/todolist_next.git
synced 2025-12-16 12:33:57 +00:00
wip
This commit is contained in:
@@ -4,7 +4,11 @@
|
|||||||
"project": "./tsconfig.json"
|
"project": "./tsconfig.json"
|
||||||
},
|
},
|
||||||
"plugins": ["@typescript-eslint"],
|
"plugins": ["@typescript-eslint"],
|
||||||
"extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "@it-incubator/eslint-config"],
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"@it-incubator/eslint-config"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/consistent-type-imports": "warn"
|
"@typescript-eslint/consistent-type-imports": "warn"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,21 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||||
"@tanstack/react-query": "^4.28.0",
|
"@tanstack/react-query": "^4.28.0",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
"axios": "^1.3.4",
|
"axios": "^1.3.4",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.377.0",
|
||||||
"next": "13.2.4",
|
"next": "13.2.4",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"tailwind-merge": "^2.3.0",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
4049
pnpm-lock.yaml
generated
4049
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
/** @type {import("prettier").Config} */
|
/** @type {import("prettier").Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: [require.resolve("prettier-plugin-tailwindcss"), require.resolve("@it-incubator/prettier-config")],
|
plugins: [
|
||||||
|
require.resolve("prettier-plugin-tailwindcss"),
|
||||||
|
require.resolve("@it-incubator/prettier-config"),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import type { ButtonHTMLAttributes, DetailedHTMLProps, FC } from "react";
|
import type { ComponentPropsWithoutRef, FC } from "react";
|
||||||
|
|
||||||
type Props = DetailedHTMLProps<
|
import { cn } from "@/helpers";
|
||||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
HTMLButtonElement
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const Button: FC<Props> = ({ className, ...rest }) => {
|
type Props = ComponentPropsWithoutRef<"button"> & {
|
||||||
|
variant?: "primary" | "outlined" | "icon";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button: FC<Props> = ({
|
||||||
|
className,
|
||||||
|
variant = "primary",
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<button
|
||||||
<button
|
className={cn(
|
||||||
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}`}
|
"flex items-center justify-center 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",
|
||||||
{...rest}
|
variant === "outlined" && "border-sky-700 bg-inherit text-sky-700",
|
||||||
/>
|
variant === "icon" &&
|
||||||
</div>
|
"h-6 w-6 shrink-0 border-none bg-inherit p-0 text-sky-700 hover:bg-slate-100 hover:text-sky-800",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ type Props = DetailedHTMLProps<
|
|||||||
|
|
||||||
export const Input: FC<Props> = ({ className, ...rest }) => {
|
export const Input: FC<Props> = ({ className, ...rest }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<input
|
||||||
<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}`}
|
||||||
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}
|
||||||
{...rest}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import type { ChangeEvent, FC, MouseEventHandler } from "react";
|
import type { ChangeEvent, FC, FormEvent, MouseEventHandler } from "react";
|
||||||
import { memo, useState } from "react";
|
import { memo, useState } from "react";
|
||||||
|
|
||||||
import type { Task, Todolist as TodolistType } from "../../services/todolists";
|
import { Trash, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Task,
|
||||||
|
Todolist as TodolistType,
|
||||||
|
} from "../../services/todolist-api/todolists";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { Input } from "../input";
|
import { Input } from "../input";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/components/toggle-group/toggle-group";
|
||||||
|
import {
|
||||||
|
TaskStatus,
|
||||||
useCreateTaskMutation,
|
useCreateTaskMutation,
|
||||||
useDeleteTaskMutation,
|
useDeleteTaskMutation,
|
||||||
useDeleteTodolistMutation,
|
useDeleteTodolistMutation,
|
||||||
@@ -29,7 +39,11 @@ export const Todolist: FC<Props> = memo(({ todolist }) => {
|
|||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
|
|
||||||
const handleChangeStatus = (todolistId: string, task: Task) => {
|
const handleChangeStatus = (todolistId: string, task: Task) => {
|
||||||
const newTask = { ...task, status: task.status === 0 ? 2 : 0 };
|
const newTask: Task = {
|
||||||
|
...task,
|
||||||
|
status:
|
||||||
|
task.status === TaskStatus.DONE ? TaskStatus.OPEN : TaskStatus.DONE,
|
||||||
|
};
|
||||||
|
|
||||||
putTask({ todolistId, task: newTask });
|
putTask({ todolistId, task: newTask });
|
||||||
};
|
};
|
||||||
@@ -39,7 +53,8 @@ export const Todolist: FC<Props> = memo(({ todolist }) => {
|
|||||||
const handleDeleteTodolist = (todolistId: string) => {
|
const handleDeleteTodolist = (todolistId: string) => {
|
||||||
deleteTodolist({ todolistId });
|
deleteTodolist({ todolistId });
|
||||||
};
|
};
|
||||||
const handleAddTask = () => {
|
const handleAddTask = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
createTask({ todolistId: todolist.id, title: newTaskTitle });
|
createTask({ todolistId: todolist.id, title: newTaskTitle });
|
||||||
setNewTaskTitle("");
|
setNewTaskTitle("");
|
||||||
};
|
};
|
||||||
@@ -47,17 +62,17 @@ export const Todolist: FC<Props> = memo(({ todolist }) => {
|
|||||||
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>;
|
if (isLoading) return <div>loading...</div>;
|
||||||
const filteredTasks = tasks?.items?.filter((task) => {
|
const filteredTasks = tasks?.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;
|
||||||
}
|
}
|
||||||
@@ -67,40 +82,65 @@ export const Todolist: FC<Props> = memo(({ todolist }) => {
|
|||||||
<div
|
<div
|
||||||
key={todolist.id}
|
key={todolist.id}
|
||||||
className={
|
className={
|
||||||
"w- flex w-72 flex-col gap-3 rounded-md border border-gray-300 p-4"
|
"flex w-72 flex-col gap-3 rounded-md border border-gray-300 p-4"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={"flex items-center justify-between "}>
|
<div className={"flex items-center justify-between "}>
|
||||||
<h2 className={"text-xl"}>{todolist.title}</h2>
|
<h2 className={"text-xl"}>{todolist.title}</h2>
|
||||||
<button onClick={() => handleDeleteTodolist(todolist.id)}>x</button>
|
<Button
|
||||||
|
variant={"icon"}
|
||||||
|
onClick={() => handleDeleteTodolist(todolist.id)}
|
||||||
|
>
|
||||||
|
<Trash size={16} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={"flex w-52 gap-4"}>
|
<form
|
||||||
|
onSubmit={handleAddTask}
|
||||||
|
className={"relative flex w-full items-center gap-4"}
|
||||||
|
>
|
||||||
<Input value={newTaskTitle} onChange={handleNewTaskTitleChange} />
|
<Input value={newTaskTitle} onChange={handleNewTaskTitleChange} />
|
||||||
<Button onClick={handleAddTask}>+</Button>
|
<Button type={"submit"} variant={"icon"} className={"absolute right-4"}>
|
||||||
</div>
|
<Plus size={16} />
|
||||||
{filteredTasks?.map((task) => (
|
</Button>
|
||||||
<div className={"flex items-center gap-2"} key={task.id}>
|
</form>
|
||||||
<input
|
{filteredTasks?.map((task) => {
|
||||||
onChange={() => handleChangeStatus(todolist.id, task)}
|
return (
|
||||||
type={"checkbox"}
|
<div className={"flex cursor-pointer items-center"} key={task.id}>
|
||||||
checked={[0, 1, 3].includes(task.status)}
|
<label className={"flex cursor-pointer items-center gap-2"}>
|
||||||
/>
|
<input
|
||||||
<div key={task.id}>{task.title}</div>
|
onChange={() => handleChangeStatus(todolist.id, task)}
|
||||||
<button onClick={() => handleDeleteTask(todolist.id, task.id)}>
|
type={"checkbox"}
|
||||||
X
|
checked={TaskStatus.DONE === task.status}
|
||||||
</button>
|
/>
|
||||||
</div>
|
{task.title}
|
||||||
))}
|
</label>
|
||||||
|
<Button
|
||||||
|
variant={"icon"}
|
||||||
|
onClick={() => handleDeleteTask(todolist.id, task.id)}
|
||||||
|
className={"ml-2"}
|
||||||
|
>
|
||||||
|
<Trash size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div className={"flex"}>
|
<div className={"flex"}>
|
||||||
<Button onClick={handleFilterChange} value={"all"}>
|
<ToggleGroup
|
||||||
All
|
type="single"
|
||||||
</Button>
|
onValueChange={handleFilterChange}
|
||||||
<Button onClick={handleFilterChange} value={"active"}>
|
value={filter}
|
||||||
Active
|
className={"w-full"}
|
||||||
</Button>
|
>
|
||||||
<Button onClick={handleFilterChange} value={"completed"}>
|
<ToggleGroupItem className={"w-full"} value="all">
|
||||||
Completed
|
All
|
||||||
</Button>
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem className={"w-full"} value="active">
|
||||||
|
Active
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem className={"w-full"} value="completed">
|
||||||
|
Completed
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
83
src/components/toggle-group/toggle-group.tsx
Normal file
83
src/components/toggle-group/toggle-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/helpers";
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex border border-sky-700 items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-sky-700 data-[state=on]:text-white",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-3",
|
||||||
|
sm: "h-8 px-2",
|
||||||
|
lg: "h-10 px-3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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 };
|
||||||
6
src/env/client.mjs
vendored
6
src/env/client.mjs
vendored
@@ -5,7 +5,7 @@ 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]) => {
|
||||||
@@ -17,7 +17,7 @@ export const formatErrors = (
|
|||||||
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");
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ if (!_clientEnv.success) {
|
|||||||
for (let key of Object.keys(_clientEnv.data)) {
|
for (let 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");
|
||||||
|
|||||||
2
src/env/server.mjs
vendored
2
src/env/server.mjs
vendored
@@ -11,7 +11,7 @@ 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");
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ChangeEvent } from "react";
|
import type { ChangeEvent, FormEvent } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { type NextPage } from "next";
|
import { type NextPage } from "next";
|
||||||
@@ -23,7 +23,8 @@ const Home: NextPage = () => {
|
|||||||
|
|
||||||
const { mutate: createTodolist } = useCreateTodolistMutation();
|
const { mutate: createTodolist } = useCreateTodolistMutation();
|
||||||
|
|
||||||
const handleAddTodolist = () => {
|
const handleAddTodolist = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
createTodolist({ title: newTodolistTitle });
|
createTodolist({ title: newTodolistTitle });
|
||||||
setNewTodolistTitle("");
|
setNewTodolistTitle("");
|
||||||
};
|
};
|
||||||
@@ -46,7 +47,7 @@ const Home: NextPage = () => {
|
|||||||
<Button onClick={handleLogout}>Logout</Button>
|
<Button onClick={handleLogout}>Logout</Button>
|
||||||
</header>
|
</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"}>
|
<label className={"flex w-52 flex-col gap-0.5"}>
|
||||||
new todolist
|
new todolist
|
||||||
<Input
|
<Input
|
||||||
@@ -54,8 +55,8 @@ const Home: NextPage = () => {
|
|||||||
onChange={handleNewTodolistTitleChange}
|
onChange={handleNewTodolistTitleChange}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Button onClick={handleAddTodolist}>+</Button>
|
<Button type={"submit"}>+</Button>
|
||||||
</div>
|
</form>
|
||||||
<div className={"flex flex-wrap gap-4"}>
|
<div className={"flex flex-wrap gap-4"}>
|
||||||
{todolists?.map((todolist) => {
|
{todolists?.map((todolist) => {
|
||||||
return <Todolist todolist={todolist} key={todolist.id} />;
|
return <Todolist todolist={todolist} key={todolist.id} />;
|
||||||
|
|||||||
@@ -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,6 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export const authInstance = axios.create({
|
|
||||||
baseURL: "https://social-network.samuraijs.com/api/1.1/auth/",
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from "./auth";
|
export * from "./todolist-api/auth";
|
||||||
export * from "./todolists";
|
export * from "./todolist-api/todolists";
|
||||||
|
|||||||
35
src/services/todolist-api/auth/auth.api.ts
Normal file
35
src/services/todolist-api/auth/auth.api.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type {
|
||||||
|
LoginResponse,
|
||||||
|
LogoutResponse,
|
||||||
|
MeResponse,
|
||||||
|
PostLoginArgs,
|
||||||
|
} 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 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
|||||||
|
|
||||||
import { QUERY_KEYS, ROUTES } from "@/constants";
|
import { QUERY_KEYS, ROUTES } from "@/constants";
|
||||||
import { noRefetch } from "@/helpers";
|
import { noRefetch } from "@/helpers";
|
||||||
import { AuthApi } from "@/services/auth/auth.api";
|
import { AuthApi } from "@/services/todolist-api/auth/auth.api";
|
||||||
|
|
||||||
export const useMeQuery = () => {
|
export const useMeQuery = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
90
src/services/todolist-api/todolist-api.instance.ts
Normal file
90
src/services/todolist-api/todolist-api.instance.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Mutex } from "async-mutex";
|
||||||
|
import axios from "axios";
|
||||||
|
const mutex = new Mutex();
|
||||||
|
|
||||||
|
let refreshedAt: number | null = null;
|
||||||
|
|
||||||
|
export const todolistApiInstance = axios.create({
|
||||||
|
baseURL: "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) => {
|
||||||
|
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
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { todolistsInstance } from "./todolists.instance";
|
|
||||||
|
|
||||||
import { handleError } from "@/helpers";
|
import { handleError } from "@/helpers";
|
||||||
import type {
|
import type {
|
||||||
CreateTaskResponse,
|
CreateTaskResponse,
|
||||||
@@ -11,33 +9,37 @@ import type {
|
|||||||
Todolist,
|
Todolist,
|
||||||
UpdateTaskResponse,
|
UpdateTaskResponse,
|
||||||
} from "@/services";
|
} from "@/services";
|
||||||
|
import { todolistApiInstance } from "@/services/todolist-api/todolist-api.instance";
|
||||||
|
|
||||||
export const TodolistAPI = {
|
export const TodolistAPI = {
|
||||||
async getTodolists() {
|
async getTodolists() {
|
||||||
const res = await todolistsInstance.get<Todolist[]>("/");
|
const res = await todolistApiInstance.get<Todolist[]>("/todolists");
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTodolist({ title }: { title: string }) {
|
async createTodolist({ title }: { title: string }) {
|
||||||
const res = await todolistsInstance.post<CreateTodolistResponse>("/", {
|
const res = await todolistApiInstance.post<CreateTodolistResponse>(
|
||||||
title,
|
"/todolists",
|
||||||
});
|
{
|
||||||
|
title,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return handleError(res.data);
|
return handleError(res.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteTodolist({ todolistId }: { todolistId: string }) {
|
async deleteTodolist({ todolistId }: { todolistId: string }) {
|
||||||
const res = await todolistsInstance.delete<DeleteTodolistResponse>(
|
const res = await todolistApiInstance.delete<DeleteTodolistResponse>(
|
||||||
`/${todolistId}`
|
`/todolists/${todolistId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTodolistTasks({ todolistId }: { todolistId: string }) {
|
async getTodolistTasks({ todolistId }: { todolistId: string }) {
|
||||||
const res = await todolistsInstance.get<TasksResponse>(
|
const res = await todolistApiInstance.get<TasksResponse>(
|
||||||
`/${todolistId}/tasks`
|
`/todolists/${todolistId}/tasks`
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -50,18 +52,18 @@ export const TodolistAPI = {
|
|||||||
todolistId: string;
|
todolistId: string;
|
||||||
title: string;
|
title: string;
|
||||||
}) {
|
}) {
|
||||||
const res = await todolistsInstance.post<CreateTaskResponse>(
|
const res = await todolistApiInstance.post<CreateTaskResponse>(
|
||||||
`/${todolistId}/tasks`,
|
`/todolists/${todolistId}/tasks`,
|
||||||
{ title }
|
{ title }
|
||||||
);
|
);
|
||||||
|
|
||||||
return handleError(res.data);
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateTask({ todolistId, task }: { todolistId: string; task: Task }) {
|
async updateTask({ todolistId, task }: { todolistId: string; task: Task }) {
|
||||||
const { id, ...rest } = task;
|
const { id, ...rest } = task;
|
||||||
const res = await todolistsInstance.put<UpdateTaskResponse>(
|
const res = await todolistApiInstance.patch<UpdateTaskResponse>(
|
||||||
`/${todolistId}/tasks/${id}`,
|
`/todolists/${todolistId}/tasks/${id}`,
|
||||||
rest
|
rest
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -75,8 +77,8 @@ export const TodolistAPI = {
|
|||||||
todolistId: string;
|
todolistId: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
}) {
|
}) {
|
||||||
const res = await todolistsInstance.delete<DeleteTaskResponse>(
|
const res = await todolistApiInstance.delete<DeleteTaskResponse>(
|
||||||
`/${todolistId}/tasks/${taskId}`
|
`/todolists/${todolistId}/tasks/${taskId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -48,7 +48,7 @@ export const useCreateTaskMutation = () => {
|
|||||||
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([QUERY_KEYS.TASKS, todolistId]);
|
||||||
},
|
},
|
||||||
@@ -60,9 +60,7 @@ export const useUpdateTaskMutation = () => {
|
|||||||
|
|
||||||
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([QUERY_KEYS.TASKS, todolistId]);
|
await queryClient.invalidateQueries([QUERY_KEYS.TASKS, todolistId]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,26 +1,19 @@
|
|||||||
import type { ApiResponse } from "@/helpers";
|
import type { ApiResponse } from "@/helpers";
|
||||||
|
|
||||||
export type UpdateTaskResponseData = {
|
export type TasksResponse = Task[];
|
||||||
item: Task;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TasksResponse = {
|
|
||||||
items: Task[];
|
|
||||||
totalCount: number;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Task = {
|
export type Task = {
|
||||||
id: string;
|
order: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string | null;
|
||||||
|
deadline?: string | null;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: TaskPriority;
|
||||||
|
ownerId: string;
|
||||||
todoListId: string;
|
todoListId: string;
|
||||||
order: number;
|
id: string;
|
||||||
status: number;
|
createdAt: string;
|
||||||
priority: number;
|
updatedAt: string;
|
||||||
startDate?: Date;
|
|
||||||
deadline?: Date;
|
|
||||||
addedDate: Date;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Todolist = {
|
export type Todolist = {
|
||||||
@@ -37,7 +30,8 @@ export type PostLoginArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type LoginResponseData = {
|
export type LoginResponseData = {
|
||||||
userId: number;
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MeResponseData = {
|
export type MeResponseData = {
|
||||||
@@ -46,7 +40,7 @@ export type MeResponseData = {
|
|||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoginResponse = ApiResponse<LoginResponseData>;
|
export type LoginResponse = LoginResponseData;
|
||||||
export type LogoutResponse = ApiResponse<never>;
|
export type LogoutResponse = ApiResponse<never>;
|
||||||
export type MeResponse = ApiResponse<MeResponseData>;
|
export type MeResponse = ApiResponse<MeResponseData>;
|
||||||
|
|
||||||
@@ -56,6 +50,18 @@ export type CreateTodolistResponseData = {
|
|||||||
item: Todolist;
|
item: Todolist;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateTaskResponse = ApiResponse<{ item: Task }>;
|
export type CreateTaskResponse = Task;
|
||||||
export type DeleteTaskResponse = ApiResponse<never>;
|
export type DeleteTaskResponse = void;
|
||||||
export type UpdateTaskResponse = ApiResponse<UpdateTaskResponseData>;
|
export type UpdateTaskResponse = void;
|
||||||
|
|
||||||
|
export enum TaskStatus {
|
||||||
|
OPEN = "OPEN",
|
||||||
|
IN_PROGRESS = "IN_PROGRESS",
|
||||||
|
DONE = "DONE",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TaskPriority {
|
||||||
|
LOW = "LOW",
|
||||||
|
MEDIUM = "MEDIUM",
|
||||||
|
HIGH = "HIGH",
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export const todolistsInstance = axios.create({
|
|
||||||
baseURL: "https://social-network.samuraijs.com/api/1.1/todo-lists",
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
@@ -2,47 +2,47 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
.loader {
|
.loader {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border: 3px dotted #000;
|
border: 3px dotted #000;
|
||||||
border-style: solid solid dotted dotted;
|
border-style: solid solid dotted dotted;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
animation: rotation 2s linear infinite;
|
animation: rotation 2s linear infinite;
|
||||||
}
|
}
|
||||||
.loader::after {
|
.loader::after {
|
||||||
content: '';
|
content: "";
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
border: 3px dotted #FF3D00;
|
border: 3px dotted #ff3d00;
|
||||||
border-style: solid solid dotted;
|
border-style: solid solid dotted;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: rotationBack 1s linear infinite;
|
animation: rotationBack 1s linear infinite;
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rotation {
|
@keyframes rotation {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes rotationBack {
|
@keyframes rotationBack {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(-360deg);
|
transform: rotate(-360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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