feat: add authentication pages and layouts

This commit is contained in:
javayhu 2025-02-21 01:13:33 +08:00
parent 0298d48f8d
commit fd30aaecf4
17 changed files with 501 additions and 2 deletions

BIN
public/placeholder.svg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,15 @@
import { ErrorCard } from "@/components/auth/error-card";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
export const metadata = constructMetadata({
title: "Auth Error",
description: "Auth Error",
canonicalUrl: `${siteConfig.url}/auth/error`,
});
const AuthErrorPage = () => {
return <ErrorCard />;
};
export default AuthErrorPage;

View File

@ -0,0 +1,37 @@
import BackButtonSmall from "@/components/shared/back-button-small";
import Image from "next/image";
/**
* auth layout is different from other public layouts,
* so auth directory is not put in (public) directory.
*
* https://ui.shadcn.com/blocks#authentication-04
*/
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<div className="w-full lg:grid lg:min-h-screen lg:grid-cols-2">
{/* auth form */}
<div className="flex items-center justify-center relative w-full h-full min-h-screen">
<BackButtonSmall className="absolute top-6 left-6" />
<div className="w-full max-w-md px-4">{children}</div>
</div>
{/* brand image */}
<div className="hidden bg-muted lg:block">
<Image
src="/placeholder.svg"
alt="Image"
width="1920"
height="1080"
className="h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { Loader2Icon } from "lucide-react";
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -0,0 +1,15 @@
import { LoginForm } from "@/components/auth/login-form";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
export const metadata = constructMetadata({
title: "Login",
description: "Login to your account",
canonicalUrl: `${siteConfig.url}/auth/login`,
});
const LoginPage = () => {
return <LoginForm className="border-none" />;
};
export default LoginPage;

View File

@ -0,0 +1,15 @@
import { NewPasswordForm } from "@/components/auth/new-password-form";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
export const metadata = constructMetadata({
title: "New Password",
description: "Set a new password",
canonicalUrl: `${siteConfig.url}/auth/new-password`,
});
const NewPasswordPage = () => {
return <NewPasswordForm />;
};
export default NewPasswordPage;

View File

@ -0,0 +1,15 @@
import { NewVerificationForm } from "@/components/auth/new-verification-form";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
export const metadata = constructMetadata({
title: "New Verification",
description: "New Verification",
canonicalUrl: `${siteConfig.url}/auth/new-verification`,
});
const NewVerificationPage = () => {
return <NewVerificationForm />;
};
export default NewVerificationPage;

View File

@ -0,0 +1,15 @@
import { RegisterForm } from "@/components/auth/register-form";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
export const metadata = constructMetadata({
title: "Register",
description: "Create an account to get started",
canonicalUrl: `${siteConfig.url}/auth/register`,
});
const RegisterPage = () => {
return <RegisterForm />;
};
export default RegisterPage;

View File

@ -0,0 +1,15 @@
import { ResetForm } from "@/components/auth/reset-form";
import { siteConfig } from "@/config/site";
import { constructMetadata } from "@/lib/metadata";
export const metadata = constructMetadata({
title: "Reset Password",
description: "Reset your password",
canonicalUrl: `${siteConfig.url}/auth/reset`,
});
const ResetPage = () => {
return <ResetForm />;
};
export default ResetPage;

View File

@ -6,7 +6,7 @@ export default function Home() {
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
src="/logo.png"
alt="Next.js logo"
width={180}
height={38}

16
src/app/login/layout.tsx Normal file
View File

@ -0,0 +1,16 @@
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View File

@ -0,0 +1,37 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowLeftIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
interface BackButtonSmallProps {
href?: string;
className?: string;
}
export default function BackButtonSmall({
href,
className,
}: BackButtonSmallProps) {
const router = useRouter();
const handleBack = () => {
router.back();
};
return (
<Button
size="sm"
variant="outline"
className={cn("size-8 px-0", className)}
asChild
>
{/* if href is provided, use it, otherwise use the router.back() */}
<Link href={href || "#"} onClick={handleBack}>
<ArrowLeftIcon className="size-5" />
</Link>
</Button>
);
}

View File

@ -0,0 +1,16 @@
import { TriangleAlertIcon } from "lucide-react";
interface FormErrorProps {
message?: string;
}
export const FormError = ({ message }: FormErrorProps) => {
if (!message) return null;
return (
<div className="bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive">
<TriangleAlertIcon className="h-4 w-4" />
<p>{message}</p>
</div>
);
};

View File

@ -0,0 +1,16 @@
import { CircleCheckIcon } from "lucide-react";
interface FormSuccessProps {
message?: string;
}
export const FormSuccess = ({ message }: FormSuccessProps) => {
if (!message) return null;
return (
<div className="bg-emerald-500/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-emerald-500">
<CircleCheckIcon className="h-4 w-4" />
<p>{message}</p>
</div>
);
};

View File

@ -1,6 +1,6 @@
import type { SiteConfig } from "@/types";
const SITE_URL = process.env.NEXT_PUBLIC_APP_URL;
const SITE_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
export const siteConfig: SiteConfig = {
name: "Mkdirs",

69
src/lib/metadata.ts Normal file
View File

@ -0,0 +1,69 @@
import { siteConfig } from "@/config/site";
import type { Metadata } from "next";
/**
* Construct the metadata object for the current page (in docs/guides)
*/
export function constructMetadata({
title = siteConfig.name,
description = siteConfig.description,
canonicalUrl,
image = siteConfig.image,
noIndex = false,
}: {
title?: string;
description?: string;
canonicalUrl?: string;
image?: string;
noIndex?: boolean;
} = {}): Metadata {
const fullTitle = title
? `${title} - ${siteConfig.title}`
: siteConfig.title;
return {
title: fullTitle,
description,
keywords: siteConfig.keywords,
creator: siteConfig.author,
authors: [
{
name: siteConfig.author,
},
],
alternates: canonicalUrl
? {
canonical: canonicalUrl,
}
: undefined,
openGraph: {
type: "website",
locale: "en_US",
url: siteConfig.url,
title: fullTitle,
description,
siteName: title,
images: [image],
},
twitter: {
card: "summary_large_image",
title: fullTitle,
description,
images: [image],
site: siteConfig.url,
creator: siteConfig.author,
},
icons: {
icon: "/favicon.ico",
shortcut: "/favicon-32x32.png",
apple: "/apple-touch-icon.png",
},
metadataBase: new URL(siteConfig.url),
manifest: `${siteConfig.url}/site.webmanifest`,
...(noIndex && {
robots: {
index: false,
follow: false,
},
}),
};
}

213
src/lib/schemas.ts Normal file
View File

@ -0,0 +1,213 @@
import * as z from "zod";
/**
* newsletter schema
*/
export const NewsletterFormSchema = z.object({
email: z.string().email({
message: "Enter a valid email",
}),
});
export type NewsletterFormData = z.infer<typeof NewsletterFormSchema>;
/**
* submit item
*/
export const SubmitSchema = z.object({
name: z
.string()
.min(1, { message: "Name is required" })
.max(32, { message: "Name must be 32 or fewer characters long" }),
link: z.string().url({ message: "Invalid url" }),
description: z
.string()
.min(1, { message: "Description is required" })
.max(256, { message: "Description must be 256 or fewer characters long" }),
introduction: z
.string()
.min(1, { message: "Introduction is required" })
.max(4096, {
message: "Introduction must be 4096 or fewer characters long",
}),
tags: z.array(z.string()).min(1, { message: "Must select at least one tag" }),
categories: z
.array(z.string())
.min(1, { message: "Must select at least one category" }),
imageId: z.string().min(1, { message: "Must upload an image" }),
});
/**
* edit item
*/
export const EditSchema = SubmitSchema.extend({
id: z.string().min(1, { message: "ID is required" }),
pricePlan: z.string().min(1, { message: "Price plan is required" }),
planStatus: z.string().min(1, { message: "Plan status is required" }),
});
/**
* account settings
*/
export const SettingsSchema = z
.object({
name: z.string().min(1, { message: "Name is required" }),
link: z.string().optional(),
password: z.optional(z.string().min(6)),
newPassword: z.optional(z.string().min(6)),
})
.refine(
(data) => {
if (data.password && !data.newPassword) {
return false;
}
return true;
},
{
message: "New password is required!",
path: ["newPassword"],
},
)
.refine(
(data) => {
if (data.newPassword && !data.password) {
return false;
}
return true;
},
{
message: "Password is required!",
path: ["password"],
},
);
export const UserNameSchema = z.object({
name: z
.string()
.min(1, { message: "Name is required" })
.max(32, { message: "Name must be 32 or fewer characters long" }),
});
export type UserNameData = z.infer<typeof UserNameSchema>;
export const UserLinkSchema = z.object({
link: z
.string()
.min(0, { message: "Link is optional" })
.max(128, { message: "Link must be 128 or fewer characters long" }),
});
export type UserLinkData = z.infer<typeof UserLinkSchema>;
export const UserPasswordSchema = z
.object({
password: z.string().min(6, {
message: "Minimum of 6 characters required",
}),
newPassword: z.string().min(6, {
message: "Minimum of 6 characters required",
}),
confirmPassword: z.string().min(6, {
message: "Minimum of 6 characters required",
}),
})
.refine(
(data) => {
if (data.newPassword && !data.password) {
return false;
}
return true;
},
{
message: "Password is required",
path: ["password"],
},
)
.refine(
(data) => {
if (data.password && !data.newPassword) {
return false;
}
return true;
},
{
message: "New password is required",
path: ["newPassword"],
},
)
.refine(
(data) => {
if (data.newPassword !== data.confirmPassword) {
return false;
}
return true;
},
{
message: "Passwords do not match",
path: ["confirmPassword"],
},
);
export type UserPasswordData = z.infer<typeof UserPasswordSchema>;
/**
* auth related schemas
*/
export const NewPasswordSchema = z.object({
password: z.string().min(6, {
message: "Minimum of 6 characters required",
}),
});
export const ResetSchema = z.object({
email: z.string().email({
message: "Email is required",
}),
});
export const LoginSchema = z.object({
email: z.string().email({
message: "Email is required",
}),
password: z.string().min(1, {
message: "Password is required",
}),
});
export const RegisterSchema = z.object({
email: z.string().email({
message: "Email is required",
}),
password: z.string().min(6, {
message: "Minimum 6 characters required",
}),
name: z.string().min(1, {
message: "Name is required",
}),
});
export const ActivateSchema = z.object({
name: z.string().min(1, {
message: "GitHub username is required",
}),
license: z.string().min(1, {
message: "License key is required",
}),
});
export type ActivateFormData = z.infer<typeof ActivateSchema>;
/**
* og image schema
*/
export const ogImageSchema = z.object({
title: z.string(),
description: z.string().optional(),
type: z.string().optional(),
mode: z.enum(["light", "dark"]).default("light"),
});