diff --git a/public/placeholder.svg b/public/placeholder.svg new file mode 100644 index 0000000..e763910 Binary files /dev/null and b/public/placeholder.svg differ 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"), +});