mirror of
https://gitea.gofwd.group/dstrawsb/ballistic-builder.git
synced 2025-12-06 02:36:44 -05:00
trying lucia
This commit is contained in:
28
.env
28
.env
@@ -2,3 +2,31 @@
|
|||||||
DATABASE_URL="postgresql://postgres:cul8rman@portainer.dev.gofwd.group:5433/ballistic?schema=public"
|
DATABASE_URL="postgresql://postgres:cul8rman@portainer.dev.gofwd.group:5433/ballistic?schema=public"
|
||||||
|
|
||||||
|
|
||||||
|
# This file will be committed to version control, so make sure not to have any
|
||||||
|
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||||
|
# ".env" and populate it with your secrets.
|
||||||
|
|
||||||
|
# When adding additional environment variables, the schema in "/src/env.js"
|
||||||
|
# should be updated accordingly.
|
||||||
|
|
||||||
|
# DATABASE_URL='postgresql://postgres:cul8rman@r710.gofwd.group:5433/luciatest'
|
||||||
|
NEXT_PUBLIC_APP_URL='http://localhost:3000'
|
||||||
|
MOCK_SEND_EMAIL=true
|
||||||
|
|
||||||
|
SMTP_HOST='smtp.example-host.com'
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_USER='smtp_example_username'
|
||||||
|
SMTP_PASSWORD='smtp_example_password'
|
||||||
|
|
||||||
|
DISCORD_CLIENT_ID='discord_client_id'
|
||||||
|
DISCORD_CLIENT_SECRET='discord_client_secret'
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
# Stripe Secret Key found at https://dashboard.stripe.com/test/apikeys
|
||||||
|
STRIPE_API_KEY='sk_test_'
|
||||||
|
# Stripe Webhook Secret found at https://dashboard.stripe.com/test/webhooks/create?endpoint_location=local
|
||||||
|
# This need to replaced with the webhook secret for your webhook endpoint in production
|
||||||
|
STRIPE_WEBHOOK_SECRET='whsec_'
|
||||||
|
# Stripe Product and Price IDs for your created products
|
||||||
|
# found at https://dashboard.stripe.com/test/products
|
||||||
|
STRIPE_PRO_MONTHLY_PLAN_ID='price_'
|
||||||
@@ -4,4 +4,7 @@
|
|||||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||||
|
|
||||||
DATABASE_URL="postgresql://postgres:cul8rman@192.168.11.210:5433/ballistic?schema=public"
|
DATABASE_URL="postgresql://postgres:cul8rman@192.168.11.210:5433/ballistic?schema=public"
|
||||||
|
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_bmV3LXN3YW4tMjguY2xlcmsuYWNjb3VudHMuZGV2JA
|
||||||
|
CLERK_SECRET_KEY=••••••••••••••••••••••••••••••••••••••••••••••••••
|
||||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"@emotion/styled": "^11.13.5",
|
"@emotion/styled": "^11.13.5",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@lucia-auth/adapter-drizzle": "^1.1.0",
|
||||||
"@mui/icons-material": "^6.1.7",
|
"@mui/icons-material": "^6.1.7",
|
||||||
"@mui/joy": "^5.0.0-beta.48",
|
"@mui/joy": "^5.0.0-beta.48",
|
||||||
"@mui/material": "^6.1.7",
|
"@mui/material": "^6.1.7",
|
||||||
@@ -28,9 +29,11 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"fontsource-roboto": "^4.0.0",
|
"fontsource-roboto": "^4.0.0",
|
||||||
"framer-motion": "^11.18.0",
|
"framer-motion": "^11.18.0",
|
||||||
|
"lucia": "^3.2.2",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"next": "15.1.0",
|
"next": "15.1.0",
|
||||||
"next-themes": "^0.4.3",
|
"next-themes": "^0.4.3",
|
||||||
|
"oslo": "^1.2.1",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
|||||||
6922
pnpm-lock.yaml
generated
Normal file
6922
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,8 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
module.exports = config;
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
9
src/(auth)/layout.tsx
Normal file
9
src/(auth)/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const AuthLayout = ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen place-items-center p-4">{children}</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthLayout;
|
||||||
112
src/(auth)/login/discord/callback/route.ts
Normal file
112
src/(auth)/login/discord/callback/route.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { generateId } from "lucia";
|
||||||
|
import { OAuth2RequestError } from "arctic";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { discord, lucia } from "@/lib/auth";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { Paths } from "@/lib/constants";
|
||||||
|
import { users } from "@/server/db/schema";
|
||||||
|
|
||||||
|
export async function GET(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const state = url.searchParams.get("state");
|
||||||
|
const storedState = cookies().get("discord_oauth_state")?.value ?? null;
|
||||||
|
|
||||||
|
if (!code || !state || !storedState || state !== storedState) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 400,
|
||||||
|
headers: { Location: Paths.Login },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await discord.validateAuthorizationCode(code);
|
||||||
|
|
||||||
|
const discordUserRes = await fetch("https://discord.com/api/users/@me", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens.accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const discordUser = (await discordUserRes.json()) as DiscordUser;
|
||||||
|
|
||||||
|
if (!discordUser.email || !discordUser.verified) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Your Discord account must have a verified email address.",
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { Location: Paths.Login } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existingUser = await db.query.users.findFirst({
|
||||||
|
where: (table, { eq, or }) =>
|
||||||
|
or(eq(table.discordId, discordUser.id), eq(table.email, discordUser.email!)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatar = discordUser.avatar
|
||||||
|
? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.webp`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
const userId = generateId(21);
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: userId,
|
||||||
|
email: discordUser.email,
|
||||||
|
emailVerified: true,
|
||||||
|
discordId: discordUser.id,
|
||||||
|
avatar,
|
||||||
|
});
|
||||||
|
const session = await lucia.createSession(userId, {});
|
||||||
|
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||||
|
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { Location: Paths.Dashboard },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUser.discordId !== discordUser.id || existingUser.avatar !== avatar) {
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
discordId: discordUser.id,
|
||||||
|
emailVerified: true,
|
||||||
|
avatar,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, existingUser.id));
|
||||||
|
}
|
||||||
|
const session = await lucia.createSession(existingUser.id, {});
|
||||||
|
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||||
|
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { Location: Paths.Dashboard },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// the specific error message depends on the provider
|
||||||
|
if (e instanceof OAuth2RequestError) {
|
||||||
|
// invalid code
|
||||||
|
return new Response(JSON.stringify({ message: "Invalid code" }), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ message: "internal server error" }), {
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
banner: string | null;
|
||||||
|
global_name: string | null;
|
||||||
|
banner_color: string | null;
|
||||||
|
mfa_enabled: boolean;
|
||||||
|
locale: string;
|
||||||
|
email: string | null;
|
||||||
|
verified: boolean;
|
||||||
|
}
|
||||||
21
src/(auth)/login/discord/route.ts
Normal file
21
src/(auth)/login/discord/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { generateState } from "arctic";
|
||||||
|
import { discord } from "@/lib/auth";
|
||||||
|
import { env } from "@/env";
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
const state = generateState();
|
||||||
|
const url = await discord.createAuthorizationURL(state, {
|
||||||
|
scopes: ["identify", "email"],
|
||||||
|
});
|
||||||
|
|
||||||
|
cookies().set("discord_oauth_state", state, {
|
||||||
|
path: "/",
|
||||||
|
secure: env.NODE_ENV === "production",
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 60 * 10,
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.redirect(url);
|
||||||
|
}
|
||||||
92
src/(auth)/login/login.tsx
Normal file
92
src/(auth)/login/login.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { DiscordLogoIcon } from "@/components/icons";
|
||||||
|
import { APP_TITLE } from "@/lib/constants";
|
||||||
|
import { login } from "@/lib/auth/actions";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const [state, formAction] = useFormState(login, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>{APP_TITLE} Log In</CardTitle>
|
||||||
|
<CardDescription>Log in to your account to access your dashboard</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" className="w-full" asChild>
|
||||||
|
<Link href="/login/discord" prefetch={false}>
|
||||||
|
<DiscordLogoIcon className="mr-2 h-5 w-5" />
|
||||||
|
Log in with Discord
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="my-2 flex items-center">
|
||||||
|
<div className="flex-grow border-t border-muted" />
|
||||||
|
<div className="mx-2 text-muted-foreground">or</div>
|
||||||
|
<div className="flex-grow border-t border-muted" />
|
||||||
|
</div>
|
||||||
|
<form action={formAction} className="grid gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
id="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="********"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-between">
|
||||||
|
<Button variant={"link"} size={"sm"} className="p-0" asChild>
|
||||||
|
<Link href={"/signup"}>Not signed up? Sign up now.</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant={"link"} size={"sm"} className="p-0" asChild>
|
||||||
|
<Link href={"/reset-password"}>Forgot password?</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state?.fieldError ? (
|
||||||
|
<ul className="list-disc space-y-1 rounded-lg border bg-destructive/10 p-2 text-[0.8rem] font-medium text-destructive">
|
||||||
|
{Object.values(state.fieldError).map((err) => (
|
||||||
|
<li className="ml-4" key={err}>
|
||||||
|
{err}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : state?.formError ? (
|
||||||
|
<p className="rounded-lg border bg-destructive/10 p-2 text-[0.8rem] font-medium text-destructive">
|
||||||
|
{state?.formError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<SubmitButton className="w-full" aria-label="submit-btn">
|
||||||
|
Log In
|
||||||
|
</SubmitButton>
|
||||||
|
<Button variant="outline" className="w-full" asChild>
|
||||||
|
<Link href="/">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/(auth)/login/page.tsx
Normal file
17
src/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import { Paths } from "@/lib/constants";
|
||||||
|
import { Login } from "./login";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Login",
|
||||||
|
description: "Login Page",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LoginPage() {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
|
||||||
|
if (user) redirect(Paths.Dashboard);
|
||||||
|
|
||||||
|
return <Login />;
|
||||||
|
}
|
||||||
31
src/(auth)/reset-password/[token]/page.tsx
Normal file
31
src/(auth)/reset-password/[token]/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { ResetPassword } from "./reset-password";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Reset Password",
|
||||||
|
description: "Reset Password Page",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResetPasswordPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { token: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle>Reset password</CardTitle>
|
||||||
|
<CardDescription>Enter new password.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResetPassword token={params.token} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/(auth)/reset-password/[token]/reset-password.tsx
Normal file
38
src/(auth)/reset-password/[token]/reset-password.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ExclamationTriangleIcon } from "@/components/icons";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { resetPassword } from "@/lib/auth/actions";
|
||||||
|
|
||||||
|
export function ResetPassword({ token }: { token: string }) {
|
||||||
|
const [state, formAction] = useFormState(resetPassword, null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state?.error) {
|
||||||
|
toast(state.error, {
|
||||||
|
icon: <ExclamationTriangleIcon className="h-5 w-5 text-destructive" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [state?.error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<input type="hidden" name="token" value={token} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>New Password</Label>
|
||||||
|
<PasswordInput
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="********"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SubmitButton className="w-full">Reset Password</SubmitButton>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/(auth)/reset-password/page.tsx
Normal file
36
src/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { SendResetEmail } from "./send-reset-email";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import { Paths } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Forgot Password",
|
||||||
|
description: "Forgot Password Page",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ForgotPasswordPage() {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
|
||||||
|
if (user) redirect(Paths.Dashboard);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Forgot password?</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Password reset link will be sent to your email.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SendResetEmail />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/(auth)/reset-password/send-reset-email.tsx
Normal file
59
src/(auth)/reset-password/send-reset-email.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
|
import { sendPasswordResetLink } from "@/lib/auth/actions";
|
||||||
|
import { ExclamationTriangleIcon } from "@/components/icons";
|
||||||
|
import { Paths } from "@/lib/constants";
|
||||||
|
|
||||||
|
export function SendResetEmail() {
|
||||||
|
const [state, formAction] = useFormState(sendPasswordResetLink, null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state?.success) {
|
||||||
|
toast("A password reset link has been sent to your email.");
|
||||||
|
router.push(Paths.Login);
|
||||||
|
}
|
||||||
|
if (state?.error) {
|
||||||
|
toast(state.error, {
|
||||||
|
icon: <ExclamationTriangleIcon className="h-5 w-5 text-destructive" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [state?.error, state?.success]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-4" action={formAction}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Your Email</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
placeholder="email@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-between">
|
||||||
|
<Link href={Paths.Signup}>
|
||||||
|
<Button variant={"link"} size={"sm"} className="p-0">
|
||||||
|
Not signed up? Sign up now
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SubmitButton className="w-full">Reset Password</SubmitButton>
|
||||||
|
<Button variant="outline" className="w-full" asChild>
|
||||||
|
<Link href="/">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/(auth)/signup/page.tsx
Normal file
17
src/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Signup } from "./signup";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import { Paths } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Sign Up",
|
||||||
|
description: "Signup Page",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SignupPage() {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
|
||||||
|
if (user) redirect(Paths.Dashboard);
|
||||||
|
|
||||||
|
return <Signup />;
|
||||||
|
}
|
||||||
91
src/(auth)/signup/signup.tsx
Normal file
91
src/(auth)/signup/signup.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { DiscordLogoIcon } from "@/components/icons";
|
||||||
|
import { APP_TITLE } from "@/lib/constants";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { signup } from "@/lib/auth/actions";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
|
|
||||||
|
export function Signup() {
|
||||||
|
const [state, formAction] = useFormState(signup, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle>{APP_TITLE} Sign Up</CardTitle>
|
||||||
|
<CardDescription>Sign up to start using the app</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" className="w-full" asChild>
|
||||||
|
<Link href="/login/discord" prefetch={false}>
|
||||||
|
<DiscordLogoIcon className="mr-2 h-5 w-5" />
|
||||||
|
Sign up with Discord
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="my-2 flex items-center">
|
||||||
|
<div className="flex-grow border-t border-muted" />
|
||||||
|
<div className="mx-2 text-muted-foreground">or</div>
|
||||||
|
<div className="flex-grow border-t border-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
placeholder="email@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="********"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state?.fieldError ? (
|
||||||
|
<ul className="list-disc space-y-1 rounded-lg border bg-destructive/10 p-2 text-[0.8rem] font-medium text-destructive">
|
||||||
|
{Object.values(state.fieldError).map((err) => (
|
||||||
|
<li className="ml-4" key={err}>
|
||||||
|
{err}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : state?.formError ? (
|
||||||
|
<p className="rounded-lg border bg-destructive/10 p-2 text-[0.8rem] font-medium text-destructive">
|
||||||
|
{state?.formError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<Link href={"/login"}>
|
||||||
|
<span className="p-0 text-xs font-medium underline-offset-4 hover:underline">
|
||||||
|
Already signed up? Login instead.
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SubmitButton className="w-full" aria-label="submit-btn">
|
||||||
|
Sign Up
|
||||||
|
</SubmitButton>
|
||||||
|
<Button variant="outline" className="w-full" asChild>
|
||||||
|
<Link href="/">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/(auth)/verify-email/page.tsx
Normal file
38
src/(auth)/verify-email/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import { VerifyCode } from "./verify-code";
|
||||||
|
import { Paths } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Verify Email",
|
||||||
|
description: "Verify Email Page",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function VerifyEmailPage() {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
|
||||||
|
if (!user) redirect(Paths.Login);
|
||||||
|
if (user.emailVerified) redirect(Paths.Dashboard);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Verify Email</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Verification code was sent to <strong>{user.email}</strong>. Check
|
||||||
|
your spam folder if you can't find the email.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<VerifyCode />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/(auth)/verify-email/verify-code.tsx
Normal file
56
src/(auth)/verify-email/verify-code.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@radix-ui/react-label";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ExclamationTriangleIcon } from "@/components/icons";
|
||||||
|
import { logout, verifyEmail, resendVerificationEmail as resendEmail } from "@/lib/auth/actions";
|
||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
|
|
||||||
|
export const VerifyCode = () => {
|
||||||
|
const [verifyEmailState, verifyEmailAction] = useFormState(verifyEmail, null);
|
||||||
|
const [resendState, resendAction] = useFormState(resendEmail, null);
|
||||||
|
const codeFormRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resendState?.success) {
|
||||||
|
toast("Email sent!");
|
||||||
|
}
|
||||||
|
if (resendState?.error) {
|
||||||
|
toast(resendState.error, {
|
||||||
|
icon: <ExclamationTriangleIcon className="h-5 w-5 text-destructive" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [resendState?.error, resendState?.success]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (verifyEmailState?.error) {
|
||||||
|
toast(verifyEmailState.error, {
|
||||||
|
icon: <ExclamationTriangleIcon className="h-5 w-5 text-destructive" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [verifyEmailState?.error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<form ref={codeFormRef} action={verifyEmailAction}>
|
||||||
|
<Label htmlFor="code">Verification Code</Label>
|
||||||
|
<Input className="mt-2" type="text" id="code" name="code" required />
|
||||||
|
<SubmitButton className="mt-4 w-full" aria-label="submit-btn">
|
||||||
|
Verify
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
<form action={resendAction}>
|
||||||
|
<SubmitButton className="w-full" variant="secondary">
|
||||||
|
Resend Code
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
<form action={logout}>
|
||||||
|
<SubmitButton variant="link" className="p-0 font-normal">
|
||||||
|
want to use another email? Log out now.
|
||||||
|
</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
src/(landing)/_components/copy-to-clipboard.tsx
Normal file
39
src/(landing)/_components/copy-to-clipboard.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckIcon, CopyIcon } from "@radix-ui/react-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const CopyToClipboard = ({ text }: { text: string }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 2000);
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast("Copied to clipboard", {
|
||||||
|
icon: <CopyIcon className="h-4 w-4" />,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
|
<Input readOnly value={text} className="bg-secondary text-muted-foreground" />
|
||||||
|
<Button size="icon" onClick={() => copyToClipboard()}>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
copied ? "opacity-100" : "opacity-0",
|
||||||
|
"h-5 w-5 transition-opacity duration-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
375
src/(landing)/_components/feature-icons.tsx
Normal file
375
src/(landing)/_components/feature-icons.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import { forwardRef, type SVGProps } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const NextjsLight = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 180 180"
|
||||||
|
className={cn(className)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_408_134"
|
||||||
|
style={{ maskType: "alpha" }}
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="180"
|
||||||
|
height="180"
|
||||||
|
>
|
||||||
|
<circle cx="90" cy="90" r="90" fill="black" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_408_134)">
|
||||||
|
<circle cx="90" cy="90" r="90" fill="black" />
|
||||||
|
<path
|
||||||
|
d="M149.508 157.52L69.142 54H54V125.97H66.1136V69.3836L139.999 164.845C143.333 162.614 146.509 160.165 149.508 157.52Z"
|
||||||
|
fill="url(#paint0_linear_408_134)"
|
||||||
|
/>
|
||||||
|
<rect x="115" y="54" width="12" height="72" fill="url(#paint1_linear_408_134)" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="paint0_linear_408_134"
|
||||||
|
x1="109"
|
||||||
|
y1="116.5"
|
||||||
|
x2="144.5"
|
||||||
|
y2="160.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stopColor="white" />
|
||||||
|
<stop offset="1" stopColor="white" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="paint1_linear_408_134"
|
||||||
|
x1="121"
|
||||||
|
y1="54"
|
||||||
|
x2="120.799"
|
||||||
|
y2="106.875"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stopColor="white" />
|
||||||
|
<stop offset="1" stopColor="white" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
NextjsLight.displayName = "NextjsLight";
|
||||||
|
|
||||||
|
const NextjsDark = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 180 180"
|
||||||
|
className={cn(className)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_408_139"
|
||||||
|
style={{ maskType: "alpha" }}
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="180"
|
||||||
|
height="180"
|
||||||
|
>
|
||||||
|
<circle cx="90" cy="90" r="90" fill="black" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_408_139)">
|
||||||
|
<circle cx="90" cy="90" r="87" fill="black" stroke="white" strokeWidth="6" />
|
||||||
|
<path
|
||||||
|
d="M149.508 157.52L69.142 54H54V125.97H66.1136V69.3836L139.999 164.845C143.333 162.614 146.509 160.165 149.508 157.52Z"
|
||||||
|
fill="url(#paint0_linear_408_139)"
|
||||||
|
/>
|
||||||
|
<rect x="115" y="54" width="12" height="72" fill="url(#paint1_linear_408_139)" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="paint0_linear_408_139"
|
||||||
|
x1="109"
|
||||||
|
y1="116.5"
|
||||||
|
x2="144.5"
|
||||||
|
y2="160.5"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stopColor="white" />
|
||||||
|
<stop offset="1" stopColor="white" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="paint1_linear_408_139"
|
||||||
|
x1="121"
|
||||||
|
y1="54"
|
||||||
|
x2="120.799"
|
||||||
|
y2="106.875"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stopColor="white" />
|
||||||
|
<stop offset="1" stopColor="white" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
NextjsDark.displayName = "NextjsDark";
|
||||||
|
|
||||||
|
const ReactJs = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<path d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38a2.167 2.167 0 0 0-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44a23.476 23.476 0 0 0-3.107-.534A23.892 23.892 0 0 0 12.769 4.7c1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442a22.73 22.73 0 0 0-3.113.538 15.02 15.02 0 0 1-.254-1.42c-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87a25.64 25.64 0 0 1-4.412.005 26.64 26.64 0 0 1-1.183-1.86c-.372-.64-.71-1.29-1.018-1.946a25.17 25.17 0 0 1 1.013-1.954c.38-.66.773-1.286 1.18-1.868A25.245 25.245 0 0 1 12 8.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933a25.952 25.952 0 0 0-1.345-2.32zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493a23.966 23.966 0 0 0-1.1-2.98c.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98a23.142 23.142 0 0 0-1.086 2.964c-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39a25.819 25.819 0 0 0 1.341-2.338zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143a22.005 22.005 0 0 1-2.006-.386c.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295a1.185 1.185 0 0 1-.553-.132c-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"></path>{" "}
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ReactJs.displayName = "ReactJs";
|
||||||
|
|
||||||
|
const TailwindCss = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
className={cn(className)}
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M12.001 4.8c-3.2 0-5.2 1.6-6 4.8 1.2-1.6 2.6-2.2 4.2-1.8.913.228 1.565.89 2.288 1.624C13.666 10.618 15.027 12 18.001 12c3.2 0 5.2-1.6 6-4.8-1.2 1.6-2.6 2.2-4.2 1.8-.913-.228-1.565-.89-2.288-1.624C16.337 6.182 14.976 4.8 12.001 4.8zm-6 7.2c-3.2 0-5.2 1.6-6 4.8 1.2-1.6 2.6-2.2 4.2-1.8.913.228 1.565.89 2.288 1.624 1.177 1.194 2.538 2.576 5.512 2.576 3.2 0 5.2-1.6 6-4.8-1.2 1.6-2.6 2.2-4.2 1.8-.913-.228-1.565-.89-2.288-1.624C10.337 13.382 8.976 12 6.001 12z"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TailwindCss.displayName = "TailwindCss";
|
||||||
|
|
||||||
|
const LuciaAuth = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 2000 2000"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<path d="m1647.66,1673.36L1000,72.73,352.34,1673.36l-102.74,253.91h1500.8l-102.74-253.91Zm-647.66-549l-442.82,545.39,99.55-246.04,343.27-848.35,343.26,848.35,99.55,246.04-442.81-545.39Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
LuciaAuth.displayName = "LuciaAuth";
|
||||||
|
|
||||||
|
const Drizzle = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 160 160"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
width="9.63139"
|
||||||
|
height="40.8516"
|
||||||
|
rx="4.8157"
|
||||||
|
transform="matrix(0.873028 0.48767 -0.497212 0.867629 43.4805 67.3037)"
|
||||||
|
fill="currentColor"
|
||||||
|
></rect>
|
||||||
|
<rect
|
||||||
|
width="9.63139"
|
||||||
|
height="40.8516"
|
||||||
|
rx="4.8157"
|
||||||
|
transform="matrix(0.873028 0.48767 -0.497212 0.867629 76.9395 46.5342)"
|
||||||
|
fill="currentColor"
|
||||||
|
></rect>
|
||||||
|
<rect
|
||||||
|
width="9.63139"
|
||||||
|
height="40.8516"
|
||||||
|
rx="4.8157"
|
||||||
|
transform="matrix(0.873028 0.48767 -0.497212 0.867629 128.424 46.5352)"
|
||||||
|
fill="currentColor"
|
||||||
|
></rect>
|
||||||
|
<rect
|
||||||
|
width="9.63139"
|
||||||
|
height="40.8516"
|
||||||
|
rx="4.8157"
|
||||||
|
transform="matrix(0.873028 0.48767 -0.497212 0.867629 94.957 67.3037)"
|
||||||
|
fill="currentColor"
|
||||||
|
></rect>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Drizzle.displayName = "Drizzle";
|
||||||
|
|
||||||
|
const TRPC = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<g>
|
||||||
|
<polygon points="246.2,162.3 202.9,137.3 202.9,187.4 246.2,212.4" />
|
||||||
|
<polygon points="96.5,357.9 139.9,382.9 139.9,332.9 96.5,307.8" />
|
||||||
|
<polygon points="149.1,266.8 105.7,291.9 149.1,316.9 192.4,291.9" />
|
||||||
|
<polygon points="264.7,212.4 308,187.4 308,137.3 264.7,162.3" />
|
||||||
|
<polygon points="298.8,121.3 255.4,96.3 212.2,121.3 255.4,146.4" />
|
||||||
|
<polygon points="201.7,307.8 158.3,332.9 158.3,382.9 201.7,357.9" />
|
||||||
|
<path
|
||||||
|
d="M362,0H150C67.2,0,0,67.2,0,150v212c0,82.8,67.2,150,150,150h212c82.8,0,150-67.2,150-150V150
|
||||||
|
C512,67.2,444.8,0,362,0z M435.6,368.6l-71,41l-31.5-18.2l-76.7,44.3l-76.2-44l-31.1,18l-71-41.1v-82l22.2-12.8v-85.5l84.2-48.6
|
||||||
|
l0,0V116l71-41l71.1,41v22.5l86,49.7v85l23.1,13.3V368.6z"
|
||||||
|
/>
|
||||||
|
<polygon points="373.8,383 417.2,357.9 417.2,307.8 373.8,332.9" />
|
||||||
|
<polygon points="364.6,266.9 321.3,291.9 364.6,317 407.9,291.9" />
|
||||||
|
<polygon
|
||||||
|
points="293.6,286.5 364.6,245.5 394.1,262.6 394.1,198.9 326.5,159.9 326.5,198 255.5,239 184.5,198
|
||||||
|
184.5,160.9 184.4,160.9 118.7,198.9 118.7,263.1 149.1,245.5 220.1,286.5 220.1,368.5 198.6,381 256.4,414.3 314.6,380.7
|
||||||
|
293.6,368.5"
|
||||||
|
/>
|
||||||
|
<polygon points="312,358 355.4,383 355.4,332.9 312,307.9" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
));
|
||||||
|
TRPC.displayName = "TRPC";
|
||||||
|
|
||||||
|
const ShadcnUi = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<rect width="256" height="256" fill="none"></rect>
|
||||||
|
<line
|
||||||
|
x1="208"
|
||||||
|
y1="128"
|
||||||
|
x2="128"
|
||||||
|
y2="208"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="16"
|
||||||
|
></line>
|
||||||
|
<line
|
||||||
|
x1="192"
|
||||||
|
y1="40"
|
||||||
|
x2="40"
|
||||||
|
y2="192"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="16"
|
||||||
|
></line>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ShadcnUi.displayName = "ShadcnUi";
|
||||||
|
|
||||||
|
const ReactEmail = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<g clipPath="url(#clip0_27_291)">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M24.4558 24.4853C25.2339 23.7073 25.3805 22.6549 25.2947 21.746C25.2078 20.8254 24.8697 19.8258 24.3896 18.8287C23.957 17.9302 23.3802 16.9745 22.6821 16C23.3802 15.0255 23.957 14.0698 24.3896 13.1713C24.8697 12.1742 25.2078 11.1746 25.2947 10.254C25.3805 9.34508 25.2339 8.29273 24.4558 7.51472C23.6778 6.73671 22.6255 6.59004 21.7165 6.67584C20.796 6.76273 19.7964 7.10086 18.7993 7.58094C17.9007 8.01357 16.945 8.59036 15.9706 9.28842C14.9961 8.59036 14.0404 8.01357 13.1418 7.58094C12.1447 7.10086 11.1451 6.76273 10.2246 6.67584C9.31564 6.59004 8.26329 6.73671 7.48528 7.51472C6.70727 8.29273 6.5606 9.34508 6.6464 10.254C6.7333 11.1746 7.07142 12.1742 7.5515 13.1713C7.98414 14.0698 8.56092 15.0255 9.25898 16C8.56092 16.9745 7.98414 17.9302 7.5515 18.8287C7.07142 19.8258 6.7333 20.8254 6.6464 21.746C6.5606 22.6549 6.70727 23.7073 7.48528 24.4853C8.26329 25.2633 9.31564 25.41 10.2246 25.3242C11.1451 25.2373 12.1447 24.8991 13.1418 24.4191C14.0404 23.9864 14.9961 23.4096 15.9706 22.7116C16.945 23.4096 17.9007 23.9864 18.7993 24.4191C19.7964 24.8991 20.796 25.2373 21.7165 25.3242C22.6255 25.41 23.6778 25.2633 24.4558 24.4853ZM15.9706 20.948C16.8399 20.2684 17.724 19.4874 18.591 18.6205C19.458 17.7535 20.239 16.8693 20.9186 16C20.239 15.1307 19.458 14.2465 18.591 13.3795C17.724 12.5126 16.8399 11.7316 15.9706 11.052C15.1012 11.7316 14.2171 12.5126 13.3501 13.3795C12.4831 14.2465 11.7021 15.1307 11.0225 16C11.7021 16.8693 12.4831 17.7535 13.3501 18.6205C14.2171 19.4874 15.1012 20.2684 15.9706 20.948ZM17.1498 21.8145C17.968 21.1558 18.7885 20.4195 19.5893 19.6187C20.39 18.818 21.1264 17.9974 21.7851 17.1792C23.7187 19.9919 24.4627 22.4819 23.4576 23.487C22.4524 24.4922 19.9625 23.7482 17.1498 21.8145ZM10.156 17.1792C10.8148 17.9974 11.5511 18.818 12.3518 19.6187C13.1526 20.4195 13.9731 21.1558 14.7914 21.8145C11.9786 23.7482 9.48871 24.4922 8.48355 23.487C7.47839 22.4819 8.22238 19.9919 10.156 17.1792ZM10.156 14.8208C10.8148 14.0026 11.5511 13.182 12.3518 12.3813C13.1526 11.5805 13.9731 10.8442 14.7914 10.1855C11.9786 8.25182 9.48871 7.50783 8.48355 8.51299C7.47839 9.51815 8.22238 12.0081 10.156 14.8208ZM17.1498 10.1855C17.968 10.8442 18.7885 11.5805 19.5893 12.3813C20.39 13.182 21.1264 14.0026 21.7851 14.8208C23.7187 12.0081 24.4627 9.51815 23.4576 8.51299C22.4524 7.50783 19.9625 8.25182 17.1498 10.1855Z"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d="M36 22.176V13.744H37.936L37.968 16.432L37.696 15.824C37.8133 15.3973 38.016 15.0133 38.304 14.672C38.592 14.3307 38.9227 14.064 39.296 13.872C39.68 13.6693 40.08 13.568 40.496 13.568C40.6773 13.568 40.848 13.584 41.008 13.616C41.1787 13.648 41.3173 13.6853 41.424 13.728L40.896 15.888C40.7787 15.824 40.6347 15.7707 40.464 15.728C40.2933 15.6853 40.1227 15.664 39.952 15.664C39.6853 15.664 39.4293 15.7173 39.184 15.824C38.9493 15.92 38.7413 16.0587 38.56 16.24C38.3787 16.4213 38.2347 16.6347 38.128 16.88C38.032 17.1147 37.984 17.3813 37.984 17.68V22.176H36Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M45.907 22.336C45.0217 22.336 44.2377 22.1493 43.555 21.776C42.883 21.4027 42.355 20.896 41.971 20.256C41.5977 19.6053 41.411 18.864 41.411 18.032C41.411 17.3707 41.5177 16.768 41.731 16.224C41.9443 15.68 42.2377 15.2107 42.611 14.816C42.995 14.4107 43.4483 14.1013 43.971 13.888C44.5043 13.664 45.0857 13.552 45.715 13.552C46.2697 13.552 46.787 13.6587 47.267 13.872C47.747 14.0853 48.163 14.3787 48.515 14.752C48.867 15.1147 49.1337 15.552 49.315 16.064C49.507 16.5653 49.5977 17.1147 49.587 17.712L49.571 18.4H42.739L42.371 17.056H47.923L47.667 17.328V16.976C47.635 16.6453 47.5283 16.3573 47.347 16.112C47.1657 15.856 46.931 15.6587 46.643 15.52C46.3657 15.3707 46.0563 15.296 45.715 15.296C45.1923 15.296 44.7497 15.3973 44.387 15.6C44.035 15.8027 43.7683 16.096 43.587 16.48C43.4057 16.8533 43.315 17.3227 43.315 17.888C43.315 18.432 43.427 18.9067 43.651 19.312C43.8857 19.7173 44.211 20.032 44.627 20.256C45.0537 20.4693 45.5497 20.576 46.115 20.576C46.5097 20.576 46.8723 20.512 47.203 20.384C47.5337 20.256 47.891 20.0267 48.275 19.696L49.251 21.056C48.963 21.3227 48.6323 21.552 48.259 21.744C47.8963 21.9253 47.5123 22.0693 47.107 22.176C46.7017 22.2827 46.3017 22.336 45.907 22.336Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M54.094 22.336C53.4007 22.336 52.7713 22.144 52.206 21.76C51.6407 21.376 51.1873 20.8533 50.846 20.192C50.5047 19.5307 50.334 18.7787 50.334 17.936C50.334 17.0933 50.5047 16.3413 50.846 15.68C51.1873 15.0187 51.6513 14.5013 52.238 14.128C52.8247 13.7547 53.486 13.568 54.222 13.568C54.6487 13.568 55.038 13.632 55.39 13.76C55.742 13.8773 56.0513 14.048 56.318 14.272C56.5847 14.496 56.8033 14.752 56.974 15.04C57.1553 15.328 57.278 15.6373 57.342 15.968L56.91 15.856V13.744H58.894V22.176H56.894V20.16L57.358 20.08C57.2833 20.368 57.1447 20.6507 56.942 20.928C56.75 21.1947 56.5047 21.4347 56.206 21.648C55.918 21.8507 55.5927 22.016 55.23 22.144C54.878 22.272 54.4993 22.336 54.094 22.336ZM54.638 20.592C55.0967 20.592 55.502 20.48 55.854 20.256C56.206 20.032 56.478 19.7227 56.67 19.328C56.8727 18.9227 56.974 18.4587 56.974 17.936C56.974 17.424 56.8727 16.9707 56.67 16.576C56.478 16.1813 56.206 15.872 55.854 15.648C55.502 15.424 55.0967 15.312 54.638 15.312C54.1793 15.312 53.774 15.424 53.422 15.648C53.0807 15.872 52.814 16.1813 52.622 16.576C52.43 16.9707 52.334 17.424 52.334 17.936C52.334 18.4587 52.43 18.9227 52.622 19.328C52.814 19.7227 53.0807 20.032 53.422 20.256C53.774 20.48 54.1793 20.592 54.638 20.592Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M64.3716 22.336C63.5823 22.336 62.873 22.144 62.2436 21.76C61.6143 21.376 61.1183 20.8533 60.7556 20.192C60.393 19.5307 60.2116 18.784 60.2116 17.952C60.2116 17.12 60.393 16.3733 60.7556 15.712C61.1183 15.0507 61.6143 14.528 62.2436 14.144C62.873 13.76 63.5823 13.568 64.3716 13.568C65.129 13.568 65.817 13.712 66.4356 14C67.0543 14.288 67.5343 14.688 67.8756 15.2L66.7876 16.512C66.6276 16.288 66.425 16.0853 66.1796 15.904C65.9343 15.7227 65.673 15.5787 65.3956 15.472C65.1183 15.3653 64.841 15.312 64.5636 15.312C64.0943 15.312 63.673 15.4293 63.2996 15.664C62.937 15.888 62.649 16.2027 62.4356 16.608C62.2223 17.0027 62.1156 17.4507 62.1156 17.952C62.1156 18.4533 62.2223 18.9013 62.4356 19.296C62.6596 19.6907 62.9583 20.0053 63.3316 20.24C63.705 20.4747 64.121 20.592 64.5796 20.592C64.857 20.592 65.1236 20.5493 65.3796 20.464C65.6463 20.368 65.897 20.2347 66.1316 20.064C66.3663 19.8933 66.585 19.68 66.7876 19.424L67.8756 20.752C67.513 21.2213 67.0116 21.6053 66.3716 21.904C65.7423 22.192 65.0756 22.336 64.3716 22.336Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M69.8726 22.176V11.6H71.8406V22.176H69.8726ZM68.2086 15.568V13.744H73.6806V15.568H68.2086Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M82.9945 22.336C82.1092 22.336 81.3252 22.1493 80.6425 21.776C79.9705 21.4027 79.4425 20.896 79.0585 20.256C78.6852 19.6053 78.4985 18.864 78.4985 18.032C78.4985 17.3707 78.6052 16.768 78.8185 16.224C79.0318 15.68 79.3252 15.2107 79.6985 14.816C80.0825 14.4107 80.5358 14.1013 81.0585 13.888C81.5918 13.664 82.1732 13.552 82.8025 13.552C83.3572 13.552 83.8745 13.6587 84.3545 13.872C84.8345 14.0853 85.2505 14.3787 85.6025 14.752C85.9545 15.1147 86.2212 15.552 86.4025 16.064C86.5945 16.5653 86.6852 17.1147 86.6745 17.712L86.6585 18.4H79.8265L79.4585 17.056H85.0105L84.7545 17.328V16.976C84.7225 16.6453 84.6158 16.3573 84.4345 16.112C84.2532 15.856 84.0185 15.6587 83.7305 15.52C83.4532 15.3707 83.1438 15.296 82.8025 15.296C82.2798 15.296 81.8372 15.3973 81.4745 15.6C81.1225 15.8027 80.8558 16.096 80.6745 16.48C80.4932 16.8533 80.4025 17.3227 80.4025 17.888C80.4025 18.432 80.5145 18.9067 80.7385 19.312C80.9732 19.7173 81.2985 20.032 81.7145 20.256C82.1412 20.4693 82.6372 20.576 83.2025 20.576C83.5972 20.576 83.9598 20.512 84.2905 20.384C84.6212 20.256 84.9785 20.0267 85.3625 19.696L86.3385 21.056C86.0505 21.3227 85.7198 21.552 85.3465 21.744C84.9838 21.9253 84.5998 22.0693 84.1945 22.176C83.7892 22.2827 83.3892 22.336 82.9945 22.336Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M87.9655 22.176V13.744H89.9015L89.9335 15.44L89.6135 15.568C89.7095 15.2907 89.8535 15.0347 90.0455 14.8C90.2375 14.5547 90.4668 14.3467 90.7335 14.176C91.0002 13.9947 91.2828 13.856 91.5815 13.76C91.8802 13.6533 92.1842 13.6 92.4935 13.6C92.9522 13.6 93.3575 13.6747 93.7095 13.824C94.0722 13.9627 94.3708 14.1867 94.6055 14.496C94.8508 14.8053 95.0322 15.2 95.1495 15.68L94.8455 15.616L94.9735 15.36C95.0908 15.104 95.2562 14.8747 95.4695 14.672C95.6828 14.4587 95.9228 14.272 96.1895 14.112C96.4562 13.9413 96.7335 13.8133 97.0215 13.728C97.3202 13.6427 97.6135 13.6 97.9015 13.6C98.5415 13.6 99.0748 13.728 99.5015 13.984C99.9282 14.24 100.248 14.6293 100.462 15.152C100.675 15.6747 100.782 16.32 100.782 17.088V22.176H98.7975V17.216C98.7975 16.7893 98.7388 16.4373 98.6215 16.16C98.5148 15.8827 98.3442 15.68 98.1095 15.552C97.8855 15.4133 97.6028 15.344 97.2615 15.344C96.9948 15.344 96.7388 15.392 96.4935 15.488C96.2588 15.5733 96.0562 15.7013 95.8855 15.872C95.7148 16.032 95.5815 16.2187 95.4855 16.432C95.3895 16.6453 95.3415 16.88 95.3415 17.136V22.176H93.3575V17.2C93.3575 16.7947 93.2988 16.4587 93.1815 16.192C93.0642 15.9147 92.8935 15.7067 92.6695 15.568C92.4455 15.4187 92.1735 15.344 91.8535 15.344C91.5868 15.344 91.3362 15.392 91.1015 15.488C90.8668 15.5733 90.6642 15.696 90.4935 15.856C90.3228 16.016 90.1895 16.2027 90.0935 16.416C89.9975 16.6293 89.9495 16.864 89.9495 17.12V22.176H87.9655Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M105.73 22.336C105.037 22.336 104.408 22.144 103.842 21.76C103.277 21.376 102.824 20.8533 102.482 20.192C102.141 19.5307 101.97 18.7787 101.97 17.936C101.97 17.0933 102.141 16.3413 102.482 15.68C102.824 15.0187 103.288 14.5013 103.874 14.128C104.461 13.7547 105.122 13.568 105.858 13.568C106.285 13.568 106.674 13.632 107.026 13.76C107.378 13.8773 107.688 14.048 107.954 14.272C108.221 14.496 108.44 14.752 108.61 15.04C108.792 15.328 108.914 15.6373 108.978 15.968L108.546 15.856V13.744H110.53V22.176H108.53V20.16L108.994 20.08C108.92 20.368 108.781 20.6507 108.578 20.928C108.386 21.1947 108.141 21.4347 107.842 21.648C107.554 21.8507 107.229 22.016 106.866 22.144C106.514 22.272 106.136 22.336 105.73 22.336ZM106.274 20.592C106.733 20.592 107.138 20.48 107.49 20.256C107.842 20.032 108.114 19.7227 108.306 19.328C108.509 18.9227 108.61 18.4587 108.61 17.936C108.61 17.424 108.509 16.9707 108.306 16.576C108.114 16.1813 107.842 15.872 107.49 15.648C107.138 15.424 106.733 15.312 106.274 15.312C105.816 15.312 105.41 15.424 105.058 15.648C104.717 15.872 104.45 16.1813 104.258 16.576C104.066 16.9707 103.97 17.424 103.97 17.936C103.97 18.4587 104.066 18.9227 104.258 19.328C104.45 19.7227 104.717 20.032 105.058 20.256C105.41 20.48 105.816 20.592 106.274 20.592Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M112.616 22.176V13.744H114.584V22.176H112.616ZM113.576 11.952C113.181 11.952 112.872 11.856 112.648 11.664C112.435 11.4613 112.328 11.1787 112.328 10.816C112.328 10.4747 112.44 10.1973 112.664 9.984C112.888 9.77067 113.192 9.664 113.576 9.664C113.981 9.664 114.291 9.76534 114.504 9.968C114.728 10.16 114.84 10.4427 114.84 10.816C114.84 11.1467 114.728 11.4187 114.504 11.632C114.28 11.8453 113.971 11.952 113.576 11.952Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
<path d="M116.675 22.176V10.336H118.659V22.176H116.675Z" fill="currentColor"></path>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_27_291">
|
||||||
|
<rect width="32" height="32" rx="8" fill="currentColor"></rect>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ReactEmail.displayName = "ReactEmail";
|
||||||
|
|
||||||
|
const StripeLogo = forwardRef<SVGSVGElement, SVGProps<SVGSVGElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
viewBox="0 0 384 512"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<path d="M155.3 154.6c0-22.3 18.6-30.9 48.4-30.9 43.4 0 98.5 13.3 141.9 36.7V26.1C298.3 7.2 251.1 0 203.8 0 88.1 0 11 60.4 11 161.4c0 157.9 216.8 132.3 216.8 200.4 0 26.4-22.9 34.9-54.7 34.9-47.2 0-108.2-19.5-156.1-45.5v128.5a396.1 396.1 0 0 0 156 32.4c118.6 0 200.3-51 200.3-153.6 0-170.2-218-139.7-218-203.9z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
StripeLogo.displayName = "StripeLogo";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drizzle,
|
||||||
|
LuciaAuth,
|
||||||
|
NextjsDark,
|
||||||
|
NextjsLight,
|
||||||
|
ReactEmail,
|
||||||
|
ReactJs,
|
||||||
|
ShadcnUi,
|
||||||
|
StripeLogo,
|
||||||
|
TailwindCss,
|
||||||
|
TRPC,
|
||||||
|
};
|
||||||
29
src/(landing)/_components/footer.tsx
Normal file
29
src/(landing)/_components/footer.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
import { CodeIcon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
|
const githubUrl = "https://github.com/iamtouha/next-lucia-auth";
|
||||||
|
const twitterUrl = "https://twitter.com/iamtouha";
|
||||||
|
|
||||||
|
export const Footer = () => {
|
||||||
|
return (
|
||||||
|
<footer className="px-4 py-6">
|
||||||
|
<div className="container flex items-center p-0">
|
||||||
|
<CodeIcon className="mr-2 h-6 w-6" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Built by{" "}
|
||||||
|
<a className="underline underline-offset-4" href={twitterUrl}>
|
||||||
|
iamtouha
|
||||||
|
</a>
|
||||||
|
. Get the source code from{" "}
|
||||||
|
<a className="underline underline-offset-4" href={githubUrl}>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
71
src/(landing)/_components/header.tsx
Normal file
71
src/(landing)/_components/header.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { RocketIcon } from "@/components/icons";
|
||||||
|
import { APP_TITLE } from "@/lib/constants";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { HamburgerMenuIcon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ name: "Home", href: "/" },
|
||||||
|
{ name: "Features", href: "/#features" },
|
||||||
|
{
|
||||||
|
name: "Documentation",
|
||||||
|
href: "https://www.touha.dev/posts/simple-nextjs-t3-authentication-with-lucia",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const Header = () => {
|
||||||
|
return (
|
||||||
|
<header className="px-2 py-4 lg:py-6">
|
||||||
|
<div className="container flex items-center gap-2 p-0">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="focus:outline-none focus:ring-1 md:hidden"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<HamburgerMenuIcon className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<div className="py-1">
|
||||||
|
{routes.map(({ name, href }) => (
|
||||||
|
<DropdownMenuItem key={name} asChild>
|
||||||
|
<Link href={href}>{name}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Link
|
||||||
|
className="flex items-center justify-center text-xl font-medium"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
<RocketIcon className="mr-2 h-5 w-5" /> {APP_TITLE}
|
||||||
|
</Link>
|
||||||
|
<nav className="ml-10 hidden gap-4 sm:gap-6 md:flex">
|
||||||
|
{routes.map(({ name, href }) => (
|
||||||
|
<Link
|
||||||
|
key={name}
|
||||||
|
className="text-sm font-medium text-muted-foreground/70 transition-colors hover:text-muted-foreground"
|
||||||
|
href={href}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Button asChild variant={"secondary"}>
|
||||||
|
<Link href="/login">Login</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
src/(landing)/_components/hover-card.tsx
Normal file
70
src/(landing)/_components/hover-card.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
|
||||||
|
type FeaturesProps = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
logo: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CardSpotlight = (props: FeaturesProps) => {
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [opacity, setOpacity] = useState(0);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!divRef.current || isFocused) return;
|
||||||
|
|
||||||
|
const div = divRef.current;
|
||||||
|
const rect = div.getBoundingClientRect();
|
||||||
|
|
||||||
|
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
setOpacity(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
setOpacity(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setOpacity(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setOpacity(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={divRef}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
className="relative overflow-hidden rounded-xl border bg-white dark:border-gray-800 dark:bg-gradient-to-r dark:from-black dark:to-neutral-950 dark:shadow-2xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -inset-px opacity-0 transition duration-300"
|
||||||
|
style={{
|
||||||
|
opacity,
|
||||||
|
background: `radial-gradient(600px circle at ${position.x}px ${position.y}px, rgba(255,182,255,.1), transparent 40%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="pl-6 pt-6">{props.logo}</div>
|
||||||
|
<CardHeader className="pb-6">
|
||||||
|
<CardTitle className="text-xl">{props.name}</CardTitle>
|
||||||
|
<CardDescription>{props.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CardSpotlight;
|
||||||
23
src/(landing)/layout.tsx
Normal file
23
src/(landing)/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { APP_TITLE } from "@/lib/constants";
|
||||||
|
import { type Metadata } from "next";
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { Footer } from "./_components/footer";
|
||||||
|
import { Header } from "./_components/header";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: APP_TITLE,
|
||||||
|
description: "A Next.js starter with T3 stack and Lucia auth.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function LandingPageLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
<div className="h-20"></div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LandingPageLayout;
|
||||||
146
src/(landing)/page.tsx
Normal file
146
src/(landing)/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { PlusIcon } from "@/components/icons";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||||
|
import { type Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CopyToClipboard } from "./_components/copy-to-clipboard";
|
||||||
|
import {
|
||||||
|
Drizzle,
|
||||||
|
LuciaAuth,
|
||||||
|
NextjsDark,
|
||||||
|
NextjsLight,
|
||||||
|
ReactEmail,
|
||||||
|
ReactJs,
|
||||||
|
ShadcnUi,
|
||||||
|
StripeLogo,
|
||||||
|
TRPC,
|
||||||
|
TailwindCss,
|
||||||
|
} from "./_components/feature-icons";
|
||||||
|
import CardSpotlight from "./_components/hover-card";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Next.js Lucia Auth Starter Template",
|
||||||
|
description:
|
||||||
|
"A Next.js starter template with nextjs and Lucia auth. Includes drizzle, trpc, react-email, tailwindcss and shadcn-ui",
|
||||||
|
};
|
||||||
|
|
||||||
|
const githubUrl = "https://github.com/iamtouha/next-lucia-auth";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
name: "Next.js",
|
||||||
|
description: "The React Framework for Production",
|
||||||
|
logo: NextjsIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "React.js",
|
||||||
|
description: "Server and client components.",
|
||||||
|
logo: ReactJs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Authentication",
|
||||||
|
description: "Credential authentication with password reset and email validation",
|
||||||
|
logo: LuciaAuth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Database",
|
||||||
|
description: "Drizzle with postgres database",
|
||||||
|
logo: Drizzle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TypeSafe Backend",
|
||||||
|
description: "Preserve type safety from backend to frontend with tRPC",
|
||||||
|
logo: TRPC,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Subscription",
|
||||||
|
description: "Subscription with stripe",
|
||||||
|
logo: StripeLogo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tailwindcss",
|
||||||
|
description: "Simple and elegant UI components built with Tailwind CSS",
|
||||||
|
logo: TailwindCss,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Shadcn UI",
|
||||||
|
description: "A set of beautifully designed UI components for React",
|
||||||
|
logo: ShadcnUi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "React Email",
|
||||||
|
description: "Write emails in React with ease.",
|
||||||
|
logo: ReactEmail,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const HomePage = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="mx-auto grid min-h-[calc(100vh-300px)] max-w-5xl flex-col items-center justify-center gap-4 py-10 text-center md:py-12">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-10 flex items-center justify-center gap-3">
|
||||||
|
<NextjsIcon className="h-[52px] w-[52px]" />
|
||||||
|
<PlusIcon className="h-8 w-8" />
|
||||||
|
<LuciaAuth className="h-14 w-14" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-balance bg-gradient-to-tr from-black/70 via-black to-black/60 bg-clip-text text-center text-3xl font-bold text-transparent dark:from-zinc-400/10 dark:via-white/90 dark:to-white/20 sm:text-5xl md:text-6xl lg:text-7xl">
|
||||||
|
Next.js Lucia Auth Starter Template
|
||||||
|
</h1>
|
||||||
|
<p className="mb-10 mt-4 text-balance text-center text-muted-foreground md:text-lg lg:text-xl">
|
||||||
|
A Next.js Authentication starter template (password reset, email validation and oAuth).
|
||||||
|
Includes Lucia, Drizzle, tRPC, Stripe, tailwindcss, shadcn-ui and react-email.
|
||||||
|
</p>
|
||||||
|
<div className="mb-10">
|
||||||
|
<div className="mx-auto max-w-[430px]">
|
||||||
|
<CopyToClipboard text={"git clone " + githubUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center gap-4">
|
||||||
|
<Button size="lg" variant="outline" asChild>
|
||||||
|
<a href={githubUrl}>
|
||||||
|
<GitHubLogoIcon className="mr-1 h-5 w-5" />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" asChild>
|
||||||
|
<Link href="/login">Get Started</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div className="container mx-auto lg:max-w-screen-lg">
|
||||||
|
<h1 className="mb-4 text-center text-3xl font-bold md:text-4xl lg:text-5xl">
|
||||||
|
<a id="features"></a> Features
|
||||||
|
</h1>
|
||||||
|
<p className="mb-10 text-balance text-center text-muted-foreground md:text-lg lg:text-xl">
|
||||||
|
This starter template is a guide to help you get started with Next.js for large scale
|
||||||
|
applications. Feel free to add or remove features to suit your needs.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3">
|
||||||
|
{features.map((feature, i) => (
|
||||||
|
<CardSpotlight
|
||||||
|
key={i}
|
||||||
|
name={feature.name}
|
||||||
|
description={feature.description}
|
||||||
|
logo={<feature.logo className="h-12 w-12" />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
|
|
||||||
|
function NextjsIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NextjsLight className={className + " dark:hidden"} />
|
||||||
|
<NextjsDark className={className + " hidden dark:block"} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/(main)/_components/footer.tsx
Normal file
29
src/(main)/_components/footer.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
import { CodeIcon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
|
const githubUrl = "https://github.com/iamtouha/next-lucia-auth";
|
||||||
|
const twitterUrl = "https://twitter.com/iamtouha";
|
||||||
|
|
||||||
|
export const Footer = () => {
|
||||||
|
return (
|
||||||
|
<footer className="mt-6 px-4 py-6">
|
||||||
|
<div className="container flex items-center p-0">
|
||||||
|
<CodeIcon className="mr-2 h-6 w-6" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Built by{" "}
|
||||||
|
<a className="underline underline-offset-4" href={twitterUrl}>
|
||||||
|
iamtouha
|
||||||
|
</a>
|
||||||
|
. Get the source code from{" "}
|
||||||
|
<a className="underline underline-offset-4" href={githubUrl}>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
src/(main)/_components/header.tsx
Normal file
20
src/(main)/_components/header.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { UserDropdown } from "@/app/(main)/_components/user-dropdown";
|
||||||
|
import { RocketIcon } from "@/components/icons";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import { APP_TITLE } from "@/lib/constants";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const Header = async () => {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-10 border-b bg-background/80 p-0">
|
||||||
|
<div className="container flex items-center gap-2 px-2 py-2 lg:px-4">
|
||||||
|
<Link className="flex items-center justify-center text-xl font-medium" href="/">
|
||||||
|
<RocketIcon className="mr-2 h-5 w-5" /> {APP_TITLE} Dashboard
|
||||||
|
</Link>
|
||||||
|
{user ? <UserDropdown email={user.email} avatar={user.avatar} className="ml-auto" /> : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
119
src/(main)/_components/user-dropdown.tsx
Normal file
119
src/(main)/_components/user-dropdown.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExclamationTriangleIcon } from "@/components/icons";
|
||||||
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { logout } from "@/lib/auth/actions";
|
||||||
|
import { APP_TITLE } from "@/lib/constants";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const UserDropdown = ({
|
||||||
|
email,
|
||||||
|
avatar,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className={className}>
|
||||||
|
{/* eslint @next/next/no-img-element:off */}
|
||||||
|
<img
|
||||||
|
src={avatar ?? "https://source.boringavatars.com/marble/60/" + email}
|
||||||
|
alt="Avatar"
|
||||||
|
className="block h-8 w-8 rounded-full leading-none"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
></img>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel className="text-muted-foreground">{email}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem className="cursor-pointer text-muted-foreground" asChild>
|
||||||
|
<Link href="/dashboard">Dashboard</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer text-muted-foreground" asChild>
|
||||||
|
<Link href="/dashboard/billing">Billing</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer text-muted-foreground" asChild>
|
||||||
|
<Link href="/dashboard/settings">Settings</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuLabel className="p-0">
|
||||||
|
<SignoutConfirmation />
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SignoutConfirmation = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSignout = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
toast("Signed out successfully");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast(error.message, {
|
||||||
|
icon: <ExclamationTriangleIcon className="h-4 w-4 text-destructive" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setOpen(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
className="px-2 py-1.5 text-sm text-muted-foreground outline-none"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<button>Sign out</button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent className="max-w-xs">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-center">Sign out from {APP_TITLE}?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>You will be redirected to the home page.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-center">
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<LoadingButton loading={isLoading} onClick={handleSignout}>
|
||||||
|
Continue
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
35
src/(main)/account/page.tsx
Normal file
35
src/(main)/account/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { logout } from "@/lib/auth/actions";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import { Paths } from "@/lib/constants";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function AccountPage() {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
if (!user) redirect(Paths.Login);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto min-h-screen p-4">
|
||||||
|
<Card className="max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle> {user.email}!</CardTitle>
|
||||||
|
<CardDescription>You've successfully logged in!</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>This is a private page.</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<form action={logout}>
|
||||||
|
<SubmitButton variant="outline">Logout</SubmitButton>
|
||||||
|
</form>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/(main)/dashboard/_components/dashboard-nav.tsx
Normal file
52
src/(main)/dashboard/_components/dashboard-nav.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CreditCard, FileTextIcon, GearIcon } from "@/components/icons";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
title: "Posts",
|
||||||
|
href: "/dashboard",
|
||||||
|
icon: FileTextIcon,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: "Billing",
|
||||||
|
href: "/dashboard/billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
href: "/dashboard/settings",
|
||||||
|
icon: GearIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardNav({ className }: Props) {
|
||||||
|
const path = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={cn(className)}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link href={item.href} key={item.href}>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground",
|
||||||
|
path === item.href ? "bg-accent" : "transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="mr-2 h-4 w-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/(main)/dashboard/_components/new-post.tsx
Normal file
79
src/(main)/dashboard/_components/new-post.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FilePlusIcon } from "@/components/icons";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import { type RouterOutputs } from "@/trpc/shared";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
interface NewPostProps {
|
||||||
|
isEligible: boolean;
|
||||||
|
setOptimisticPosts: (action: {
|
||||||
|
action: "add" | "delete" | "update";
|
||||||
|
post: RouterOutputs["post"]["myPosts"][number];
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewPost = ({ isEligible, setOptimisticPosts }: NewPostProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const post = api.post.create.useMutation();
|
||||||
|
const [isCreatePending, startCreateTransaction] = React.useTransition();
|
||||||
|
|
||||||
|
const createPost = () => {
|
||||||
|
if (!isEligible) {
|
||||||
|
toast.message("You've reached the limit of posts for your current plan", {
|
||||||
|
description: "Upgrade to create more posts",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startCreateTransaction(async () => {
|
||||||
|
await post.mutateAsync(
|
||||||
|
{
|
||||||
|
title: "Untitled Post",
|
||||||
|
content: "Write your content here",
|
||||||
|
excerpt: "untitled post",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
setOptimisticPosts({
|
||||||
|
action: "add",
|
||||||
|
post: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: "Untitled Post",
|
||||||
|
excerpt: "untitled post",
|
||||||
|
status: "draft",
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: ({ id }) => {
|
||||||
|
toast.success("Post created");
|
||||||
|
router.refresh();
|
||||||
|
// This is a workaround for a bug in navigation because of router.refresh()
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/editor/${id}`);
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to create post");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={createPost}
|
||||||
|
className="flex h-full cursor-pointer items-center justify-center bg-card p-6 text-muted-foreground transition-colors hover:bg-secondary/10 dark:border-none dark:bg-secondary/30 dark:hover:bg-secondary/50"
|
||||||
|
disabled={isCreatePending}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<FilePlusIcon className="h-10 w-10" />
|
||||||
|
<p className="text-sm">New Post</p>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
src/(main)/dashboard/_components/post-card-skeleton.tsx
Normal file
23
src/(main)/dashboard/_components/post-card-skeleton.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export function PostCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-7 w-24" />
|
||||||
|
<Skeleton className="h-3 w-36" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="line-clamp-3 text-sm">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-between gap-2">
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-8 w-12" />
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/(main)/dashboard/_components/post-card.tsx
Normal file
92
src/(main)/dashboard/_components/post-card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Pencil2Icon, TrashIcon } from "@/components/icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import { type RouterOutputs } from "@/trpc/shared";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface PostCardProps {
|
||||||
|
post: RouterOutputs["post"]["myPosts"][number];
|
||||||
|
userName?: string;
|
||||||
|
setOptimisticPosts: (action: {
|
||||||
|
action: "add" | "delete" | "update";
|
||||||
|
post: RouterOutputs["post"]["myPosts"][number];
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PostCard = ({ post, userName, setOptimisticPosts }: PostCardProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const postMutation = api.post.delete.useMutation();
|
||||||
|
const [isDeletePending, startDeleteTransition] = React.useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="line-clamp-2 text-base">{post.title}</CardTitle>
|
||||||
|
<CardDescription className="line-clamp-1 text-sm">
|
||||||
|
{userName ? <span>{userName} at</span> : null}
|
||||||
|
{new Date(post.createdAt.toJSON()).toLocaleString(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="line-clamp-3 text-sm">{post.excerpt}</CardContent>
|
||||||
|
<CardFooter className="flex-row-reverse gap-2">
|
||||||
|
<Button variant="secondary" size="sm" asChild>
|
||||||
|
<Link href={`/editor/${post.id}`}>
|
||||||
|
<Pencil2Icon className="mr-1 h-4 w-4" />
|
||||||
|
<span>Edit</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
startDeleteTransition(async () => {
|
||||||
|
await postMutation.mutateAsync(
|
||||||
|
{ id: post.id },
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
setOptimisticPosts({
|
||||||
|
action: "delete",
|
||||||
|
post,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Post deleted");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to delete post");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isDeletePending}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
<Badge variant="outline" className="mr-auto rounded-lg capitalize">
|
||||||
|
{post.status} Post
|
||||||
|
</Badge>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
11
src/(main)/dashboard/_components/posts-skeleton.tsx
Normal file
11
src/(main)/dashboard/_components/posts-skeleton.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { PostCardSkeleton } from "./post-card-skeleton";
|
||||||
|
|
||||||
|
export function PostsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<PostCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/(main)/dashboard/_components/posts.tsx
Normal file
58
src/(main)/dashboard/_components/posts.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type RouterOutputs } from "@/trpc/shared";
|
||||||
|
import * as React from "react";
|
||||||
|
import { NewPost } from "./new-post";
|
||||||
|
import { PostCard } from "./post-card";
|
||||||
|
|
||||||
|
interface PostsProps {
|
||||||
|
promises: Promise<[RouterOutputs["post"]["myPosts"], RouterOutputs["stripe"]["getPlan"]]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Posts({ promises }: PostsProps) {
|
||||||
|
/**
|
||||||
|
* use is a React Hook that lets you read the value of a resource like a Promise or context.
|
||||||
|
* @see https://react.dev/reference/react/use
|
||||||
|
*/
|
||||||
|
const [posts, subscriptionPlan] = React.use(promises);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useOptimistic is a React Hook that lets you show a different state while an async action is underway.
|
||||||
|
* It accepts some state as an argument and returns a copy of that state that can be different during the duration of an async action such as a network request.
|
||||||
|
* @see https://react.dev/reference/react/useOptimistic
|
||||||
|
*/
|
||||||
|
const [optimisticPosts, setOptimisticPosts] = React.useOptimistic(
|
||||||
|
posts,
|
||||||
|
(
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
post,
|
||||||
|
}: {
|
||||||
|
action: "add" | "delete" | "update";
|
||||||
|
post: RouterOutputs["post"]["myPosts"][number];
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
switch (action) {
|
||||||
|
case "delete":
|
||||||
|
return state.filter((p) => p.id !== post.id);
|
||||||
|
case "update":
|
||||||
|
return state.map((p) => (p.id === post.id ? post : p));
|
||||||
|
default:
|
||||||
|
return [...state, post];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<NewPost
|
||||||
|
isEligible={(optimisticPosts.length < 2 || subscriptionPlan?.isPro) ?? false}
|
||||||
|
setOptimisticPosts={setOptimisticPosts}
|
||||||
|
/>
|
||||||
|
{optimisticPosts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} setOptimisticPosts={setOptimisticPosts} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/(main)/dashboard/_components/verificiation-warning.tsx
Normal file
28
src/(main)/dashboard/_components/verificiation-warning.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ExclamationTriangleIcon } from "@/components/icons";
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export async function VerificiationWarning() {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
|
||||||
|
return user?.emailVerified === false ? (
|
||||||
|
<Alert className="rounded-lg bg-yellow-50 text-yellow-700 dark:bg-gray-800 dark:text-yellow-400">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 !text-yellow-700 dark:!text-yellow-400" />
|
||||||
|
<div className="flex lg:items-center">
|
||||||
|
<div className="w-full">
|
||||||
|
<AlertTitle>Account verification required</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
A verification email has been sent to your email address. Please verify your account to
|
||||||
|
access all features.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" asChild>
|
||||||
|
<Link href="/verify-email">Verify Email</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export function BillingSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section>
|
||||||
|
<Card className="space-y-2 p-8">
|
||||||
|
<Skeleton className="h-7 w-24" />
|
||||||
|
<Skeleton className="h-5 w-36" />
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
|
<Card key={i} className="flex flex-col p-2">
|
||||||
|
<CardHeader className="h-full">
|
||||||
|
<Skeleton className="h-7 w-24" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-full flex-1 space-y-6">
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-5 w-5 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-4">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/(main)/dashboard/billing/_components/billing.tsx
Normal file
87
src/(main)/dashboard/billing/_components/billing.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { CheckIcon } from "@/components/icons";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import { type RouterOutputs } from "@/trpc/shared";
|
||||||
|
import { ManageSubscriptionForm } from "./manage-subscription-form";
|
||||||
|
|
||||||
|
interface BillingProps {
|
||||||
|
stripePromises: Promise<
|
||||||
|
[RouterOutputs["stripe"]["getPlans"], RouterOutputs["stripe"]["getPlan"]]
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function Billing({ stripePromises }: BillingProps) {
|
||||||
|
const [plans, plan] = await stripePromises;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section>
|
||||||
|
<Card className="space-y-2 p-8">
|
||||||
|
<h3 className="text-lg font-semibold sm:text-xl">{plan?.name ?? "Free"} plan</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{!plan?.isPro
|
||||||
|
? "The free plan is limited to 2 posts. Upgrade to the Pro plan to unlock unlimited posts."
|
||||||
|
: plan.isCanceled
|
||||||
|
? "Your plan will be canceled on "
|
||||||
|
: "Your plan renews on "}
|
||||||
|
{plan?.stripeCurrentPeriodEnd ? formatDate(plan.stripeCurrentPeriodEnd) : null}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{plans.map((item) => (
|
||||||
|
<Card key={item.name} className="flex flex-col p-2">
|
||||||
|
<CardHeader className="h-full">
|
||||||
|
<CardTitle className="line-clamp-1">{item.name}</CardTitle>
|
||||||
|
<CardDescription className="line-clamp-2">{item.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-full flex-1 space-y-6">
|
||||||
|
<div className="text-3xl font-bold">
|
||||||
|
{item.price}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">/month</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{item.features.map((feature) => (
|
||||||
|
<div key={feature} className="flex items-center gap-2">
|
||||||
|
<div className="aspect-square shrink-0 rounded-full bg-foreground p-px text-background">
|
||||||
|
<CheckIcon className="size-4" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-4">
|
||||||
|
{item.name === "Free" ? (
|
||||||
|
<Button className="w-full" asChild>
|
||||||
|
<Link href="/dashboard">
|
||||||
|
Get started
|
||||||
|
<span className="sr-only">Get started</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<ManageSubscriptionForm
|
||||||
|
stripePriceId={item.stripePriceId}
|
||||||
|
isPro={plan?.isPro ?? false}
|
||||||
|
stripeCustomerId={plan?.stripeCustomerId}
|
||||||
|
stripeSubscriptionId={plan?.stripeSubscriptionId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ManageSubscriptionInput } from "@/server/api/routers/stripe/stripe.input";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function ManageSubscriptionForm({
|
||||||
|
isPro,
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
stripePriceId,
|
||||||
|
}: ManageSubscriptionInput) {
|
||||||
|
const [isPending, startTransition] = React.useTransition();
|
||||||
|
const managePlanMutation = api.stripe.managePlan.useMutation();
|
||||||
|
|
||||||
|
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const session = await managePlanMutation.mutateAsync({
|
||||||
|
isPro,
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
stripePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
window.location.href = session.url ?? "/dashboard/billing";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
err instanceof Error
|
||||||
|
? toast.error(err.message)
|
||||||
|
: toast.error("An error occurred. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="w-full" onSubmit={onSubmit}>
|
||||||
|
<Button className="w-full" disabled={isPending}>
|
||||||
|
{isPending ? "Loading..." : isPro ? "Manage plan" : "Subscribe now"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/(main)/dashboard/billing/page.tsx
Normal file
60
src/(main)/dashboard/billing/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { ExclamationTriangleIcon } from "@/components/icons";
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { env } from "@/env";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import { APP_TITLE } from "@/lib/constants";
|
||||||
|
import { api } from "@/trpc/server";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Billing } from "./_components/billing";
|
||||||
|
import { BillingSkeleton } from "./_components/billing-skeleton";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(env.NEXT_PUBLIC_APP_URL),
|
||||||
|
title: "Billing",
|
||||||
|
description: "Manage your billing and subscription",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BillingPage() {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripePromises = Promise.all([api.stripe.getPlans.query(), api.stripe.getPlan.query()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold md:text-4xl">Billing</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Manage your billing and subscription</p>
|
||||||
|
</div>
|
||||||
|
<section>
|
||||||
|
<Alert className="p-6 [&>svg]:left-6 [&>svg]:top-6 [&>svg~*]:pl-10">
|
||||||
|
<ExclamationTriangleIcon className="h-6 w-6" />
|
||||||
|
<AlertTitle>This is a demo app.</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{APP_TITLE} app is a demo app using a Stripe test environment. You can find a list of
|
||||||
|
test card numbers on the{" "}
|
||||||
|
<a
|
||||||
|
href="https://stripe.com/docs/testing#cards"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Stripe docs
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</section>
|
||||||
|
<React.Suspense fallback={<BillingSkeleton />}>
|
||||||
|
<Billing stripePromises={stripePromises} />
|
||||||
|
</React.Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/(main)/dashboard/layout.tsx
Normal file
20
src/(main)/dashboard/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { DashboardNav } from "./_components/dashboard-nav";
|
||||||
|
import { VerificiationWarning } from "./_components/verificiation-warning";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="container min-h-[calc(100vh-180px)] px-2 pt-6 md:px-4">
|
||||||
|
<div className="flex flex-col gap-6 md:flex-row lg:gap-10">
|
||||||
|
<DashboardNav className="flex flex-shrink-0 gap-2 md:w-48 md:flex-col lg:w-80" />
|
||||||
|
<main className="w-full space-y-4">
|
||||||
|
<VerificiationWarning />
|
||||||
|
<div>{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/(main)/dashboard/page.tsx
Normal file
50
src/(main)/dashboard/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { env } from "@/env";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import { Paths } from "@/lib/constants";
|
||||||
|
import { myPostsSchema } from "@/server/api/routers/post/post.input";
|
||||||
|
import { api } from "@/trpc/server";
|
||||||
|
import { type Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Posts } from "./_components/posts";
|
||||||
|
import { PostsSkeleton } from "./_components/posts-skeleton";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(env.NEXT_PUBLIC_APP_URL),
|
||||||
|
title: "Posts",
|
||||||
|
description: "Manage your posts here",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
searchParams: Record<string, string | string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage({ searchParams }: Props) {
|
||||||
|
const { page, perPage } = myPostsSchema.parse(searchParams);
|
||||||
|
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
if (!user) redirect(Paths.Login);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passing multiple promises to `Promise.all` to fetch data in parallel to prevent waterfall requests.
|
||||||
|
* Passing promises to the `Posts` component to make them hot promises (they can run without being awaited) to prevent waterfall requests.
|
||||||
|
* @see https://www.youtube.com/shorts/A7GGjutZxrs
|
||||||
|
* @see https://nextjs.org/docs/app/building-your-application/data-fetching/patterns#parallel-data-fetching
|
||||||
|
*/
|
||||||
|
const promises = Promise.all([
|
||||||
|
api.post.myPosts.query({ page, perPage }),
|
||||||
|
api.stripe.getPlan.query(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold md:text-4xl">Posts</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Manage your posts here</p>
|
||||||
|
</div>
|
||||||
|
<React.Suspense fallback={<PostsSkeleton />}>
|
||||||
|
<Posts promises={promises} />
|
||||||
|
</React.Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/(main)/dashboard/settings/page.tsx
Normal file
29
src/(main)/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { env } from "@/env";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(env.NEXT_PUBLIC_APP_URL),
|
||||||
|
title: "Billing",
|
||||||
|
description: "Manage your billing and subscription",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BillingPage() {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold md:text-4xl">Settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Manage your account settings</p>
|
||||||
|
</div>
|
||||||
|
<p>Work in progress...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/(main)/editor/[postId]/_components/post-editor.tsx
Normal file
127
src/(main)/editor/[postId]/_components/post-editor.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { type RouterOutputs } from "@/trpc/shared";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { PostPreview } from "./post-preview";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import { Pencil2Icon } from "@/components/icons";
|
||||||
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { createPostSchema } from "@/server/api/routers/post/post.input";
|
||||||
|
|
||||||
|
const markdownlink = "https://remarkjs.github.io/react-markdown/";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
post: RouterOutputs["post"]["get"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PostEditor = ({ post }: Props) => {
|
||||||
|
if (!post) return null;
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const updatePost = api.post.update.useMutation();
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
title: post.title,
|
||||||
|
excerpt: post.excerpt,
|
||||||
|
content: post.content,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(createPostSchema),
|
||||||
|
});
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
updatePost.mutate({ id: post.id, ...values });
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Pencil2Icon className="h-5 w-5" />
|
||||||
|
<h1 className="text-2xl font-bold">{post.title}</h1>
|
||||||
|
|
||||||
|
<LoadingButton
|
||||||
|
disabled={!form.formState.isDirty}
|
||||||
|
loading={updatePost.isLoading}
|
||||||
|
onClick={() => formRef.current?.requestSubmit()}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
<div className="h-6"></div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form ref={formRef} onSubmit={onSubmit} className="block max-w-screen-md space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Post Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="excerpt"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Excerpt</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} rows={2} className="min-h-0" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>A short description of your post</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="content"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Tabs defaultValue="code">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="code">Code</TabsTrigger>
|
||||||
|
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="code">
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} className="min-h-[200px]" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="preview" className="space-y-2">
|
||||||
|
<div className="prose prose-sm min-h-[200px] max-w-[none] rounded-lg border px-3 py-2 dark:prose-invert">
|
||||||
|
<PostPreview text={form.watch("content") || post.content} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<Link href={markdownlink}>
|
||||||
|
<span className="text-[0.8rem] text-muted-foreground underline underline-offset-4">
|
||||||
|
Supports markdown
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
30
src/(main)/editor/[postId]/_components/post-preview.tsx
Normal file
30
src/(main)/editor/[postId]/_components/post-preview.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import Markdown, { type Components } from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
|
import { materialOceanic } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||||
|
|
||||||
|
const options: Components = {
|
||||||
|
code: (props) => (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={props.className?.replace(/(?:lang(?:uage)?-)/, "")}
|
||||||
|
style={materialOceanic}
|
||||||
|
wrapLines={true}
|
||||||
|
className="not-prose rounded-md"
|
||||||
|
>
|
||||||
|
{String(props.children)}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PostPreview = ({ text }: { text: string }) => {
|
||||||
|
return (
|
||||||
|
<Markdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw]}
|
||||||
|
components={options}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Markdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
35
src/(main)/editor/[postId]/page.tsx
Normal file
35
src/(main)/editor/[postId]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { api } from "@/trpc/server";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { PostEditor } from "./_components/post-editor";
|
||||||
|
import { ArrowLeftIcon } from "@/components/icons";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { validateRequest } from "@/lib/auth/validate-request";
|
||||||
|
import { Paths } from "@/lib/constants";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: {
|
||||||
|
postId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditPostPage({ params }: Props) {
|
||||||
|
const { user } = await validateRequest();
|
||||||
|
if (!user) redirect(Paths.Login);
|
||||||
|
|
||||||
|
const post = await api.post.get.query({ id: params.postId });
|
||||||
|
if (!post) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container min-h-[calc(100vh-160px)] pt-3 md:max-w-screen-md">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="mb-3 flex items-center gap-2 text-sm text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-5 w-5" /> back to dashboard
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PostEditor post={post} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/(main)/layout.tsx
Normal file
15
src/(main)/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { Header } from "./_components/header";
|
||||||
|
import { Footer } from "./_components/footer";
|
||||||
|
|
||||||
|
const MainLayout = ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainLayout;
|
||||||
@@ -3,6 +3,8 @@ import { eq, not , asc} from "drizzle-orm";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { db } from "@src/db";
|
import { db } from "@src/db";
|
||||||
import { users } from "@schemas/schema";
|
import { users } from "@schemas/schema";
|
||||||
|
import { stringWidth } from "bun";
|
||||||
|
import { generateId } from "lucia";
|
||||||
|
|
||||||
export const getData = async () => {
|
export const getData = async () => {
|
||||||
const data = await db.select().from(users).orderBy(asc(users.last_name));
|
const data = await db.select().from(users).orderBy(asc(users.last_name));
|
||||||
@@ -22,19 +24,27 @@ export const getUserByUUID = async (uuid:string) => {
|
|||||||
return data[0];
|
return data[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getUserByID = async (id:string) => {
|
||||||
|
const data = await db.select().from(users).where(eq(users.id, id));
|
||||||
|
return data[0];
|
||||||
|
};
|
||||||
|
|
||||||
/*export const addUser = async ( first_name: string, last_name: string, username: string, email: string, password_hash : string) => {
|
/*export const addUser = async ( first_name: string, last_name: string, username: string, email: string, password_hash : string) => {
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
first_name : first_name, last_name: last_name, username: email, email: email, password_hash : password_hash
|
first_name : first_name, last_name: last_name, username: email, email: email, password_hash : password_hash
|
||||||
});
|
});
|
||||||
};*/
|
};*/
|
||||||
export const addUser = async (first_name: string, last_name: string, username: string, email: string, password_hash: string) => {
|
export const addUser = async (id: string, first_name: string, last_name: string, username: string, email: string, emailVerified: boolean, password_hash: string, hashedPassword:string) => {
|
||||||
const [addedUser] = await db.insert(users).values({
|
const [addedUser] = await db.insert(users).values({
|
||||||
|
id: id,
|
||||||
name: `${first_name} ${last_name}`,
|
name: `${first_name} ${last_name}`,
|
||||||
first_name: first_name,
|
first_name: first_name,
|
||||||
last_name: last_name,
|
last_name: last_name,
|
||||||
username: email,
|
username: email,
|
||||||
email: email,
|
email: email,
|
||||||
|
emailVerified:emailVerified,
|
||||||
password_hash: password_hash,
|
password_hash: password_hash,
|
||||||
|
hash_password: hashedPassword,
|
||||||
full_name: `${first_name} ${last_name}`,
|
full_name: `${first_name} ${last_name}`,
|
||||||
}).returning(); // Returns the inserted user (adjust "*" to specific columns if necessary)
|
}).returning(); // Returns the inserted user (adjust "*" to specific columns if necessary)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import BetaTester from "@components/BetaTester";
|
import BetaTester from "@components/BetaTester";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { globalConfig } from '@src/customGlobals';
|
import { globalConfig } from '@src/styles/customGlobals';
|
||||||
import constants from "@src/lib/constants";
|
import constants from "@src/lib/constants";
|
||||||
export default async function Home( ) {
|
export default async function Home( ) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,38 +4,38 @@ import { Herr_Von_Muellerhoff } from "next/font/google";
|
|||||||
|
|
||||||
export default async function UserProfilePage(props: any) {
|
export default async function UserProfilePage(props: any) {
|
||||||
|
|
||||||
let a_user:any = "";
|
let a_user: any = "";
|
||||||
const {uuid} = await props.params;
|
const { uuid } = await props.params;
|
||||||
|
|
||||||
if (uuid) {
|
if (uuid) {
|
||||||
a_user = await getUserByUUID(uuid as unknown as string);
|
a_user = await getUserByUUID(uuid as unknown as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.params || !uuid || !a_user ) {
|
if (!props.params || !uuid || !a_user) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="p-4 pt-16 mx-auto max-w-screen-lg">
|
<div className="p-4 pt-16 mx-auto max-w-screen-lg">
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||||
|
|
||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-4"> User Profile </h1>
|
<h1 className="text-3xl font-bold mb-4"> User Profile </h1>
|
||||||
<div className="bg-white rounded-lg shadow-md p-4 mb-6">
|
<div className="bg-white rounded-lg shadow-md p-4 mb-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">{a_user.first_name}{" "}{a_user.last_name} </h2>
|
<h2 className="text-xl font-semibold mb-4">{a_user.first_name}{" "}{a_user.last_name} </h2>
|
||||||
<h2 className="text-xl font-semibold mb-4">{a_user.email}</h2>
|
<h2 className="text-xl font-semibold mb-4">{a_user.email}</h2>
|
||||||
<h2 className="text-xl font-semibold mb-4">{a_user.username} </h2>
|
<h2 className="text-xl font-semibold mb-4">{a_user.username} </h2>
|
||||||
<h2 className="text-xl font-semibold mb-4">{(a_user.isAdmin)? "True":"False"} </h2>
|
<h2 className="text-xl font-semibold mb-4">{(a_user.isAdmin) ? "True" : "False"} </h2>
|
||||||
<h2 className="text-xl font-semibold mb-4">{a_user.first_name}{" "}{a_user.last_name} </h2>
|
<h2 className="text-xl font-semibold mb-4">{a_user.first_name}{" "}{a_user.last_name} </h2>
|
||||||
<h2 className="text-xl font-semibold mb-4">{a_user.first_name}{" "}{a_user.last_name} </h2>
|
<h2 className="text-xl font-semibold mb-4">{a_user.first_name}{" "}{a_user.last_name} </h2>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { getUserByUUID } from "@actions/userActions";
|
|
||||||
import { NextPage } from "next";
|
|
||||||
import { Herr_Von_Muellerhoff } from "next/font/google";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {db} from "@db/index";
|
|
||||||
import router from "next/router";
|
|
||||||
|
|
||||||
export default function UserProfilePage(params: any) {
|
|
||||||
|
|
||||||
const { uuid } = router.query;
|
|
||||||
// const uuid = "8ad8da5e-1ecd-402b-8211-bc93f2c3331a";
|
|
||||||
// const [user, setUser] = useState<{ uuid: string | null; id: string; username: string; email: string; emailVerifiedOn: Date | null; password_hash: string; first_name: string | null; last_name: string | null; full_name: string | null; buildPrivacySetting: string | null; }[] | null>(null);
|
|
||||||
let user:any = null;
|
|
||||||
//useEffect(() => {
|
|
||||||
// if (uuid) {
|
|
||||||
// getUserByUUID(uuid as string)
|
|
||||||
// .then((user) => setUser(user))
|
|
||||||
// .catch((error) => console.error("Error fetching user data:", error));
|
|
||||||
// }
|
|
||||||
//}, [uuid]);
|
|
||||||
user = getUserByUUID(uuid as string)
|
|
||||||
if (!user) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 pt-16 mx-auto max-w-screen-lg">
|
|
||||||
UUID Page
|
|
||||||
{user[0].first_name}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
|
||||||
|
|
||||||
{/* Product Info */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold mb-4">{user[0].first_name} anything else</h1>
|
|
||||||
|
|
||||||
{/* Vendor Pricing Table */}
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-4 mb-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Available From</h2>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
anything
|
|
||||||
{/* <table className="min-w-full table-auto">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-50 border-b">
|
|
||||||
<th className="px-4 py-2 text-left">Vendor</th>
|
|
||||||
<th className="px-4 py-2 text-left">Price</th>
|
|
||||||
<th className="px-4 py-2 text-left">Stock</th>
|
|
||||||
<th className="px-4 py-2 text-left">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{product.vendors.map((vendor, index) => (
|
|
||||||
<tr key={index} className="border-b hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-2">{vendor.name}</td>
|
|
||||||
<td className="px-4 py-2">{vendor.price}</td>
|
|
||||||
<td className="px-4 py-2">{vendor.stock}</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<button className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
|
|
||||||
Buy Now
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Product Specifications */}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,7 @@ import PopNav from "@components/PopNav/page";
|
|||||||
import { Roboto } from 'next/font/google'
|
import { Roboto } from 'next/font/google'
|
||||||
import constants from "@src/lib/constants";
|
import constants from "@src/lib/constants";
|
||||||
import Footer from "@components/footer";
|
import Footer from "@components/footer";
|
||||||
import '../styles/globals.css'
|
// import { ClerkProvider, SignInButton, SignedIn, SignedOut,UserButton} from '@clerk/nextjs';
|
||||||
import {
|
|
||||||
ClerkProvider,
|
|
||||||
SignInButton,
|
|
||||||
SignedIn,
|
|
||||||
SignedOut,
|
|
||||||
UserButton
|
|
||||||
} from '@clerk/nextjs';
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: constants.APP_NAME,
|
title: constants.APP_NAME,
|
||||||
description: constants.DESCRIPTION,
|
description: constants.DESCRIPTION,
|
||||||
@@ -28,16 +21,16 @@ export default function RootLayout(props: { children: React.ReactNode }) {
|
|||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClerkProvider>
|
/* <ClerkProvider> */
|
||||||
<html lang="en" suppressHydrationWarning className={roboto.className}>
|
<html lang="en" suppressHydrationWarning className={roboto.className}>
|
||||||
<body className="bg-slate-200 ">
|
<body className="bg-slate-200 ">
|
||||||
<header>
|
<header>
|
||||||
<SignedOut>
|
{/* <SignedOut>
|
||||||
<SignInButton />
|
<SignInButton />
|
||||||
</SignedOut>
|
</SignedOut>
|
||||||
<SignedIn>
|
<SignedIn>
|
||||||
<UserButton />
|
<UserButton />
|
||||||
</SignedIn>
|
</SignedIn> */}
|
||||||
</header>
|
</header>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<PopNav />
|
<PopNav />
|
||||||
@@ -47,6 +40,6 @@ export default function RootLayout(props: { children: React.ReactNode }) {
|
|||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</ClerkProvider>
|
/* </ClerkProvider> */
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Header from "../Header";
|
import Header from "@components/Header";
|
||||||
import Hero from "../Hero";
|
import Hero from "@components/Hero";
|
||||||
import FeaturesSection from "../FeaturesSection";
|
import FeaturesSection from "@components/FeaturesSection";
|
||||||
import About from "@siteInfo/About/page";
|
import About from "@siteInfo/About/page";
|
||||||
import Contact from "@siteInfo/Contact/page";
|
import Contact from "@siteInfo/Contact/page";
|
||||||
import Footer from "@siteInfo/Footer/page";
|
import Footer from "@siteInfo/Footer/page";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use server'
|
'use server'
|
||||||
export const TestProductPage = async (props: any) => {
|
export const ProductPage = async (props: any) => {
|
||||||
return (
|
return (
|
||||||
<div className="fixed pin z-50 overflow-auto bg-smoke-light flex">
|
<div className="fixed pin z-50 overflow-auto bg-smoke-light flex">
|
||||||
{props.data.modelnumber}
|
{props.data.modelnumber}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { psa } from "@schemas/schema";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TestProductPage } from "@src/components/TestProductPage";
|
import { ProductPage } from "@src/components/ProductPage";
|
||||||
|
|
||||||
export default async function SortTable(props: any) {
|
export default async function SortTable(props: any) {
|
||||||
return (
|
return (
|
||||||
@@ -128,7 +128,7 @@ export default async function SortTable(props: any) {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td style={{display:'none'}}>
|
<td style={{display:'none'}}>
|
||||||
<TestProductPage data={item}></TestProductPage>
|
<ProductPage data={item}></ProductPage>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { pgTable, integer, varchar, text, numeric, timestamp, unique, check, bigserial, date, boolean, uuid, bigint, real, doublePrecision, primaryKey, pgView } from "drizzle-orm/pg-core"
|
import { pgTableCreator, integer, varchar, text, numeric, timestamp, unique, check, bigserial, date, boolean, uuid, bigint, real, doublePrecision, primaryKey, pgView, index, serial } from "drizzle-orm/pg-core"
|
||||||
import { sql } from "drizzle-orm"
|
import { relations, sql } from "drizzle-orm"
|
||||||
|
import { DATABASE_PREFIX as prefix } from "@lib/constants";
|
||||||
|
|
||||||
|
export const pgTable = pgTableCreator((name) => (prefix == "") ? name:`${prefix}_${name}`);
|
||||||
|
|
||||||
export const products = pgTable("products", {
|
export const products = pgTable("products", {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "products_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "products_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||||
@@ -14,36 +17,6 @@ export const products = pgTable("products", {
|
|||||||
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
deletedAt: timestamp("deleted_at", { mode: 'string' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
name: varchar("name").notNull(),
|
|
||||||
username: varchar({ length: 50 }).notNull(),
|
|
||||||
email: varchar({ length: 255 }).notNull(),
|
|
||||||
emailVerifiedOn: timestamp("email_verified_on", { mode: "date" }),
|
|
||||||
password_hash: varchar("password_hash", { length: 255 }).notNull(),
|
|
||||||
first_name: varchar("first_name", { length: 50 }),
|
|
||||||
last_name: varchar("last_name", { length: 50 }),
|
|
||||||
full_name: varchar("full_name", { length: 50 }),
|
|
||||||
profilePicture: varchar("profile_picture", { length: 255 }),
|
|
||||||
image: text("image"),
|
|
||||||
dateOfBirth: date("date_of_birth"),
|
|
||||||
phoneNumber: varchar("phone_number", { length: 20 }),
|
|
||||||
createdAt: timestamp("created_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
updatedAt: timestamp("updated_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
isAdmin: boolean("is_admin").default(false),
|
|
||||||
lastLogin: timestamp("last_login", { mode: 'string' }),
|
|
||||||
emailVerified: boolean("email_verified").default(false),
|
|
||||||
buildPrivacySetting: text("build_privacy_setting").default('public'),
|
|
||||||
uuid: uuid().defaultRandom(),
|
|
||||||
}, (table) => {
|
|
||||||
return {
|
|
||||||
usersUsernameKey: unique("users_username_key").on(table.username),
|
|
||||||
usersEmailKey: unique("users_email_key").on(table.email),
|
|
||||||
usersBuildPrivacySettingCheck: check("users_build_privacy_setting_check", sql`build_privacy_setting = ANY (ARRAY['private'::text, 'public'::text])`),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const categories = pgTable("categories", {
|
export const categories = pgTable("categories", {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "categories_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "categories_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||||
name: varchar({ length: 100 }).notNull(),
|
name: varchar({ length: 100 }).notNull(),
|
||||||
@@ -91,13 +64,7 @@ export const brands = pgTable("brands", {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sessions = pgTable("sessions", {
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
sessionToken: varchar("session_token").notNull(),
|
|
||||||
userId: uuid("user_id").notNull(),
|
|
||||||
expires: timestamp("expires").notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const manufacturer = pgTable("manufacturer", {
|
export const manufacturer = pgTable("manufacturer", {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "manufacturer_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
id: integer().primaryKey().generatedAlwaysAsIdentity({ name: "manufacturer_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
|
||||||
name: varchar({ length: 100 }).notNull(),
|
name: varchar({ length: 100 }).notNull(),
|
||||||
@@ -182,7 +149,6 @@ export const builds = pgTable("builds", {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const bb_products = pgTable("bb_products", {
|
export const bb_products = pgTable("bb_products", {
|
||||||
uuid: uuid().defaultRandom().primaryKey().notNull(),
|
uuid: uuid().defaultRandom().primaryKey().notNull(),
|
||||||
upc: varchar("UPC", { length: 100 }),
|
upc: varchar("UPC", { length: 100 }),
|
||||||
@@ -454,4 +420,115 @@ export const accounts = pgTable("accounts", {
|
|||||||
first_name: text("first_name"),
|
first_name: text("first_name"),
|
||||||
last_name: text("last_name"),
|
last_name: text("last_name"),
|
||||||
|
|
||||||
},).existing(); */
|
},).existing(); */
|
||||||
|
|
||||||
|
/* From here down is the authentication library Lusia tables */
|
||||||
|
|
||||||
|
export const users = pgTable("users",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 21 }).primaryKey(),
|
||||||
|
name: varchar("name").notNull(),
|
||||||
|
username: varchar({ length: 50 }).notNull(),
|
||||||
|
discordId: varchar("discord_id", { length: 255 }).unique(),
|
||||||
|
password_hash: varchar("password_hash", { length: 255 }).notNull(),
|
||||||
|
email: varchar("email", { length: 255 }).unique().notNull(),
|
||||||
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
|
hashedPassword: varchar("hashed_password", { length: 255 }),
|
||||||
|
first_name: varchar("first_name", { length: 50 }),
|
||||||
|
last_name: varchar("last_name", { length: 50 }),
|
||||||
|
full_name: varchar("full_name", { length: 50 }),
|
||||||
|
profilePicture: varchar("profile_picture", { length: 255 }),
|
||||||
|
image: text("image"),
|
||||||
|
dateOfBirth: date("date_of_birth"),
|
||||||
|
phoneNumber: varchar("phone_number", { length: 20 }),
|
||||||
|
createdAt: timestamp("created_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
isAdmin: boolean("is_admin").default(false),
|
||||||
|
lastLogin: timestamp("last_login", { mode: 'string' }),
|
||||||
|
buildPrivacySetting: text("build_privacy_setting").default('public'),
|
||||||
|
uuid: uuid().defaultRandom(),
|
||||||
|
avatar: varchar("avatar", { length: 255 }),
|
||||||
|
stripeSubscriptionId: varchar("stripe_subscription_id", { length: 191 }),
|
||||||
|
stripePriceId: varchar("stripe_price_id", { length: 191 }),
|
||||||
|
stripeCustomerId: varchar("stripe_customer_id", { length: 191 }),
|
||||||
|
stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"),
|
||||||
|
}, (table) => ({
|
||||||
|
usersUsernameKey: unique("users_username_key").on(table.username),
|
||||||
|
usersEmailKey: unique("users_email_key").on(table.email),
|
||||||
|
usersBuildPrivacySettingCheck: check("users_build_privacy_setting_check", sql`build_privacy_setting = ANY (ARRAY['private'::text, 'public'::text])`),
|
||||||
|
emailIdx: index("user_email_idx").on(table.email),
|
||||||
|
discordIdx: index("user_discord_idx").on(table.discordId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type NewUser = typeof users.$inferInsert;
|
||||||
|
|
||||||
|
export const sessions = pgTable(
|
||||||
|
"sessions",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 21 }).notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
userIdx: index("session_user_idx").on(t.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const emailVerificationCodes = pgTable(
|
||||||
|
"email_verification_codes",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 21 }).unique().notNull(),
|
||||||
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
|
code: varchar("code", { length: 8 }).notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
userIdx: index("verification_code_user_idx").on(t.userId),
|
||||||
|
emailIdx: index("verification_code_email_idx").on(t.email),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const passwordResetTokens = pgTable(
|
||||||
|
"password_reset_tokens",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 40 }).primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 21 }).notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
userIdx: index("password_token_user_idx").on(t.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const posts = pgTable(
|
||||||
|
"posts",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 15 }).primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 255 }).notNull(),
|
||||||
|
title: varchar("title", { length: 255 }).notNull(),
|
||||||
|
excerpt: varchar("excerpt", { length: 255 }).notNull(),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
status: varchar("status", { length: 10, enum: ["draft", "published"] })
|
||||||
|
.default("draft")
|
||||||
|
.notNull(),
|
||||||
|
tags: varchar("tags", { length: 255 }),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: "date" }).$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
userIdx: index("post_user_idx").on(t.userId),
|
||||||
|
createdAtIdx: index("post_created_at_idx").on(t.createdAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const postRelations = relations(posts, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [posts.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type Post = typeof posts.$inferSelect;
|
||||||
|
export type NewPost = typeof posts.$inferInsert;
|
||||||
@@ -21,4 +21,18 @@ export enum SITE_CONT_TYPE {
|
|||||||
TERMSOFSERVICE = "TOS",
|
TERMSOFSERVICE = "TOS",
|
||||||
ABOUTUS="ABOUTUS",
|
ABOUTUS="ABOUTUS",
|
||||||
DISCLOSURE="DISCLOSURE"
|
DISCLOSURE="DISCLOSURE"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_TITLE = "BB Test";
|
||||||
|
export const DATABASE_PREFIX = "";
|
||||||
|
export const TEST_DB_PREFIX = "test_acme";
|
||||||
|
export const EMAIL_SENDER = '"BB" <don@goforward.group>';
|
||||||
|
|
||||||
|
export enum Paths {
|
||||||
|
Home = "/",
|
||||||
|
Login = "/login",
|
||||||
|
Signup = "/signup",
|
||||||
|
Dashboard = "/dashboard",
|
||||||
|
VerifyEmail = "/verify-email",
|
||||||
|
ResetPassword = "/reset-password",
|
||||||
}
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// pages/register.js
|
|
||||||
import UserRegistration from '@components/UserRegistration';
|
|
||||||
|
|
||||||
export default function Register() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<UserRegistration />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user