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:
2024-08-15 16:15:54 +02:00
65 changed files with 1618 additions and 650 deletions

View File

@@ -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
View 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
View 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
View 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 }
}

BIN
bun.lockb

Binary file not shown.

17
components.json Normal file
View 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"
}
}

View File

@@ -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"

View File

@@ -1,4 +0,0 @@
/** @type {import("prettier").Config} */
module.exports = {
plugins: [require.resolve("prettier-plugin-tailwindcss"), require.resolve("@it-incubator/prettier-config")],
};

View File

@@ -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}</>
}; }

View File

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

View File

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

View File

@@ -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"

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

View File

@@ -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"

View File

@@ -2,4 +2,4 @@ export const QUERY_KEYS = {
TODOLISTS: "todolists", TODOLISTS: "todolists",
TASKS: "tasks", TASKS: "tasks",
ME: "me", ME: "me",
} as const; } as const

View File

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

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

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

@@ -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 }

View File

@@ -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>

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"; 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;
};

View File

@@ -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"

View File

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

View File

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

View File

@@ -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

View File

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

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

View File

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

View File

@@ -1,2 +0,0 @@
export * from "./auth.api";
export * from "./auth.hooks";

View File

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

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

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

View File

@@ -0,0 +1,2 @@
export * from "./auth.api"
export * from "./auth.hooks"

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

View File

@@ -0,0 +1,3 @@
export * from "./todolists.hooks"
export * from "./todolists.api"
export * from "./todolists.types"

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

View File

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

View 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",
}

View File

@@ -1,3 +0,0 @@
export * from "./todolists.hooks";
export * from "./todolists.api";
export * from "./todolists.types";

View File

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

View File

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

View File

@@ -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>;

View File

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

View File

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

View File

@@ -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"]