mirror of
https://github.com/sern-handler/frontpage-bot
synced 2026-06-06 01:16:54 +00:00
feat: initial discord bot additions done
This commit is contained in:
@@ -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",
|
||||
|
||||
15
prisma/migrations/20240517181500_add_bot/migration.sql
Normal file
15
prisma/migrations/20240517181500_add_bot/migration.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Bot" ALTER COLUMN "verified" SET DEFAULT false;
|
||||
@@ -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
9
src/app/add/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import BotForm from "@/components/app/BotForm/BotForm";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<BotForm />
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
src/app/dashboard/[...id]/page.tsx
Normal file
24
src/app/dashboard/[...id]/page.tsx
Normal 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) } />
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
src/app/dashboard/page.tsx
Normal file
22
src/app/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
57
src/components/app/BotForm/BotForm.tsx
Normal file
57
src/components/app/BotForm/BotForm.tsx
Normal 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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
38
src/components/app/UserCard/UserCard.tsx
Normal file
38
src/components/app/UserCard/UserCard.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
70
src/lib/actions.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
11
src/lib/zod.ts
Normal 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()
|
||||
})
|
||||
@@ -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))",
|
||||
|
||||
14
yarn.lock
14
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user