From fd30aaecf4075118951d35bb6849e275f9e6ce81 Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 21 Feb 2025 01:13:33 +0800 Subject: [PATCH] feat: add authentication pages and layouts --- public/placeholder.svg | Bin 0 -> 3253 bytes src/app/(marketing)/auth/error/page.tsx | 15 ++ src/app/(marketing)/auth/layout.tsx | 37 +++ src/app/(marketing)/auth/loading.tsx | 5 + src/app/(marketing)/auth/login/page.tsx | 15 ++ .../(marketing)/auth/new-password/page.tsx | 15 ++ .../auth/new-verification/page.tsx | 15 ++ src/app/(marketing)/auth/register/page.tsx | 15 ++ src/app/(marketing)/auth/reset/page.tsx | 15 ++ src/app/(marketing)/page.tsx | 2 +- src/app/login/layout.tsx | 16 ++ src/components/shared/back-button-small.tsx | 37 +++ src/components/shared/form-error.tsx | 16 ++ src/components/shared/form-success.tsx | 16 ++ src/config/site.ts | 2 +- src/lib/metadata.ts | 69 ++++++ src/lib/schemas.ts | 213 ++++++++++++++++++ 17 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 public/placeholder.svg create mode 100644 src/app/(marketing)/auth/error/page.tsx create mode 100644 src/app/(marketing)/auth/layout.tsx create mode 100644 src/app/(marketing)/auth/loading.tsx create mode 100644 src/app/(marketing)/auth/login/page.tsx create mode 100644 src/app/(marketing)/auth/new-password/page.tsx create mode 100644 src/app/(marketing)/auth/new-verification/page.tsx create mode 100644 src/app/(marketing)/auth/register/page.tsx create mode 100644 src/app/(marketing)/auth/reset/page.tsx create mode 100644 src/app/login/layout.tsx create mode 100644 src/components/shared/back-button-small.tsx create mode 100644 src/components/shared/form-error.tsx create mode 100644 src/components/shared/form-success.tsx create mode 100644 src/lib/metadata.ts create mode 100644 src/lib/schemas.ts diff --git a/public/placeholder.svg b/public/placeholder.svg new file mode 100644 index 0000000000000000000000000000000000000000..e763910b27fdd9ac872f56baede51bc839402347 GIT binary patch literal 3253 zcmd^COK;ma5dJF!b4d=Q`F<2pfTHN`X|W3|+Cy%tEn7kr%a9a1-Cw^mByGfvH``;2 zKoF%F4re$Y=ONur`we+|=(kfv+j-u-TPzNT13zd!jGKiLLM%Xnl0&ze+loryP|`Nt zrk#(}y6gLjZij6{?{3FtIsd=#)yMaEQ8GSNRMW-X?S>4ydfCn2D#|VB`JUd@b4#+N ztKZ(^Main7e>fqy7m;}FxLq=Dxv_=_CV_TJGAFro{zPUr%ooA)X>wduo+L?WBFY5G z;z#NzC5l`zR;G*1Lfxa2$%$zmhp$aVuRcV)D9H>~5LVpC@C*93*nH>T%ODb8~Ghkd_T~kq$>x{W zY#L#BFpk(5xbw2rpvo(ES<~`0O*TeuiI0|hqducvA^l}Nt5@_qBn{GoCWzqZ8Scm! zS)M0_CHB|r^?7hO>mUKll4;3xz|aRqP?jl31yEC{^uXC7b~a0%3t5sxFzU;qEPJG~ zEX-!HVyDbzbEZ%!=r?59cRPl$U(s|x%?%{@^MrQJ(Ujb5eC#1Lntih!R;%KublfO7 zu>kml79B9!?13x2>mhQiW;hydwPZN7kqZ}kF1%qlkid=8ERC(=*4f^383+#YbJlzR zz$Bccgcvj2K?%{^#gN8GAWVZ!qhe`2Nh5}DP-)c6as{`Hv1Ja-yB;Vj0SO#&8yfGu z;=#K*B{?X8R0kmOdd6uL2158RA=m*d+k-n=`aXMA#^g|T3oS~x6*?BVHXe3jjM_t( z23x}xA)WG=H-PUhbB-H8mPsrKoOGoH=%2BB2G&IAA?mCH479apc!2Hf_MS=4V*p$< zq+E!5rQra3#-)KHJl+1D=0G_Q0jQcUD$9>o&YwOx(6?2yp6+h@Zrjx3?_<5{n(a(F zXl^wnPqLyI+QLD3d{Px<+(38u25dM-4R64MH&4l?Ed8|YW@zS*Q#1YzEw%Y``)R_? zJk7%ndd;w7%b_2}vjzmrQ~MQIa+2_{UQZ3Qc_`9g^U~56l0K>*lLU7zDa_3Cy)4e5 zxd_#l3>DVUC}9f-6_#Wx02xFJt95h*uuRxDj@dF}mcF3y*buW!n2ebbPy{^!2r~$+ z2q5;R;|zKdreykxfcQbkQp|Y(2Ez;?i=u>Cg6vHphMoQ|{8GpAg+=Fimp^^Fz_%XP g4q9Un(5`J;`a*sj+r&Ruh89PK@S4jc{*B!I3#bTKvH$=8 literal 0 HcmV?d00001 diff --git a/src/app/(marketing)/auth/error/page.tsx b/src/app/(marketing)/auth/error/page.tsx new file mode 100644 index 0000000..314cf9d --- /dev/null +++ b/src/app/(marketing)/auth/error/page.tsx @@ -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 ; +}; + +export default AuthErrorPage; diff --git a/src/app/(marketing)/auth/layout.tsx b/src/app/(marketing)/auth/layout.tsx new file mode 100644 index 0000000..7db59ab --- /dev/null +++ b/src/app/(marketing)/auth/layout.tsx @@ -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 ( +
+
+ {/* auth form */} +
+ +
{children}
+
+ + {/* brand image */} +
+ Image +
+
+
+ ); +} diff --git a/src/app/(marketing)/auth/loading.tsx b/src/app/(marketing)/auth/loading.tsx new file mode 100644 index 0000000..d730bb7 --- /dev/null +++ b/src/app/(marketing)/auth/loading.tsx @@ -0,0 +1,5 @@ +import { Loader2Icon } from "lucide-react"; + +export default function Loading() { + return ; +} diff --git a/src/app/(marketing)/auth/login/page.tsx b/src/app/(marketing)/auth/login/page.tsx new file mode 100644 index 0000000..183c561 --- /dev/null +++ b/src/app/(marketing)/auth/login/page.tsx @@ -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 ; +}; + +export default LoginPage; diff --git a/src/app/(marketing)/auth/new-password/page.tsx b/src/app/(marketing)/auth/new-password/page.tsx new file mode 100644 index 0000000..20e1744 --- /dev/null +++ b/src/app/(marketing)/auth/new-password/page.tsx @@ -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 ; +}; + +export default NewPasswordPage; diff --git a/src/app/(marketing)/auth/new-verification/page.tsx b/src/app/(marketing)/auth/new-verification/page.tsx new file mode 100644 index 0000000..92cb654 --- /dev/null +++ b/src/app/(marketing)/auth/new-verification/page.tsx @@ -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 ; +}; + +export default NewVerificationPage; diff --git a/src/app/(marketing)/auth/register/page.tsx b/src/app/(marketing)/auth/register/page.tsx new file mode 100644 index 0000000..6a5388c --- /dev/null +++ b/src/app/(marketing)/auth/register/page.tsx @@ -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 ; +}; + +export default RegisterPage; diff --git a/src/app/(marketing)/auth/reset/page.tsx b/src/app/(marketing)/auth/reset/page.tsx new file mode 100644 index 0000000..43b12a8 --- /dev/null +++ b/src/app/(marketing)/auth/reset/page.tsx @@ -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 ; +}; + +export default ResetPage; diff --git a/src/app/(marketing)/page.tsx b/src/app/(marketing)/page.tsx index 3eee014..78dfe60 100644 --- a/src/app/(marketing)/page.tsx +++ b/src/app/(marketing)/page.tsx @@ -6,7 +6,7 @@ export default function Home() {
Next.js logo + {children} + + ) +} diff --git a/src/components/shared/back-button-small.tsx b/src/components/shared/back-button-small.tsx new file mode 100644 index 0000000..edaaac6 --- /dev/null +++ b/src/components/shared/back-button-small.tsx @@ -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 ( + + ); +} diff --git a/src/components/shared/form-error.tsx b/src/components/shared/form-error.tsx new file mode 100644 index 0000000..6e0179c --- /dev/null +++ b/src/components/shared/form-error.tsx @@ -0,0 +1,16 @@ +import { TriangleAlertIcon } from "lucide-react"; + +interface FormErrorProps { + message?: string; +} + +export const FormError = ({ message }: FormErrorProps) => { + if (!message) return null; + + return ( +
+ +

{message}

+
+ ); +}; diff --git a/src/components/shared/form-success.tsx b/src/components/shared/form-success.tsx new file mode 100644 index 0000000..02e4dae --- /dev/null +++ b/src/components/shared/form-success.tsx @@ -0,0 +1,16 @@ +import { CircleCheckIcon } from "lucide-react"; + +interface FormSuccessProps { + message?: string; +} + +export const FormSuccess = ({ message }: FormSuccessProps) => { + if (!message) return null; + + return ( +
+ +

{message}

+
+ ); +}; diff --git a/src/config/site.ts b/src/config/site.ts index c9daa24..5ff9cdb 100644 --- a/src/config/site.ts +++ b/src/config/site.ts @@ -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", diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts new file mode 100644 index 0000000..c2430c2 --- /dev/null +++ b/src/lib/metadata.ts @@ -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, + }, + }), + }; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts new file mode 100644 index 0000000..71608ac --- /dev/null +++ b/src/lib/schemas.ts @@ -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; + +/** + * 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; + +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; + +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; + +/** + * 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; + +/** + * 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"), +});