mirror of
https://gitea.gofwd.group/dstrawsb/ballistic-builder.git
synced 2025-12-06 02:36:44 -05:00
lucia authentication
This commit is contained in:
2
.env
2
.env
@@ -1,5 +1,5 @@
|
||||
|
||||
DATABASE_URL="postgresql://postgres:cul8rman@portainer.dev.gofwd.group:5433/ballistic?schema=public"
|
||||
DATABASE_URL="postgresql://postgres:cul8rman@portainer.dev.gofwd.group:5433/ballistic"
|
||||
|
||||
|
||||
# This file will be committed to version control, so make sure not to have any
|
||||
|
||||
@@ -2,3 +2,4 @@ AUTH_SECRET="a73X70xifFO5+V9oQ+/NKDDTgA4dsuWWxvFX6T1v1ns=" # Added by `npx auth`
|
||||
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_bmV3LXN3YW4tMjguY2xlcmsuYWNjb3VudHMuZGV2JA
|
||||
CLERK_SECRET_KEY=••••••••••••••••••••••••••••••••••••••••••••••••••
|
||||
REACT_EDITOR=atom
|
||||
1
application.log
Normal file
1
application.log
Normal file
@@ -0,0 +1 @@
|
||||
[2025-01-28T04:58:56.488Z] [INFO] 📨 Email sent to: don@strawsburg.com with template: EmailVerification and props: {"code":"70365595"}
|
||||
@@ -2,9 +2,10 @@ import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
export default defineConfig({
|
||||
out: './src/drizzle/schema/',
|
||||
schema: './src/drizzle/schema/',
|
||||
schema: './src/drizzle/schema/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
24
package.json
24
package.json
@@ -21,8 +21,22 @@
|
||||
"@mui/styles": "^6.1.7",
|
||||
"@mui/system": "^6.1.7",
|
||||
"@mui/x-data-grid": "^7.22.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@react-email/components": "^0.0.32",
|
||||
"@react-email/render": "^1.0.4",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tanstack/react-query": "^4.35.3",
|
||||
|
||||
"@trpc/react-query": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/next": "^10.45.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"arctic": "^3.2.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -33,15 +47,23 @@
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "15.1.0",
|
||||
"next-themes": "^0.4.3",
|
||||
"nodemailer": "^6.10.0",
|
||||
"observable": "link:@trpc/server/observable",
|
||||
"oslo": "^1.2.1",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.13.1",
|
||||
"postgres": "^3.4.5",
|
||||
"prettier": "^3.4.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"sonner": "^1.7.2",
|
||||
"stripe": "^17.6.0",
|
||||
"superjson": "^2.2.2",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^11.0.3"
|
||||
"uuid": "^11.0.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@auth/drizzle-adapter": "^1.7.4",
|
||||
|
||||
1630
pnpm-lock.yaml
generated
1630
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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">
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
32
src/app/api/[trpc]/route.ts
Normal file
32
src/app/api/[trpc]/route.ts
Normal 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 };
|
||||
98
src/app/api/webhooks/stripe/route.ts
Normal file
98
src/app/api/webhooks/stripe/route.ts
Normal 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
48
src/app/icon.tsx
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
115
src/components/icons copy.tsx
Normal file
115
src/components/icons copy.tsx
Normal 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
115
src/components/icons.tsx
Normal 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";
|
||||
35
src/components/loading-button copy.tsx
Normal file
35
src/components/loading-button copy.tsx
Normal 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 };
|
||||
35
src/components/loading-button.tsx
Normal file
35
src/components/loading-button.tsx
Normal 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 };
|
||||
45
src/components/password-input copy.tsx
Normal file
45
src/components/password-input copy.tsx
Normal 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;
|
||||
45
src/components/password-input.tsx
Normal file
45
src/components/password-input.tsx
Normal 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;
|
||||
102
src/components/responsive-dialog copy.tsx
Normal file
102
src/components/responsive-dialog copy.tsx
Normal 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";
|
||||
};
|
||||
102
src/components/responsive-dialog.tsx
Normal file
102
src/components/responsive-dialog.tsx
Normal 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";
|
||||
};
|
||||
25
src/components/submit-button copy.tsx
Normal file
25
src/components/submit-button copy.tsx
Normal 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 };
|
||||
25
src/components/submit-button.tsx
Normal file
25
src/components/submit-button.tsx
Normal 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 };
|
||||
9
src/components/theme-provider copy.tsx
Normal file
9
src/components/theme-provider copy.tsx
Normal 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>;
|
||||
}
|
||||
9
src/components/theme-provider.tsx
Normal file
9
src/components/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
38
src/components/theme-toggle copy.tsx
Normal file
38
src/components/theme-toggle copy.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
src/components/theme-toggle.tsx
Normal file
38
src/components/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal 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 }
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 }
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal 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 }
|
||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal 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 }
|
||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
118
src/components/ui/drawer.tsx
Normal file
118
src/components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
205
src/components/ui/dropdown-menu.tsx
Normal file
205
src/components/ui/dropdown-menu.tsx
Normal 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
177
src/components/ui/form.tsx
Normal 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,
|
||||
};
|
||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal 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 }
|
||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal 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 }
|
||||
125
src/components/ui/pagination.tsx
Normal file
125
src/components/ui/pagination.tsx
Normal 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,
|
||||
};
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
31
src/components/ui/sonner.tsx
Normal file
31
src/components/ui/sonner.tsx
Normal 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 }
|
||||
55
src/components/ui/tabs.tsx
Normal file
55
src/components/ui/tabs.tsx
Normal 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 }
|
||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal 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 }
|
||||
24
src/config/subscriptions.ts
Normal file
24
src/config/subscriptions.ts
Normal 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];
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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
71
src/env.js
Normal 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
282
src/lib/auth/actions.ts
Normal 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
55
src/lib/auth/index.ts
Normal 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"> {}
|
||||
40
src/lib/auth/validate-request.ts
Normal file
40
src/lib/auth/validate-request.ts
Normal 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
23
src/lib/bb_utils.ts
Normal 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
67
src/lib/email/index.tsx
Normal 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 });
|
||||
};
|
||||
75
src/lib/email/templates/email-verification.tsx
Normal file
75
src/lib/email/templates/email-verification.tsx
Normal 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",
|
||||
// };
|
||||
92
src/lib/email/templates/reset-password.tsx
Normal file
92
src/lib/email/templates/reset-password.tsx
Normal 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't want to change your password or didn't request this, just
|
||||
ignore and delete this message.
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
To keep your account secure, please don'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
8
src/lib/fonts.ts
Normal 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",
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user