mirror of
https://github.com/community-scripts/ProxmoxVED.git
synced 2026-02-28 08:55:53 +00:00
Initial Release
This commit is contained in:
132
frontend/src/app/scripts/_components/ScriptAccordion.tsx
Normal file
132
frontend/src/app/scripts/_components/ScriptAccordion.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { formattedBadge } from "@/components/CommandMenu";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Category } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { basePath } from "@/config/siteConfig";
|
||||
|
||||
export default function ScriptAccordion({
|
||||
items,
|
||||
selectedScript,
|
||||
setSelectedScript,
|
||||
}: {
|
||||
items: Category[];
|
||||
selectedScript: string | null;
|
||||
setSelectedScript: (script: string | null) => void;
|
||||
}) {
|
||||
const [expandedItem, setExpandedItem] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const linkRefs = useRef<{ [key: string]: HTMLAnchorElement | null }>({});
|
||||
|
||||
const handleAccordionChange = (value: string | undefined) => {
|
||||
setExpandedItem(value);
|
||||
};
|
||||
|
||||
const handleSelected = useCallback(
|
||||
(slug: string) => {
|
||||
setSelectedScript(slug);
|
||||
},
|
||||
[setSelectedScript],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedScript) {
|
||||
const category = items.find((category) =>
|
||||
category.scripts.some((script) => script.slug === selectedScript),
|
||||
);
|
||||
if (category) {
|
||||
setExpandedItem(category.name);
|
||||
handleSelected(selectedScript);
|
||||
}
|
||||
}
|
||||
}, [selectedScript, items, handleSelected]);
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
value={expandedItem}
|
||||
onValueChange={handleAccordionChange}
|
||||
collapsible
|
||||
className="overflow-y-scroll max-h-[calc(100vh-220px)] overflow-x-hidden mt-3 p-2"
|
||||
>
|
||||
{items.map((category) => (
|
||||
<AccordionItem
|
||||
key={category.id + ":category"}
|
||||
value={category.name}
|
||||
className={cn("sm:text-md flex flex-col border-none", {
|
||||
"rounded-lg bg-accent/30": expandedItem === category.name,
|
||||
})}
|
||||
>
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"duration-250 rounded-lg transition ease-in-out hover:-translate-y-1 hover:scale-105 hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<div className="mr-2 flex w-full items-center justify-between">
|
||||
<span className="pl-2 text-left">{category.name} </span>
|
||||
<span className="rounded-full bg-gray-200 px-2 py-1 text-xs text-muted-foreground hover:no-underline dark:bg-blue-800/20">
|
||||
{category.scripts.length}
|
||||
</span>
|
||||
</div>{" "}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
data-state={expandedItem === category.name ? "open" : "closed"}
|
||||
className="pt-0"
|
||||
>
|
||||
{category.scripts
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((script, index) => (
|
||||
<div key={index}>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/scripts",
|
||||
query: { id: script.slug },
|
||||
}}
|
||||
prefetch={false}
|
||||
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.slug
|
||||
? "rounded-lg bg-accent font-semibold dark:bg-accent/30 dark:text-white"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleSelected(script.slug)}
|
||||
ref={(el) => {
|
||||
linkRefs.current[script.slug] = el;
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src={script.logo || `/${basePath}/logo.png`}
|
||||
height={16}
|
||||
width={16}
|
||||
unoptimized
|
||||
onError={(e) =>
|
||||
((e.currentTarget as HTMLImageElement).src =
|
||||
`/${basePath}/logo.png`)
|
||||
}
|
||||
alt={script.name}
|
||||
className="mr-1 w-4 h-4 rounded-full"
|
||||
/>
|
||||
<span className="flex items-center gap-2">
|
||||
{script.name}
|
||||
</span>
|
||||
</div>
|
||||
{formattedBadge(script.type)}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
223
frontend/src/app/scripts/_components/ScriptInfoBlocks.tsx
Normal file
223
frontend/src/app/scripts/_components/ScriptInfoBlocks.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { basePath, mostPopularScripts } from "@/config/siteConfig";
|
||||
import { extractDate } from "@/lib/time";
|
||||
import { Category, Script } from "@/lib/types";
|
||||
import { CalendarPlus } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const ITEMS_PER_PAGE = 3;
|
||||
|
||||
export const getDisplayValueFromType = (type: string) => {
|
||||
switch (type) {
|
||||
case "ct":
|
||||
return "LXC";
|
||||
case "vm":
|
||||
return "VM";
|
||||
case "misc":
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export function LatestScripts({ items }: { items: Category[] }) {
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const latestScripts = useMemo(() => {
|
||||
if (!items) return [];
|
||||
|
||||
const scripts = items.flatMap((category) => category.scripts || []);
|
||||
|
||||
// Filter out duplicates by slug
|
||||
const uniqueScriptsMap = new Map<string, Script>();
|
||||
scripts.forEach((script) => {
|
||||
if (!uniqueScriptsMap.has(script.slug)) {
|
||||
uniqueScriptsMap.set(script.slug, script);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueScriptsMap.values()).sort(
|
||||
(a, b) =>
|
||||
new Date(b.date_created).getTime() - new Date(a.date_created).getTime(),
|
||||
);
|
||||
}, [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;
|
||||
|
||||
if (!items) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{latestScripts.length > 0 && (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Newest Scripts</h2>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{page > 1 && (
|
||||
<div
|
||||
className="cursor-pointer select-none p-2 text-sm font-semibold"
|
||||
onClick={goToPreviousPage}
|
||||
>
|
||||
Previous
|
||||
</div>
|
||||
)}
|
||||
{endIndex < latestScripts.length && (
|
||||
<div
|
||||
onClick={goToNextPage}
|
||||
className="cursor-pointer select-none p-2 text-sm font-semibold"
|
||||
>
|
||||
{page === 1 ? "More.." : "Next"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w flex w-full flex-row flex-wrap gap-4">
|
||||
{latestScripts.slice(startIndex, endIndex).map((script) => (
|
||||
<Card
|
||||
key={script.slug}
|
||||
className="min-w-[250px] flex-1 flex-grow bg-accent/30"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 min-w-16 items-center justify-center rounded-lg bg-accent p-1">
|
||||
<Image
|
||||
src={script.logo || `/${basePath}/logo.png`}
|
||||
unoptimized
|
||||
height={64}
|
||||
width={64}
|
||||
alt=""
|
||||
onError={(e) =>
|
||||
((e.currentTarget as HTMLImageElement).src =
|
||||
`/${basePath}/logo.png`)
|
||||
}
|
||||
className="h-11 w-11 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-lg line-clamp-1">
|
||||
{script.name} {getDisplayValueFromType(script.type)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
{extractDate(script.date_created)}
|
||||
</p>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="line-clamp-3 text-card-foreground">
|
||||
{script.description}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter className="">
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/scripts",
|
||||
query: { id: script.slug },
|
||||
}}
|
||||
>
|
||||
View Script
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MostViewedScripts({ items }: { items: Category[] }) {
|
||||
const mostViewedScripts = items.reduce((acc: Script[], category) => {
|
||||
const foundScripts = category.scripts.filter((script) =>
|
||||
mostPopularScripts.includes(script.slug),
|
||||
);
|
||||
return acc.concat(foundScripts);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{mostViewedScripts.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.map((script) => (
|
||||
<Card
|
||||
key={script.slug}
|
||||
className="min-w-[250px] flex-1 flex-grow bg-accent/30"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="flex size-16 min-w-16 items-center justify-center rounded-lg bg-accent p-1">
|
||||
<Image
|
||||
unoptimized
|
||||
src={script.logo || `/${basePath}/logo.png`}
|
||||
height={64}
|
||||
width={64}
|
||||
alt=""
|
||||
onError={(e) =>
|
||||
((e.currentTarget as HTMLImageElement).src =
|
||||
`/${basePath}/logo.png`)
|
||||
}
|
||||
className="h-11 w-11 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="line-clamp-1 text-lg">
|
||||
{script.name} {getDisplayValueFromType(script.type)}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
{extractDate(script.date_created)}
|
||||
</p>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="line-clamp-3 text-card-foreground break-words">
|
||||
{script.description}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter className="">
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/scripts",
|
||||
query: { id: script.slug },
|
||||
}}
|
||||
prefetch={false}
|
||||
>
|
||||
View Script
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/app/scripts/_components/ScriptItem.tsx
Normal file
107
frontend/src/app/scripts/_components/ScriptItem.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { extractDate } from "@/lib/time";
|
||||
import { Script } from "@/lib/types";
|
||||
import { X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { getDisplayValueFromType } from "./ScriptInfoBlocks";
|
||||
import Alerts from "./ScriptItems/Alerts";
|
||||
import Buttons from "./ScriptItems/Buttons";
|
||||
import DefaultPassword from "./ScriptItems/DefaultPassword";
|
||||
import DefaultSettings from "./ScriptItems/DefaultSettings";
|
||||
import Description from "./ScriptItems/Description";
|
||||
import InstallCommand from "./ScriptItems/InstallCommand";
|
||||
import InterFaces from "./ScriptItems/InterFaces";
|
||||
import Tooltips from "./ScriptItems/Tooltips";
|
||||
import { basePath } from "@/config/siteConfig";
|
||||
|
||||
function ScriptItem({
|
||||
item,
|
||||
setSelectedScript,
|
||||
}: {
|
||||
item: Script;
|
||||
setSelectedScript: (script: string | null) => void;
|
||||
}) {
|
||||
const closeScript = () => {
|
||||
window.history.pushState({}, document.title, window.location.pathname);
|
||||
setSelectedScript(null);
|
||||
};
|
||||
|
||||
const defaultInstallMethod = item.install_methods?.[0];
|
||||
const os = defaultInstallMethod?.resources?.os || "Proxmox Node";
|
||||
const version = defaultInstallMethod?.resources?.version || "";
|
||||
|
||||
return (
|
||||
<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 || `/${basePath}/logo.png`}
|
||||
width={400}
|
||||
onError={(e) =>
|
||||
((e.currentTarget as HTMLImageElement).src =
|
||||
`/${basePath}/logo.png`)
|
||||
}
|
||||
height={400}
|
||||
alt={item.name}
|
||||
unoptimized
|
||||
/>
|
||||
<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.name} {getDisplayValueFromType(item.type)}
|
||||
</h1>
|
||||
<p className="w-full text-sm text-muted-foreground">
|
||||
Date added: {extractDate(item.date_created)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Default OS: {os} {version}
|
||||
</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.type == "misc" ? "use" : "install"}
|
||||
</h2>
|
||||
<Tooltips item={item} />
|
||||
</div>
|
||||
<Separator className="w-full"></Separator>
|
||||
<InstallCommand item={item} />
|
||||
</div>
|
||||
</div>
|
||||
<DefaultPassword item={item} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScriptItem;
|
||||
35
frontend/src/app/scripts/_components/ScriptItems/Alerts.tsx
Normal file
35
frontend/src/app/scripts/_components/ScriptItems/Alerts.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import TextCopyBlock from "@/components/TextCopyBlock";
|
||||
import { AlertColors } from "@/config/siteConfig";
|
||||
import { Script } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, NotepadText } from "lucide-react";
|
||||
|
||||
type NoteProps = {
|
||||
text: string;
|
||||
type: keyof typeof AlertColors;
|
||||
}
|
||||
|
||||
export default function Alerts({ item }: { item: Script }) {
|
||||
return (
|
||||
<>
|
||||
{item?.notes?.length > 0 &&
|
||||
item.notes.map((note: NoteProps, index: number) => (
|
||||
<div key={index} className="mt-4 flex flex-col gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-lg border p-2 pl-4 text-sm",
|
||||
AlertColors[note.type],
|
||||
)}
|
||||
>
|
||||
{note.type == "info" ? (
|
||||
<NotepadText className="h-4 min-h-4 w-4 min-w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 min-h-4 w-4 min-w-4" />
|
||||
)}
|
||||
<span>{TextCopyBlock(note.text)}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
frontend/src/app/scripts/_components/ScriptItems/Buttons.tsx
Normal file
81
frontend/src/app/scripts/_components/ScriptItems/Buttons.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { basePath } from "@/config/siteConfig";
|
||||
import { Script } from "@/lib/types";
|
||||
import { BookOpenText, Code, Globe, RefreshCcw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
const generateInstallSourceUrl = (slug: string) => {
|
||||
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
|
||||
return `${baseUrl}/install/${slug}-install.sh`;
|
||||
};
|
||||
|
||||
const generateSourceUrl = (slug: string, type: string) => {
|
||||
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
|
||||
return type === "vm" ? `${baseUrl}/vm/${slug}.sh` : `${baseUrl}/misc/${slug}.sh`;
|
||||
return `${baseUrl}/misc/${slug}.sh`;
|
||||
};
|
||||
|
||||
const generateUpdateUrl = (slug: string) => {
|
||||
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
|
||||
return `${baseUrl}/ct/${slug}.sh`;
|
||||
};
|
||||
|
||||
interface ButtonLinkProps {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const ButtonLink = ({ href, icon, text }: ButtonLinkProps) => (
|
||||
<Button variant="secondary" asChild>
|
||||
<Link target="_blank" href={href}>
|
||||
<span className="flex items-center gap-2">
|
||||
{icon}
|
||||
{text}
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export default function Buttons({ item }: { item: Script }) {
|
||||
const isCtOrDefault = ["ct"].includes(item.type);
|
||||
const installSourceUrl = isCtOrDefault ? generateInstallSourceUrl(item.slug) : null;
|
||||
const updateSourceUrl = isCtOrDefault ? generateUpdateUrl(item.slug) : null;
|
||||
const sourceUrl = !isCtOrDefault ? generateSourceUrl(item.slug) : null;
|
||||
|
||||
const buttons = [
|
||||
item.website && {
|
||||
href: item.website,
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
text: "Website",
|
||||
},
|
||||
item.documentation && {
|
||||
href: item.documentation,
|
||||
icon: <BookOpenText className="h-4 w-4" />,
|
||||
text: "Documentation",
|
||||
},
|
||||
installSourceUrl && {
|
||||
href: installSourceUrl,
|
||||
icon: <Code className="h-4 w-4" />,
|
||||
text: "Install-Source",
|
||||
},
|
||||
updateSourceUrl && {
|
||||
href: updateSourceUrl,
|
||||
icon: <RefreshCcw className="h-4 w-4" />,
|
||||
text: "Update-Source",
|
||||
},
|
||||
sourceUrl && {
|
||||
href: sourceUrl,
|
||||
icon: <Code className="h-4 w-4" />,
|
||||
text: "Source Code",
|
||||
},
|
||||
].filter(Boolean) as ButtonLinkProps[];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{buttons.map((props, index) => (
|
||||
<ButtonLink key={index} {...props} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import handleCopy from "@/components/handleCopy";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Script } from "@/lib/types";
|
||||
|
||||
export default function DefaultPassword({ item }: { item: Script }) {
|
||||
const { username, password } = item.default_credentials;
|
||||
const hasDefaultLogin = username && password;
|
||||
|
||||
if (!hasDefaultLogin) return null;
|
||||
|
||||
const copyCredential = (type: "username" | "password") => {
|
||||
handleCopy(type, item.default_credentials[type] ?? "");
|
||||
};
|
||||
|
||||
return (
|
||||
<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">Default Login Credentials</h2>
|
||||
</div>
|
||||
<Separator className="w-full" />
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<p className="mb-2 text-sm">
|
||||
You can use the following credentials to login to the {item.name}{" "}
|
||||
{item.type}.
|
||||
</p>
|
||||
{["username", "password"].map((type) => (
|
||||
<div key={type} className="text-sm">
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}:{" "}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="null"
|
||||
onClick={() => copyCredential(type as "username" | "password")}
|
||||
>
|
||||
{item.default_credentials[type as "username" | "password"]}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Script } from "@/lib/types";
|
||||
|
||||
export default function DefaultSettings({ item }: { item: Script }) {
|
||||
const getDisplayValueFromRAM = (ram: number) =>
|
||||
ram >= 1024 ? `${Math.floor(ram / 1024)}GB` : `${ram}MB`;
|
||||
|
||||
const ResourceDisplay = ({
|
||||
settings,
|
||||
title,
|
||||
}: {
|
||||
settings: (typeof item.install_methods)[0];
|
||||
title: string;
|
||||
}) => {
|
||||
const { cpu, ram, hdd } = settings.resources;
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">CPU: {cpu}vCPU</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
RAM: {getDisplayValueFromRAM(ram ?? 0)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">HDD: {hdd}GB</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultSettings = item.install_methods.find(
|
||||
(method) => method.type === "default",
|
||||
);
|
||||
const defaultAlpineSettings = item.install_methods.find(
|
||||
(method) => method.type === "alpine",
|
||||
);
|
||||
|
||||
const hasDefaultSettings =
|
||||
defaultSettings?.resources &&
|
||||
Object.values(defaultSettings.resources).some(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasDefaultSettings && (
|
||||
<ResourceDisplay settings={defaultSettings} title="Default settings" />
|
||||
)}
|
||||
{defaultAlpineSettings && (
|
||||
<ResourceDisplay
|
||||
settings={defaultAlpineSettings}
|
||||
title="Default Alpine settings"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import TextCopyBlock from "@/components/TextCopyBlock";
|
||||
import { Script } from "@/lib/types";
|
||||
|
||||
export default function Description({ item }: { item: Script }) {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h2 className="mb-2 max-w-prose text-lg font-semibold">Description</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{TextCopyBlock(item.description)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import CodeCopyButton from "@/components/ui/code-copy-button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { basePath } from "@/config/siteConfig";
|
||||
import { Script } from "@/lib/types";
|
||||
import { getDisplayValueFromType } from "../ScriptInfoBlocks";
|
||||
|
||||
const getInstallCommand = (scriptPath?: string, isAlpine = false) => {
|
||||
return `bash -c "$(wget -q${isAlpine ? "" : "L"}O - https://github.com/community-scripts/${basePath}/raw/main/${scriptPath})"`;
|
||||
};
|
||||
|
||||
export default function InstallCommand({ item }: { item: Script }) {
|
||||
const alpineScript = item.install_methods.find(
|
||||
(method) => method.type === "alpine",
|
||||
);
|
||||
|
||||
const defaultScript = item.install_methods.find(
|
||||
(method) => method.type === "default",
|
||||
);
|
||||
|
||||
const renderInstructions = (isAlpine = false) => (
|
||||
<>
|
||||
<p className="text-sm mt-2">
|
||||
{isAlpine ? (
|
||||
<>
|
||||
As an alternative option, you can use Alpine Linux and the{" "}
|
||||
{item.name} package to create a {item.name}{" "}
|
||||
{getDisplayValueFromType(item.type)} container with faster creation
|
||||
time and minimal system resource usage. You are also obliged to
|
||||
adhere to updates provided by the package maintainer.
|
||||
</>
|
||||
) : item.type == "misc" ? (
|
||||
<>
|
||||
To use the {item.name} script, run the command below in the shell.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{" "}
|
||||
To create a new Proxmox VE {item.name}{" "}
|
||||
{getDisplayValueFromType(item.type)}, run the command below in the
|
||||
Proxmox VE Shell.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{isAlpine && (
|
||||
<p className="mt-2 text-sm">
|
||||
To create a new Proxmox VE Alpine-{item.name}{" "}
|
||||
{getDisplayValueFromType(item.type)}, run the command below in the
|
||||
Proxmox VE Shell
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{alpineScript ? (
|
||||
<Tabs defaultValue="default" className="mt-2 w-full max-w-4xl">
|
||||
<TabsList>
|
||||
<TabsTrigger value="default">Default</TabsTrigger>
|
||||
<TabsTrigger value="alpine">Alpine Linux</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="default">
|
||||
{renderInstructions()}
|
||||
<CodeCopyButton>
|
||||
{getInstallCommand(defaultScript?.script)}
|
||||
</CodeCopyButton>
|
||||
</TabsContent>
|
||||
<TabsContent value="alpine">
|
||||
{renderInstructions(true)}
|
||||
<CodeCopyButton>
|
||||
{getInstallCommand(alpineScript.script, true)}
|
||||
</CodeCopyButton>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : defaultScript?.script ? (
|
||||
<>
|
||||
{renderInstructions()}
|
||||
<CodeCopyButton>
|
||||
{getInstallCommand(defaultScript.script)}
|
||||
</CodeCopyButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import handleCopy from "@/components/handleCopy";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Script } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ClipboardIcon } from "lucide-react";
|
||||
|
||||
const CopyButton = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) => (
|
||||
<span
|
||||
className={cn(
|
||||
buttonVariants({ size: "sm", variant: "secondary" }),
|
||||
"flex items-center gap-2",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
<ClipboardIcon
|
||||
onClick={() => handleCopy(label, String(value))}
|
||||
className="size-4 cursor-pointer"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default function InterFaces({ item }: { item: Script }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{item.interface_port !== null ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<h2 className="mr-2 text-end text-lg font-semibold">
|
||||
{"Default Interface:"}
|
||||
</h2>{" "}
|
||||
<CopyButton label="default interface" value={item.interface_port} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Script } from "@/lib/types";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
variant: "warning" | "success";
|
||||
label: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const TooltipBadge: React.FC<TooltipProps> = ({ variant, label, content }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger className="flex items-center">
|
||||
<Badge variant={variant} className="flex items-center gap-1">
|
||||
{label} <CircleHelp className="size-3" />
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-sm max-w-64">
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
export default function Tooltips({ item }: { item: Script }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{item.privileged && (
|
||||
<TooltipBadge
|
||||
variant="warning"
|
||||
label="Privileged"
|
||||
content="This script will be run in a privileged LXC"
|
||||
/>
|
||||
)}
|
||||
{item.updateable && (
|
||||
<TooltipBadge
|
||||
variant="success"
|
||||
label="Updateable"
|
||||
content={`To Update ${item.name}, run the command below (or type update) in the LXC Console.`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/app/scripts/_components/Sidebar.tsx
Normal file
43
frontend/src/app/scripts/_components/Sidebar.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type { Category, Script } from "@/lib/types";
|
||||
import ScriptAccordion from "./ScriptAccordion";
|
||||
|
||||
const Sidebar = ({
|
||||
items,
|
||||
selectedScript,
|
||||
setSelectedScript,
|
||||
}: {
|
||||
items: Category[];
|
||||
selectedScript: string | null;
|
||||
setSelectedScript: (script: string | null) => void;
|
||||
}) => {
|
||||
const uniqueScripts = items.reduce((acc, category) => {
|
||||
for (const script of category.scripts) {
|
||||
if (!acc.some((s) => s.name === script.name)) {
|
||||
acc.push(script);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, [] as Script[]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-72 flex-col sm:max-w-72">
|
||||
<div className="flex items-end justify-between pb-4">
|
||||
<h1 className="text-xl font-bold">Categories</h1>
|
||||
<p className="text-xs italic text-muted-foreground">
|
||||
{uniqueScripts.length} Total scripts
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg">
|
||||
<ScriptAccordion
|
||||
items={items}
|
||||
selectedScript={selectedScript}
|
||||
setSelectedScript={setSelectedScript}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
79
frontend/src/app/scripts/page.tsx
Normal file
79
frontend/src/app/scripts/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
import ScriptItem from "@/app/scripts/_components/ScriptItem";
|
||||
import { fetchCategories } from "@/lib/data";
|
||||
import { Category, Script } from "@/lib/types";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
LatestScripts,
|
||||
MostViewedScripts,
|
||||
} from "./_components/ScriptInfoBlocks";
|
||||
import Sidebar from "./_components/Sidebar";
|
||||
|
||||
function ScriptContent() {
|
||||
const [selectedScript, setSelectedScript] = useQueryState("id");
|
||||
const [links, setLinks] = useState<Category[]>([]);
|
||||
const [item, setItem] = useState<Script>();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedScript && links.length > 0) {
|
||||
const script = links
|
||||
.map((category) => category.scripts)
|
||||
.flat()
|
||||
.find((script) => script.slug === selectedScript);
|
||||
setItem(script);
|
||||
}
|
||||
}, [selectedScript, links]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
.then((categories) => {
|
||||
setLinks(categories);
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mt-20 flex sm:px-4 xl:px-0">
|
||||
<div className="hidden sm:flex">
|
||||
<Sidebar
|
||||
items={links}
|
||||
selectedScript={selectedScript}
|
||||
setSelectedScript={setSelectedScript}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-7 w-full sm:mx-0 sm:ml-7">
|
||||
{selectedScript && item ? (
|
||||
<ScriptItem item={item} setSelectedScript={setSelectedScript} />
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<LatestScripts items={links} />
|
||||
<MostViewedScripts items={links} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<ScriptContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user