feat: initial discord bot additions done

This commit is contained in:
2024-05-18 00:31:09 +02:00
parent 5060adb9d3
commit 6767bb36b0
24 changed files with 340 additions and 32 deletions

View File

@@ -17,6 +17,7 @@
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@sern/poster": "^1.2.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucia": "^3.1.1",

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "Bot" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"verified" BOOLEAN NOT NULL,
"inviteLink" TEXT NOT NULL,
"pfpLink" TEXT NOT NULL,
"srcLink" TEXT,
CONSTRAINT "Bot_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Bot" ADD CONSTRAINT "Bot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `botId` to the `Bot` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Bot" ADD COLUMN "botId" TEXT NOT NULL;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `description` to the `Bot` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Bot" ADD COLUMN "description" TEXT NOT NULL;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Bot" ALTER COLUMN "verified" SET DEFAULT false;

View File

@@ -14,10 +14,11 @@ datasource db {
}
model User {
id String @id @default(cuid())
username String @unique
id String @id @default(cuid())
username String @unique
hashed_password String
sessions Session[]
sessions Session[]
bots Bot[]
}
model Session {
@@ -25,4 +26,18 @@ model Session {
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
}
}
model Bot {
id String @id @default(cuid())
userId String
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
name String
description String
verified Boolean @default(false)
inviteLink String
pfpLink String
srcLink String?
botId String
}

9
src/app/add/page.tsx Normal file
View File

@@ -0,0 +1,9 @@
import BotForm from "@/components/app/BotForm/BotForm";
export default function Page() {
return (
<>
<BotForm />
</>
)
}

View File

@@ -0,0 +1,24 @@
import { validateRequest } from "@/lib/auth";
import { redirect } from "next/navigation";
import BotForm from "@/components/app/BotForm/BotForm";
import prisma from "@/lib/db";
import { nullsToUndefined } from "@/lib/utils";
export default async function Page({ params }: { params: { id: string } }) {
const { user } = await validateRequest()
if (!user) return redirect('/auth/signIn')
const dbFetch = await prisma.bot.findUnique({
where: {
id: params.id
}
})
if (!dbFetch) return redirect('/dashboard')
if (dbFetch.userId !== user.id) return redirect('/dashboard')
return (
<>
<BotForm { ...nullsToUndefined(dbFetch) } />
</>
)
}

View File

@@ -0,0 +1,22 @@
import UserCard from "@/components/app/UserCard/UserCard";
import { validateRequest } from "@/lib/auth";
import prisma from "@/lib/db";
import { redirect } from "next/navigation";
export default async function Page() {
const { user } = await validateRequest()
if (!user) return redirect('/auth/signIn')
const dbFetch = await prisma.bot.findMany({
where: {
userId: user.id
}
})
return (
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 p-4">
{dbFetch.map((bot) => (
<UserCard key={bot.id} {...bot} />
))}
</div>
)
}

View File

@@ -38,6 +38,7 @@
.dark {
--background: 240 21.053% 14.902%; /* base */
--background-darker: 220 23.077% 12%; /* base but darker */
--foreground: 226.154 63.934% 88.039%; /* text */
--muted: 236.842 16.239% 22.941%; /* surface0 */

View File

@@ -10,8 +10,8 @@ import { ThemeProvider } from "@/lib/providers/ThemeProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "stack",
description: "The tech stack for your next(.js) project.",
title: "sern Frontpage Bot",
description: "Show your sern bot in the sern.dev frontpage!",
};
export default async function RootLayout({

View File

@@ -11,11 +11,10 @@ export default function Home() {
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none">
The modern tech stack for your next(.js) project
Add your discord bot to sern's front page!
</h1>
<p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
stack is a comprehensive tech stack that includes everything you need to build and deploy your next
web application.
Do you want to show off your discord bot to the world? Add it to sern's front page!
</p>
</div>
<div className="space-x-4">
@@ -26,7 +25,7 @@ export default function Home() {
</div>
</div>
</section>
<section className="w-full py-12 md:py-24 lg:py-32 bg-gray-100 dark:bg-gray-900" id="features">
<section className="w-full py-12 md:py-24 lg:py-32 bg-gray-100 dark:bg-backgroundDarker" id="features">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">

View File

@@ -1,14 +0,0 @@
import { validateRequest } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function Page() {
const { user } = await validateRequest()
if (!user) return redirect('/auth/signIn')
return (
<div>
<h1 className="text-3xl font-bold text-center">Welcome {user?.username}!</h1>
<p>You are actually on a protected route!</p>
<p>Your ID is: {user.id}</p>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import SubmitButton from "../SubmitButton/SubmitButton";
import { submitBotData } from "@/lib/actions";
import { useFormState } from "react-dom";
import { useEffect } from "react";
import { toast } from "sonner";
export default function BotForm(props: Props) {
const [submitData, submitDataAction] = useFormState(submitBotData, null);
useEffect(() => {
if (submitData?.error) {
toast.error(submitData.error)
}
}, [submitData])
return (
<form action={submitDataAction}>
{props.id && <input type="hidden" name="id" value={props.id} />}
<div className="flex flex-col items-center justify-center">
<div className="grid grid-cols-2 p-2 gap-4 sm:p-4 sm:w-[50%] w-full">
<div>
<Label htmlFor="botName">Bot name</Label>
<Input name="name" id="name" required type="text" defaultValue={props?.name} />
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input name="description" id="description" required type="text" defaultValue={props?.description} />
</div>
<div>
<Label htmlFor="botId">Bot user id</Label>
<Input name="botId" id="botId" required type="text" defaultValue={props?.botId} />
</div>
<div>
<Label htmlFor="inviteLink">Invite link</Label>
<Input name="inviteLink" id="inviteLink" required type="text" defaultValue={props?.inviteLink} />
</div>
<div className="col-span-2">
<Label htmlFor="srcLink">Repo link (optional)</Label>
<Input name="srcLink" id="srcLink" type="text" defaultValue={props?.srcLink || ''} />
</div>
</div>
<SubmitButton buttonText="Submit" />
</div>
</form>
)
}
interface Props {
id?: string;
name?: string;
description?: string;
botId?: string;
inviteLink?: string;
srcLink?: string;
}

View File

@@ -18,8 +18,8 @@ import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher"
export const links = [
{ href: '/', name: 'Home' },
{ href: 'https://github.com/SrIzan10/stack', name: 'Github' },
{ href: '/protected', name: 'Protected route' }
{ href: '/dashboard', name: 'Dashboard' },
{ href: '/add', name: 'Submit' },
]
function NavbarLinks() {
@@ -41,7 +41,7 @@ export default function Navbar() {
<>
<nav className="flex items-center h-16 px-4 border-b gap-3 shrink-0">
<Link href="/" className="hidden md:flex">
<Button>stack</Button>
<Button>sern Frontpage</Button>
</Link>
<MobileNavbarLinks />
<div className="hidden md:flex">

View File

@@ -1,13 +1,14 @@
'use client'
import { Button, ButtonProps, buttonVariants } from "@/components/ui/button"
import { Button, buttonVariants } from "@/components/ui/button"
import { VariantProps } from "class-variance-authority"
import { useFormStatus } from "react-dom"
export default function SubmitButton(props: Props) {
const { pending } = useFormStatus()
const { buttonText, ...propsRest } = props
return (
<Button type="submit" loading={pending} {...props}>
<Button type="submit" loading={pending} {...propsRest}>
{props.buttonText}
</Button>
)

View File

@@ -0,0 +1,38 @@
import { Avatar, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import type { Bot } from "@prisma/client"
import Link from "next/link"
export default function UserCard(props: Bot) {
const yes = <span className="text-green-500">Yes</span>
const no = <span className="text-red-500">No</span>
return (
<>
<div className="rounded-lg border bg-card text-card-foreground shadow-sm p-4">
<div className="flex items-center justify-center space-x-4">
<Avatar className="w-14 h-14">
<AvatarImage src={props.pfpLink} alt="sernbot" />
</Avatar>
<h1 className="flex-grow text-center font-extrabold text-2xl">{props.name}</h1>
</div>
<div className="pt-4">
<p>{props.description}</p>
<p className="text-sm">Verified: {props.verified ? yes : no}</p>
</div>
<div className="flex justify-end mt-4 gap-4">
{props.srcLink && (
<Link href={props.srcLink} target="_blank">
<Button variant={'secondaryFilledLink'}>Source</Button>
</Link>
)}
<Link href={props.inviteLink} target="_blank">
<Button variant={'secondaryFilledLink'}>Invite</Button>
</Link>
<Link href={`/dashboard/${props.id}`} target="_blank">
<Button>Settings</Button>
</Link>
</div>
</div>
</>
)
}

View File

@@ -2,7 +2,7 @@ import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { LoaderCircle } from "lucide-react"
import { LinkIcon, LoaderCircle } from "lucide-react"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
@@ -20,6 +20,7 @@ const buttonVariants = cva(
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
secondaryFilledLink: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
},
size: {
default: "h-10 px-4 py-2",
@@ -53,6 +54,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
>
{loading && <LoaderCircle className={cn('h-4 w-4 animate-spin', props.children && 'mr-2')} />}
{variant === 'secondaryFilledLink' && <LinkIcon className="h-3 w-3 mr-2" />}
{props.children}
</Comp>
)

70
src/lib/actions.ts Normal file
View File

@@ -0,0 +1,70 @@
'use server'
import { redirect } from "next/navigation";
import { validateRequest } from "./auth";
import prisma from "./db";
import { botSubmitSchema } from "./zod";
import poster from '@sern/poster';
export async function submitBotData(prev: any, formData: FormData): Promise<DefaultActionResponse> {
const parsedData = await botSubmitSchema.safeParseAsync(Object.fromEntries(formData.entries()));
const { user } = await validateRequest();
if (!user) {
return {
success: false,
error: "You must be logged in to perform this action",
};
}
if (!parsedData.success) {
return {
success: false,
error: `From ${parsedData.error.errors[0].path[0]}: ${parsedData.error.errors[0].message}`,
};
}
if (parsedData.data.id) {
await prisma.bot.update({
where: {
id: parsedData.data.id,
},
data: parsedData.data,
});
} else {
const { name, botId, inviteLink, srcLink, description } = parsedData.data;
const botClient = await poster.client(process.env.DSC_TOKEN!);
const userAvatarHash = (await (await botClient('user/get', { user_id: botId })).json()).avatar;
if (!userAvatarHash) {
return {
success: false,
error: "Bot not found on Discord",
};
}
const userAvatar = `https://cdn.discordapp.com/avatars/${botId}/${userAvatarHash}.webp`
await prisma.bot.create({
data: {
name,
botId,
inviteLink,
srcLink,
description,
pfpLink: userAvatar,
userId: user.id,
},
});
redirect('/dashboard')
}
return {
success: true,
message: "Data submitted successfully",
};
}
interface DefaultActionResponse {
success: boolean;
error?: string;
message?: string;
data?: any;
}

View File

@@ -87,6 +87,6 @@ export async function signup(prev: any, formData: FormData): Promise<ActionResul
}
interface ActionResult {
error: string | null;
success: boolean | null;
error: string;
success: boolean;
}

View File

@@ -4,3 +4,27 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
type RecursivelyReplaceNullWithUndefined<T> = T extends null
? undefined
: T extends Date
? T
: {
[K in keyof T]: T[K] extends (infer U)[]
? RecursivelyReplaceNullWithUndefined<U>[]
: RecursivelyReplaceNullWithUndefined<T[K]>;
};
export function nullsToUndefined<T>(obj: T): RecursivelyReplaceNullWithUndefined<T> {
if (obj === null) {
return undefined as any;
}
// object check based on: https://stackoverflow.com/a/51458052/6489012
if (obj!.constructor.name === "Object") {
for (let key in obj) {
obj[key] = nullsToUndefined(obj[key]) as any;
}
}
return obj as any;
}

11
src/lib/zod.ts Normal file
View File

@@ -0,0 +1,11 @@
import { describe } from 'node:test'
import { z } from 'zod'
export const botSubmitSchema = z.object({
id: z.string().optional(),
name: z.string(),
description: z.string(),
botId: z.string(),
inviteLink: z.string(),
srcLink: z.string().nullable().optional()
})

View File

@@ -23,6 +23,7 @@ const config = {
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
backgroundDarker: "hsl(var(--background-darker))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",

View File

@@ -740,6 +740,13 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.2.tgz#053f1540703faa81dea2966b768ee5581c66aeda"
integrity sha512-hw437iINopmQuxWPSUEvqE56NCPsiU8N4AYtfHmJFckclktzK9YQJieD3XkDCDH4OjL+C7zgPUh73R/nrcHrqw==
"@sern/poster@^1.2.6":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@sern/poster/-/poster-1.2.6.tgz#64223181c984e5286901c74a71373d6313123994"
integrity sha512-9pmZ8gp7qzyItwGAEXkWxkukNBO2Z4db4Uvs/xjeJMuG1u5H0RSGWsCubUYVcTuDi9LxePXFQUWbXvpFjUSHqA==
dependencies:
squint-cljs latest
"@swc/counter@^0.1.3":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9"
@@ -2973,6 +2980,13 @@ source-map-js@^1.0.2, source-map-js@^1.2.0:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
squint-cljs@latest:
version "0.7.108"
resolved "https://registry.yarnpkg.com/squint-cljs/-/squint-cljs-0.7.108.tgz#f057e813ab644628f50b16f3b00335f79a571c6b"
integrity sha512-GHALQ+6EZEXzpygBHj7RSPi3evzIXIZXvv543zqhND4/26NhTNF5nnbg8InQPKp3heDg2PLZEO/VtFs+Sd1UHA==
dependencies:
chokidar "^3.5.3"
streamsearch@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"