Initial Setup für Dokploy
This commit is contained in:
commit
85f20147ed
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
README.md
Normal file
36
README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
45
app/(app)/account/AccountCard.tsx
Normal file
45
app/(app)/account/AccountCard.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface AccountCardProps {
|
||||
params: {
|
||||
header: string;
|
||||
description: string;
|
||||
price?: number;
|
||||
};
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AccountCard({ params, children }: AccountCardProps) {
|
||||
const { header, description } = params;
|
||||
return (
|
||||
<Card>
|
||||
<div id="body" className="p-4 ">
|
||||
<h3 className="text-xl font-semibold">{header}</h3>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountCardBody({ children }: { children: React.ReactNode }) {
|
||||
return <div className="p-4">{children}</div>;
|
||||
}
|
||||
|
||||
export function AccountCardFooter({
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="bg-muted p-4 border dark:bg-card flex justify-between items-center rounded-b-lg"
|
||||
id="footer"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
app/(app)/account/PlanSettings.tsx
Normal file
73
app/(app)/account/PlanSettings.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import {
|
||||
AccountCard,
|
||||
AccountCardBody,
|
||||
AccountCardFooter,
|
||||
} from "./AccountCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { AuthSession } from "@/lib/auth/utils";
|
||||
|
||||
interface PlanSettingsProps {
|
||||
stripeSubscriptionId: string | null;
|
||||
stripeCurrentPeriodEnd: Date | null;
|
||||
stripeCustomerId: string | null;
|
||||
isSubscribed: boolean | "" | null;
|
||||
isCanceled: boolean;
|
||||
id?: string | undefined;
|
||||
name?: string | undefined;
|
||||
description?: string | undefined;
|
||||
stripePriceId?: string | undefined;
|
||||
price?: number | undefined;
|
||||
}
|
||||
export default function PlanSettings({
|
||||
subscriptionPlan,
|
||||
session,
|
||||
}: {
|
||||
subscriptionPlan: PlanSettingsProps;
|
||||
session: AuthSession["session"];
|
||||
}) {
|
||||
return (
|
||||
<AccountCard
|
||||
params={{
|
||||
header: "Your Plan",
|
||||
description: subscriptionPlan.isSubscribed
|
||||
? `You are currently on the ${subscriptionPlan.name} plan.`
|
||||
: `You are not subscribed to any plan.`.concat(
|
||||
!session?.user?.email || session?.user?.email.length < 5
|
||||
? " Please add your email to upgrade your account."
|
||||
: ""
|
||||
),
|
||||
}}
|
||||
>
|
||||
<AccountCardBody>
|
||||
{subscriptionPlan.isSubscribed ? (
|
||||
<h3 className="font-semibold text-lg">
|
||||
${subscriptionPlan.price ? subscriptionPlan.price / 100 : 0} / month
|
||||
</h3>
|
||||
) : null}
|
||||
{subscriptionPlan.stripeCurrentPeriodEnd ? (
|
||||
<p className="text-sm mb-4 text-muted-foreground ">
|
||||
Your plan will{" "}
|
||||
{!subscriptionPlan.isSubscribed
|
||||
? null
|
||||
: subscriptionPlan.isCanceled
|
||||
? "cancel"
|
||||
: "renew"}
|
||||
{" on "}
|
||||
<span className="font-semibold">
|
||||
{subscriptionPlan.stripeCurrentPeriodEnd.toLocaleDateString(
|
||||
"en-us"
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</AccountCardBody>
|
||||
<AccountCardFooter description="Manage your subscription on Stripe.">
|
||||
<Link href="/account/billing">
|
||||
<Button variant="outline">Go to billing</Button>
|
||||
</Link>
|
||||
</AccountCardFooter>
|
||||
</AccountCard>
|
||||
);
|
||||
}
|
||||
52
app/(app)/account/UpdateEmailCard.tsx
Normal file
52
app/(app)/account/UpdateEmailCard.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { AccountCard, AccountCardFooter, AccountCardBody } from "./AccountCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function UpdateEmailCard({ email }: { email: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
const target = event.target as HTMLFormElement;
|
||||
const form = new FormData(target);
|
||||
const { email } = Object.fromEntries(form.entries()) as { email: string };
|
||||
if (email.length < 3) {
|
||||
toast.error("Email must be longer than 3 characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/account", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ email }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (res.status === 200)
|
||||
toast.success("Successfully updated email!");
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AccountCard
|
||||
params={{
|
||||
header: "Your Email",
|
||||
description:
|
||||
"Please enter the email address you want to use with your account.",
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<AccountCardBody>
|
||||
<Input defaultValue={email ?? ""} name="email" disabled={true} />
|
||||
</AccountCardBody>
|
||||
<AccountCardFooter description="We will email vou to verify the change.">
|
||||
<Button disabled={true}>Update Email</Button>
|
||||
</AccountCardFooter>
|
||||
</form>
|
||||
</AccountCard>
|
||||
);
|
||||
}
|
||||
52
app/(app)/account/UpdateNameCard.tsx
Normal file
52
app/(app)/account/UpdateNameCard.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
import { AccountCard, AccountCardFooter, AccountCardBody } from "./AccountCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function UpdateNameCard({ name }: { name: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const handleSubmit = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
const target = event.target as HTMLFormElement;
|
||||
const form = new FormData(target);
|
||||
const { name } = Object.fromEntries(form.entries()) as { name: string };
|
||||
if (name.length < 3) {
|
||||
toast.error("Name must be longer than 3 characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/account", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (res.status === 200)
|
||||
toast.success("Successfully updated name!");
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AccountCard
|
||||
params={{
|
||||
header: "Your Name",
|
||||
description:
|
||||
"Please enter your full name, or a display name you are comfortable with.",
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<AccountCardBody>
|
||||
<Input defaultValue={name ?? ""} name="name" disabled={true} />
|
||||
</AccountCardBody>
|
||||
<AccountCardFooter description="64 characters maximum">
|
||||
<Button disabled={true}>Update Name</Button>
|
||||
</AccountCardFooter>
|
||||
</form>
|
||||
</AccountCard>
|
||||
);
|
||||
}
|
||||
17
app/(app)/account/UserSettings.tsx
Normal file
17
app/(app)/account/UserSettings.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
import UpdateNameCard from "./UpdateNameCard";
|
||||
import UpdateEmailCard from "./UpdateEmailCard";
|
||||
import { AuthSession } from "@/lib/auth/utils";
|
||||
|
||||
export default function UserSettings({
|
||||
session,
|
||||
}: {
|
||||
session: AuthSession["session"];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<UpdateNameCard name={session?.user.name ?? ""} />
|
||||
<UpdateEmailCard email={session?.user.email ?? ""} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
app/(app)/account/billing/ManageSubscription.tsx
Normal file
67
app/(app)/account/billing/ManageSubscription.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface ManageUserSubscriptionButtonProps {
|
||||
userId: string;
|
||||
email: string;
|
||||
isCurrentPlan: boolean;
|
||||
isSubscribed: boolean;
|
||||
stripeCustomerId?: string | null;
|
||||
stripePriceId: string;
|
||||
}
|
||||
|
||||
export function ManageUserSubscriptionButton({
|
||||
userId,
|
||||
email,
|
||||
isCurrentPlan,
|
||||
isSubscribed,
|
||||
stripeCustomerId,
|
||||
stripePriceId,
|
||||
}: ManageUserSubscriptionButtonProps) {
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/billing/manage-subscription", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
userId,
|
||||
isSubscribed,
|
||||
isCurrentPlan,
|
||||
stripeCustomerId,
|
||||
stripePriceId,
|
||||
}),
|
||||
});
|
||||
const session: { url: string } = await res.json();
|
||||
if (session) {
|
||||
window.location.href = session.url ?? "/dashboard/billing";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<Button
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
variant={isCurrentPlan ? "default" : "outline"}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isCurrentPlan ? "Manage Subscription" : "Subscribe"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
18
app/(app)/account/billing/SuccessToast.tsx
Normal file
18
app/(app)/account/billing/SuccessToast.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function SuccessToast() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const success = searchParams.get("success") as Boolean | null;
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
toast.success("Successfully updated subscription.");
|
||||
}
|
||||
}, [success]);
|
||||
|
||||
return null;
|
||||
}
|
||||
112
app/(app)/account/billing/page.tsx
Normal file
112
app/(app)/account/billing/page.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import SuccessToast from "./SuccessToast";
|
||||
import { ManageUserSubscriptionButton } from "./ManageSubscription";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { storeSubscriptionPlans } from "@/config/subscriptions";
|
||||
import { checkAuth, getUserAuth } from "@/lib/auth/utils";
|
||||
import { getUserSubscriptionPlan } from "@/lib/stripe/subscription";
|
||||
import { CheckCircle2Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Billing() {
|
||||
await checkAuth();
|
||||
const { session } = await getUserAuth();
|
||||
const subscriptionPlan = await getUserSubscriptionPlan();
|
||||
|
||||
if (!session) return redirect("/");
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-57px)] ">
|
||||
<SuccessToast />
|
||||
<Link href="/account">
|
||||
<Button variant={"link"} className="px-0">
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-semibold mb-4">Billing</h1>
|
||||
<Card className="p-6 mb-2">
|
||||
<h3 className="uppercase text-xs font-bold text-muted-foreground">
|
||||
Subscription Details
|
||||
</h3>
|
||||
<p className="text-lg font-semibold leading-none my-2">
|
||||
{subscriptionPlan.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{!subscriptionPlan.isSubscribed
|
||||
? "You are not subscribed to any plan."
|
||||
: subscriptionPlan.isCanceled
|
||||
? "Your plan will be canceled on "
|
||||
: "Your plan renews on "}
|
||||
{subscriptionPlan?.stripeCurrentPeriodEnd
|
||||
? subscriptionPlan.stripeCurrentPeriodEnd.toLocaleDateString()
|
||||
: null}
|
||||
</p>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-4">
|
||||
{storeSubscriptionPlans.map((plan) => (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className={
|
||||
plan.name === subscriptionPlan.name ? "border-primary" : ""
|
||||
}
|
||||
>
|
||||
{plan.name === subscriptionPlan.name ? (
|
||||
<div className="w-full relative">
|
||||
<div className="text-center px-3 py-1 bg-secondary-foreground text-secondary text-xs w-fit rounded-l-lg rounded-t-none absolute right-0 font-semibold">
|
||||
Current Plan
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<CardHeader className="mt-2">
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
<CardDescription>{plan.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mt-2 mb-8">
|
||||
<h3 className="font-bold">
|
||||
<span className="text-3xl">${plan.price / 100}</span> / month
|
||||
</h3>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{plan.features.map((feature, i) => (
|
||||
<li key={`feature_${i + 1}`} className="flex gap-x-2 text-sm">
|
||||
<CheckCircle2Icon className="text-green-400 h-5 w-5" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-end justify-center">
|
||||
{session?.user.email ? (
|
||||
<ManageUserSubscriptionButton
|
||||
userId={session.user.id}
|
||||
email={session.user.email || ""}
|
||||
stripePriceId={plan.stripePriceId}
|
||||
stripeCustomerId={subscriptionPlan?.stripeCustomerId}
|
||||
isSubscribed={!!subscriptionPlan.isSubscribed}
|
||||
isCurrentPlan={subscriptionPlan?.name === plan.name}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<Link href="/account">
|
||||
<Button className="text-center" variant="ghost">
|
||||
Add Email to Subscribe
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
app/(app)/account/page.tsx
Normal file
20
app/(app)/account/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import UserSettings from "./UserSettings";
|
||||
import PlanSettings from "./PlanSettings";
|
||||
import { checkAuth, getUserAuth } from "@/lib/auth/utils";
|
||||
import { getUserSubscriptionPlan } from "@/lib/stripe/subscription";
|
||||
|
||||
export default async function Account() {
|
||||
await checkAuth();
|
||||
const { session } = await getUserAuth();
|
||||
const subscriptionPlan = await getUserSubscriptionPlan();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1 className="text-2xl font-semibold my-4">Account</h1>
|
||||
<div className="space-y-4">
|
||||
<PlanSettings subscriptionPlan={subscriptionPlan} session={session} />
|
||||
<UserSettings session={session} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
17
app/(app)/dashboard/page.tsx
Normal file
17
app/(app)/dashboard/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
import SignIn from "@/components/auth/SignIn";
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
|
||||
export default async function Home() {
|
||||
const { session } = await getUserAuth();
|
||||
return (
|
||||
<main className="space-y-4">
|
||||
{session ? (
|
||||
<pre className="bg-secondary p-4 rounded-sm shadow-sm text-secondary-foreground break-all whitespace-break-spaces">
|
||||
{JSON.stringify(session, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
<SignIn />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
app/(app)/layout.tsx
Normal file
23
app/(app)/layout.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { checkAuth } from "@/lib/auth/utils";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import NextAuthProvider from "@/lib/auth/Provider";
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
await checkAuth();
|
||||
return ( <main>
|
||||
<NextAuthProvider><div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 md:p-8 pt-2 p-8 overflow-y-auto">
|
||||
<Navbar />
|
||||
{children}
|
||||
</main>
|
||||
</div></NextAuthProvider>
|
||||
|
||||
<Toaster richColors />
|
||||
</main> )
|
||||
}
|
||||
124
app/(app)/resend/page.tsx
Normal file
124
app/(app)/resend/page.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
import Link from "next/link"
|
||||
import { emailSchema } from "@/lib/email/utils";
|
||||
import { useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
type FormInput = z.infer<typeof emailSchema>;
|
||||
type Errors = { [K in keyof FormInput]: string[] };
|
||||
|
||||
export default function Home() {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [errors, setErrors] = useState<Errors | null>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const sendEmail = async () => {
|
||||
setSending(true);
|
||||
setErrors(null);
|
||||
try {
|
||||
const payload = emailSchema.parse({
|
||||
name: nameInputRef.current?.value,
|
||||
email: emailInputRef.current?.value,
|
||||
});
|
||||
console.log(payload);
|
||||
const req = await fetch("/api/email", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const { id } = await req.json();
|
||||
if (id) alert("Successfully sent!");
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
setErrors(err.flatten().fieldErrors as Errors);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<main className="p-4 md:p-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold my-4">Send Email with Resend</h1>
|
||||
<div>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>
|
||||
<Link
|
||||
className="text-primary hover:text-muted-foreground underline"
|
||||
href="https://resend.com/signup"
|
||||
>
|
||||
Sign up
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link
|
||||
className="text-primary hover:text-muted-foreground underline"
|
||||
href="https://resend.com/login"
|
||||
>
|
||||
Login
|
||||
</Link>{" "}
|
||||
to your Resend account
|
||||
</li>
|
||||
<li>Add and verify your domain</li>
|
||||
<li>
|
||||
Create an API Key and add to{" "}
|
||||
<span className="ml-1 font-mono font-thin text-neutral-600 bg-neutral-100 p-0.5">
|
||||
.env
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Update "from:" in{" "}
|
||||
<span className="ml-1 font-mono font-thin text-neutral-600 bg-neutral-100 p-0.5">
|
||||
app/api/email/route.ts
|
||||
</span>
|
||||
</li>
|
||||
<li>Send email 🎉</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="space-y-3 pt-4 border-t mt-4"
|
||||
>
|
||||
{errors && (
|
||||
<p className="bg-neutral-50 p-3">{JSON.stringify(errors, null, 2)}</p>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-neutral-700 text-sm">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tim"
|
||||
name="name"
|
||||
ref={nameInputRef}
|
||||
className={`
|
||||
w-full px-3 py-2 text-sm rounded-md border focus:outline-neutral-700 ${
|
||||
!!errors?.name ? "border-red-700" : "border-neutral-200"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="tim@apple.com"
|
||||
name="email"
|
||||
ref={emailInputRef}
|
||||
className={`
|
||||
w-full px-3 py-2 text-sm rounded-md border focus:outline-neutral-700 ${
|
||||
!!errors?.email ? "border-red-700" : "border-neutral-200"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => sendEmail()}
|
||||
className="text-sm bg-black text-white px-4 py-2.5 rounded-lg hover:bg-gray-800 disabled:opacity-70"
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? "sending..." : "Send Email"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
106
app/(app)/settings/page.tsx
Normal file
106
app/(app)/settings/page.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export default function Page() {
|
||||
const { setTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
<div className="space-y-4 my-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Appearance</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize the appearance of the app. Automatically switch between
|
||||
day and night themes.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
className="w-fit h-fit"
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
|
||||
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
||||
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
Light
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
onClick={() => setTheme("dark")}
|
||||
className="w-fit h-fit"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
||||
<div className="space-y-2 rounded-sm bg-neutral-950 p-2">
|
||||
<div className="space-y-2 rounded-md bg-neutral-800 p-2 shadow-sm">
|
||||
<div className="h-2 w-[80px] rounded-lg bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-neutral-800 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-neutral-800 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
Dark
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
onClick={() => setTheme("system")}
|
||||
className="w-fit h-fit"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
||||
<div className="space-y-2 rounded-sm bg-neutral-300 p-2">
|
||||
<div className="space-y-2 rounded-md bg-neutral-600 p-2 shadow-sm">
|
||||
<div className="h-2 w-[80px] rounded-lg bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-neutral-600 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-neutral-600 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
System
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
app/(auth)/layout.tsx
Normal file
13
app/(auth)/layout.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getUserAuth();
|
||||
if (session?.session) redirect("/dashboard");
|
||||
|
||||
return ( <div className="bg-muted h-screen pt-8">{children}</div> );
|
||||
}
|
||||
23
app/(auth)/sign-in/page.tsx
Normal file
23
app/(auth)/sign-in/page.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<main className="bg-popover max-w-lg mx-auto my-4 rounded-lg p-10">
|
||||
<h1 className="text-2xl font-bold text-center">
|
||||
Sign in to your account
|
||||
</h1>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => signIn(undefined, { callbackUrl: "/dashboard" })}
|
||||
className="w-full bg-primary text-primary-foreground text-center hover:opacity-90 font-medium px-4 py-2 rounded-lg block"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
36
app/Dockerfile
Normal file
36
app/Dockerfile
Normal file
@ -0,0 +1,36 @@
|
||||
# 1. Stage: Abhängigkeiten installieren
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# 2. Stage: Builder (Der Code wird kompiliert)
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Hier werden auch die Environment Variables für den Build gebraucht
|
||||
# (In Dokploy setzt du diese später, aber für den Build ignorieren wir ESLint Fehler oft)
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN npm run build
|
||||
|
||||
# 3. Stage: Runner (Das eigentliche Image für Dokploy - winzig klein)
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Wir kopieren nur das Nötigste aus dem Builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
15
app/api/account/route.ts
Normal file
15
app/api/account/route.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
import { db } from "@/lib/db/index";
|
||||
import { users } from "@/lib/db/schema/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const { session } = await getUserAuth();
|
||||
if (!session) return new Response("Error", { status: 400 });
|
||||
const body = (await request.json()) as { name?: string; email?: string };
|
||||
|
||||
await db.update(users).set({ ...body }).where(eq(users.id, session.user.id));
|
||||
revalidatePath("/account");
|
||||
return new Response(JSON.stringify({ message: "ok" }), { status: 200 });
|
||||
}
|
||||
14
app/api/auth/[...nextauth]/route.ts
Normal file
14
app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { DefaultSession } from "next-auth";
|
||||
import NextAuth from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth/utils";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
export { handler as GET, handler as POST };
|
||||
51
app/api/billing/manage-subscription/route.ts
Normal file
51
app/api/billing/manage-subscription/route.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { stripe } from "@/lib/stripe/index";
|
||||
import { absoluteUrl } from "@/lib/utils";
|
||||
|
||||
interface ManageStripeSubscriptionActionProps {
|
||||
isSubscribed: boolean;
|
||||
stripeCustomerId?: string | null;
|
||||
isCurrentPlan: boolean;
|
||||
stripePriceId: string;
|
||||
email: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body: ManageStripeSubscriptionActionProps = await req.json();
|
||||
const { isSubscribed, stripeCustomerId, userId, stripePriceId, email } = body;
|
||||
console.log(body);
|
||||
const billingUrl = absoluteUrl("/account/billing");
|
||||
|
||||
if (isSubscribed && stripeCustomerId) {
|
||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: billingUrl,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: stripeSession.url }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
const stripeSession = await stripe.checkout.sessions.create({
|
||||
success_url: billingUrl.concat("?success=true"),
|
||||
cancel_url: billingUrl,
|
||||
payment_method_types: ["card"],
|
||||
mode: "subscription",
|
||||
billing_address_collection: "auto",
|
||||
customer_email: email,
|
||||
line_items: [
|
||||
{
|
||||
price: stripePriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: stripeSession.url }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
22
app/api/email/route.ts
Normal file
22
app/api/email/route.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { EmailTemplate } from "@/components/emails/FirstEmail";
|
||||
import { resend } from "@/lib/email/index";
|
||||
import { emailSchema } from "@/lib/email/utils";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { name, email } = emailSchema.parse(body);
|
||||
try {
|
||||
const data = await resend.emails.send({
|
||||
from: "Kirimase <onboarding@resend.dev>",
|
||||
to: [email],
|
||||
subject: "Hello world!",
|
||||
react: EmailTemplate({ firstName: name }),
|
||||
text: "Email powered by Resend.",
|
||||
});
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error });
|
||||
}
|
||||
}
|
||||
98
app/api/webhooks/stripe/route.ts
Normal file
98
app/api/webhooks/stripe/route.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { db } from "@/lib/db/index";
|
||||
import { stripe } from "@/lib/stripe/index";
|
||||
import { headers } from "next/headers";
|
||||
import type Stripe from "stripe";
|
||||
import { subscriptions } from "@/lib/db/schema/subscriptions";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.text();
|
||||
const signature = headers().get("Stripe-Signature") ?? "";
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET || ""
|
||||
);
|
||||
console.log(event.type);
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
`Webhook Error: ${err instanceof Error ? err.message : "Unknown Error"}`,
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
// console.log("this is the session metadata -> ", session);
|
||||
|
||||
if (!session?.metadata?.userId && session.customer == null) {
|
||||
console.error("session customer", session.customer);
|
||||
console.error("no metadata for userid");
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string
|
||||
);
|
||||
const updatedData = {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
stripeCustomerId: subscription.customer as string,
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
||||
};
|
||||
|
||||
if (session?.metadata?.userId != null) {
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(eq(subscriptions.userId, session.metadata.userId));
|
||||
if (sub != undefined) {
|
||||
await db
|
||||
.update(subscriptions)
|
||||
.set(updatedData)
|
||||
.where(eq(subscriptions.userId, sub.userId!));
|
||||
} else {
|
||||
await db
|
||||
.insert(subscriptions)
|
||||
.values({ ...updatedData, userId: session.metadata.userId });
|
||||
}
|
||||
|
||||
} else if (
|
||||
typeof session.customer === "string" &&
|
||||
session.customer != null
|
||||
) {
|
||||
await db
|
||||
.update(subscriptions)
|
||||
.set(updatedData)
|
||||
.where(eq(subscriptions.stripeCustomerId, session.customer));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "invoice.payment_succeeded") {
|
||||
// Retrieve the subscription details from Stripe.
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string
|
||||
);
|
||||
|
||||
// Update the price id and set the new period end.
|
||||
await db
|
||||
.update(subscriptions)
|
||||
.set({
|
||||
stripePriceId: subscription.items.data[0].price.id,
|
||||
stripeCurrentPeriodEnd: new Date(
|
||||
subscription.current_period_end * 1000
|
||||
),
|
||||
})
|
||||
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
|
||||
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
76
app/globals.css
Normal file
76
app/globals.css
Normal file
@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
37
app/layout.tsx
Normal file
37
app/layout.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>{children}</ThemeProvider>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
25
app/loading.tsx
Normal file
25
app/loading.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="grid place-items-center animate-pulse text-neutral-300 p-4">
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-neutral-200 dark:text-neutral-600 fill-neutral-600 animate-spin"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
app/page.tsx
Normal file
183
app/page.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* v0 by Vercel.
|
||||
* @see https://v0.dev/t/PmwTvNfrVgf
|
||||
* Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
|
||||
*/
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<header className="px-4 lg:px-6 h-14 flex items-center">
|
||||
<Link className="flex items-center justify-center" href="#">
|
||||
<MountainIcon className="h-6 w-6" />
|
||||
<span className="sr-only">Acme Inc</span>
|
||||
</Link>
|
||||
<nav className="ml-auto flex gap-4 sm:gap-6">
|
||||
<Link
|
||||
className="text-sm font-medium hover:underline underline-offset-4"
|
||||
href="#features"
|
||||
>
|
||||
Features
|
||||
</Link>
|
||||
<Link
|
||||
className="text-sm font-medium hover:underline underline-offset-4"
|
||||
href="/sign-in"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
<section className="w-full py-6 sm:py-12 md:py-24 lg:py-32 xl:py-48">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_400px] lg:gap-12 xl:grid-cols-[1fr_600px]">
|
||||
<div className="bg-neutral-100 dark:bg-neutral-800 mx-auto aspect-video overflow-hidden rounded-xl object-cover sm:w-full lg:order-last lg:aspect-square" />
|
||||
<div className="flex flex-col justify-center space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-5xl xl:text-6xl/none">
|
||||
The complete platform <br />
|
||||
for building the Web
|
||||
</h1>
|
||||
<p className="max-w-[600px] text-neutral-500 md:text-xl dark:text-neutral-400">
|
||||
Give your team the toolkit to stop configuring and start
|
||||
innovating. Securely build, deploy, and scale the best web
|
||||
experiences.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 min-[400px]:flex-row">
|
||||
<Link
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-8 text-sm font-medium text-neutral-50 shadow transition-colors hover:bg-neutral-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90 dark:focus-visible:ring-neutral-300"
|
||||
href="#"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border border-neutral-200 bg-white px-8 text-sm font-medium shadow-sm transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50 dark:focus-visible:ring-neutral-300"
|
||||
href="#"
|
||||
>
|
||||
Contact Sales
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="features" className="w-full py-12 md:py-24 lg:py-32">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-block rounded-lg bg-neutral-100 px-3 py-1 text-sm dark:bg-neutral-800">
|
||||
Key Features
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight">
|
||||
Faster iteration. More innovation.
|
||||
</h2>
|
||||
<p className="max-w-[900px] text-neutral-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-neutral-400">
|
||||
The platform for rapid progress. Let your team focus on
|
||||
shipping features instead of managing infrastructure with
|
||||
automated CI/CD.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto grid max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-10">
|
||||
<div className="mx-auto aspect-video overflow-hidden bg-neutral-100 dark:bg-neutral-800 rounded-xl object-cover object-center sm:w-full lg:order-last" />
|
||||
<div className="flex flex-col justify-center space-y-4">
|
||||
<ul className="grid gap-6">
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Collaboration</h3>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">
|
||||
Make collaboration seamless with built-in code review
|
||||
tools.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Automation</h3>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">
|
||||
Automate your workflow with continuous integration.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="grid gap-1">
|
||||
<h3 className="text-xl font-bold">Scale</h3>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">
|
||||
Deploy to the cloud with a single click and scale with
|
||||
ease.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 border-t">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl">
|
||||
Sign Up for Updates
|
||||
</h2>
|
||||
<p className="max-w-[600px] text-neutral-500 md:text-xl dark:text-neutral-400">
|
||||
Stay updated with the latest product news and updates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<form className="flex sm:flex-row flex-col space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<input
|
||||
className="max-w-lg flex-1 px-4 py-2 border-border border rounded-md "
|
||||
placeholder="Enter your email"
|
||||
type="email"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-4 text-sm font-medium text-neutral-50 shadow transition-colors hover:bg-neutral-900/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90 dark:focus-visible:ring-neutral-300"
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
© 2024 Acme Inc. All rights reserved.
|
||||
</p>
|
||||
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
|
||||
<Link className="text-xs hover:underline underline-offset-4" href="#">
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link className="text-xs hover:underline underline-offset-4" href="#">
|
||||
Privacy
|
||||
</Link>
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MountainIcon(props: any) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m8 3 4 8 5-5 5 15H2L8 3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
components.json
Normal file
16
components.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
45
components/Navbar.tsx
Normal file
45
components/Navbar.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { AlignRight } from "lucide-react";
|
||||
import { defaultLinks } from "@/config/nav";
|
||||
|
||||
export default function Navbar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="md:hidden border-b mb-4 pb-2 w-full">
|
||||
<nav className="flex justify-between w-full items-center">
|
||||
<div className="font-semibold text-lg">Logo</div>
|
||||
<Button variant="ghost" onClick={() => setOpen(!open)}>
|
||||
<AlignRight />
|
||||
</Button>
|
||||
</nav>
|
||||
{open ? (
|
||||
<div className="my-4 p-4 bg-muted">
|
||||
<ul className="space-y-2">
|
||||
{defaultLinks.map((link) => (
|
||||
<li key={link.title} onClick={() => setOpen(false)} className="">
|
||||
<Link
|
||||
href={link.href}
|
||||
className={
|
||||
pathname === link.href
|
||||
? "text-primary hover:text-primary font-semibold"
|
||||
: "text-muted-foreground hover:text-primary"
|
||||
}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
components/Sidebar.tsx
Normal file
55
components/Sidebar.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import SidebarItems from "./SidebarItems";
|
||||
import { Avatar, AvatarFallback } from "./ui/avatar";
|
||||
|
||||
import { AuthSession, getUserAuth } from "@/lib/auth/utils";
|
||||
|
||||
const Sidebar = async () => {
|
||||
const session = await getUserAuth();
|
||||
if (session.session === null) return null;
|
||||
|
||||
return (
|
||||
<aside className="h-screen min-w-52 bg-muted hidden md:block p-4 pt-8 border-r border-border shadow-inner">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold ml-4">Logo</h3>
|
||||
<SidebarItems />
|
||||
</div>
|
||||
<UserDetails session={session} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
const UserDetails = ({ session }: { session: AuthSession }) => {
|
||||
if (session.session === null) return null;
|
||||
const { user } = session.session;
|
||||
|
||||
if (!user?.name || user.name.length == 0) return null;
|
||||
|
||||
return (
|
||||
<Link href="/account">
|
||||
<div className="flex items-center justify-between w-full border-t border-border pt-4 px-2">
|
||||
<div className="text-muted-foreground">
|
||||
<p className="text-xs">{user.name ?? "John Doe"}</p>
|
||||
<p className="text-xs font-light pr-4">
|
||||
{user.email ?? "john@doe.com"}
|
||||
</p>
|
||||
</div>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="border-border border-2 text-muted-foreground">
|
||||
{user.name
|
||||
? user.name
|
||||
?.split(" ")
|
||||
.map((word) => word[0].toUpperCase())
|
||||
.join("")
|
||||
: "~"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
91
components/SidebarItems.tsx
Normal file
91
components/SidebarItems.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { defaultLinks, additionalLinks } from "@/config/nav";
|
||||
|
||||
export interface SidebarLink {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const SidebarItems = () => {
|
||||
return (
|
||||
<>
|
||||
<SidebarLinkGroup links={defaultLinks} />
|
||||
{additionalLinks.length > 0
|
||||
? additionalLinks.map((l) => (
|
||||
<SidebarLinkGroup
|
||||
links={l.links}
|
||||
title={l.title}
|
||||
border
|
||||
key={l.title}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default SidebarItems;
|
||||
|
||||
const SidebarLinkGroup = ({
|
||||
links,
|
||||
title,
|
||||
border,
|
||||
}: {
|
||||
links: SidebarLink[];
|
||||
title?: string;
|
||||
border?: boolean;
|
||||
}) => {
|
||||
const fullPathname = usePathname();
|
||||
const pathname = "/" + fullPathname.split("/")[1];
|
||||
|
||||
return (
|
||||
<div className={border ? "border-border border-t my-8 pt-4" : ""}>
|
||||
{title ? (
|
||||
<h4 className="px-2 mb-2 text-xs uppercase text-muted-foreground tracking-wider">
|
||||
{title}
|
||||
</h4>
|
||||
) : null}
|
||||
<ul>
|
||||
{links.map((link) => (
|
||||
<li key={link.title}>
|
||||
<SidebarLink link={link} active={pathname === link.href} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const SidebarLink = ({
|
||||
link,
|
||||
active,
|
||||
}: {
|
||||
link: SidebarLink;
|
||||
active: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Link
|
||||
href={link.href}
|
||||
className={`group transition-colors p-2 inline-block hover:bg-popover hover:text-primary text-muted-foreground text-xs hover:shadow rounded-md w-full${
|
||||
active ? " text-primary font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"opacity-0 left-0 h-6 w-[4px] absolute rounded-r-lg bg-primary",
|
||||
active ? "opacity-100" : "",
|
||||
)}
|
||||
/>
|
||||
<link.icon className="h-3.5 mr-1" />
|
||||
<span>{link.title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
9
components/ThemeProvider.tsx
Normal file
9
components/ThemeProvider.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>;
|
||||
}
|
||||
29
components/auth/SignIn.tsx
Normal file
29
components/auth/SignIn.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
import { useSession, signIn, signOut } from "next-auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function SignIn() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === "loading") return <div>Loading...</div>;
|
||||
|
||||
if (session) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Signed in as{" "}
|
||||
<span className="font-medium">{session.user?.email}</span>
|
||||
</p>
|
||||
<Button variant={"destructive"} onClick={() => signOut({ callbackUrl: "/" })}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p>Not signed in </p>
|
||||
<Button onClick={() => signIn()}>Sign in</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
components/auth/SignOutBtn.tsx
Normal file
24
components/auth/SignOutBtn.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function SignOutBtn() {
|
||||
const router = useRouter();
|
||||
const handleSignOut = async () => {
|
||||
const response = await fetch("/api/sign-out", {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (response.status === 0) {
|
||||
// redirected
|
||||
// when using `redirect: "manual"`, response status 0 is returned
|
||||
return router.refresh();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button onClick={handleSignOut} className="w-full text-left">
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
27
components/emails/FirstEmail.tsx
Normal file
27
components/emails/FirstEmail.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface EmailTemplateProps {
|
||||
firstName: string;
|
||||
}
|
||||
|
||||
export const EmailTemplate: React.FC<Readonly<EmailTemplateProps>> = ({
|
||||
firstName,
|
||||
}) => (
|
||||
<div>
|
||||
<h1>Welcome, {firstName}!</h1>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim
|
||||
labore culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet.
|
||||
Nisi anim cupidatat excepteur officia. Reprehenderit nostrud nostrud ipsum
|
||||
Lorem est aliquip amet voluptate voluptate dolor minim nulla est proident.
|
||||
Nostrud officia pariatur ut officia. Sit irure elit esse ea nulla sunt ex
|
||||
occaecat reprehenderit commodo officia dolor Lorem duis laboris cupidatat
|
||||
officia voluptate. Culpa proident adipisicing id nulla nisi laboris ex in
|
||||
Lorem sunt duis officia eiusmod. Aliqua reprehenderit commodo ex non
|
||||
excepteur duis sunt velit enim. Voluptate laboris sint cupidatat ullamco
|
||||
ut ea consectetur et est culpa et culpa duis.
|
||||
</p>
|
||||
<hr />
|
||||
<p>Sent with help from Resend and Kirimase 😊</p>
|
||||
</div>
|
||||
);
|
||||
40
components/ui/ThemeToggle.tsx
Normal file
40
components/ui/ThemeToggle.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function ModeToggle() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
50
components/ui/avatar.tsx
Normal file
50
components/ui/avatar.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
56
components/ui/button.tsx
Normal file
56
components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
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 gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
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
components/ui/card.tsx
Normal file
79
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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
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 }
|
||||
200
components/ui/dropdown-menu.tsx
Normal file
200
components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
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 gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
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 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
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 gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
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>
|
||||
<Check 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>
|
||||
<Circle className="h-2 w-2 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,
|
||||
}
|
||||
22
components/ui/input.tsx
Normal file
22
components/ui/input.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground 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 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
26
components/ui/label.tsx
Normal file
26
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 }
|
||||
45
components/ui/sonner.tsx
Normal file
45
components/ui/sonner.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheck,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
OctagonX,
|
||||
TriangleAlert,
|
||||
} from "lucide-react"
|
||||
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"
|
||||
icons={{
|
||||
success: <CircleCheck className="h-4 w-4" />,
|
||||
info: <Info className="h-4 w-4" />,
|
||||
warning: <TriangleAlert className="h-4 w-4" />,
|
||||
error: <OctagonX className="h-4 w-4" />,
|
||||
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
|
||||
}}
|
||||
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 }
|
||||
15
config/nav.ts
Normal file
15
config/nav.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { SidebarLink } from "@/components/SidebarItems";
|
||||
import { Cog, Globe, User, HomeIcon } from "lucide-react";
|
||||
|
||||
type AdditionalLinks = {
|
||||
title: string;
|
||||
links: SidebarLink[];
|
||||
};
|
||||
|
||||
export const defaultLinks: SidebarLink[] = [
|
||||
{ href: "/dashboard", title: "Home", icon: HomeIcon },
|
||||
{ href: "/account", title: "Account", icon: User },
|
||||
{ href: "/settings", title: "Settings", icon: Cog },
|
||||
];
|
||||
|
||||
export const additionalLinks: AdditionalLinks[] = [];
|
||||
35
config/subscriptions.ts
Normal file
35
config/subscriptions.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export interface SubscriptionPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
stripePriceId: string;
|
||||
price: number;
|
||||
features: Array<string>;
|
||||
}
|
||||
|
||||
export const storeSubscriptionPlans: SubscriptionPlan[] = [
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
description: "Pro tier that offers x, y, and z features.",
|
||||
stripePriceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID ?? "",
|
||||
price: 1000,
|
||||
features: ["Feature 1", "Feature 2", "Feature 3"],
|
||||
},
|
||||
{
|
||||
id: "max",
|
||||
name: "Max",
|
||||
description: "Super Pro tier that offers x, y, and z features.",
|
||||
stripePriceId: process.env.NEXT_PUBLIC_STRIPE_MAX_PRICE_ID ?? "",
|
||||
price: 3000,
|
||||
features: ["Feature 1", "Feature 2", "Feature 3"],
|
||||
},
|
||||
{
|
||||
id: "ultra",
|
||||
name: "Ultra",
|
||||
description: "Ultra Pro tier that offers x, y, and z features.",
|
||||
stripePriceId: process.env.NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID ?? "",
|
||||
price: 5000,
|
||||
features: ["Feature 1", "Feature 2", "Feature 3"],
|
||||
},
|
||||
];
|
||||
11
drizzle.config.ts
Normal file
11
drizzle.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
export default {
|
||||
schema: "./lib/db/schema",
|
||||
dialect: "postgresql",
|
||||
out: "./lib/db/migrations",
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL,
|
||||
}
|
||||
} satisfies Config;
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
20
kirimase.config.json
Normal file
20
kirimase.config.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"hasSrc": false,
|
||||
"packages": [
|
||||
"shadcn-ui",
|
||||
"drizzle",
|
||||
"next-auth",
|
||||
"resend",
|
||||
"stripe"
|
||||
],
|
||||
"preferredPackageManager": "npm",
|
||||
"t3": false,
|
||||
"alias": "@",
|
||||
"analytics": true,
|
||||
"rootPath": "",
|
||||
"componentLib": "shadcn-ui",
|
||||
"driver": "pg",
|
||||
"provider": "node-postgres",
|
||||
"orm": "drizzle",
|
||||
"auth": "next-auth"
|
||||
}
|
||||
11
lib/auth/Provider.tsx
Normal file
11
lib/auth/Provider.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function NextAuthProvider({ children }: Props) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
};
|
||||
50
lib/auth/utils.ts
Normal file
50
lib/auth/utils.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { db } from "@/lib/db/index";
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import { DefaultSession, getServerSession, NextAuthOptions } from "next-auth";
|
||||
import { Adapter } from "next-auth/adapters";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/lib/env.mjs"
|
||||
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AuthSession = {
|
||||
session: {
|
||||
user: {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: DrizzleAdapter(db) as Adapter,
|
||||
callbacks: {
|
||||
session: ({ session, user }) => {
|
||||
session.user.id = user.id;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
providers: [
|
||||
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
export const getUserAuth = async () => {
|
||||
const session = await getServerSession(authOptions);
|
||||
return { session } as AuthSession;
|
||||
};
|
||||
|
||||
export const checkAuth = async () => {
|
||||
const { session } = await getUserAuth();
|
||||
if (!session) redirect("/api/auth/signin");
|
||||
};
|
||||
|
||||
8
lib/db/index.ts
Normal file
8
lib/db/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg"
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
});
|
||||
export const db = drizzle(pool);
|
||||
39
lib/db/migrate.ts
Normal file
39
lib/db/migrate.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { Client } from "pg";
|
||||
|
||||
|
||||
const runMigrate = async () => {
|
||||
if (!env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is not defined");
|
||||
}
|
||||
|
||||
|
||||
const client = new Client({
|
||||
connectionString: env.DATABASE_URL,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
const db = drizzle(client);
|
||||
|
||||
|
||||
console.log("⏳ Running migrations...");
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
await migrate(db, { migrationsFolder: 'lib/db/migrations' });
|
||||
|
||||
const end = Date.now();
|
||||
|
||||
console.log("✅ Migrations completed in", end - start, "ms");
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
runMigrate().catch((err) => {
|
||||
console.error("❌ Migration failed");
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
58
lib/db/schema/auth.ts
Normal file
58
lib/db/schema/auth.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
timestamp,
|
||||
pgTable,
|
||||
text,
|
||||
primaryKey,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
|
||||
export const users = pgTable("user", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name"),
|
||||
email: text("email").notNull(),
|
||||
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
||||
image: text("image"),
|
||||
});
|
||||
|
||||
export const accounts = pgTable(
|
||||
"account",
|
||||
{
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: text("type").$type<AdapterAccount["type"]>().notNull(),
|
||||
provider: text("provider").notNull(),
|
||||
providerAccountId: text("providerAccountId").notNull(),
|
||||
refresh_token: text("refresh_token"),
|
||||
access_token: text("access_token"),
|
||||
expires_at: integer("expires_at"),
|
||||
token_type: text("token_type"),
|
||||
scope: text("scope"),
|
||||
id_token: text("id_token"),
|
||||
session_state: text("session_state"),
|
||||
},
|
||||
(account) => ({
|
||||
compoundKey: primaryKey(account.provider, account.providerAccountId),
|
||||
})
|
||||
);
|
||||
|
||||
export const sessions = pgTable("session", {
|
||||
sessionToken: text("sessionToken").notNull().primaryKey(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expires: timestamp("expires", { mode: "date" }).notNull(),
|
||||
});
|
||||
|
||||
export const verificationTokens = pgTable(
|
||||
"verificationToken",
|
||||
{
|
||||
identifier: text("identifier").notNull(),
|
||||
token: text("token").notNull(),
|
||||
expires: timestamp("expires", { mode: "date" }).notNull(),
|
||||
},
|
||||
(vt) => ({
|
||||
compoundKey: primaryKey(vt.identifier, vt.token),
|
||||
})
|
||||
);
|
||||
27
lib/db/schema/subscriptions.ts
Normal file
27
lib/db/schema/subscriptions.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import {
|
||||
pgTable,
|
||||
primaryKey,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { users } from "./auth";
|
||||
|
||||
export const subscriptions = pgTable(
|
||||
"subscriptions",
|
||||
{
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.unique()
|
||||
.references(() => users.id),
|
||||
stripeCustomerId: varchar("stripe_customer_id", { length: 255 }).unique(),
|
||||
stripeSubscriptionId: varchar("stripe_subscription_id", {
|
||||
length: 255,
|
||||
}).unique(),
|
||||
stripePriceId: varchar("stripe_price_id", { length: 255 }),
|
||||
stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"),
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
pk: primaryKey(table.userId, table.stripeCustomerId),
|
||||
};
|
||||
}
|
||||
);
|
||||
4
lib/email/index.ts
Normal file
4
lib/email/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Resend } from "resend";
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
export const resend = new Resend(env.RESEND_API_KEY);
|
||||
6
lib/email/utils.ts
Normal file
6
lib/email/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const emailSchema = z.object({
|
||||
name: z.string().min(3),
|
||||
email: z.string().email(),
|
||||
});
|
||||
44
lib/env.mjs
Normal file
44
lib/env.mjs
Normal file
@ -0,0 +1,44 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
import "dotenv/config";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
|
||||
NEXTAUTH_SECRET: process.env.NODE_ENV === "production"
|
||||
? z.string().min(1)
|
||||
: z.string().min(1).optional(),
|
||||
NEXTAUTH_URL: z.preprocess(
|
||||
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
|
||||
// Since NextAuth.js automatically uses the VERCEL_URL if present.
|
||||
(str) => process.env.VERCEL_URL ?? str,
|
||||
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
|
||||
process.env.VERCEL_URL ? z.string().min(1) : z.string().url()
|
||||
),
|
||||
RESEND_API_KEY: z.string().min(1),
|
||||
STRIPE_SECRET_KEY: z.string().min(1),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().min(1),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
|
||||
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID: z.string().min(1),
|
||||
NEXT_PUBLIC_STRIPE_MAX_PRICE_ID: z.string().min(1),
|
||||
NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID: z.string().min(1), // NEXT_PUBLIC_PUBLISHABLE_KEY: z.string().min(1),
|
||||
},
|
||||
// If you're using Next.js < 13.4.4, you'll need to specify the runtimeEnv manually
|
||||
// runtimeEnv: {
|
||||
// DATABASE_URL: process.env.DATABASE_URL,
|
||||
// NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY,
|
||||
// },
|
||||
// For Next.js >= 13.4.4, you only need to destructure client variables:
|
||||
experimental__runtimeEnv: {
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
||||
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID,
|
||||
NEXT_PUBLIC_STRIPE_MAX_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_MAX_PRICE_ID,
|
||||
NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID, // NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY,
|
||||
},
|
||||
});
|
||||
6
lib/stripe/index.ts
Normal file
6
lib/stripe/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", {
|
||||
apiVersion: "2024-06-20",
|
||||
typescript: true,
|
||||
});
|
||||
61
lib/stripe/subscription.ts
Normal file
61
lib/stripe/subscription.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { storeSubscriptionPlans } from "@/config/subscriptions";
|
||||
import { db } from "@/lib/db/index";
|
||||
import { subscriptions } from "@/lib/db/schema/subscriptions";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { stripe } from "@/lib/stripe/index";
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
|
||||
export async function getUserSubscriptionPlan() {
|
||||
const { session } = await getUserAuth();
|
||||
|
||||
if (!session || !session.user) {
|
||||
throw new Error("User not found.");
|
||||
}
|
||||
|
||||
const [ subscription ] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(eq(subscriptions.userId, session.user.id));
|
||||
|
||||
if (!subscription)
|
||||
return {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
stripePriceId: undefined,
|
||||
price: undefined,
|
||||
stripeSubscriptionId: null,
|
||||
stripeCurrentPeriodEnd: null,
|
||||
stripeCustomerId: null,
|
||||
isSubscribed: false,
|
||||
isCanceled: false,
|
||||
};
|
||||
|
||||
const isSubscribed =
|
||||
subscription.stripePriceId &&
|
||||
subscription.stripeCurrentPeriodEnd &&
|
||||
subscription.stripeCurrentPeriodEnd.getTime() + 86_400_000 > Date.now();
|
||||
|
||||
const plan = isSubscribed
|
||||
? storeSubscriptionPlans.find(
|
||||
(plan) => plan.stripePriceId === subscription.stripePriceId
|
||||
)
|
||||
: null;
|
||||
|
||||
let isCanceled = false;
|
||||
if (isSubscribed && subscription.stripeSubscriptionId) {
|
||||
const stripePlan = await stripe.subscriptions.retrieve(
|
||||
subscription.stripeSubscriptionId
|
||||
);
|
||||
isCanceled = stripePlan.cancel_at_period_end;
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
stripeCurrentPeriodEnd: subscription.stripeCurrentPeriodEnd,
|
||||
stripeCustomerId: subscription.stripeCustomerId,
|
||||
isSubscribed,
|
||||
isCanceled,
|
||||
};
|
||||
}
|
||||
15
lib/utils.ts
Normal file
15
lib/utils.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789");
|
||||
|
||||
export function absoluteUrl(path: string) {
|
||||
return `${
|
||||
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"
|
||||
}${path}`;
|
||||
}
|
||||
8
next.config.ts
Normal file
8
next.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
9784
package-lock.json
generated
Normal file
9784
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
package.json
Normal file
62
package.json
Normal file
@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "tenantpilot",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx lib/db/migrate.ts",
|
||||
"db:drop": "drizzle-kit drop",
|
||||
"db:pull": "drizzle-kit introspect",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:check": "drizzle-kit check",
|
||||
"stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.34.3",
|
||||
"@auth/drizzle-adapter": "^1.11.1",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@stripe/stripe-js": "^8.5.2",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"lucide-react": "^0.554.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.0.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"resend": "^6.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"stripe": "^20.0.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.15.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
76
tailwind.config.ts
Normal file
76
tailwind.config.ts
Normal file
@ -0,0 +1,76 @@
|
||||
const { fontFamily } = require("tailwindcss/defaultTheme")
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: `var(--radius)`,
|
||||
md: `calc(var(--radius) - 2px)`,
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
43
tsconfig.json
Normal file
43
tsconfig.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user