refactor: refactor the code base to use more server components

This commit is contained in:
Bram Suurd
2024-08-25 19:39:25 +02:00
parent d172acc733
commit 3f5e2abd12
22 changed files with 384 additions and 298 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -27,6 +27,7 @@
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@vercel/analytics": "^1.2.2",
"@vercel/og": "^0.6.2",
"@vercel/speed-insights": "^1.0.12",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -41,6 +42,7 @@
"react": "^18",
"react-code-blocks": "^0.1.6",
"react-dom": "^18",
"react-helmet": "^6.1.0",
"react-icons": "^5.1.0",
"react-simple-typewriter": "^5.0.1",
"sharp": "^0.33.4",

51
src/app/api/og/route.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
export const config = {
runtime: "edge",
}
export default function handler(req: NextRequest) {
const { searchParams } = new URL(req.url);
const title = searchParams.get("title");
const logo = searchParams.get("logo");
if (!title || !logo) {
return new Response("Invalid request", { status: 400 });
}
return new ImageResponse(
(
<div
className="bg-background h-full w-full flex flex-col justify-center items-center"
>
<img
src={logo}
alt={title}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
}}
/>
<p
style={{
color: "white",
fontSize: "24px",
fontWeight: "bold",
position: "absolute",
bottom: "20px",
left: "20px",
textShadow: "2px 2px 2px black",
}}
>
{title}
</p>
</div>
),
{
width: 1200,
height: 630,
}
);
}

View File

@@ -6,9 +6,10 @@ export const dynamic = "force-dynamic";
export async function GET() {
try {
const response = await pb.collection("categories").getFullList<Category>({
expand: "items.alerts,items.alpine_script,items.default_login",
sort: "order",
const response = await pb.collection("categories").getFullList<Category>({
expand: "items",
fields:
"catagoryName, expand.items.title, expand.items.logo, expand.items.item_type, expand.items.isMostViewed, id",
});
return NextResponse.json(response);

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { pb } from "@/lib/pocketbase";
import { Script } from "@/lib/types";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const page = parseInt(req.nextUrl.searchParams.get("page") || "1", 10);
try {
const response = await pb
.collection("proxmox_scripts")
.getList<Script>(page, 3, {
sort: "-created",
fields: "title, logo, description, created, id",
});
return NextResponse.json(response);
} catch (error) {
console.error("Error fetching categories:", error);
return NextResponse.json(
{ error: "Failed to fetch script" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { pb } from "@/lib/pocketbase";
import { Script } from "@/lib/types";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
const response = await pb
.collection("proxmox_scripts")
.getList<Script>(1, 3, {
filter: `isMostViewed = true`,
fields: "title, logo, description, created, id"
})
return NextResponse.json(response);
} catch (error) {
console.error("Error fetching categories:", error);
return NextResponse.json(
{ error: "Failed to fetch script" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { pb } from "@/lib/pocketbase";
import { Script } from "@/lib/types";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const title = searchParams.get("title");
try {
const response = await pb
.collection("proxmox_scripts")
.getFirstListItem<Script>(`title="${title}"`, {
expand: "alerts,alpine_script,default_login",
});
return NextResponse.json(response);
} catch (error) {
console.error("Error fetching categories:", error);
return NextResponse.json(
{ error: "Failed to fetch script" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,115 @@
import Image from "next/image";
import { extractDate } from "@/lib/time";
import CloseButton from "@/components/closeButton";
import { Separator } from "@/components/ui/separator";
import DefaultSettings from "../_components/ScriptItems/DefaultSettings";
import InterFaces from "../_components/ScriptItems/InterFaces";
import Buttons from "../_components/ScriptItems/Buttons";
import Description from "../_components/ScriptItems/Description";
import Alerts from "../_components/ScriptItems/Alerts";
import Tooltips from "../_components/ScriptItems/Tooltips";
import InstallCommand from "../_components/ScriptItems/InstallCommand";
import DefaultPassword from "../_components/ScriptItems/DefaultPassword";
import type { Metadata, ResolvingMetadata } from "next";
type Props = {
params: { id: string };
searchParams: { [key: string]: string | string[] | undefined };
};
export async function generateMetadata({ params, searchParams }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const id = params.id;
const data = await fetch(`http://localhost:3000/api/scripts/script?title=${id}`).then((res) => res.json());
const imgURL = `http://localhost:3000/api/og?title=${data.title}?logo=${data.logo}`;
return {
title: `${data.title} | Proxmox VE Helper-Scripts`,
description: `This script will fully install and configure ${data.title} on your Proxmox VE host.`,
openGraph: {
title: `${data.title} | Proxmox VE Helper-Scripts`,
description: `This script will fully install and configure ${data.title} on your Proxmox VE host.`,
images: [
{
url: imgURL,
width: 1200,
height: 630,
alt: data.title,
},
],
},
};
}
export default async function Page({ params }: { params: { id: string } }) {
const data = await fetch(
`http://localhost:3000/api/scripts/script?title=${params.id}`,
).then((res) => res.json());
return (
<div>
{data && (
<div className="mr-7 mt-0 flex w-full min-w-fit">
<div className="flex w-full min-w-fit">
<div className="flex w-full flex-col">
<div className="datas-center flex h-[36px] min-w-max justify-between">
<h2 className="text-lg font-semibold">Selected Script</h2>
<CloseButton />
</div>
<div className="rounded-lg border bg-accent/20 p-4">
<div className="flex justify-between">
<div className="flex">
<Image
className="h-32 w-32 rounded-lg bg-accent/60 object-contain p-3 shadow-md"
src={data.logo}
width={400}
height={400}
alt={data.title}
priority
/>
<div className="ml-4 flex flex-col justify-between">
<div className="flex h-full w-full flex-col justify-between">
<div>
<h1 className="text-lg font-semibold">{data.title}</h1>
<p className="w-full text-sm text-muted-foreground">
Date added: {extractDate(data.created)}
</p>
</div>
<div className="flex gap-5">
<DefaultSettings item={data} />
</div>
</div>
</div>
</div>
<div className="hidden flex-col justify-between gap-2 sm:flex">
<InterFaces item={data} />
<Buttons item={data} />
</div>
</div>
<Separator className="mt-4" />
<div>
<div className="mt-4">
<Description item={data} />
<Alerts item={data} />
</div>
<div className="mt-4 rounded-lg border bg-accent/50">
<div className="flex gap-3 px-4 py-2">
<h2 className="text-lg font-semibold">
How to {data.data_type ? "install" : "use"}
</h2>
<Tooltips item={data} />
</div>
<Separator className="w-full"></Separator>
<InstallCommand item={data} />
</div>
</div>
<DefaultPassword item={data} />
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,3 +1,4 @@
"use client";
import {
Card,
CardContent,
@@ -6,8 +7,8 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useMemo, useState } from "react";
import { Category } from "@/lib/types";
import { useEffect, useMemo, useState } from "react";
import { Category, Script } from "@/lib/types";
import Link from "next/link";
import Image from "next/image";
import { extractDate } from "@/lib/time";
@@ -15,16 +16,23 @@ import { Button } from "@/components/ui/button";
const ITEMS_PER_PAGE = 3;
export function LatestScripts({ items }: { items: Category[] }) {
export function LatestScripts() {
const [page, setPage] = useState(1);
const [latestScripts, setLatestScripts] = useState<Script[]>([]);
const latestScripts = useMemo(() => {
if (!items) return [];
const scripts = items.flatMap((category) => category.expand.items || []);
return scripts.sort(
(a, b) => new Date(b.created).getTime() - new Date(a.created).getTime(),
);
}, [items]);
useEffect(() => {
fetch(`/api/scripts/latest?page=${page}`)
.then((res) => res.json())
.then((data: any) => {
if (Array.isArray(data.items)) {
setLatestScripts(data.items as Script[]);
} else {
console.error("Unexpected data format: ", data);
setLatestScripts([]);
}
})
.catch((err) => console.error(err));
}, []);
const goToNextPage = () => {
setPage((prevPage) => prevPage + 1);
@@ -37,10 +45,6 @@ export function LatestScripts({ items }: { items: Category[] }) {
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = page * ITEMS_PER_PAGE;
if (!items) {
return null;
}
return (
<div className="">
{latestScripts.length > 0 && (
@@ -67,7 +71,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
</div>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{latestScripts.slice(startIndex, endIndex).map((item) => (
{latestScripts.map((item) => (
<Card
key={item.id}
className="min-w-[250px] flex-1 flex-grow bg-accent/30"
@@ -99,10 +103,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
<CardFooter className="">
<Button asChild variant="secondary">
<Link
href={{
pathname: "/scripts",
query: { id: item.title },
}}
href={`/scripts/${item.title}`}
className="text-muted-foreground"
>
View Script
@@ -116,40 +117,32 @@ export function LatestScripts({ items }: { items: Category[] }) {
);
}
export function MostViewedScripts({ items }: { items: Category[] }) {
const [page, setPage] = useState(1);
export function MostViewedScripts() {
const [scripts, setScripts] = useState<Script[]>([]);
const mostViewedScripts = useMemo(() => {
if (!items) return [];
const scripts = items.flatMap((category) => category.expand.items || []);
const mostViewedScripts = scripts
.filter((script) => script.isMostViewed)
.map((script) => ({
...script,
}));
return mostViewedScripts;
}, [items]);
const goToNextPage = () => {
setPage((prevPage) => prevPage + 1);
};
const goToPreviousPage = () => {
setPage((prevPage) => prevPage - 1);
};
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = page * ITEMS_PER_PAGE;
useEffect(() => {
fetch("/api/scripts/mostviewed")
.then((res) => res.json())
.then((data: any) => {
if (Array.isArray(data.items)) {
setScripts(data.items as Script[]);
} else {
console.error("Unexpected data format: ", data);
setScripts([]);
}
})
.catch((err) => console.error(err));
}, []);
return (
<div className="">
{mostViewedScripts.length > 0 && (
{scripts.length > 0 && (
<>
<h2 className="text-lg font-semibold">Most Viewed Scripts</h2>
</>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{mostViewedScripts.slice(startIndex, endIndex).map((item) => (
{scripts.map((item: Script) => (
<Card
key={item.id}
className="min-w-[250px] flex-1 flex-grow bg-accent/30"
@@ -181,10 +174,7 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
<CardFooter className="">
<Button asChild variant="secondary">
<Link
href={{
pathname: "/scripts",
query: { id: item.title },
}}
href={`/scripts/${item.title}`}
className="text-muted-foreground"
>
View Script
@@ -194,18 +184,6 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
</Card>
))}
</div>
<div className="flex justify-end gap-1 p-2">
{page > 1 && (
<Button onClick={goToPreviousPage} variant="outline">
Previous
</Button>
)}
{endIndex < mostViewedScripts.length && (
<Button onClick={goToNextPage} variant="outline">
{page === 1 ? "More.." : "Next"}
</Button>
)}
</div>
</div>
);
}

View File

@@ -1,127 +0,0 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { extractDate } from "@/lib/time";
import { X } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { Category, Script } from "@/lib/types";
import { useSearchParams } from "next/navigation";
import { MostViewedScripts, LatestScripts } from "./ScriptInfoBlocks";
import DefaultPassword from "./ScriptItems/DefaultPassword";
import InstallCommand from "./ScriptItems/InstallCommand";
import Tooltips from "./ScriptItems/Tooltips";
import Alerts from "./ScriptItems/Alerts";
import Description from "./ScriptItems/Description";
import Buttons from "./ScriptItems/Buttons";
import DefaultSettings from "./ScriptItems/DefaultSettings";
import InterFaces from "./ScriptItems/InterFaces";
function ScriptItem({
items,
selectedScript,
setSelectedScript,
}: {
items: Category[];
selectedScript: string | null;
setSelectedScript: (script: string | null) => void;
}) {
const [item, setItem] = useState<Script | null>(null);
const searchParams = useSearchParams();
const id = searchParams.get("id");
useEffect(() => {
if (items) {
const script = items
.map((category) => category.expand.items)
.flat()
.find((script) => script.title === id);
setItem(script || null);
if (script && !selectedScript) {
setSelectedScript(script.title);
}
}
}, [id, items, setSelectedScript, selectedScript]);
const closeScript = () => {
window.history.pushState({}, document.title, window.location.pathname);
setSelectedScript(null);
};
return (
<>
{item && (
<div className="mr-7 mt-0 flex w-full min-w-fit">
<div className="flex w-full min-w-fit">
<div className="flex w-full flex-col">
<div className="flex h-[36px] min-w-max items-center justify-between">
<h2 className="text-lg font-semibold">Selected Script</h2>
<X onClick={closeScript} className="cursor-pointer" />
</div>
<div className="rounded-lg border bg-accent/20 p-4">
<div className="flex justify-between">
<div className="flex">
<Image
className="h-32 w-32 rounded-lg bg-accent/60 object-contain p-3 shadow-md"
src={item.logo}
width={400}
height={400}
alt={item.title}
priority
/>
<div className="ml-4 flex flex-col justify-between">
<div className="flex h-full w-full flex-col justify-between">
<div>
<h1 className="text-lg font-semibold">
{item.title}
</h1>
<p className="w-full text-sm text-muted-foreground">
Date added: {extractDate(item.created)}
</p>
</div>
<div className="flex gap-5">
<DefaultSettings item={item} />
</div>
</div>
</div>
</div>
<div className="hidden flex-col justify-between gap-2 sm:flex">
<InterFaces item={item} />
<Buttons item={item} />
</div>
</div>
<Separator className="mt-4" />
<div>
<div className="mt-4">
<Description item={item} />
<Alerts item={item} />
</div>
<div className="mt-4 rounded-lg border bg-accent/50">
<div className="flex gap-3 px-4 py-2">
<h2 className="text-lg font-semibold">
How to {item.item_type ? "install" : "use"}
</h2>
<Tooltips item={item} />
</div>
<Separator className="w-full"></Separator>
<InstallCommand item={item} />
</div>
</div>
<DefaultPassword item={item} />
</div>
</div>
</div>
</div>
)}
{id ? null : (
<div className="flex w-full flex-col gap-5">
<LatestScripts items={items} />
<MostViewedScripts items={items} />
</div>
)}
</>
);
}
export default ScriptItem;

View File

@@ -1,3 +1,4 @@
'use client'
import TextCopyBlock from "@/lib/TextCopyBlock";
import { Script } from "@/lib/types";
import { Info } from "lucide-react";

View File

@@ -1,3 +1,4 @@
'use client';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import handleCopy from '@/lib/handleCopy';

View File

@@ -1,3 +1,4 @@
'use client';
import TextCopyBlock from '@/lib/TextCopyBlock';
import { Script } from '@/lib/types';
import React from 'react'

View File

@@ -1,3 +1,4 @@
'use client';
import CodeCopyButton from '@/components/ui/code-copy-button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Script } from '@/lib/types';

View File

@@ -1,3 +1,4 @@
'use client';
import { Button } from '@/components/ui/button';
import handleCopy from '@/lib/handleCopy';
import React from 'react'

View File

@@ -11,40 +11,51 @@ import { EyeOff, Eye, Star } from "lucide-react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Category } from "@/lib/types";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import classNames from "clsx";
import { useSearchParams } from "next/navigation";
import { usePathname } from "next/navigation";
const ScriptBrowser = ({
items,
selectedScript,
setSelectedScript,
}: {
items: Category[];
selectedScript: string | null;
setSelectedScript: (script: string | null) => void;
}) => {
const sortCategories = (categories: Category[]): Category[] => {
return categories.sort((a: Category, b: Category) => {
if (
a.catagoryName === "Proxmox VE Tools" &&
b.catagoryName !== "Proxmox VE Tools"
) {
return -1;
} else if (
a.catagoryName !== "Proxmox VE Tools" &&
b.catagoryName === "Proxmox VE Tools"
) {
return 1;
} else {
return a.catagoryName.localeCompare(b.catagoryName);
}
});
};
export const ScriptBrowser = () => {
const [links, setLinks] = useState<Category[]>([]);
const [expandedItem, setExpandedItem] = useState<string | undefined>(
undefined,
);
const linkRefs = useRef<{ [key: string]: HTMLAnchorElement | null }>({});
const searchParams = useSearchParams();
const pathname = usePathname();
const [selectedScript, setSelectedScript] = useState<string | null>(null);
useEffect(() => {
if (items) {
setLinks(items);
}
}, [items]);
fetch("/api/scripts/categories")
.then((res) => res.json())
.then((data: any) => setLinks(data as Category[]))
.catch((err) => console.error(err));
}, []);
useEffect(() => {
const id = searchParams.get("id");
if (id) {
setSelectedScript(id);
const scriptTitle = pathname.split("/scripts/")[1];
if (scriptTitle) {
setSelectedScript(decodeURIComponent(scriptTitle));
} else {
setSelectedScript(null);
}
}, [searchParams, setSelectedScript]);
}, [pathname]);
const handleSelected = useCallback(
(title: string) => {
@@ -119,10 +130,7 @@ const ScriptBrowser = ({
{category.expand.items.map((script, index) => (
<p key={index}>
<Link
href={{
pathname: "/scripts",
query: { id: script.title },
}}
href={`/scripts/${script.title}`}
className={`flex cursor-pointer items-center justify-between gap-1 px-1 py-1 text-muted-foreground hover:rounded-lg hover:bg-accent/60 hover:dark:bg-accent/20 ${
selectedScript === script.title
? "rounded-lg bg-accent font-semibold dark:bg-accent/30 dark:text-white"

View File

@@ -0,0 +1,16 @@
import Sidebar from "./_components/Sidebar";
export default async function ScriptLayout({children}: { children: React.ReactNode }) {
return (
<>
<div className="mb-3">
<div className="mt-20 flex sm:px-4 xl:px-0">
<div className="hidden sm:flex">
<Sidebar />
</div>
<div className="mx-7 w-full sm:mx-0 sm:ml-7">{children}</div>
</div>
</div>
</>
);
}

View File

@@ -1,85 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import ScriptItem from "@/app/scripts/_components/ScriptItem";
import ScriptBrowser from "@/app/scripts/_components/ScriptBrowser";
import { Category } from "@/lib/types";
const sortCategories = (categories: Category[]): Category[] => {
return categories.sort((a: Category, b: Category) => {
if (
a.catagoryName === "Proxmox VE Tools" &&
b.catagoryName !== "Proxmox VE Tools"
) {
return -1;
} else if (
a.catagoryName !== "Proxmox VE Tools" &&
b.catagoryName === "Proxmox VE Tools"
) {
return 1;
} else {
return a.catagoryName.localeCompare(b.catagoryName);
}
});
};
export default function Page() {
const [links, setLinks] = useState<Category[]>([]);
const [selectedScript, setSelectedScript] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchCategories = async (): Promise<void> => {
try {
const response = await fetch("/api/categories");
if (!response.ok) {
throw new Error("Failed to fetch categories");
}
const categories: Category[] = await response.json();
if (categories.length === 0) {
throw new Error("Empty response");
}
const sortedCategories = sortCategories(categories);
setLinks(sortedCategories);
setLoading(false);
} catch (error) {
console.error(error);
setLoading(false);
}
};
fetchCategories();
}, []);
if (loading) {
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-5 bg-background px-4 md:px-6">
<div className="space-y-2 text-center">
<Loader2 className="h-10 w-10 animate-spin" />
</div>
</div>
);
}
import React, { Suspense } from 'react'
import { LatestScripts, MostViewedScripts } from './_components/ScriptInfoBlocks';
import { Loader2 } from 'lucide-react';
export default function page() {
return (
<div className="mb-3">
<div className="mt-20 flex sm:px-4 xl:px-0">
<div className="hidden sm:flex">
<ScriptBrowser
items={links}
selectedScript={selectedScript}
setSelectedScript={setSelectedScript}
/>
</div>
<div className="mx-7 w-full sm:mx-0 sm:ml-7">
<ScriptItem
items={links}
selectedScript={selectedScript}
setSelectedScript={setSelectedScript}
/>
</div>
</div>
<div className="flex w-full flex-col gap-5">
<Suspense
fallback={
<div>
<Loader2 className="h-10 w-10 animate-spin" />
</div>
}
>
<LatestScripts />
</Suspense>
<Suspense
fallback={
<div>
<Loader2 className="h-10 w-10 animate-spin" />
</div>
}
>
<MostViewedScripts />
</Suspense>
</div>
);
}

View File

@@ -1,3 +1,5 @@
'use client';
import React from "react";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
@@ -12,7 +14,7 @@ import {
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import { Category } from "@/lib/types";
import { Category, Script } from "@/lib/types";
import Image from "next/image";
import { useRouter } from "next/navigation";
@@ -37,6 +39,7 @@ const sortCategories = (categories: Category[]): Category[] => {
export default function CommandMenu() {
const [open, setOpen] = React.useState(false);
const [links, setLinks] = React.useState<Category[]>([]);
const [mostViewed, setMostViewed] = React.useState<Script[]>([]);
const router = useRouter();
React.useEffect(() => {
@@ -54,7 +57,7 @@ export default function CommandMenu() {
React.useEffect(() => {
const fetchCategories = async (): Promise<void> => {
try {
const response = await fetch("/api/categories");
const response = await fetch("/api/scripts/categories");
if (!response.ok) {
throw new Error("Failed to fetch categories");
}
@@ -101,7 +104,7 @@ export default function CommandMenu() {
value={script.title}
onSelect={() => {
setOpen(false);
router.push(`/scripts?id=${script.title}`);
router.push(`/scripts/${script.title}`);
}}
>
<div className="flex gap-2" onClick={() => setOpen(false)}>

View File

@@ -25,7 +25,7 @@ export default function MobileNav() {
const [links, setLinks] = useState<Category[]>([]);
const fetchLinks = async () => {
const res = await fetch("/api/categories", {
const res = await fetch("/api/scripts/categories", {
});
if (!res.ok) {
throw new Error("Failed to fetch categories");

View File

@@ -0,0 +1,14 @@
"use client";
import { X } from "lucide-react";
import React from "react";
import { useRouter } from "next/navigation";
export default function CloseButton() {
const router = useRouter();
const closeScript = () => {
router.push("/scripts");
};
return <X onClick={closeScript} className="cursor-pointer" />;
}

View File

@@ -1,3 +1,4 @@
'use client';
import { Button } from "@/components/ui/button";
import handleCopy from "./handleCopy";