lucia authentication

This commit is contained in:
2025-01-28 15:09:06 -05:00
parent 84e5c9e399
commit 0ab4c88264
121 changed files with 5613 additions and 66 deletions

View File

@@ -5,13 +5,13 @@ import { eq } from "drizzle-orm";
import { discord, lucia } from "@/lib/auth";
import { db } from "@/server/db";
import { Paths } from "@/lib/constants";
import { users } from "@/server/db/schema";
import { users } from "@schemas/schema";
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const storedState = cookies().get("discord_oauth_state")?.value ?? null;
const storedState = (await cookies()).get("discord_oauth_state")?.value ?? null;
if (!code || !state || !storedState || state !== storedState) {
return new Response(null, {
@@ -58,10 +58,10 @@ export async function GET(request: Request): Promise<Response> {
});
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return new Response(null, {
status: 302,
headers: { Location: Paths.Dashboard },
headers: { Location: Paths.Home },
});
}
@@ -77,10 +77,10 @@ export async function GET(request: Request): Promise<Response> {
}
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return new Response(null, {
status: 302,
headers: { Location: Paths.Dashboard },
headers: { Location: Paths.Home },
});
} catch (e) {
// the specific error message depends on the provider

View File

@@ -11,7 +11,7 @@ export const metadata = {
export default async function LoginPage() {
const { user } = await validateRequest();
if (user) redirect(Paths.Dashboard);
if (user) redirect(Paths.Home);
return <Login />;
}

View File

@@ -18,7 +18,7 @@ export const metadata = {
export default async function ForgotPasswordPage() {
const { user } = await validateRequest();
if (user) redirect(Paths.Dashboard);
if (user) redirect(Paths.Home);
return (
<Card className="w-full max-w-md">

View File

@@ -19,7 +19,7 @@ export default async function VerifyEmailPage() {
const { user } = await validateRequest();
if (!user) redirect(Paths.Login);
if (user.emailVerified) redirect(Paths.Dashboard);
if (user.emailVerified) redirect(Paths.Home);
return (
<Card className="w-full max-w-md">

View File

@@ -1,3 +1,4 @@
import { env } from "@/env";
import { validateRequest } from "@/lib/auth/validate-request";
import { Paths } from "@/lib/constants";
@@ -20,7 +21,7 @@ interface Props {
}
export default async function DashboardPage({ searchParams }: Props) {
const { page, perPage } = myPostsSchema.parse(searchParams);
//const { page, perPage } = myPostsSchema.parse(searchParams);
const { user } = await validateRequest();
if (!user) redirect(Paths.Login);
@@ -32,7 +33,7 @@ export default async function DashboardPage({ searchParams }: Props) {
* @see https://nextjs.org/docs/app/building-your-application/data-fetching/patterns#parallel-data-fetching
*/
const promises = Promise.all([
api.post.myPosts.query({ page, perPage }),
// api.post.myPosts.query({ page, perPage }),
api.stripe.getPlan.query(),
]);
@@ -43,7 +44,7 @@ export default async function DashboardPage({ searchParams }: Props) {
<p className="text-sm text-muted-foreground">Manage your posts here</p>
</div>
<React.Suspense fallback={<PostsSkeleton />}>
<Posts promises={promises} />
{/* <Posts promises={promises} /> */}
</React.Suspense>
</div>
);

View File

@@ -2,7 +2,7 @@ import { accounts } from "@schemas/schema";
import { getViewAccounts } from "@actions/accountActions";
import AccountsTable from "@components/AccountsTable"; // Adjust the import path as necessary
import React, { Suspense } from 'react';
import { ColumnHeadings } from "@lib/utils";
import { ColumnHeadings } from "@src/lib/bb_utils";
import PageHero from "@components/PageHero";

View File

@@ -4,7 +4,7 @@ import PageHero from "@components/PageHero";
import UsersTable from "@src/components/admin/UsersTable";
import { Suspense } from "react";
import Loading from "@src/components/Loading/loading";
import { ColumnHeadings } from "@src/lib/utils";
import { ColumnHeadings } from "@src/lib/bb_utils";
export default async function UsersPage() {
const data = await getData();

View File

@@ -0,0 +1,32 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";
import { env } from "@/env";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g. when you make requests from Client Components).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({ headers: req.headers });
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,98 @@
import { headers } from "next/headers";
import type Stripe from "stripe";
import { env } from "@/env";
import { stripe } from "@/lib/stripe";
import { db } from "@/server/db";
import { users } from "@schemas/schema";
import { eq } from "drizzle-orm";
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get("Stripe-Signature") ?? "";
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
return new Response(
`Webhook Error: ${err instanceof Error ? err.message : "Unknown error."}`,
{ status: 400 },
);
}
switch (event.type) {
case "checkout.session.completed": {
const checkoutSessionCompleted = event.data.object;
const userId = checkoutSessionCompleted?.metadata?.userId;
if (!userId) {
return new Response("User id not found in checkout session metadata.", {
status: 404,
});
}
// Retrieve the subscription details from Stripe
const subscription = await stripe.subscriptions.retrieve(
checkoutSessionCompleted.subscription as string,
);
// Update the user stripe into in our database
// Since this is the initial subscription, we need to update
// the subscription id and customer id
await db
.update(users)
.set({
stripeSubscriptionId: subscription.id,
stripeCustomerId: subscription.customer as string,
stripePriceId: subscription.items.data[0]?.price.id,
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000,
),
})
.where(eq(users.id, userId));
break;
}
case "invoice.payment_succeeded": {
const invoicePaymentSucceeded = event.data.object;
const userId = invoicePaymentSucceeded?.metadata?.userId;
if (!userId) {
return new Response("User id not found in invoice metadata.", {
status: 404,
});
}
// Retrieve the subscription details from Stripe
const subscription = await stripe.subscriptions.retrieve(
invoicePaymentSucceeded.subscription as string,
);
// Update the price id and set the new period end
await db
.update(users)
.set({
stripePriceId: subscription.items.data[0]?.price.id,
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000,
),
})
.where(eq(users.id, userId));
break;
}
default:
console.warn(`Unhandled event type: ${event.type}`);
}
return new Response(null, { status: 200 });
}

48
src/app/icon.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { ImageResponse } from "next/og";
// Route segment config
export const runtime = "edge";
// Image metadata
export const size = {
width: 32,
height: 32,
};
export const contentType = "image/png";
// Image generation
export default function Icon() {
return new ImageResponse(
(
// ImageResponse JSX element
<div
tw="flex items-center justify-center bg-black text-[24px] leading-8 text-white"
style={{
width: 32,
height: 32,
}}
>
<svg
width="24"
height="24"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.85357 3.85355L7.65355 3.05353C8.2981 2.40901 9.42858 1.96172 10.552 1.80125C11.1056 1.72217 11.6291 1.71725 12.0564 1.78124C12.4987 1.84748 12.7698 1.97696 12.8965 2.10357C13.0231 2.23018 13.1526 2.50125 13.2188 2.94357C13.2828 3.37086 13.2779 3.89439 13.1988 4.44801C13.0383 5.57139 12.591 6.70188 11.9464 7.34645L7.49999 11.7929L6.35354 10.6465C6.15827 10.4512 5.84169 10.4512 5.64643 10.6465C5.45117 10.8417 5.45117 11.1583 5.64643 11.3536L7.14644 12.8536C7.34171 13.0488 7.65829 13.0488 7.85355 12.8536L8.40073 12.3064L9.57124 14.2572C9.65046 14.3893 9.78608 14.4774 9.9389 14.4963C10.0917 14.5151 10.2447 14.4624 10.3535 14.3536L12.3535 12.3536C12.4648 12.2423 12.5172 12.0851 12.495 11.9293L12.0303 8.67679L12.6536 8.05355C13.509 7.19808 14.0117 5.82855 14.1887 4.58943C14.2784 3.9618 14.2891 3.33847 14.2078 2.79546C14.1287 2.26748 13.9519 1.74482 13.6035 1.39645C13.2552 1.04809 12.7325 0.871332 12.2045 0.792264C11.6615 0.710945 11.0382 0.721644 10.4105 0.8113C9.17143 0.988306 7.80189 1.491 6.94644 2.34642L6.32322 2.96968L3.07071 2.50504C2.91492 2.48278 2.75773 2.53517 2.64645 2.64646L0.646451 4.64645C0.537579 4.75533 0.484938 4.90829 0.50375 5.0611C0.522563 5.21391 0.61073 5.34954 0.742757 5.42876L2.69364 6.59928L2.14646 7.14645C2.0527 7.24022 2.00002 7.3674 2.00002 7.50001C2.00002 7.63261 2.0527 7.75979 2.14646 7.85356L3.64647 9.35356C3.84173 9.54883 4.15831 9.54883 4.35357 9.35356C4.54884 9.1583 4.54884 8.84172 4.35357 8.64646L3.20712 7.50001L3.85357 6.85356L6.85357 3.85355ZM10.0993 13.1936L9.12959 11.5775L11.1464 9.56067L11.4697 11.8232L10.0993 13.1936ZM3.42251 5.87041L5.43935 3.85356L3.17678 3.53034L1.80638 4.90074L3.42251 5.87041ZM2.35356 10.3535C2.54882 10.1583 2.54882 9.8417 2.35356 9.64644C2.1583 9.45118 1.84171 9.45118 1.64645 9.64644L0.646451 10.6464C0.451188 10.8417 0.451188 11.1583 0.646451 11.3535C0.841713 11.5488 1.1583 11.5488 1.35356 11.3535L2.35356 10.3535ZM3.85358 11.8536C4.04884 11.6583 4.04885 11.3417 3.85359 11.1465C3.65833 10.9512 3.34175 10.9512 3.14648 11.1465L1.14645 13.1464C0.95119 13.3417 0.951187 13.6583 1.14645 13.8535C1.34171 14.0488 1.65829 14.0488 1.85355 13.8536L3.85358 11.8536ZM5.35356 13.3535C5.54882 13.1583 5.54882 12.8417 5.35356 12.6464C5.1583 12.4512 4.84171 12.4512 4.64645 12.6464L3.64645 13.6464C3.45119 13.8417 3.45119 14.1583 3.64645 14.3535C3.84171 14.5488 4.1583 14.5488 4.35356 14.3535L5.35356 13.3535ZM9.49997 6.74881C10.1897 6.74881 10.7488 6.1897 10.7488 5.5C10.7488 4.8103 10.1897 4.25118 9.49997 4.25118C8.81026 4.25118 8.25115 4.8103 8.25115 5.5C8.25115 6.1897 8.81026 6.74881 9.49997 6.74881Z"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>
</div>
),
// ImageResponse options
{
// For convenience, we can re-use the exported icons size metadata
// config to also set the ImageResponse's width and height.
...size,
},
);
}

View File

@@ -1,7 +1,7 @@
import React from "react";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { ColumnHeadings } from "@src/lib/utils";
import { ColumnHeadings } from "@src/lib/bb_utils";
import styles from '../styles.module.css';
import PageHero from "@components/PageHero";

View File

@@ -27,7 +27,7 @@ const Header: React.FC = () => {
href="/auth/signin"
passHref
style={{ color: '#FFFFFF', textDecoration: 'underline' }}>
Sign In
Sign In to BB
</NextLink>
</nav>
</div>

View File

@@ -205,10 +205,10 @@ export default function PopNav() {
<div className="space-y-6 border-t border-gray-200 px-4 py-6">
<div className="flow-root">
<a
href="/signin"
href="/login"
className="-m-2 block p-2 font-medium text-gray-900"
>
Sign in
Sign In
</a>
</div>
<div className="flow-root">
@@ -360,10 +360,10 @@ export default function PopNav() {
<div className="ml-auto flex items-center">
<div className="hidden lg:flex lg:flex-1 lg:items-center lg:justify-end lg:space-x-6">
<a
href="/signin"
href="/login"
className="text-sm font-medium text-gray-700 hover:text-gray-800"
>
Sign In
Sign In beats me
</a>
<span aria-hidden="true" className="h-6 w-px bg-gray-200" />
</div>

View File

@@ -0,0 +1,115 @@
import { forwardRef, type SVGProps } from "react";
import { cn } from "@/lib/utils";
const AnimatedSpinner = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
({ className, ...props }, ref) => (
<svg
ref={ref}
{...props}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={cn(className)}
>
<g className="animated-spinner">
<rect x="11" y="1" width="2" height="5" opacity=".14" />
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(30 12 12)"
opacity=".29"
/>
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(60 12 12)"
opacity=".43"
/>
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(90 12 12)"
opacity=".57"
/>
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(120 12 12)"
opacity=".71"
/>
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(150 12 12)"
opacity=".86"
/>
<rect x="11" y="1" width="2" height="5" transform="rotate(180 12 12)" />
</g>
</svg>
),
);
AnimatedSpinner.displayName = "AnimatedSpinner";
const CreditCard = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
({ className, ...props }, ref) => (
<svg
xmlns="http://www.w3.org/2000/svg"
ref={ref}
{...props}
viewBox="0 0 24 24"
className={cn(className)}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="5" width="20" height="14" rx="2"></rect>
<line x1="2" y1="10" x2="22" y2="10"></line>
</svg>
),
);
CreditCard.displayName = "CreditCard";
export { AnimatedSpinner, CreditCard };
export {
EyeOpenIcon,
EyeNoneIcon as EyeCloseIcon,
SunIcon,
MoonIcon,
ExclamationTriangleIcon,
ExitIcon,
EnterIcon,
GearIcon,
RocketIcon,
PlusIcon,
HamburgerMenuIcon,
Pencil2Icon,
UpdateIcon,
CheckCircledIcon,
PlayIcon,
TrashIcon,
ArchiveIcon,
ResetIcon,
DiscordLogoIcon,
FileTextIcon,
IdCardIcon,
PlusCircledIcon,
FilePlusIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
DotsHorizontalIcon,
ArrowLeftIcon,
} from "@radix-ui/react-icons";

115
src/components/icons.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { forwardRef, type SVGProps } from "react";
import { cn } from "@/lib/utils";
const AnimatedSpinner = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
({ className, ...props }, ref) => (
<svg
ref={ref}
{...props}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={cn(className)}
>
<g className="animated-spinner">
<rect x="11" y="1" width="2" height="5" opacity=".14" />
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(30 12 12)"
opacity=".29"
/>
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(60 12 12)"
opacity=".43"
/>
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(90 12 12)"
opacity=".57"
/>
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(120 12 12)"
opacity=".71"
/>
<rect
x="11"
y="1"
width="2"
height="5"
transform="rotate(150 12 12)"
opacity=".86"
/>
<rect x="11" y="1" width="2" height="5" transform="rotate(180 12 12)" />
</g>
</svg>
),
);
AnimatedSpinner.displayName = "AnimatedSpinner";
const CreditCard = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
({ className, ...props }, ref) => (
<svg
xmlns="http://www.w3.org/2000/svg"
ref={ref}
{...props}
viewBox="0 0 24 24"
className={cn(className)}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="5" width="20" height="14" rx="2"></rect>
<line x1="2" y1="10" x2="22" y2="10"></line>
</svg>
),
);
CreditCard.displayName = "CreditCard";
export { AnimatedSpinner, CreditCard };
export {
EyeOpenIcon,
EyeNoneIcon as EyeCloseIcon,
SunIcon,
MoonIcon,
ExclamationTriangleIcon,
ExitIcon,
EnterIcon,
GearIcon,
RocketIcon,
PlusIcon,
HamburgerMenuIcon,
Pencil2Icon,
UpdateIcon,
CheckCircledIcon,
PlayIcon,
TrashIcon,
ArchiveIcon,
ResetIcon,
DiscordLogoIcon,
FileTextIcon,
IdCardIcon,
PlusCircledIcon,
FilePlusIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
DotsHorizontalIcon,
ArrowLeftIcon,
} from "@radix-ui/react-icons";

View File

@@ -0,0 +1,35 @@
"use client";
import { forwardRef } from "react";
import { AnimatedSpinner } from "@/components/icons";
import { Button, type ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface LoadingButtonProps extends ButtonProps {
loading?: boolean;
}
const LoadingButton = forwardRef<HTMLButtonElement, LoadingButtonProps>(
({ loading = false, className, children, ...props }, ref) => {
return (
<Button
ref={ref}
{...props}
disabled={props.disabled ? props.disabled : loading}
className={cn(className, "relative")}
>
<span className={cn(loading ? "opacity-0" : "")}>{children}</span>
{loading ? (
<div className="absolute inset-0 grid place-items-center">
<AnimatedSpinner className="h-6 w-6" />
</div>
) : null}
</Button>
);
},
);
LoadingButton.displayName = "LoadingButton";
export { LoadingButton };

View File

@@ -0,0 +1,35 @@
"use client";
import { forwardRef } from "react";
import { AnimatedSpinner } from "@/components/icons";
import { Button, type ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface LoadingButtonProps extends ButtonProps {
loading?: boolean;
}
const LoadingButton = forwardRef<HTMLButtonElement, LoadingButtonProps>(
({ loading = false, className, children, ...props }, ref) => {
return (
<Button
ref={ref}
{...props}
disabled={props.disabled ? props.disabled : loading}
className={cn(className, "relative")}
>
<span className={cn(loading ? "opacity-0" : "")}>{children}</span>
{loading ? (
<div className="absolute inset-0 grid place-items-center">
<AnimatedSpinner className="h-6 w-6" />
</div>
) : null}
</Button>
);
},
);
LoadingButton.displayName = "LoadingButton";
export { LoadingButton };

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { EyeOpenIcon, EyeCloseIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Input, type InputProps } from "@/components/ui/input";
import { cn } from "@/lib/utils";
const PasswordInputComponent = React.forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
className={cn("pr-10", className)}
ref={ref}
{...props}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
disabled={props.value === "" || props.disabled}
>
{showPassword ? (
<EyeCloseIcon className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOpenIcon className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">
{showPassword ? "Hide password" : "Show password"}
</span>
</Button>
</div>
);
},
);
PasswordInputComponent.displayName = "PasswordInput";
export const PasswordInput = PasswordInputComponent;

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { EyeOpenIcon, EyeCloseIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Input, type InputProps } from "@/components/ui/input";
import { cn } from "@/lib/utils";
const PasswordInputComponent = React.forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
className={cn("pr-10", className)}
ref={ref}
{...props}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
disabled={props.value === "" || props.disabled}
>
{showPassword ? (
<EyeCloseIcon className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOpenIcon className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">
{showPassword ? "Hide password" : "Show password"}
</span>
</Button>
</div>
);
},
);
PasswordInputComponent.displayName = "PasswordInput";
export const PasswordInput = PasswordInputComponent;

View File

@@ -0,0 +1,102 @@
"use client";
import {
useState,
type ReactNode,
type Dispatch,
type SetStateAction,
} from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogTitle,
DialogContent,
DialogDescription,
DialogHeader,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { useMediaQuery } from "@/lib/hooks/use-media-query";
import { cn } from "@/lib/utils";
type StatefulContent = ({
open,
setOpen,
}: {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}) => ReactNode | ReactNode[];
export const ResponsiveDialog = (props: {
trigger: ReactNode;
title?: ReactNode;
description?: ReactNode;
children: ReactNode | ReactNode[] | StatefulContent;
footer?: ReactNode;
contentClassName?: string;
}) => {
const [open, setOpen] = useState(false);
const isDesktop = useMediaQuery("(min-width: 640px)");
return isDesktop ? (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{props.trigger}</DialogTrigger>
<DialogContent className={cn("max-w-md", props.contentClassName)}>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
<DialogDescription>{props.description}</DialogDescription>
</DialogHeader>
{isFunctionType(props.children)
? props.children({ open, setOpen })
: props.children}
</DialogContent>
{props.footer ? <DialogFooter>{props.footer}</DialogFooter> : null}
</Dialog>
) : (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{props.trigger}</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>{props.title}</DrawerTitle>
<DrawerDescription>{props.description}</DrawerDescription>
</DrawerHeader>
<div className={cn("px-4", props.contentClassName)}>
{isFunctionType(props.children)
? props.children({ open, setOpen })
: props.children}
</div>
<DrawerFooter className="pt-2">
{props.footer ? (
props.footer
) : (
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
)}
</DrawerFooter>
</DrawerContent>
</Drawer>
);
};
const isFunctionType = (
prop: ReactNode | ReactNode[] | StatefulContent,
): prop is ({
open,
setOpen,
}: {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}) => ReactNode | ReactNode[] => {
return typeof prop === "function";
};

View File

@@ -0,0 +1,102 @@
"use client";
import {
useState,
type ReactNode,
type Dispatch,
type SetStateAction,
} from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogTitle,
DialogContent,
DialogDescription,
DialogHeader,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { useMediaQuery } from "@/lib/hooks/use-media-query";
import { cn } from "@/lib/utils";
type StatefulContent = ({
open,
setOpen,
}: {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}) => ReactNode | ReactNode[];
export const ResponsiveDialog = (props: {
trigger: ReactNode;
title?: ReactNode;
description?: ReactNode;
children: ReactNode | ReactNode[] | StatefulContent;
footer?: ReactNode;
contentClassName?: string;
}) => {
const [open, setOpen] = useState(false);
const isDesktop = useMediaQuery("(min-width: 640px)");
return isDesktop ? (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{props.trigger}</DialogTrigger>
<DialogContent className={cn("max-w-md", props.contentClassName)}>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
<DialogDescription>{props.description}</DialogDescription>
</DialogHeader>
{isFunctionType(props.children)
? props.children({ open, setOpen })
: props.children}
</DialogContent>
{props.footer ? <DialogFooter>{props.footer}</DialogFooter> : null}
</Dialog>
) : (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{props.trigger}</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>{props.title}</DrawerTitle>
<DrawerDescription>{props.description}</DrawerDescription>
</DrawerHeader>
<div className={cn("px-4", props.contentClassName)}>
{isFunctionType(props.children)
? props.children({ open, setOpen })
: props.children}
</div>
<DrawerFooter className="pt-2">
{props.footer ? (
props.footer
) : (
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
)}
</DrawerFooter>
</DrawerContent>
</Drawer>
);
};
const isFunctionType = (
prop: ReactNode | ReactNode[] | StatefulContent,
): prop is ({
open,
setOpen,
}: {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}) => ReactNode | ReactNode[] => {
return typeof prop === "function";
};

View File

@@ -0,0 +1,25 @@
"use client";
import { forwardRef } from "react";
import { useFormStatus } from "react-dom";
import { LoadingButton } from "@/components/loading-button";
import type { ButtonProps } from "@/components/ui/button";
const SubmitButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, children, ...props }, ref) => {
const { pending } = useFormStatus();
return (
<LoadingButton
ref={ref}
{...props}
loading={pending}
className={className}
>
{children}
</LoadingButton>
);
},
);
SubmitButton.displayName = "SubmitButton";
export { SubmitButton };

View File

@@ -0,0 +1,25 @@
"use client";
import { forwardRef } from "react";
import { useFormStatus } from "react-dom";
import { LoadingButton } from "@/components/loading-button";
import type { ButtonProps } from "@/components/ui/button";
const SubmitButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, children, ...props }, ref) => {
const { pending } = useFormStatus();
return (
<LoadingButton
ref={ref}
{...props}
loading={pending}
className={className}
>
{children}
</LoadingButton>
);
},
);
SubmitButton.displayName = "SubmitButton";
export { SubmitButton };

View File

@@ -0,0 +1,9 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,9 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useTheme } from "next-themes";
import { SunIcon, MoonIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export const ThemeToggle = () => {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,38 @@
"use client";
import { useTheme } from "next-themes";
import { SunIcon, MoonIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export const ThemeToggle = () => {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross1Icon } from "@radix-ui/react-icons";
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross1Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,205 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

177
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,177 @@
import * as React from "react";
import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,125 @@
import * as React from "react";
import Link from "next/link";
import {
ChevronLeftIcon,
ChevronRightIcon,
DotsHorizontalIcon,
} from "@/components/icons";
import { cn } from "@/lib/utils";
import { buttonVariants, type ButtonProps } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
));
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<typeof Link>;
const PaginationLink = ({
className,
isActive,
size = "icon",
children,
...props
}: PaginationLinkProps) => (
<Link
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
>
{children}
</Link>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeftIcon className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRightIcon className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,24 @@
import { env } from "@/env";
export interface SubscriptionPlan {
name: string;
description: string;
features: string[];
stripePriceId: string;
}
export const freePlan: SubscriptionPlan = {
name: "Free",
description: "The free plan is limited to 3 posts.",
features: ["Up to 3 posts", "Limited support"],
stripePriceId: "",
};
export const proPlan: SubscriptionPlan = {
name: "Pro",
description: "The Pro plan has unlimited posts.",
features: ["Unlimited posts", "Priority support"],
stripePriceId: env.STRIPE_PRO_MONTHLY_PLAN_ID,
};
export const subscriptionPlans = [freePlan, proPlan];

View File

@@ -1,13 +1,13 @@
import { relations } from "drizzle-orm/relations";
import { user, authenticator } from "./schema";
import { users, authenticator } from "@schemas/schema";
export const authenticatorRelations = relations(authenticator, ({one}) => ({
user: one(user, {
user: one(users, {
fields: [authenticator.userId],
references: [user.id]
references: [users.id]
}),
}));
export const userRelations = relations(user, ({many}) => ({
export const userRelations = relations(users, ({many}) => ({
authenticators: many(authenticator),
}));

View File

@@ -1,9 +1,9 @@
import { pgTableCreator, integer, varchar, text, numeric, timestamp, unique, check, bigserial, date, boolean, uuid, bigint, real, doublePrecision, primaryKey, pgView, index, serial } from "drizzle-orm/pg-core"
import { relations, sql } from "drizzle-orm"
import { ConsoleLogWriter, relations, sql } from "drizzle-orm"
import { DATABASE_PREFIX as prefix } from "@lib/constants";
export const pgTable = pgTableCreator((name) => (prefix == "") ? name:`${prefix}_${name}`);
export const pgTable = pgTableCreator((name) => (prefix == "" || prefix == null) ? name: `${prefix}_${name}`);
///
export const products = pgTable("products", {
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "products_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
name: varchar({ length: 255 }).notNull(),
@@ -427,10 +427,10 @@ export const accounts = pgTable("accounts", {
export const users = pgTable("users",
{
id: varchar("id", { length: 21 }).primaryKey(),
name: varchar("name").notNull(),
username: varchar({ length: 50 }).notNull(),
name: varchar("name"),
username: varchar({ length: 50 }),
discordId: varchar("discord_id", { length: 255 }).unique(),
password_hash: varchar("password_hash", { length: 255 }).notNull(),
password_hash: varchar("password_hash", { length: 255 }),
email: varchar("email", { length: 255 }).unique().notNull(),
emailVerified: boolean("email_verified").default(false).notNull(),
hashedPassword: varchar("hashed_password", { length: 255 }),

71
src/env.js Normal file
View File

@@ -0,0 +1,71 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
DATABASE_URL: z
.string()
.url()
.refine(
(str) => !str.includes("YOUR_DATABASE_URL_HERE"),
"You forgot to change the default URL",
),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
MOCK_SEND_EMAIL: z.boolean().default(false),
DISCORD_CLIENT_ID: z.string().trim().min(1),
DISCORD_CLIENT_SECRET: z.string().trim().min(1),
SMTP_HOST: z.string().trim().min(1),
SMTP_PORT: z.number().int().min(1),
SMTP_USER: z.string().trim().min(1),
SMTP_PASSWORD: z.string().trim().min(1),
STRIPE_API_KEY: z.string().trim().min(1),
STRIPE_WEBHOOK_SECRET: z.string().trim().min(1),
STRIPE_PRO_MONTHLY_PLAN_ID: z.string().trim().min(1),
},
/**
* 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_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
NEXT_PUBLIC_APP_URL: z.string().url(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
// Server-side env vars
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: parseInt(process.env.SMTP_PORT ?? ""),
SMTP_USER: process.env.SMTP_USER,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
MOCK_SEND_EMAIL: process.env.MOCK_SEND_EMAIL === "true" || process.env.MOCK_SEND_EMAIL === "1",
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
STRIPE_API_KEY: process.env.STRIPE_API_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
STRIPE_PRO_MONTHLY_PLAN_ID: process.env.STRIPE_PRO_MONTHLY_PLAN_ID,
// Client-side env vars
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined.
* `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

282
src/lib/auth/actions.ts Normal file
View File

@@ -0,0 +1,282 @@
"use server";
/* eslint @typescript-eslint/no-explicit-any:0, @typescript-eslint/prefer-optional-chain:0 */
import { z } from "zod";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { generateId, Scrypt } from "lucia";
import { isWithinExpirationDate, TimeSpan, createDate } from "oslo";
import { generateRandomString, alphabet } from "oslo/crypto";
import { eq } from "drizzle-orm";
import { lucia } from "@/lib/auth";
import { db } from "@/server/db";
import {
loginSchema,
signupSchema,
type LoginInput,
type SignupInput,
resetPasswordSchema,
} from "@/lib/validators/auth";
import { emailVerificationCodes, passwordResetTokens, users } from "@schemas/schema";
import { sendMail, EmailTemplate } from "@/lib/email";
import { validateRequest } from "@/lib/auth/validate-request";
import { Paths } from "../constants";
import { env } from "@/env";
export interface ActionResponse<T> {
fieldError?: Partial<Record<keyof T, string | undefined>>;
formError?: string;
}
export async function login(_: any, formData: FormData): Promise<ActionResponse<LoginInput>> {
const obj = Object.fromEntries(formData.entries());
const parsed = loginSchema.safeParse(obj);
if (!parsed.success) {
const err = parsed.error.flatten();
return {
fieldError: {
email: err.fieldErrors.email?.[0],
password: err.fieldErrors.password?.[0],
},
};
}
const { email, password } = parsed.data;
const existingUser = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.email, email),
});
if (!existingUser || !existingUser?.hashedPassword) {
return {
formError: "Incorrect email or password",
};
}
const validPassword = await new Scrypt().verify(existingUser.hashedPassword, password);
if (!validPassword) {
return {
formError: "Incorrect email or password",
};
}
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect(Paths.Home);
}
export async function signup(_: any, formData: FormData): Promise<ActionResponse<SignupInput>> {
const obj = Object.fromEntries(formData.entries());
const parsed = signupSchema.safeParse(obj);
if (!parsed.success) {
const err = parsed.error.flatten();
return {
fieldError: {
email: err.fieldErrors.email?.[0],
password: err.fieldErrors.password?.[0],
},
};
}
const { email, password } = parsed.data;
const existingUser = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.email, email),
columns: { email: true },
});
if (existingUser) {
return {
formError: "Cannot create account with that email",
};
}
const userId = generateId(21);
const hashedPassword = await new Scrypt().hash(password);
await db.insert(users).values({
id: userId,
email,
hashedPassword,
});
const verificationCode = await generateEmailVerificationCode(userId, email);
await sendMail(email, EmailTemplate.EmailVerification, { code: verificationCode });
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect(Paths.VerifyEmail);
}
export async function logout(): Promise<{ error: string } | void> {
const { session } = await validateRequest();
if (!session) {
return {
error: "No session found",
};
}
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie();
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect("/");
}
export async function resendVerificationEmail(): Promise<{
error?: string;
success?: boolean;
}> {
const { user } = await validateRequest();
if (!user) {
return redirect(Paths.Login);
}
const lastSent = await db.query.emailVerificationCodes.findFirst({
where: (table, { eq }) => eq(table.userId, user.id),
columns: { expiresAt: true },
});
if (lastSent && isWithinExpirationDate(lastSent.expiresAt)) {
return {
error: `Please wait ${timeFromNow(lastSent.expiresAt)} before resending`,
};
}
const verificationCode = await generateEmailVerificationCode(user.id, user.email);
await sendMail(user.email, EmailTemplate.EmailVerification, { code: verificationCode });
return { success: true };
}
export async function verifyEmail(_: any, formData: FormData): Promise<{ error: string } | void> {
const code = formData.get("code");
if (typeof code !== "string" || code.length !== 8) {
return { error: "Invalid code" };
}
const { user } = await validateRequest();
if (!user) {
return redirect(Paths.Login);
}
const dbCode = await db.transaction(async (tx) => {
const item = await tx.query.emailVerificationCodes.findFirst({
where: (table, { eq }) => eq(table.userId, user.id),
});
if (item) {
await tx.delete(emailVerificationCodes).where(eq(emailVerificationCodes.id, item.id));
}
return item;
});
if (!dbCode || dbCode.code !== code) return { error: "Invalid verification code" };
if (!isWithinExpirationDate(dbCode.expiresAt)) return { error: "Verification code expired" };
if (dbCode.email !== user.email) return { error: "Email does not match" };
await lucia.invalidateUserSessions(user.id);
await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id));
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
redirect(Paths.Home);
}
export async function sendPasswordResetLink(
_: any,
formData: FormData,
): Promise<{ error?: string; success?: boolean }> {
const email = formData.get("email");
const parsed = z.string().trim().email().safeParse(email);
if (!parsed.success) {
return { error: "Provided email is invalid." };
}
try {
const user = await db.query.users.findFirst({
where: (table, { eq }) => eq(table.email, parsed.data),
});
if (!user || !user.emailVerified) return { error: "Provided email is invalid." };
const verificationToken = await generatePasswordResetToken(user.id);
const verificationLink = `${env.NEXT_PUBLIC_APP_URL}/reset-password/${verificationToken}`;
await sendMail(user.email, EmailTemplate.PasswordReset, { link: verificationLink });
return { success: true };
} catch (error) {
return { error: "Failed to send verification email." };
}
}
export async function resetPassword(
_: any,
formData: FormData,
): Promise<{ error?: string; success?: boolean }> {
const obj = Object.fromEntries(formData.entries());
const parsed = resetPasswordSchema.safeParse(obj);
if (!parsed.success) {
const err = parsed.error.flatten();
return {
error: err.fieldErrors.password?.[0] ?? err.fieldErrors.token?.[0],
};
}
const { token, password } = parsed.data;
const dbToken = await db.transaction(async (tx) => {
const item = await tx.query.passwordResetTokens.findFirst({
where: (table, { eq }) => eq(table.id, token),
});
if (item) {
await tx.delete(passwordResetTokens).where(eq(passwordResetTokens.id, item.id));
}
return item;
});
if (!dbToken) return { error: "Invalid password reset link" };
if (!isWithinExpirationDate(dbToken.expiresAt)) return { error: "Password reset link expired." };
await lucia.invalidateUserSessions(dbToken.userId);
const hashedPassword = await new Scrypt().hash(password);
await db.update(users).set({ hashedPassword }).where(eq(users.id, dbToken.userId));
const session = await lucia.createSession(dbToken.userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
redirect(Paths.Home);
}
const timeFromNow = (time: Date) => {
const now = new Date();
const diff = time.getTime() - now.getTime();
const minutes = Math.floor(diff / 1000 / 60);
const seconds = Math.floor(diff / 1000) % 60;
return `${minutes}m ${seconds}s`;
};
async function generateEmailVerificationCode(userId: string, email: string): Promise<string> {
await db.delete(emailVerificationCodes).where(eq(emailVerificationCodes.userId, userId));
const code = generateRandomString(8, alphabet("0-9")); // 8 digit code
await db.insert(emailVerificationCodes).values({
userId,
email,
code,
expiresAt: createDate(new TimeSpan(10, "m")), // 10 minutes
});
return code;
}
async function generatePasswordResetToken(userId: string): Promise<string> {
await db.delete(passwordResetTokens).where(eq(passwordResetTokens.userId, userId));
const tokenId = generateId(40);
await db.insert(passwordResetTokens).values({
id: tokenId,
userId,
expiresAt: createDate(new TimeSpan(2, "h")),
});
return tokenId;
}

55
src/lib/auth/index.ts Normal file
View File

@@ -0,0 +1,55 @@
import { Lucia, TimeSpan } from "lucia";
import { Discord } from "arctic";
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { env } from "@/env.js";
import { db } from "@/server/db";
import { sessions, users, type User as DbUser } from "@schemas/schema";
import { absoluteUrl } from "@/lib/utils"
// Uncomment the following lines if you are using nodejs 18 or lower. Not required in Node.js 20, CloudFlare Workers, Deno, Bun, and Vercel Edge Functions.
// import { webcrypto } from "node:crypto";
// globalThis.crypto = webcrypto as Crypto;
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users);
export const lucia = new Lucia(adapter, {
getSessionAttributes: (/* attributes */) => {
return {};
},
getUserAttributes: (attributes) => {
return {
id: attributes.id,
email: attributes.email,
emailVerified: attributes.emailVerified,
avatar: attributes.avatar,
createdAt: attributes.createdAt,
updatedAt: attributes.updatedAt,
};
},
sessionExpiresIn: new TimeSpan(30, "d"),
sessionCookie: {
name: "session",
expires: false, // session cookies have very long lifespan (2 years)
attributes: {
secure: env.NODE_ENV === "production",
},
},
});
export const discord = new Discord(
env.DISCORD_CLIENT_ID,
env.DISCORD_CLIENT_SECRET,
absoluteUrl("/login/discord/callback")
);
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseSessionAttributes: DatabaseSessionAttributes;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseSessionAttributes {}
interface DatabaseUserAttributes extends Omit<DbUser, "hashedPassword"> {}

View File

@@ -0,0 +1,40 @@
import { cache } from "react";
import { cookies } from "next/headers";
import type { Session, User } from "lucia";
import { lucia } from "@/lib/auth";
export const uncachedValidateRequest = async (): Promise<
{ user: User; session: Session } | { user: null; session: null }
> => {
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
//const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) {
return { user: null, session: null };
}
const result = await lucia.validateSession(sessionId);
// next.js throws when you attempt to set cookie when rendering page
try {
if (result.session && result.session.fresh) {
const sessionCookie = lucia.createSessionCookie(result.session.id);
(await cookies()).set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
}
if (!result.session) {
const sessionCookie = lucia.createBlankSessionCookie();
(await cookies()).set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
);
}
} catch {
console.error("Failed to set session cookie");
}
return result;
};
export const validateRequest = cache(uncachedValidateRequest);

23
src/lib/bb_utils.ts Normal file
View File

@@ -0,0 +1,23 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export class ColumnHeadings {
headingNames: string[];
current: number;
constructor(headingNames: string[]) {
console.log(headingNames.length);
this.headingNames = headingNames;
this.current = 0;
}
getHeading = () => {
const returnedHeading:string = this.headingNames[this.current];
this.current = this.current+1;
return returnedHeading;
}
}

67
src/lib/email/index.tsx Normal file
View File

@@ -0,0 +1,67 @@
import "server-only";
import { EmailVerificationTemplate } from "./templates/email-verification";
import { ResetPasswordTemplate } from "./templates/reset-password";
import { render } from "@react-email/render";
import { env } from "@/env";
import { EMAIL_SENDER } from "@/lib/constants";
import { createTransport, type TransportOptions } from "nodemailer";
import type { ComponentProps } from "react";
import { logger } from "../logger";
export enum EmailTemplate {
EmailVerification = "EmailVerification",
PasswordReset = "PasswordReset",
}
export type PropsMap = {
[EmailTemplate.EmailVerification]: ComponentProps<typeof EmailVerificationTemplate>;
[EmailTemplate.PasswordReset]: ComponentProps<typeof ResetPasswordTemplate>;
};
const getEmailTemplate = <T extends EmailTemplate>(template: T, props: PropsMap[NoInfer<T>]) => {
switch (template) {
case EmailTemplate.EmailVerification:
return {
subject: "Verify your email address",
body: render(
<EmailVerificationTemplate {...(props as PropsMap[EmailTemplate.EmailVerification])} />,
),
};
case EmailTemplate.PasswordReset:
return {
subject: "Reset your password",
body: render(
<ResetPasswordTemplate {...(props as PropsMap[EmailTemplate.PasswordReset])} />,
),
};
default:
throw new Error("Invalid email template");
}
};
const smtpConfig = {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
},
};
const transporter = createTransport(smtpConfig as TransportOptions);
export const sendMail = async <T extends EmailTemplate>(
to: string,
template: T,
props: PropsMap[NoInfer<T>],
) => {
if (env.MOCK_SEND_EMAIL) {
logger.info("📨 Email sent to:", to, "with template:", template, "and props:", props);
return;
}
const { subject, body } = getEmailTemplate(template, props);
return transporter.sendMail({ from: EMAIL_SENDER, to, subject, html: body });
};

View File

@@ -0,0 +1,75 @@
import { Body, Container, Head, Html, Preview, Section, Text } from "@react-email/components";
import { APP_TITLE } from "@/lib/constants";
export interface EmailVerificationTemplateProps {
code: string;
}
export const EmailVerificationTemplate = ({ code }: EmailVerificationTemplateProps) => {
return (
<Html>
<Head />
<Preview>Verify your email address to complete your {APP_TITLE} registration</Preview>
<Body style={main}>
<Container style={container}>
<Section>
<Text style={title}>{APP_TITLE}</Text>
<Text style={text}>Hi,</Text>
<Text style={text}>
Thank you for registering for an account on {APP_TITLE}. To complete your
registration, please verify your your account by using the following code:
</Text>
<Text style={codePlaceholder}>{code}</Text>
<Text style={text}>Have a nice day!</Text>
</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#f6f9fc",
padding: "10px 0",
};
const container = {
backgroundColor: "#ffffff",
border: "1px solid #f0f0f0",
padding: "45px",
};
const text = {
fontSize: "16px",
fontFamily:
"'Open Sans', 'HelveticaNeue-Light', 'Helvetica Neue Light', 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif",
fontWeight: "300",
color: "#404040",
lineHeight: "26px",
};
const title = {
...text,
fontSize: "22px",
fontWeight: "700",
lineHeight: "32px",
};
const codePlaceholder = {
backgroundColor: "#fbfbfb",
border: "1px solid #f0f0f0",
borderRadius: "4px",
color: "#1c1c1c",
fontFamily: "'Open Sans', 'Helvetica Neue', Arial",
fontSize: "15px",
textDecoration: "none",
textAlign: "center" as const,
display: "block",
width: "210px",
padding: "14px 7px",
};
// const anchor = {
// textDecoration: "underline",
// };

View File

@@ -0,0 +1,92 @@
import { render } from "@react-email/render";
import {
Body,
Button,
Container,
Head,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import { APP_TITLE } from "@/lib/constants";
export interface ResetPasswordTemplateProps {
link: string;
}
export const ResetPasswordTemplate = ({ link }: ResetPasswordTemplateProps) => {
return (
<Html>
<Head />
<Preview>Reset your password</Preview>
<Body style={main}>
<Container style={container}>
<Section>
<Text style={title}>{APP_TITLE}</Text>
<Text style={text}>Hi,</Text>
<Text style={text}>
Someone recently requested a password change for your {APP_TITLE} account. If this was
you, you can set a new password here:
</Text>
<Button style={button} href={link}>
Reset password
</Button>
<Text style={text}>
If you don&apos;t want to change your password or didn&apos;t request this, just
ignore and delete this message.
</Text>
<Text style={text}>
To keep your account secure, please don&apos;t forward this email to anyone.
</Text>
<Text style={text}>Have a nice day!</Text>
</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#f6f9fc",
padding: "10px 0",
};
const container = {
backgroundColor: "#ffffff",
border: "1px solid #f0f0f0",
padding: "45px",
};
const text = {
fontSize: "16px",
fontFamily:
"'Open Sans', 'HelveticaNeue-Light', 'Helvetica Neue Light', 'Helvetica Neue', Helvetica, Arial, 'Lucida Grande', sans-serif",
fontWeight: "300",
color: "#404040",
lineHeight: "26px",
};
const title = {
...text,
fontSize: "22px",
fontWeight: "700",
lineHeight: "32px",
};
const button = {
backgroundColor: "#09090b",
borderRadius: "4px",
color: "#fafafa",
fontFamily: "'Open Sans', 'Helvetica Neue', Arial",
fontSize: "15px",
textDecoration: "none",
textAlign: "center" as const,
display: "block",
width: "210px",
padding: "14px 7px",
};
// const anchor = {
// textDecoration: "underline",
// };

8
src/lib/fonts.ts Normal file
View File

@@ -0,0 +1,8 @@
import "@/styles/globals.css";
import { Inter as FontSans } from "next/font/google";
export const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});

68
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,68 @@
import { env } from "@/env";
import * as fs from "fs";
import * as path from "path";
enum LogLevel {
DEBUG = "DEBUG",
INFO = "INFO",
WARN = "WARN",
ERROR = "ERROR",
}
class Logger {
private level: LogLevel;
private logFilePath: string;
constructor(level: LogLevel = LogLevel.INFO, logFilePath = "application.log") {
this.level = level;
this.logFilePath = path.resolve(logFilePath);
}
private getTimestamp(): string {
return new Date().toISOString();
}
private formatMessage(level: LogLevel, args: unknown[]): string {
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : arg))
.join(" ");
if (env.NODE_ENV === "development") {
console.log(message);
}
return `[${this.getTimestamp()}] [${level}] ${message}`;
}
private log(level: LogLevel, ...args: unknown[]): void {
if (this.shouldLog(level)) {
const logMessage = this.formatMessage(level, args) + "\n";
fs.appendFile(this.logFilePath, logMessage, (err) => {
if (err) throw err;
});
}
}
private shouldLog(level: LogLevel): boolean {
const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
return levels.indexOf(level) >= levels.indexOf(this.level);
}
debug(...args: unknown[]): void {
this.log(LogLevel.DEBUG, ...args);
}
info(...args: unknown[]): void {
this.log(LogLevel.INFO, ...args);
}
warn(...args: unknown[]): void {
this.log(LogLevel.WARN, ...args);
}
error(...args: unknown[]): void {
this.log(LogLevel.ERROR, ...args);
}
}
export const logger = new Logger(env.NODE_ENV === "development" ? LogLevel.DEBUG : LogLevel.INFO);

7
src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,7 @@
import { env } from "@/env";
import Stripe from "stripe";
export const stripe = new Stripe(env.STRIPE_API_KEY, {
apiVersion: "2023-10-16",
typescript: true,
});

View File

@@ -1,23 +1,56 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { env } from "@/env";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
export class ColumnHeadings {
headingNames: string[];
current: number;
export const getExceptionType = (error: unknown) => {
const UnknownException = {
type: "UnknownException",
status: 500,
message: "An unknown error occurred",
};
constructor(headingNames: string[]) {
console.log(headingNames.length);
this.headingNames = headingNames;
this.current = 0;
}
getHeading = () => {
const returnedHeading:string = this.headingNames[this.current];
this.current = this.current+1;
return returnedHeading;
if (!error) return UnknownException;
if ((error as Record<string, unknown>).name === "DatabaseError") {
return {
type: "DatabaseException",
status: 400,
message: "Duplicate key entry",
};
}
return UnknownException;
};
export function formatDate(
date: Date | string | number,
options: Intl.DateTimeFormatOptions = {
month: "long",
day: "numeric",
year: "numeric",
},
) {
return new Intl.DateTimeFormat("en-US", {
...options,
}).format(new Date(date));
}
export function formatPrice(
price: number | string,
options: Intl.NumberFormatOptions = {},
) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: options.currency ?? "USD",
notation: options.notation ?? "compact",
...options,
}).format(Number(price));
}
export function absoluteUrl(path: string) {
return new URL(path, env.NEXT_PUBLIC_APP_URL).href
}

View File

@@ -0,0 +1,27 @@
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(1, "Please provide your password.").max(255),
});
export type SignupInput = z.infer<typeof signupSchema>;
export const loginSchema = z.object({
email: z.string().email("Please enter a valid email."),
password: z
.string()
.min(8, "Password is too short. Minimum 8 characters required.")
.max(255),
});
export type LoginInput = z.infer<typeof loginSchema>;
export const forgotPasswordSchema = z.object({
email: z.string().email(),
});
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
export const resetPasswordSchema = z.object({
token: z.string().min(1, "Invalid token"),
password: z.string().min(8, "Password is too short").max(255),
});
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;

View File

@@ -8,5 +8,30 @@ export const config = {
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
"/((?!api|static|.*\\..*|_next|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
};
// middleware.ts
import { verifyRequestOrigin } from "lucia";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest): Promise<NextResponse> {
if (request.method === "GET") {
return NextResponse.next();
}
const originHeader = request.headers.get("Origin");
const hostHeader = request.headers.get("Host");
if (
!originHeader ||
!hostHeader ||
!verifyRequestOrigin(originHeader, [hostHeader])
) {
return new NextResponse(null, {
status: 403,
});
}
return NextResponse.next();
}

12
src/server/api/root.ts Normal file
View File

@@ -0,0 +1,12 @@
import { postRouter } from "./routers/post/post.procedure";
import { stripeRouter } from "./routers/stripe/stripe.procedure";
import { userRouter } from "./routers/user/user.procedure";
import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
user: userRouter,
post: postRouter,
stripe: stripeRouter,
});
export type AppRouter = typeof appRouter;

Some files were not shown because too many files have changed in this diff Show More