feat: admin panel

This commit is contained in:
2024-05-19 16:32:59 +02:00
parent a958aab97a
commit 2163407080
20 changed files with 396 additions and 42 deletions

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"useTabs": false,
"printWidth": 800,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "es5",
"semi": false
}

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",
"@radix-ui/react-switch": "^1.0.3",
"@sern/poster": "^1.2.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Bot" ALTER COLUMN "inviteLink" DROP NOT NULL;

View File

@@ -17,6 +17,7 @@ model User {
id String @id @default(cuid())
username String @unique
hashed_password String
isAdmin Boolean @default(false)
sessions Session[]
bots Bot[]
}
@@ -36,8 +37,8 @@ model Bot {
name String
description String
verified Boolean @default(false)
inviteLink String
pfpLink String
inviteLink String?
srcLink String?
botId String
}

24
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
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')
if (!user.isAdmin) return (
<h1 className="text-center font-extrabold text-6xl">Sorry</h1>
)
const getBots = await prisma.bot.findMany()
return (
<>
<h1 className="text-center font-extrabold text-4xl">wow an admin panel</h1>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 p-4">
{getBots.map((bot) => (
<UserCard key={bot.id} {...bot} isAdminPanel />
))}
</div>
</>
)
}

View File

@@ -22,6 +22,8 @@
--accent-foreground: 0 0% 100%;
--destructive: 360 80.95% 45.49%;
--destructive-foreground: 0 0% 100%;
--warning: 41deg, 86%, 83%;
--warning-foreground: 41deg, 86%, 83%;
--ring: 342 59% 54%;
--radius: 0.5rem;
}
@@ -45,6 +47,8 @@
--accent-foreground: 0 0% 100%;
--destructive: 360 69.52% 39.02%;
--destructive-foreground: 0 0% 100%;
--warning: 41deg, 86%, 83%;
--warning-foreground: 41deg, 86%, 83%;
--ring: 342 59% 54%;
}
}

View File

@@ -19,7 +19,7 @@ export default function Home() {
</div>
<div className="space-x-4">
<Link href="/dashboard">
<Button>Start right NOW!</Button>
<Button>Let's go!</Button>
</Link>
</div>
</div>

View File

@@ -3,13 +3,19 @@
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import SubmitButton from "../SubmitButton/SubmitButton";
import { submitBotData } from "@/lib/actions";
import { revalidatePathServer, submitBotData, updateBotProfilePicture } from "@/lib/actions";
import { useFormState } from "react-dom";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
export default function BotForm(props: Props) {
const router = useRouter()
const [submitData, submitDataAction] = useFormState(submitBotData, null);
const [regenLoading, setRegenLoading] = useState(false)
useEffect(() => {
if (submitData?.error) {
toast.error(submitData.error)
@@ -34,15 +40,47 @@ export default function BotForm(props: Props) {
</div>
<div>
<Label htmlFor="inviteLink">Invite link</Label>
<Input name="inviteLink" id="inviteLink" required type="text" defaultValue={props?.inviteLink} />
<Input name="inviteLink" id="inviteLink" 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 className="flex items-center justify-center p-2 gap-2">
{props.botId && (
<Button variant="secondary" loading={regenLoading} type="button" onClick={() => {
setRegenLoading(true)
updateBotProfilePicture({ botId: props.botId! }).then((res) => {
setRegenLoading(false)
if (res.error) {
toast.error(res.error)
}
if (res.success) {
toast.success(res.message)
revalidatePathServer('/dashboard')
router.push('/dashboard')
}
})
}}>
Regenerate profile picture
</Button>
)}
<SubmitButton buttonText="Submit" />
</div>
</div>
{props.id && (
<div className="flex items-center justify-center p-2">
<Alert variant={"warning"} className="w-[400px]">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Warning</AlertTitle>
<AlertDescription className="whitespace-pre-line">
When resubmitting, the bot will be unverified until the devteam verifies it again.{'\n'}
This doesn't apply when it's just regenerating the profile picture
</AlertDescription>
</Alert>
</div>
)}
</form>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import { LoaderIcon } from "lucide-react";
export default function CheckedLoading(props: Props) {
return props.loading && <LoaderIcon className="animate-spin" />
}
interface Props {
loading: boolean;
}

View File

@@ -63,6 +63,13 @@ export default function Navbar() {
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{user.isAdmin && (
<DropdownMenuItem>
<Link href="/admin">
Admin panel
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="cursor-pointer" onClick={() => {
logoutAction()
}}>

View File

@@ -1,38 +1,51 @@
import { Avatar, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import type { Bot } from "@prisma/client"
import Link from "next/link"
import VerifiedSwitch from "../VerifiedSwitch/VerifiedSwitch"
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}`}>
<Button>Settings</Button>
</Link>
</div>
</div>
</>
)
}
export default function UserCard(props: Bot & { isAdminPanel?: boolean }) {
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>
{!props.isAdminPanel && <p className="text-sm">Verified: {props.verified ? yes : no}</p>}
{props.isAdminPanel && <p className="text-sm">Bot ID: <code className="bg-zinc-800 p-0.5">{props.botId}</code></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>
)}
{props.inviteLink && (
<Link href={props.inviteLink} target="_blank">
<Button variant={"secondaryFilledLink"}>Invite</Button>
</Link>
)}
{props.isAdminPanel && <VerifiedSwitch id={props.id} verified={props.verified} />}
{!props.isAdminPanel && (
<>
<Link href={`/dashboard/${props.id}`}>
<Button>Settings</Button>
</Link>
</>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,43 @@
'use client'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import CheckedLoading from '../CheckedLoading/CheckedLoading'
import { handleBotVerificationSwitch } from '@/lib/actions'
import { useState } from 'react'
import { toast } from 'sonner'
export default function VerifiedSwitch(props: Props) {
const [loading, setLoading] = useState(false)
const [checked, setChecked] = useState(props.verified)
return (
<div className="flex items-center space-x-2">
<Label htmlFor="verified-switch">Verified</Label>
<Switch
id="verified-switch"
name="verified"
checked={checked}
onCheckedChange={(c) => {
setLoading(true)
handleBotVerificationSwitch({ id: props.id, verified: c }).then((ver) => {
setLoading(false)
// sets to the opposite of the current value, like a normal switch
setChecked(!checked)
if (ver.error) {
toast.error(ver.error)
}
if (ver.success) {
toast.success(ver.message)
}
})
}}
/>
<CheckedLoading loading={loading} />
</div>
)
}
interface Props {
id: string
verified: boolean
}

View File

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

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -3,8 +3,9 @@
import { redirect } from "next/navigation";
import { validateRequest } from "./auth";
import prisma from "./db";
import { botSubmitSchema } from "./zod";
import { botSubmitSchema, botVerificationSwitchSchema } from "./zod";
import poster from '@sern/poster';
import { revalidatePath } from "next/cache";
export async function submitBotData(prev: any, formData: FormData): Promise<DefaultActionResponse> {
const parsedData = await botSubmitSchema.safeParseAsync(Object.fromEntries(formData.entries()));
@@ -58,6 +59,83 @@ export async function submitBotData(prev: any, formData: FormData): Promise<Defa
redirect('/dashboard')
}
export async function handleBotVerificationSwitch(data: { id: string, verified: boolean }): Promise<DefaultActionResponse> {
const parsedData = await botVerificationSwitchSchema.safeParseAsync(data);
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}`,
};
}
console.log(parsedData.data.id, parsedData.data.verified)
const botUpdate = await prisma.bot.update({
where: {
id: parsedData.data.id,
},
data: {
verified: parsedData.data.verified,
},
});
return {
success: true,
message: `Bot verification status for ${botUpdate.name} updated`,
}
}
export async function updateBotProfilePicture(data: { botId: string }): Promise<DefaultActionResponse> {
const { user } = await validateRequest()
if (!user) {
return {
success: false,
error: 'You must be logged in to perform this action',
}
}
const bot = await prisma.bot.findFirst({
where: {
botId: data.botId,
},
})
if (!bot) {
return {
success: false,
error: 'Bot not found',
}
}
const botClient = await poster.client(process.env.DSC_TOKEN!)
const userAvatarHash = (await (await botClient('user/get', { user_id: bot.botId })).json()).avatar
if (!userAvatarHash) {
return {
success: false,
error: 'Bot not found on Discord',
}
}
const userAvatar = `https://cdn.discordapp.com/avatars/${bot.botId}/${userAvatarHash}.webp`
await prisma.bot.update({
where: {
id: bot.id,
},
data: {
pfpLink: userAvatar,
},
})
return {
success: true,
message: 'Profile picture updated',
}
}
export async function revalidatePathServer(path: string) {
return revalidatePath(path)
}
interface DefaultActionResponse {
success: boolean;
error?: string;

View File

@@ -18,7 +18,8 @@ export const lucia = new Lucia(adapter, {
},
getUserAttributes: (attributes) => {
return {
username: attributes.username
username: attributes.username,
isAdmin: attributes.isAdmin
};
}
});
@@ -68,4 +69,5 @@ declare module "lucia" {
interface DatabaseUserAttributes {
username: string;
isAdmin: boolean;
}

View File

@@ -6,6 +6,11 @@ export const botSubmitSchema = z.object({
name: z.string(),
description: z.string(),
botId: z.string(),
inviteLink: z.string(),
inviteLink: z.string().nullable().optional(),
srcLink: z.string().nullable().optional()
})
export const botVerificationSwitchSchema = z.object({
id: z.string().min(1),
verified: z.boolean()
})

View File

@@ -36,6 +36,10 @@ const config = {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",

View File

@@ -682,6 +682,20 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-switch@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.0.3.tgz#6119f16656a9eafb4424c600fdb36efa5ec5837e"
integrity sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-use-size" "1.0.1"
"@radix-ui/react-use-callback-ref@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
@@ -712,6 +726,13 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-previous@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz#b595c087b07317a4f143696c6a01de43b0d0ec66"
integrity sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-rect@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz#fde50b3bb9fd08f4a1cd204572e5943c244fcec2"