feat: user lookup and user punishments in admin panel
need to work on actually punishing the user
This commit is contained in:
parent
0c7be71b2c
commit
e195d2e80b
7 changed files with 534 additions and 7 deletions
36
prisma/migrations/20250524155917_punishments/migration.sql
Normal file
36
prisma/migrations/20250524155917_punishments/migration.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "PunishmentType" AS ENUM ('WARNING', 'TEMP_EXILE', 'PERM_EXILE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "miis" ADD COLUMN "punishmentId" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mii_punishments" (
|
||||
"punishmentId" INTEGER NOT NULL,
|
||||
"miiId" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "mii_punishments_pkey" PRIMARY KEY ("punishmentId","miiId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "punishments" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"type" "PunishmentType" NOT NULL,
|
||||
"notes" TEXT NOT NULL,
|
||||
"reasons" TEXT[],
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "punishments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mii_punishments" ADD CONSTRAINT "mii_punishments_punishmentId_fkey" FOREIGN KEY ("punishmentId") REFERENCES "punishments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mii_punishments" ADD CONSTRAINT "mii_punishments_miiId_fkey" FOREIGN KEY ("miiId") REFERENCES "miis"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "punishments" ADD CONSTRAINT "punishments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
|
@ -25,8 +25,10 @@ model User {
|
|||
sessions Session[]
|
||||
miis Mii[]
|
||||
likes Like[]
|
||||
|
||||
reportsAuthored Report[] @relation("ReportAuthor")
|
||||
reports Report[] @relation("ReportTargetCreator")
|
||||
punishments Punishment[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
|
@ -84,6 +86,9 @@ model Mii {
|
|||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
likedBy Like[]
|
||||
|
||||
punishmentId Int?
|
||||
punishments MiiPunishment[]
|
||||
|
||||
@@map("miis")
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +123,35 @@ model Report {
|
|||
@@map("reports")
|
||||
}
|
||||
|
||||
model MiiPunishment {
|
||||
punishmentId Int
|
||||
miiId Int
|
||||
reason String
|
||||
|
||||
punishment Punishment @relation(fields: [punishmentId], references: [id], onDelete: Cascade)
|
||||
mii Mii @relation(fields: [miiId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([punishmentId, miiId])
|
||||
@@map("mii_punishments")
|
||||
}
|
||||
|
||||
model Punishment {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
type PunishmentType
|
||||
|
||||
notes String
|
||||
reasons String[]
|
||||
violatingMiis MiiPunishment[]
|
||||
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("punishments")
|
||||
}
|
||||
|
||||
enum MiiGender {
|
||||
MALE
|
||||
FEMALE
|
||||
|
|
@ -140,3 +174,9 @@ enum ReportStatus {
|
|||
RESOLVED
|
||||
DISMISSED
|
||||
}
|
||||
|
||||
enum PunishmentType {
|
||||
WARNING
|
||||
TEMP_EXILE
|
||||
PERM_EXILE
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
|
|||
|
||||
import BannerForm from "@/components/admin/banner-form";
|
||||
import ControlCenter from "@/components/admin/control-center";
|
||||
import UserManagement from "@/components/admin/user-management";
|
||||
import Reports from "@/components/admin/reports";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -46,6 +47,15 @@ export default async function AdminPage() {
|
|||
|
||||
<ControlCenter />
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
<span>User Management</span>
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<UserManagement />
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="flex-grow border-zinc-300" />
|
||||
|
|
|
|||
53
src/app/api/admin/lookup/route.ts
Normal file
53
src/app/api/admin/lookup/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@/lib/schemas";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const parsed = idSchema.safeParse(searchParams.get("id"));
|
||||
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 });
|
||||
const userId = parsed.data;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
punishments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
notes: true,
|
||||
reasons: true,
|
||||
violatingMiis: {
|
||||
select: {
|
||||
miiId: true,
|
||||
reason: true,
|
||||
},
|
||||
},
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return NextResponse.json({ error: "No user found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
image: user.image,
|
||||
createdAt: user.createdAt,
|
||||
punishments: user.punishments,
|
||||
});
|
||||
}
|
||||
69
src/app/api/admin/punish/route.ts
Normal file
69
src/app/api/admin/punish/route.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { z } from "zod";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@/lib/schemas";
|
||||
import { PunishmentType } from "@prisma/client";
|
||||
|
||||
const punishSchema = z.object({
|
||||
type: z.enum([PunishmentType.WARNING, PunishmentType.TEMP_EXILE, PunishmentType.PERM_EXILE]),
|
||||
duration: z
|
||||
.number({ message: "Duration (days) must be a number" })
|
||||
.int({ message: "Duration (days) must be an integer" })
|
||||
.positive({ message: "Duration (days) must be valid" }),
|
||||
notes: z.string(),
|
||||
reasons: z.array(z.string()).optional(),
|
||||
miiReasons: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z
|
||||
.number({ message: "Mii ID must be a number" })
|
||||
.int({ message: "Mii ID must be an integer" })
|
||||
.positive({ message: "Mii ID must be valid" }),
|
||||
reason: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const parsedUserId = idSchema.safeParse(searchParams.get("id"));
|
||||
|
||||
if (!parsedUserId.success) return NextResponse.json({ error: parsedUserId.error.errors[0].message }, { status: 400 });
|
||||
const userId = parsedUserId.data;
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = punishSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 });
|
||||
const { type, duration, notes, reasons, miiReasons } = parsed.data;
|
||||
|
||||
const expiresAt = type === "TEMP_EXILE" ? dayjs().add(duration, "days").toDate() : null;
|
||||
|
||||
await prisma.punishment.create({
|
||||
data: {
|
||||
userId,
|
||||
type: type as PunishmentType,
|
||||
expiresAt,
|
||||
notes,
|
||||
reasons: reasons?.length !== 0 ? reasons : [],
|
||||
violatingMiis: {
|
||||
create: miiReasons?.map((mii) => ({
|
||||
miiId: mii.id,
|
||||
reason: mii.reason,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ export default async function Reports() {
|
|||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400">
|
||||
<div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
|
||||
{reports.map((report) => (
|
||||
<div key={report.id} className="p-4 bg-orange-50 border border-orange-200 rounded-md">
|
||||
<div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
|
||||
<div className="w-full overflow-x-scroll">
|
||||
<div className="flex gap-1 w-max">
|
||||
<span
|
||||
|
|
|
|||
319
src/components/admin/user-management.tsx
Normal file
319
src/components/admin/user-management.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// WARNING: this code is quite trash
|
||||
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { PunishmentType } from "@prisma/client";
|
||||
|
||||
import SubmitButton from "../submit-button";
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
name: string;
|
||||
username: string;
|
||||
image: string;
|
||||
createdAt: string;
|
||||
punishments: {
|
||||
id: number;
|
||||
userId: number;
|
||||
type: string;
|
||||
|
||||
notes: string;
|
||||
reasons: string[];
|
||||
violatingMiis: {
|
||||
miiId: number;
|
||||
reason: string;
|
||||
}[];
|
||||
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface MiiList {
|
||||
id: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export default function Punishments() {
|
||||
const [userId, setUserId] = useState(-1);
|
||||
const [user, setUser] = useState<ApiResponse | undefined>();
|
||||
|
||||
const [type, setType] = useState<PunishmentType>("WARNING");
|
||||
const [duration, setDuration] = useState(1);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [reasons, setReasons] = useState("");
|
||||
|
||||
const [miiList, setMiiList] = useState<MiiList[]>([]);
|
||||
const [newMii, setNewMii] = useState<MiiList>({
|
||||
id: 0,
|
||||
reason: "",
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const addMiiToList = () => {
|
||||
if (newMii.id && newMii.reason) {
|
||||
setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]);
|
||||
setNewMii({ id: 0, reason: "" });
|
||||
}
|
||||
};
|
||||
|
||||
const removeMiiFromList = (index: number) => {
|
||||
setMiiList(miiList.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleLookup = async () => {
|
||||
const response = await fetch(`/api/admin/lookup?id=${userId}`);
|
||||
const data = await response.json();
|
||||
setUser(data);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// todo: delete punishments
|
||||
const response = await fetch(`/api/admin/punish?id=${userId}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
duration,
|
||||
notes,
|
||||
reasons: reasons.split(","),
|
||||
miiReasons: miiList,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setError(error);
|
||||
}
|
||||
|
||||
// Set all inputs to empty/default
|
||||
setType("WARNING");
|
||||
setDuration(1);
|
||||
setNotes("");
|
||||
setReasons("");
|
||||
setMiiList([]);
|
||||
setNewMii({ id: 0, reason: "" });
|
||||
setError("");
|
||||
|
||||
await handleLookup();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 gap-2">
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Enter user ID to lookup..."
|
||||
name="user-id"
|
||||
value={userId !== -1 ? userId : ""}
|
||||
onChange={(e) => setUserId(Number(e.target.value))}
|
||||
className="pill input w-full max-w-lg"
|
||||
/>
|
||||
<button onClick={handleLookup} className="pill button">
|
||||
Lookup User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm">
|
||||
<div className="flex gap-1">
|
||||
<Image src={user.image} width={96} height={96} alt="Profile picture" className="rounded-full border-2 border-orange-400" />
|
||||
<div className="p-2 flex flex-col">
|
||||
<p className="text-xl font-bold">{user.name}</p>
|
||||
<p className="text-black/60 text-sm font-medium">@{user.username}</p>
|
||||
<p className="text-sm mt-auto">
|
||||
<span className="font-medium">Created:</span>{" "}
|
||||
{new Date(user.createdAt).toLocaleString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZone: "UTC",
|
||||
})}{" "}
|
||||
UTC
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-zinc-300 my-3" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{user.punishments.length === 0 ? (
|
||||
<p className="text-center text-zinc-500 my-2">No punishments found.</p>
|
||||
) : (
|
||||
<>
|
||||
{user.punishments.map((punishment) => (
|
||||
<div
|
||||
key={punishment.id}
|
||||
className={`border rounded-lg p-3 space-y-1 ${
|
||||
punishment.type === "WARNING"
|
||||
? "bg-yellow-50 border-yellow-400"
|
||||
: punishment.type === "TEMP_EXILE"
|
||||
? "bg-orange-100 border-orange-200"
|
||||
: "bg-red-50 border-red-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span
|
||||
className={`border px-2 py-1 rounded text-xs font-semibold ${
|
||||
punishment.type === "WARNING"
|
||||
? "bg-yellow-200 text-yellow-800 border-yellow-500"
|
||||
: punishment.type === "TEMP_EXILE"
|
||||
? "bg-orange-200 text-orange-800 border-orange-500"
|
||||
: "bg-red-200 text-red-800 border-red-500"
|
||||
}`}
|
||||
>
|
||||
{punishment.type}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-600">
|
||||
{new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600">
|
||||
<strong>Notes:</strong> {punishment.notes}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600">
|
||||
<strong>Expires:</strong>{" "}
|
||||
{punishment.expiresAt
|
||||
? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
|
||||
: "Never"}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600">
|
||||
<strong>Reasons:</strong>
|
||||
</p>
|
||||
<ul className="ml-8 list-disc text-sm text-zinc-600">
|
||||
{punishment.reasons.map((reason, index) => (
|
||||
<li key={index}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-zinc-600">
|
||||
<strong>Mii Reasons:</strong>
|
||||
</p>
|
||||
<ul className="ml-8 list-disc text-sm text-zinc-600">
|
||||
{punishment.violatingMiis.map((mii) => (
|
||||
<li key={mii.miiId}>
|
||||
{mii.miiId}: {mii.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1">
|
||||
{/* Punishment type */}
|
||||
<p className="text-sm">Punishment Type</p>
|
||||
<select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input">
|
||||
<option value="WARNING">Warning</option>
|
||||
<option value="TEMP_EXILE">Temporary Exile</option>
|
||||
<option value="PERM_EXILE">Permanent Exile</option>
|
||||
</select>
|
||||
|
||||
{/* Punishment duration */}
|
||||
{type === "TEMP_EXILE" && (
|
||||
<>
|
||||
<p className="text-sm">Duration</p>
|
||||
<select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input">
|
||||
<option value="1">1 Day</option>
|
||||
<option value="7">7 Days</option>
|
||||
<option value="30">30 Days</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Punishment notes */}
|
||||
<p className="text-sm">Notes</p>
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={256}
|
||||
placeholder="Type notes here for the punishment..."
|
||||
className="pill input !rounded-xl resize-none"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Punishment profile-related reasons */}
|
||||
<p className="text-sm">Profile-related reasons (split by comma)</p>
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={256}
|
||||
placeholder="Type profile-related reasons here for the punishment..."
|
||||
className="pill input !rounded-xl resize-none"
|
||||
value={reasons}
|
||||
onChange={(e) => setReasons(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Punishment mii-related reasons */}
|
||||
<p className="text-sm">Mii-related reasons</p>
|
||||
<div className="bg-orange-100 border border-orange-300 rounded-lg p-4">
|
||||
{/* Add Mii Form */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Mii ID"
|
||||
className="pill input w-24 text-sm"
|
||||
value={newMii.id}
|
||||
onChange={(e) => setNewMii({ ...newMii, id: Number(e.target.value) })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Reason for this Mii..."
|
||||
className="pill input flex-1 text-sm"
|
||||
value={newMii.reason}
|
||||
onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })}
|
||||
/>
|
||||
<button type="button" onClick={addMiiToList} className="pill button aspect-square !p-2.5">
|
||||
<Icon icon="ic:baseline-plus" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mii List */}
|
||||
{miiList.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-sm font-medium text-black/50">Violating Miis ({miiList.length})</p>
|
||||
{miiList.map((mii, index) => (
|
||||
<div key={index} className="bg-white border border-orange-200 rounded-md p-3 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-orange-200 text-orange-800 border border-orange-400 px-2 py-1 rounded text-xs font-semibold">
|
||||
ID: {mii.id}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">{mii.reason}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMiiFromList(index)}
|
||||
className="cursor-pointer text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
<Icon icon="iconamoon:trash" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{miiList.length === 0 && <p className="text-center text-zinc-500 text-sm my-4">No Miis added yet</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
||||
|
||||
<SubmitButton onClick={handleSubmit} className="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue