initial commit, login page

This commit is contained in:
2022-11-19 17:30:58 +01:00
commit 41b49d6306
35 changed files with 4674 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import type { FC, ReactNode } from "react";
import { useEffect } from "react";
import { useRouter } from "next/router";
import { useMeQuery } from "../services/hooks";
import { Loader } from "./loader";
export const AuthRedirect: FC<{ children: ReactNode }> = ({ children }) => {
const router = useRouter();
const { data: user, isLoading, isError } = useMeQuery();
console.log(user);
const isAuthPage = router.pathname === "/login";
useEffect(() => {
console.log("here");
if (!isLoading && !user && !isAuthPage) {
console.log("here");
router.push("/login");
}
}, [user, isError, isLoading, isAuthPage, router]);
if (isLoading || (!user && !isAuthPage)) {
return (
<div className={"h-screen"}>
<Loader />
</div>
);
}
return <>{children}</>;
};

17
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,17 @@
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>
);
};

17
src/components/Input.tsx Normal file
View File

@@ -0,0 +1,17 @@
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,7 @@
export const Loader = () => {
return (
<div className={"flex h-full w-full items-center justify-center"}>
<span className="loader"></span>
</div>
);
};

35
src/env/client.mjs vendored Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import { clientEnv, clientSchema } from "./schema.mjs";
const _clientEnv = clientSchema.safeParse(clientEnv);
export const formatErrors = (
/** @type {import('zod').ZodFormattedError<Map<string,string>,string>} */
errors,
) =>
Object.entries(errors)
.map(([name, value]) => {
if (value && "_errors" in value)
return `${name}: ${value._errors.join(", ")}\n`;
})
.filter(Boolean);
if (!_clientEnv.success) {
console.error(
"❌ Invalid environment variables:\n",
...formatErrors(_clientEnv.error.format()),
);
throw new Error("Invalid environment variables");
}
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_'`,
);
throw new Error("Invalid public environment variable name");
}
}
export const env = _clientEnv.data;

29
src/env/schema.mjs vendored Normal file
View File

@@ -0,0 +1,29 @@
// @ts-check
import { z } from "zod";
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
export const serverSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
});
/**
* Specify your client-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
* To expose them to the client, prefix them with `NEXT_PUBLIC_`.
*/
export const clientSchema = z.object({
// NEXT_PUBLIC_CLIENTVAR: z.string(),
});
/**
* You can't destruct `process.env` as a regular object, so you have to do
* it manually here. This is because Next.js evaluates this at build time,
* and only used environment variables are included in the build.
* @type {{ [k in keyof z.infer<typeof clientSchema>]: z.infer<typeof clientSchema>[k] | undefined }}
*/
export const clientEnv = {
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
};

27
src/env/server.mjs vendored Normal file
View File

@@ -0,0 +1,27 @@
// @ts-check
/**
* This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars.
* It has to be a `.mjs`-file to be imported there.
*/
import { serverSchema } from "./schema.mjs";
import { env as clientEnv, formatErrors } from "./client.mjs";
const _serverEnv = serverSchema.safeParse(process.env);
if (!_serverEnv.success) {
console.error(
"❌ Invalid environment variables:\n",
...formatErrors(_serverEnv.error.format()),
);
throw new Error("Invalid environment variables");
}
for (let key of Object.keys(_serverEnv.data)) {
if (key.startsWith("NEXT_PUBLIC_")) {
console.warn("❌ You are exposing a server-side env-variable:", key);
throw new Error("You are exposing a server-side env-variable");
}
}
export const env = { ..._serverEnv.data, ...clientEnv };

20
src/pages/_app.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { type AppType } from "next/dist/shared/lib/utils";
import "../styles/globals.css";
import { QueryClient } from "@tanstack/query-core";
import { QueryClientProvider } from "@tanstack/react-query";
import { AuthRedirect } from "../components/AuthRedirect";
const queryClient = new QueryClient();
const MyApp: AppType = ({ Component, pageProps }) => {
return (
<QueryClientProvider client={queryClient}>
<AuthRedirect>
<Component {...pageProps} />
</AuthRedirect>
</QueryClientProvider>
);
};
export default MyApp;

27
src/pages/index.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { type NextPage } from "next";
import Head from "next/head";
import { Button } from "../components/Button";
import { useLogoutMutation } from "../services/hooks";
const Home: NextPage = () => {
const { mutate: logout } = useLogoutMutation();
const handleLogout = () => {
logout();
};
return (
<>
<Head>
<title>Todolist</title>
<meta name="description" content="Incubator todolist" />
<link rel="icon" href="/favicon.ico" />
</Head>
<header>
<h1>Todolist</h1>
<Button onClick={handleLogout}>Logout</Button>
</header>
<main>Hello</main>
</>
);
};
export default Home;

73
src/pages/login.tsx Normal file
View File

@@ -0,0 +1,73 @@
import React from "react";
import type { NextPage } from "next";
import { Input } from "../components/Input";
import { Button } from "../components/Button";
import { useLoginMutation } from "../services/hooks";
import { useRouter } from "next/router";
import { useQueryClient } from "@tanstack/react-query";
const Login: NextPage = () => {
const { mutateAsync: login } = useLoginMutation();
const router = useRouter();
const queryClient = useQueryClient();
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [remember, setRemember] = React.useState(true);
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const handleRememberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setRemember(e.target.checked);
};
const handleSubmit = () => {
login({
email,
password,
rememberMe: remember,
}).then(() => {
queryClient.invalidateQueries(["me"]);
router.push("/");
});
};
return (
<div className={"flex h-screen items-center justify-center"}>
<div className={"flex w-52 flex-col gap-3"}>
<label className={"flex flex-col gap-1"}>
Email
<Input value={email} onChange={handleEmailChange} type="email" />
</label>
<label className={"flex flex-col gap-1"}>
Password
<Input
type="password"
value={password}
onChange={handlePasswordChange}
/>
</label>
<label className={"flex items-center gap-2"}>
<input
type={"checkbox"}
checked={remember}
onChange={handleRememberChange}
/>
Remember me
</label>
<Button className={"w-full"} onClick={handleSubmit}>
Login
</Button>
</div>
</div>
);
};
export default Login;

35
src/services/hooks.ts Normal file
View File

@@ -0,0 +1,35 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { PostLoginArgs } from "./index";
import { deleteMe, getMe, postLogin } from "./index";
import { useRouter } from "next/router";
export const useLoginMutation = () => {
return useMutation({
mutationFn: (args: PostLoginArgs) => postLogin(args),
});
};
export const useMeQuery = () => {
return useQuery({
queryFn: () => getMe().then((res) => res.data),
queryKey: ["me"],
refetchInterval: 1000 * 60 * 60,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
refetchIntervalInBackground: false,
retry: false,
});
};
export const useLogoutMutation = () => {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: () => deleteMe(),
onSuccess: () => {
queryClient.invalidateQueries(["me"]);
router.push("/login");
},
});
};

30
src/services/index.ts Normal file
View File

@@ -0,0 +1,30 @@
import { instance } from "./instance";
const handleError = (data: any) => {
if (data.resultCode === 0) {
return data;
} else {
throw new Error(data.messages[0]);
}
};
export type PostLoginArgs = {
email: string;
password: string;
rememberMe: boolean;
};
export const postLogin = async (args: PostLoginArgs) => {
const data = await instance.post("auth/login", args);
return handleError(data.data);
};
export const getMe = async () => {
const data = await instance.get("auth/me");
return handleError(data.data);
};
export const deleteMe = async () => {
const data = await instance.delete("auth/login");
return handleError(data.data);
};

6
src/services/instance.ts Normal file
View File

@@ -0,0 +1,6 @@
import axios from "axios";
export const instance = axios.create({
baseURL: "https://social-network.samuraijs.com/api/1.1/",
withCredentials: true,
});

48
src/styles/globals.css Normal file
View File

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