This commit is contained in:
2024-05-02 23:50:42 +02:00
parent 0cab278f45
commit 53984d9c89
29 changed files with 2818 additions and 1864 deletions

View File

@@ -4,7 +4,11 @@
"project": "./tsconfig.json"
},
"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": {
"@typescript-eslint/consistent-type-imports": "warn"
}

View File

@@ -6,14 +6,21 @@
"build": "next build",
"dev": "next dev",
"lint": "next lint",
"start": "next start"
"start": "next start",
"format": "prettier --write ."
},
"dependencies": {
"@radix-ui/react-toggle-group": "^1.0.4",
"@tanstack/react-query": "^4.28.0",
"async-mutex": "^0.5.0",
"axios": "^1.3.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.377.0",
"next": "13.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwind-merge": "^2.3.0",
"zod": "^3.21.4"
},
"devDependencies": {

4049
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
/** @type {import("prettier").Config} */
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"),
],
};

View File

@@ -1,17 +1,26 @@
import type { ButtonHTMLAttributes, DetailedHTMLProps, FC } from "react";
import type { ComponentPropsWithoutRef, FC } from "react";
type Props = DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
import { cn } from "@/helpers";
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 (
<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>
<button
className={cn(
"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",
variant === "outlined" && "border-sky-700 bg-inherit text-sky-700",
variant === "icon" &&
"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}
/>
);
};

View File

@@ -7,11 +7,9 @@ type Props = DetailedHTMLProps<
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>
<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}
/>
);
};

View File

@@ -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 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 { Input } from "../input";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/toggle-group/toggle-group";
import {
TaskStatus,
useCreateTaskMutation,
useDeleteTaskMutation,
useDeleteTodolistMutation,
@@ -29,7 +39,11 @@ export const Todolist: FC<Props> = memo(({ todolist }) => {
const [filter, setFilter] = useState("all");
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 });
};
@@ -39,7 +53,8 @@ export const Todolist: FC<Props> = memo(({ todolist }) => {
const handleDeleteTodolist = (todolistId: string) => {
deleteTodolist({ todolistId });
};
const handleAddTask = () => {
const handleAddTask = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
createTask({ todolistId: todolist.id, title: newTaskTitle });
setNewTaskTitle("");
};
@@ -47,17 +62,17 @@ export const Todolist: FC<Props> = memo(({ todolist }) => {
setNewTaskTitle(e.target.value);
};
const handleFilterChange: MouseEventHandler<HTMLButtonElement> = (e) => {
setFilter(e.currentTarget.value as Filter);
const handleFilterChange = (value: string) => {
setFilter(value as Filter);
};
if (isLoading) return <div>loading...</div>;
const filteredTasks = tasks?.items?.filter((task) => {
const filteredTasks = tasks?.filter((task) => {
switch (filter) {
case "active":
return task.status === 0;
return task.status === TaskStatus.OPEN;
case "completed":
return task.status === 2;
return task.status === TaskStatus.DONE;
default:
return true;
}
@@ -67,40 +82,65 @@ export const Todolist: FC<Props> = memo(({ todolist }) => {
<div
key={todolist.id}
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 "}>
<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 className={"flex w-52 gap-4"}>
<form
onSubmit={handleAddTask}
className={"relative flex w-full items-center gap-4"}
>
<Input value={newTaskTitle} onChange={handleNewTaskTitleChange} />
<Button onClick={handleAddTask}>+</Button>
</div>
{filteredTasks?.map((task) => (
<div className={"flex items-center gap-2"} key={task.id}>
<input
onChange={() => handleChangeStatus(todolist.id, task)}
type={"checkbox"}
checked={[0, 1, 3].includes(task.status)}
/>
<div key={task.id}>{task.title}</div>
<button onClick={() => handleDeleteTask(todolist.id, task.id)}>
X
</button>
</div>
))}
<Button type={"submit"} variant={"icon"} className={"absolute right-4"}>
<Plus size={16} />
</Button>
</form>
{filteredTasks?.map((task) => {
return (
<div className={"flex cursor-pointer items-center"} key={task.id}>
<label className={"flex cursor-pointer items-center gap-2"}>
<input
onChange={() => handleChangeStatus(todolist.id, task)}
type={"checkbox"}
checked={TaskStatus.DONE === task.status}
/>
{task.title}
</label>
<Button
variant={"icon"}
onClick={() => handleDeleteTask(todolist.id, task.id)}
className={"ml-2"}
>
<Trash size={16} />
</Button>
</div>
);
})}
<div className={"flex"}>
<Button onClick={handleFilterChange} value={"all"}>
All
</Button>
<Button onClick={handleFilterChange} value={"active"}>
Active
</Button>
<Button onClick={handleFilterChange} value={"completed"}>
Completed
</Button>
<ToggleGroup
type="single"
onValueChange={handleFilterChange}
value={filter}
className={"w-full"}
>
<ToggleGroupItem className={"w-full"} value="all">
All
</ToggleGroupItem>
<ToggleGroupItem className={"w-full"} value="active">
Active
</ToggleGroupItem>
<ToggleGroupItem className={"w-full"} value="completed">
Completed
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
);

View 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
View File

@@ -5,7 +5,7 @@ const _clientEnv = clientSchema.safeParse(clientEnv);
export const formatErrors = (
/** @type {import('zod').ZodFormattedError<Map<string,string>,string>} */
errors,
errors
) =>
Object.entries(errors)
.map(([name, value]) => {
@@ -17,7 +17,7 @@ export const formatErrors = (
if (!_clientEnv.success) {
console.error(
"❌ Invalid environment variables:\n",
...formatErrors(_clientEnv.error.format()),
...formatErrors(_clientEnv.error.format())
);
throw new Error("Invalid environment variables");
}
@@ -25,7 +25,7 @@ if (!_clientEnv.success) {
for (let key of Object.keys(_clientEnv.data)) {
if (!key.startsWith("NEXT_PUBLIC_")) {
console.warn(
`❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`,
`❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`
);
throw new Error("Invalid public environment variable name");

2
src/env/server.mjs vendored
View File

@@ -11,7 +11,7 @@ const _serverEnv = serverSchema.safeParse(process.env);
if (!_serverEnv.success) {
console.error(
"❌ Invalid environment variables:\n",
...formatErrors(_serverEnv.error.format()),
...formatErrors(_serverEnv.error.format())
);
throw new Error("Invalid environment variables");
}

View 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));
}

View File

@@ -0,0 +1 @@
export * from "./classnames";

View File

@@ -1,11 +1,11 @@
import type { ApiResponse } from "@/helpers";
export const handleError = <T>(data: ApiResponse<T>): ApiResponse<T> => {
if (data.resultCode !== 0) {
const error = data?.messages?.[0] || "An error has occurred";
throw new Error(error);
}
// if (data.resultCode !== 0) {
// const error = data?.messages?.[0] || "An error has occurred";
//
// throw new Error(error);
// }
return data;
};

View File

@@ -1,3 +1,4 @@
export * from "./handle-error";
export * from "./no-refetch";
export * from "./api-response";
export * from "./classnames";

View File

@@ -1,4 +1,4 @@
import type { ChangeEvent } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { useState } from "react";
import { type NextPage } from "next";
@@ -23,7 +23,8 @@ const Home: NextPage = () => {
const { mutate: createTodolist } = useCreateTodolistMutation();
const handleAddTodolist = () => {
const handleAddTodolist = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
createTodolist({ title: newTodolistTitle });
setNewTodolistTitle("");
};
@@ -46,7 +47,7 @@ const Home: NextPage = () => {
<Button onClick={handleLogout}>Logout</Button>
</header>
<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"}>
new todolist
<Input
@@ -54,8 +55,8 @@ const Home: NextPage = () => {
onChange={handleNewTodolistTitleChange}
/>
</label>
<Button onClick={handleAddTodolist}>+</Button>
</div>
<Button type={"submit"}>+</Button>
</form>
<div className={"flex flex-wrap gap-4"}>
{todolists?.map((todolist) => {
return <Todolist todolist={todolist} key={todolist.id} />;

View File

@@ -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);
},
};

View File

@@ -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,
});

View File

@@ -1,2 +1,2 @@
export * from "./auth";
export * from "./todolists";
export * from "./todolist-api/auth";
export * from "./todolist-api/todolists";

View 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);
},
};

View File

@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import { QUERY_KEYS, ROUTES } from "@/constants";
import { noRefetch } from "@/helpers";
import { AuthApi } from "@/services/auth/auth.api";
import { AuthApi } from "@/services/todolist-api/auth/auth.api";
export const useMeQuery = () => {
return useQuery({

View 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);
}
);

View File

@@ -1,5 +1,3 @@
import { todolistsInstance } from "./todolists.instance";
import { handleError } from "@/helpers";
import type {
CreateTaskResponse,
@@ -11,33 +9,37 @@ import type {
Todolist,
UpdateTaskResponse,
} from "@/services";
import { todolistApiInstance } from "@/services/todolist-api/todolist-api.instance";
export const TodolistAPI = {
async getTodolists() {
const res = await todolistsInstance.get<Todolist[]>("/");
const res = await todolistApiInstance.get<Todolist[]>("/todolists");
return res.data;
},
async createTodolist({ title }: { title: string }) {
const res = await todolistsInstance.post<CreateTodolistResponse>("/", {
title,
});
const res = await todolistApiInstance.post<CreateTodolistResponse>(
"/todolists",
{
title,
}
);
return handleError(res.data);
},
async deleteTodolist({ todolistId }: { todolistId: string }) {
const res = await todolistsInstance.delete<DeleteTodolistResponse>(
`/${todolistId}`
const res = await todolistApiInstance.delete<DeleteTodolistResponse>(
`/todolists/${todolistId}`
);
return res.data;
},
async getTodolistTasks({ todolistId }: { todolistId: string }) {
const res = await todolistsInstance.get<TasksResponse>(
`/${todolistId}/tasks`
const res = await todolistApiInstance.get<TasksResponse>(
`/todolists/${todolistId}/tasks`
);
return res.data;
@@ -50,18 +52,18 @@ export const TodolistAPI = {
todolistId: string;
title: string;
}) {
const res = await todolistsInstance.post<CreateTaskResponse>(
`/${todolistId}/tasks`,
const res = await todolistApiInstance.post<CreateTaskResponse>(
`/todolists/${todolistId}/tasks`,
{ title }
);
return handleError(res.data);
return res.data;
},
async updateTask({ todolistId, task }: { todolistId: string; task: Task }) {
const { id, ...rest } = task;
const res = await todolistsInstance.put<UpdateTaskResponse>(
`/${todolistId}/tasks/${id}`,
const res = await todolistApiInstance.patch<UpdateTaskResponse>(
`/todolists/${todolistId}/tasks/${id}`,
rest
);
@@ -75,8 +77,8 @@ export const TodolistAPI = {
todolistId: string;
taskId: string;
}) {
const res = await todolistsInstance.delete<DeleteTaskResponse>(
`/${todolistId}/tasks/${taskId}`
const res = await todolistApiInstance.delete<DeleteTaskResponse>(
`/todolists/${todolistId}/tasks/${taskId}`
);
return res.data;

View File

@@ -48,7 +48,7 @@ export const useCreateTaskMutation = () => {
return useMutation({
mutationFn: TodolistAPI.createTask,
onSuccess: (res) => {
const todolistId = res.data.item.todoListId;
const todolistId = res.todoListId;
queryClient.invalidateQueries([QUERY_KEYS.TASKS, todolistId]);
},
@@ -60,9 +60,7 @@ export const useUpdateTaskMutation = () => {
return useMutation({
mutationFn: TodolistAPI.updateTask,
onSuccess: async (res) => {
const todolistId = res.data.item.todoListId;
onSuccess: async (_, { todolistId }) => {
await queryClient.invalidateQueries([QUERY_KEYS.TASKS, todolistId]);
},
});

View File

@@ -1,26 +1,19 @@
import type { ApiResponse } from "@/helpers";
export type UpdateTaskResponseData = {
item: Task;
};
export type TasksResponse = {
items: Task[];
totalCount: number;
error?: string;
};
export type TasksResponse = Task[];
export type Task = {
id: string;
order: string;
title: string;
description?: string;
description?: string | null;
deadline?: string | null;
status: TaskStatus;
priority: TaskPriority;
ownerId: string;
todoListId: string;
order: number;
status: number;
priority: number;
startDate?: Date;
deadline?: Date;
addedDate: Date;
id: string;
createdAt: string;
updatedAt: string;
};
export type Todolist = {
@@ -37,7 +30,8 @@ export type PostLoginArgs = {
};
export type LoginResponseData = {
userId: number;
accessToken: string;
refreshToken: string;
};
export type MeResponseData = {
@@ -46,7 +40,7 @@ export type MeResponseData = {
email: string;
};
export type LoginResponse = ApiResponse<LoginResponseData>;
export type LoginResponse = LoginResponseData;
export type LogoutResponse = ApiResponse<never>;
export type MeResponse = ApiResponse<MeResponseData>;
@@ -56,6 +50,18 @@ export type CreateTodolistResponseData = {
item: Todolist;
};
export type CreateTaskResponse = ApiResponse<{ item: Task }>;
export type DeleteTaskResponse = ApiResponse<never>;
export type UpdateTaskResponse = ApiResponse<UpdateTaskResponseData>;
export type CreateTaskResponse = Task;
export type DeleteTaskResponse = void;
export type UpdateTaskResponse = void;
export enum TaskStatus {
OPEN = "OPEN",
IN_PROGRESS = "IN_PROGRESS",
DONE = "DONE",
}
export enum TaskPriority {
LOW = "LOW",
MEDIUM = "MEDIUM",
HIGH = "HIGH",
}

View File

@@ -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,
});

View File

@@ -2,47 +2,47 @@
@tailwind components;
@tailwind utilities;
.loader {
width: 48px;
height: 48px;
border: 3px dotted #000;
border-style: solid solid dotted dotted;
border-radius: 50%;
display: inline-block;
position: relative;
box-sizing: border-box;
animation: rotation 2s linear infinite;
width: 48px;
height: 48px;
border: 3px dotted #000;
border-style: solid solid dotted dotted;
border-radius: 50%;
display: inline-block;
position: relative;
box-sizing: border-box;
animation: rotation 2s linear infinite;
}
.loader::after {
content: '';
box-sizing: border-box;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
border: 3px dotted #FF3D00;
border-style: solid solid dotted;
width: 24px;
height: 24px;
border-radius: 50%;
animation: rotationBack 1s linear infinite;
transform-origin: center center;
content: "";
box-sizing: border-box;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
border: 3px dotted #ff3d00;
border-style: solid solid dotted;
width: 24px;
height: 24px;
border-radius: 50%;
animation: rotationBack 1s linear infinite;
transform-origin: center center;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes rotationBack {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-360deg);
}
}
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-360deg);
}
}

View File

@@ -18,7 +18,6 @@
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"],
"exclude": ["node_modules"]