diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..22ab8ed --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_ENDPOINT=https://api.pulseapp.cc/v1 \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 54e9519..5cc6e5e 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index ba9c479..5de44ae 100644 --- a/package.json +++ b/package.json @@ -14,18 +14,28 @@ "lint": "next lint" }, "dependencies": { + "@heroicons/react": "^2.1.5", + "@hookform/resolvers": "^3.9.0", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "framer-motion": "^11.5.4", "lucide-react": "^0.441.0", "next": "14.2.8", + "next-client-cookies": "^1.1.1", "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.53.0", + "react-social-icons": "^6.18.0", + "react-turnstile": "^1.1.3", "sharp": "^0.33.5", "tailwind-merge": "^2.5.2", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8", + "zustand": "^5.0.0-rc.2" }, "devDependencies": { "typescript": "^5", diff --git a/src/app/(pages)/auth/page.tsx b/src/app/(pages)/auth/page.tsx new file mode 100644 index 0000000..991a397 --- /dev/null +++ b/src/app/(pages)/auth/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ReactElement } from "react"; +import Branding from "@/components/branding"; +import OAuthProvider from "@/components/auth/oauth-provider"; +import { Separator } from "@/components/ui/separator"; +import AuthForm from "@/components/auth/auth-form"; +import { motion } from "framer-motion"; + +const AuthPage = (): ReactElement => ( +
+ +

+ Good Evening, +

+ + + +
+ +
+); + +const OAuthProviders = (): ReactElement => ( +
+

+ Continue with a third party... +

+
+ + +
+
+); + +export default AuthPage; diff --git a/src/app/(pages)/dashboard/page.tsx b/src/app/(pages)/dashboard/page.tsx new file mode 100644 index 0000000..7fb55e5 --- /dev/null +++ b/src/app/(pages)/dashboard/page.tsx @@ -0,0 +1,6 @@ +import { ReactElement } from "react"; + +const DashboardPage = (): ReactElement => ( +
PulseApp Dashboard
+); +export default DashboardPage; diff --git a/src/app/(pages)/layout.tsx b/src/app/(pages)/layout.tsx index f402f57..dbb80e1 100644 --- a/src/app/(pages)/layout.tsx +++ b/src/app/(pages)/layout.tsx @@ -5,6 +5,7 @@ import { ReactElement, ReactNode } from "react"; import { cn } from "@/lib/utils"; import { NextFont } from "next/dist/compiled/@next/font"; import { ThemeProvider } from "@/components/theme-provider"; +import { CookiesProvider } from "next-client-cookies/server"; const inter: NextFont = Inter({ subsets: ["latin"] }); @@ -46,10 +47,10 @@ const RootLayout = ({
- {children} + {children}
diff --git a/src/app/fonts/GeistMonoVF.woff b/src/app/fonts/GeistMonoVF.woff deleted file mode 100644 index f2ae185..0000000 Binary files a/src/app/fonts/GeistMonoVF.woff and /dev/null differ diff --git a/src/app/fonts/GeistVF.woff b/src/app/fonts/GeistVF.woff deleted file mode 100644 index 1b62daa..0000000 Binary files a/src/app/fonts/GeistVF.woff and /dev/null differ diff --git a/src/app/store/user-store.ts b/src/app/store/user-store.ts new file mode 100644 index 0000000..9c3ca72 --- /dev/null +++ b/src/app/store/user-store.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; +import { User } from "@/app/types/user"; + +export type UserStore = { + user: User | undefined; +}; + +const useUserStore = create((set) => ({ + user: undefined, +})); +export default useUserStore; diff --git a/src/app/types/api-error.tsx b/src/app/types/api-error.tsx new file mode 100644 index 0000000..f8f4df2 --- /dev/null +++ b/src/app/types/api-error.tsx @@ -0,0 +1,24 @@ +/** + * An error from the API. + */ +export type ApiError = { + /** + * The status of this error. + */ + status: string; + + /** + * The HTTP code of this error. + */ + code: number; + + /** + * The message for this error. + */ + message: string; + + /** + * The timestamp of this error. + */ + timestamp: Date; +}; diff --git a/src/app/types/session.ts b/src/app/types/session.ts new file mode 100644 index 0000000..958b82d --- /dev/null +++ b/src/app/types/session.ts @@ -0,0 +1,19 @@ +/** + * A session of a {@link User}. + */ +export type Session = { + /** + * The access token for this session. + */ + accessToken: string; + + /** + * The refresh token for this session. + */ + refreshToken: string; + + /** + * The unix time this session expires. + */ + expires: number; +}; diff --git a/src/app/types/user.ts b/src/app/types/user.ts new file mode 100644 index 0000000..a9d9a2d --- /dev/null +++ b/src/app/types/user.ts @@ -0,0 +1,36 @@ +export type User = { + /** + * The snowflake id of this user. + */ + snowflake: `${bigint}`; + + /** + * This user's email. + */ + email: string; + + /** + * This user's username. + */ + username: string; + + /** + * The tier of this user. + */ + tier: "FREE"; + + /** + * The flags for this user. + */ + flags: number; + + /** + * The date this user last logged in. + */ + lastLogin: Date; + + /** + * The date this user was created. + */ + created: Date; +}; diff --git a/src/components/animated-right-chevron.tsx b/src/components/animated-right-chevron.tsx new file mode 100644 index 0000000..eddf6b9 --- /dev/null +++ b/src/components/animated-right-chevron.tsx @@ -0,0 +1,23 @@ +import { ReactElement } from "react"; + +const AnimatedRightChevron = (): ReactElement => ( + +); +export default AnimatedRightChevron; diff --git a/src/components/auth/auth-form.tsx b/src/components/auth/auth-form.tsx new file mode 100644 index 0000000..52bd036 --- /dev/null +++ b/src/components/auth/auth-form.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { z } from "zod"; +import { ReactElement, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import AnimatedRightChevron from "@/components/animated-right-chevron"; +import { ArrowPathIcon } from "@heroicons/react/24/outline"; +import { apiRequest } from "@/lib/api"; +import { Session } from "@/app/types/session"; +import { Cookies, useCookies } from "next-client-cookies"; +import { useRouter } from "next/navigation"; +import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; +import { motion } from "framer-motion"; + +/** + * Define the form schemas for the various stages. + */ +const EmailSchema = z.object({ + email: z.string().email("Must be a valid email address"), +}); + +const RegisterSchema = z.object({ + email: z.string().email("Must be a valid email address"), + username: z.string(), + password: z.string(), + passwordConfirmation: z.string(), + captchaResponse: z.string(), +}); + +const LoginSchema = z.object({ + email: z.union([ + z.string().email("Must be a valid email address"), + z.string({ message: "Must be a valid username" }), + ]), + password: z.string(), + // captchaResponse: z.string(), +}); + +const inputVariants = { + hidden: { x: -50, opacity: 0 }, + visible: { x: 0, opacity: 1, transition: { duration: 0.15 } }, +}; + +const AuthForm = (): ReactElement => { + const [stage, setStage] = useState<"email" | "register" | "login">("email"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + const cookies: Cookies = useCookies(); + const router: AppRouterInstance = useRouter(); + + // Build the form + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver( + stage === "email" + ? EmailSchema + : stage === "register" + ? RegisterSchema + : LoginSchema + ), + }); + + /** + * Handle submitting the form. + */ + const onSubmit = async ({ + email, + username, + password, + passwordConfirmation, + captchaResponse, + }: any) => { + setLoading(true); + if (stage === "email") { + const { data, error } = await apiRequest<{ exists: boolean }>( + `/user/exists?email=${email}` + ); + setStage(data?.exists ? "login" : "register"); + } else if (stage === "login") { + const { data, error } = await apiRequest( + `/auth/login`, + "POST", + { + email, + password, + captchaResponse, + } + ); + if (error) { + setError(error.message); + } else { + cookies.set("session", JSON.stringify(data), { + expires: + ((data?.expires as number) - Date.now()) / 86_400_000, + secure: true, + sameSite: "lax", + }); + router.push("/dashboard"); + return; + } + } + setLoading(false); + }; + + // Render the form + return ( +
+

+ Or use your email address... +

+ + {/* Email Address */} + + + {/* Username */} + {stage === "register" && ( + + )} + + {/* Password */} + {stage !== "email" && ( + + + + )} + + {/* Password Confirmation */} + {stage === "register" && ( + + )} + + {/*{stage !== "email" && (*/} + {/* */} + {/*)}*/} + + {/* Submit Form */} + + + ); +}; +export default AuthForm; diff --git a/src/components/auth/oauth-provider.tsx b/src/components/auth/oauth-provider.tsx new file mode 100644 index 0000000..28ac623 --- /dev/null +++ b/src/components/auth/oauth-provider.tsx @@ -0,0 +1,46 @@ +import { ReactElement } from "react"; +import { Button } from "@/components/ui/button"; +import { SocialIcon } from "react-social-icons"; +import Link from "next/link"; + +/** + * The props for this oauth provider. + */ +type OAuthProviderProps = { + /** + * The name of this provider. + */ + name: string; + + /** + * The icon of this provider. + */ + icon: string; + + /** + * The link to login with this provider. + */ + link: string; +}; + +const OAuthProvider = ({ + name, + icon, + link, +}: OAuthProviderProps): ReactElement => ( + + + +); +export default OAuthProvider; diff --git a/src/components/auth/register-view.tsx b/src/components/auth/register-view.tsx new file mode 100644 index 0000000..41fbe71 --- /dev/null +++ b/src/components/auth/register-view.tsx @@ -0,0 +1,4 @@ +import { ReactElement } from "react"; + +const RegisterView = (): ReactElement =>
; +export default RegisterView; diff --git a/src/components/branding.tsx b/src/components/branding.tsx new file mode 100644 index 0000000..f1b7616 --- /dev/null +++ b/src/components/branding.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import Image from "next/image"; +import { cva } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const brandingVariants = cva( + "relative hover:opacity-75 transition-all transform-gpu", + { + variants: { + size: { + sm: "w-16 h-16", + default: "w-24 h-24", + lg: "w-32 h-32", + }, + }, + defaultVariants: { + size: "default", + }, + } +); + +/** + * The props for this component. + */ +type BrandingProps = { + /** + * The size of the branding. + */ + size?: "sm" | "default" | "lg"; + + /** + * The optional class name to apply to the branding. + */ + className?: string; +}; + +const Branding = ({ size, className }: BrandingProps) => ( + + PulseApp Logo + +); +export default Branding; diff --git a/src/components/landing/greeting.tsx b/src/components/landing/greeting.tsx index 3c544db..58fc6b6 100644 --- a/src/components/landing/greeting.tsx +++ b/src/components/landing/greeting.tsx @@ -2,18 +2,13 @@ import { Button } from "@/components/ui/button"; import Image from "next/image"; import Link from "next/link"; import { ReactElement } from "react"; +import Branding from "@/components/branding"; const Greeting = (): ReactElement => (
{/* Logo */} - PulseApp Logo + {/* Greeting */}

Pulse App

diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..e2cb807 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..2af4ec8 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,33 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..73775bf --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,33 @@ +/** + * Send a request to the API. + * + * @param endpoint the endpoint to request + * @param body the optional request body to send + * @param method the request method to use + */ +export const apiRequest = async ( + endpoint: string, + method?: string | undefined, + body?: any | undefined +): Promise<{ data: T | undefined; error: ApiError | undefined }> => { + const response: Response = await fetch( + `${process.env.NEXT_PUBLIC_API_ENDPOINT}${endpoint}`, + { + method: method ?? "GET", + body: + method === "POST" && body + ? new URLSearchParams(body) + : undefined, + headers: { + "Content-Type": `application/${method === "POST" ? "x-www-form-urlencoded" : "json"}`, + }, + } + ); + const data: T = await response.json(); + if (response.status !== 200) { + return { data: undefined, error: data as ApiError }; + } + return { data: data as T, error: undefined }; +}; + +import { ApiError } from "@/app/types/api-error"; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e644794..30bb919 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; -export function cn(...inputs: ClassValue[]) { +export const cn = (...inputs: ClassValue[]) => { return twMerge(clsx(inputs)); -} +};