mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2025-09-13 02:39:43 +00:00
Remove npm legacy errors, created single source of truth for ESlint. updated analytics url. updated script background (#5498)
* Update ScriptAccordion and ScriptItem components for improved styling * Add README.md for Proxmox VE Helper-Scripts Frontend * Remove testing dependencies and related test files from the frontend project * Update analytics URL in siteConfig to point to community-scripts.org * Refactor ESLint configuration to have one source of truth and run "npm lint" to apply new changes * Update lint script in package.json to remove npm * Add 'next' option to ESLint configuration for improved compatibility * Update package dependencies and versions in package.json and package-lock.json * Refactor theme provider import and enhance calendar component for dynamic icon rendering * rename sidebar, alerts and buttons * rename description and interfaces files * rename more files * change folder name * Refactor tooltip logic to improve updateable condition handling * Enhance CommandMenu to prevent duplicate scripts across categories * Remove test step from frontend CI/CD workflow
This commit is contained in:
parent
d60911a063
commit
0067075ed1
91 changed files with 8049 additions and 4043 deletions
|
@ -1,7 +1,8 @@
|
|||
import { Metadata, Script } from "@/lib/types";
|
||||
import { promises as fs } from "fs";
|
||||
import { NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { Metadata, Script } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
|
@ -10,21 +11,21 @@ const metadataFileName = "metadata.json";
|
|||
const versionFileName = "version.json";
|
||||
const encoding = "utf-8";
|
||||
|
||||
const getMetadata = async () => {
|
||||
async function getMetadata() {
|
||||
const filePath = path.resolve(jsonDir, metadataFileName);
|
||||
const fileContent = await fs.readFile(filePath, encoding);
|
||||
const metadata: Metadata = JSON.parse(fileContent);
|
||||
return metadata;
|
||||
};
|
||||
}
|
||||
|
||||
const getScripts = async () => {
|
||||
async function getScripts() {
|
||||
const filePaths = (await fs.readdir(jsonDir))
|
||||
.filter((fileName) =>
|
||||
fileName.endsWith(".json") &&
|
||||
fileName !== metadataFileName &&
|
||||
fileName !== versionFileName
|
||||
.filter(fileName =>
|
||||
fileName.endsWith(".json")
|
||||
&& fileName !== metadataFileName
|
||||
&& fileName !== versionFileName,
|
||||
)
|
||||
.map((fileName) => path.resolve(jsonDir, fileName));
|
||||
.map(fileName => path.resolve(jsonDir, fileName));
|
||||
|
||||
const scripts = await Promise.all(
|
||||
filePaths.map(async (filePath) => {
|
||||
|
@ -34,7 +35,7 @@ const getScripts = async () => {
|
|||
}),
|
||||
);
|
||||
return scripts;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
@ -43,7 +44,7 @@ export async function GET() {
|
|||
|
||||
const categories = metadata.categories
|
||||
.map((category) => {
|
||||
category.scripts = scripts.filter((script) =>
|
||||
category.scripts = scripts.filter(script =>
|
||||
script.categories?.includes(category.id),
|
||||
);
|
||||
return category;
|
||||
|
@ -51,7 +52,8 @@ export async function GET() {
|
|||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
return NextResponse.json(categories);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error as Error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch categories" },
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { AppVersion } from "@/lib/types";
|
||||
import { error } from "console";
|
||||
import { promises as fs } from "fs";
|
||||
// import Error from "next/error";
|
||||
import { NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AppVersion } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
|
@ -11,33 +11,32 @@ const jsonDir = "public/json";
|
|||
const versionsFileName = "versions.json";
|
||||
const encoding = "utf-8";
|
||||
|
||||
const getVersions = async () => {
|
||||
async function getVersions() {
|
||||
const filePath = path.resolve(jsonDir, versionsFileName);
|
||||
const fileContent = await fs.readFile(filePath, encoding);
|
||||
const versions: AppVersion[] = JSON.parse(fileContent);
|
||||
|
||||
const modifiedVersions = versions.map(version => {
|
||||
const modifiedVersions = versions.map((version) => {
|
||||
let newName = version.name;
|
||||
newName = newName.toLowerCase().replace(/[^a-z0-9/]/g, '');
|
||||
newName = newName.toLowerCase().replace(/[^a-z0-9/]/g, "");
|
||||
return { ...version, name: newName, date: new Date(version.date) };
|
||||
});
|
||||
|
||||
return modifiedVersions;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
const versions = await getVersions();
|
||||
return NextResponse.json(versions);
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
const err = error as globalThis.Error;
|
||||
return NextResponse.json({
|
||||
name: err.name,
|
||||
message: err.message || "An unexpected error occurred",
|
||||
version: "No version found - Error"
|
||||
version: "No version found - Error",
|
||||
}, {
|
||||
status: 500,
|
||||
});
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Category } from "@/lib/types";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { Category } from "@/lib/types";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const defaultLogo = "/default-logo.png"; // Fallback logo path
|
||||
const MAX_DESCRIPTION_LENGTH = 100; // Set max length for description
|
||||
const MAX_LOGOS = 5; // Max logos to display at once
|
||||
|
||||
const formattedBadge = (type: string) => {
|
||||
function formattedBadge(type: string) {
|
||||
switch (type) {
|
||||
case "vm":
|
||||
return <Badge className="text-blue-500/75 border-blue-500/75 badge">VM</Badge>;
|
||||
|
@ -24,9 +26,9 @@ const formattedBadge = (type: string) => {
|
|||
return <Badge className="text-green-500/75 border-green-500/75 badge">ADDON</Badge>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
const CategoryView = () => {
|
||||
function CategoryView() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [selectedCategoryIndex, setSelectedCategoryIndex] = useState<number | null>(null);
|
||||
const [currentScripts, setCurrentScripts] = useState<any[]>([]);
|
||||
|
@ -36,6 +38,7 @@ const CategoryView = () => {
|
|||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
const basePath = process.env.NODE_ENV === "production" ? "/ProxmoxVE" : "";
|
||||
const response = await fetch(`${basePath}/api/categories`);
|
||||
if (!response.ok) {
|
||||
|
@ -50,7 +53,8 @@ const CategoryView = () => {
|
|||
initialLogoIndices[category.name] = 0;
|
||||
});
|
||||
setLogoIndices(initialLogoIndices);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error fetching categories:", error);
|
||||
}
|
||||
};
|
||||
|
@ -74,8 +78,8 @@ const CategoryView = () => {
|
|||
|
||||
const navigateCategory = (direction: "prev" | "next") => {
|
||||
if (selectedCategoryIndex !== null) {
|
||||
const newIndex =
|
||||
direction === "prev"
|
||||
const newIndex
|
||||
= direction === "prev"
|
||||
? (selectedCategoryIndex - 1 + categories.length) % categories.length
|
||||
: (selectedCategoryIndex + 1) % categories.length;
|
||||
setSelectedCategoryIndex(newIndex);
|
||||
|
@ -86,12 +90,13 @@ const CategoryView = () => {
|
|||
const switchLogos = (categoryName: string, direction: "prev" | "next") => {
|
||||
setLogoIndices((prev) => {
|
||||
const currentIndex = prev[categoryName] || 0;
|
||||
const category = categories.find((cat) => cat.name === categoryName);
|
||||
if (!category || !category.scripts) return prev;
|
||||
const category = categories.find(cat => cat.name === categoryName);
|
||||
if (!category || !category.scripts)
|
||||
return prev;
|
||||
|
||||
const totalLogos = category.scripts.length;
|
||||
const newIndex =
|
||||
direction === "prev"
|
||||
const newIndex
|
||||
= direction === "prev"
|
||||
? (currentIndex - MAX_LOGOS + totalLogos) % totalLogos
|
||||
: (currentIndex + MAX_LOGOS) % totalLogos;
|
||||
|
||||
|
@ -109,35 +114,49 @@ const CategoryView = () => {
|
|||
const hdd = script.install_methods[0]?.resources.hdd;
|
||||
|
||||
const resourceParts = [];
|
||||
if (cpu)
|
||||
if (cpu) {
|
||||
resourceParts.push(
|
||||
<span key="cpu">
|
||||
<b>CPU:</b> {cpu}vCPU
|
||||
<b>CPU:</b>
|
||||
{" "}
|
||||
{cpu}
|
||||
vCPU
|
||||
</span>,
|
||||
);
|
||||
if (ram)
|
||||
}
|
||||
if (ram) {
|
||||
resourceParts.push(
|
||||
<span key="ram">
|
||||
<b>RAM:</b> {ram}MB
|
||||
<b>RAM:</b>
|
||||
{" "}
|
||||
{ram}
|
||||
MB
|
||||
</span>,
|
||||
);
|
||||
if (hdd)
|
||||
}
|
||||
if (hdd) {
|
||||
resourceParts.push(
|
||||
<span key="hdd">
|
||||
<b>HDD:</b> {hdd}GB
|
||||
<b>HDD:</b>
|
||||
{" "}
|
||||
{hdd}
|
||||
GB
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
return resourceParts.length > 0 ? (
|
||||
<div className="text-sm text-gray-400">
|
||||
{resourceParts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{part}
|
||||
{index < resourceParts.length - 1 && " | "}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
return resourceParts.length > 0
|
||||
? (
|
||||
<div className="text-sm text-gray-400">
|
||||
{resourceParts.map((part, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{part}
|
||||
{index < resourceParts.length - 1 && " | "}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -145,145 +164,151 @@ const CategoryView = () => {
|
|||
{categories.length === 0 && (
|
||||
<p className="text-center text-gray-500">No categories available. Please check the API endpoint.</p>
|
||||
)}
|
||||
{selectedCategoryIndex !== null ? (
|
||||
<div>
|
||||
{/* Header with Navigation */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigateCategory("prev")}
|
||||
className="p-2 transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
<h2 className="text-3xl font-semibold transition-opacity duration-300 hover:opacity-90">
|
||||
{categories[selectedCategoryIndex].name}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigateCategory("next")}
|
||||
className="p-2 transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scripts Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{currentScripts
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((script) => (
|
||||
<Card
|
||||
key={script.name}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow duration-300"
|
||||
onClick={() => handleScriptClick(script.slug)}
|
||||
{selectedCategoryIndex !== null
|
||||
? (
|
||||
<div>
|
||||
{/* Header with Navigation */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigateCategory("prev")}
|
||||
className="p-2 transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<h3 className="text-lg font-bold script-text text-center hover:text-blue-600 transition-colors duration-300">
|
||||
{script.name}
|
||||
</h3>
|
||||
<img
|
||||
src={script.logo || defaultLogo}
|
||||
alt={script.name || "Script logo"}
|
||||
className="h-12 w-12 object-contain mx-auto"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
<b>Created at:</b> {script.date_created || "No date available"}
|
||||
</p>
|
||||
<p
|
||||
className="text-sm text-gray-700 hover:text-gray-900 text-center transition-colors duration-300"
|
||||
title={script.description || "No description available."}
|
||||
>
|
||||
{truncateDescription(script.description || "No description available.")}
|
||||
</p>
|
||||
{renderResources(script)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
<h2 className="text-3xl font-semibold transition-opacity duration-300 hover:opacity-90">
|
||||
{categories[selectedCategoryIndex].name}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigateCategory("next")}
|
||||
className="p-2 transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Back to Categories Button */}
|
||||
<div className="mt-8 text-center">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleBackClick}
|
||||
className="px-6 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-md transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
Back to Categories
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Categories Grid */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-semibold mb-4">Categories</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{categories.reduce((total, category) => total + (category.scripts?.length || 0), 0)} Total scripts
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
|
||||
{categories.map((category, index) => (
|
||||
<Card
|
||||
key={category.name}
|
||||
onClick={() => handleCategoryClick(index)}
|
||||
className="cursor-pointer hover:shadow-lg flex flex-col items-center justify-center py-6 transition-shadow duration-300"
|
||||
>
|
||||
<CardContent className="flex flex-col items-center">
|
||||
<h3 className="text-xl font-bold mb-4 category-title transition-colors duration-300 hover:text-blue-600">
|
||||
{category.name}
|
||||
</h3>
|
||||
<div className="flex justify-center items-center gap-2 mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
switchLogos(category.name, "prev");
|
||||
}}
|
||||
className="p-1 transition-transform duration-300 hover:scale-110"
|
||||
{/* Scripts Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{currentScripts
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(script => (
|
||||
<Card
|
||||
key={script.name}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow duration-300"
|
||||
onClick={() => handleScriptClick(script.slug)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{category.scripts &&
|
||||
category.scripts
|
||||
.slice(logoIndices[category.name] || 0, (logoIndices[category.name] || 0) + MAX_LOGOS)
|
||||
.map((script, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<img
|
||||
src={script.logo || defaultLogo}
|
||||
alt={script.name || "Script logo"}
|
||||
title={script.name}
|
||||
className="h-8 w-8 object-contain cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleScriptClick(script.slug);
|
||||
}}
|
||||
/>
|
||||
{formattedBadge(script.type)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
switchLogos(category.name, "next");
|
||||
}}
|
||||
className="p-1 transition-transform duration-300 hover:scale-110"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 text-center">
|
||||
{(category as any).description || "No description available."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<h3 className="text-lg font-bold script-text text-center hover:text-blue-600 transition-colors duration-300">
|
||||
{script.name}
|
||||
</h3>
|
||||
<img
|
||||
src={script.logo || defaultLogo}
|
||||
alt={script.name || "Script logo"}
|
||||
className="h-12 w-12 object-contain mx-auto"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
<b>Created at:</b>
|
||||
{" "}
|
||||
{script.date_created || "No date available"}
|
||||
</p>
|
||||
<p
|
||||
className="text-sm text-gray-700 hover:text-gray-900 text-center transition-colors duration-300"
|
||||
title={script.description || "No description available."}
|
||||
>
|
||||
{truncateDescription(script.description || "No description available.")}
|
||||
</p>
|
||||
{renderResources(script)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Back to Categories Button */}
|
||||
<div className="mt-8 text-center">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleBackClick}
|
||||
className="px-6 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-md transition-transform duration-300 hover:scale-105"
|
||||
>
|
||||
Back to Categories
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{/* Categories Grid */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-semibold mb-4">Categories</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{categories.reduce((total, category) => total + (category.scripts?.length || 0), 0)}
|
||||
{" "}
|
||||
Total scripts
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
|
||||
{categories.map((category, index) => (
|
||||
<Card
|
||||
key={category.name}
|
||||
onClick={() => handleCategoryClick(index)}
|
||||
className="cursor-pointer hover:shadow-lg flex flex-col items-center justify-center py-6 transition-shadow duration-300"
|
||||
>
|
||||
<CardContent className="flex flex-col items-center">
|
||||
<h3 className="text-xl font-bold mb-4 category-title transition-colors duration-300 hover:text-blue-600">
|
||||
{category.name}
|
||||
</h3>
|
||||
<div className="flex justify-center items-center gap-2 mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
switchLogos(category.name, "prev");
|
||||
}}
|
||||
className="p-1 transition-transform duration-300 hover:scale-110"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{category.scripts
|
||||
&& category.scripts
|
||||
.slice(logoIndices[category.name] || 0, (logoIndices[category.name] || 0) + MAX_LOGOS)
|
||||
.map((script, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<img
|
||||
src={script.logo || defaultLogo}
|
||||
alt={script.name || "Script logo"}
|
||||
title={script.name}
|
||||
className="h-8 w-8 object-contain cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleScriptClick(script.slug);
|
||||
}}
|
||||
/>
|
||||
{formattedBadge(script.type)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
switchLogos(category.name, "next");
|
||||
}}
|
||||
className="p-1 transition-transform duration-300 hover:scale-110"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 text-center">
|
||||
{(category as any).description || "No description available."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default CategoryView;
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import React, { JSX, useEffect, useState } from "react";
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import ApplicationChart from "../../components/ApplicationChart";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
|
||||
interface DataModel {
|
||||
import ApplicationChart from "../../components/application-chart";
|
||||
|
||||
type DataModel = {
|
||||
id: number;
|
||||
ct_type: number;
|
||||
disk_size: number;
|
||||
|
@ -22,13 +22,13 @@ interface DataModel {
|
|||
error: string;
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
};
|
||||
|
||||
interface SummaryData {
|
||||
type SummaryData = {
|
||||
total_entries: number;
|
||||
status_count: Record<string, number>;
|
||||
nsapp_count: Record<string, number>;
|
||||
}
|
||||
};
|
||||
|
||||
const DataFetcher: React.FC = () => {
|
||||
const [data, setData] = useState<DataModel[]>([]);
|
||||
|
@ -37,16 +37,18 @@ const DataFetcher: React.FC = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'ascending' | 'descending' } | null>(null);
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "ascending" | "descending" } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const response = await fetch("https://api.htl-braunau.at/data/summary");
|
||||
if (!response.ok) throw new Error(`Failed to fetch summary: ${response.statusText}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch summary: ${response.statusText}`);
|
||||
const result: SummaryData = await response.json();
|
||||
setSummary(result);
|
||||
} catch (err) {
|
||||
}
|
||||
catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
@ -58,13 +60,16 @@ const DataFetcher: React.FC = () => {
|
|||
const fetchPaginatedData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage === 0 ? '' : itemsPerPage}`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch data: ${response.statusText}`);
|
||||
const response = await fetch(`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage === 0 ? "" : itemsPerPage}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch data: ${response.statusText}`);
|
||||
const result: DataModel[] = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
}
|
||||
catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
@ -73,26 +78,35 @@ const DataFetcher: React.FC = () => {
|
|||
}, [currentPage, itemsPerPage]);
|
||||
|
||||
const sortedData = React.useMemo(() => {
|
||||
if (!sortConfig) return data;
|
||||
if (!sortConfig)
|
||||
return data;
|
||||
const sorted = [...data].sort((a, b) => {
|
||||
if (a[sortConfig.key] < b[sortConfig.key]) {
|
||||
return sortConfig.direction === 'ascending' ? -1 : 1;
|
||||
return sortConfig.direction === "ascending" ? -1 : 1;
|
||||
}
|
||||
if (a[sortConfig.key] > b[sortConfig.key]) {
|
||||
return sortConfig.direction === 'ascending' ? 1 : -1;
|
||||
return sortConfig.direction === "ascending" ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}, [data, sortConfig]);
|
||||
|
||||
if (loading) return <p>Loading...</p>;
|
||||
if (error) return <p>Error: {error}</p>;
|
||||
if (loading)
|
||||
return <p>Loading...</p>;
|
||||
if (error) {
|
||||
return (
|
||||
<p>
|
||||
Error:
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const requestSort = (key: string) => {
|
||||
let direction: 'ascending' | 'descending' = 'ascending';
|
||||
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'ascending') {
|
||||
direction = 'descending';
|
||||
let direction: "ascending" | "descending" = "ascending";
|
||||
if (sortConfig && sortConfig.key === key && sortConfig.direction === "ascending") {
|
||||
direction = "descending";
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
@ -102,8 +116,8 @@ const DataFetcher: React.FC = () => {
|
|||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const timezoneOffset = dateString.slice(-6);
|
||||
return `${day}.${month}.${year} ${hours}:${minutes} ${timezoneOffset} GMT`;
|
||||
};
|
||||
|
@ -114,49 +128,76 @@ const DataFetcher: React.FC = () => {
|
|||
<ApplicationChart data={summary} />
|
||||
<p className="text-lg font-bold mt-4"> </p>
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<p className="text-lg font-bold">{summary?.total_entries} results found</p>
|
||||
<p className="text-lg font">Status Legend: 🔄 installing {summary?.status_count["installing"] ?? 0} | ✔️ completed {summary?.status_count["done"] ?? 0} | ❌ failed {summary?.status_count["failed"] ?? 0} | ❓ unknown</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold">
|
||||
{summary?.total_entries}
|
||||
{" "}
|
||||
results found
|
||||
</p>
|
||||
<p className="text-lg font">
|
||||
Status Legend: 🔄 installing
|
||||
{summary?.status_count.installing ?? 0}
|
||||
{" "}
|
||||
| ✔️ completed
|
||||
{summary?.status_count.done ?? 0}
|
||||
{" "}
|
||||
| ❌ failed
|
||||
{summary?.status_count.failed ?? 0}
|
||||
{" "}
|
||||
| ❓ unknown
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-y-auto lg:overflow-y-visible">
|
||||
<table className="min-w-full table-auto border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('status')}>Status</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('type')}>Type</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('nsapp')}>Application</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('os_type')}>OS</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('os_version')}>OS Version</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('disk_size')}>Disk Size</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('core_count')}>Core Count</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('ram_size')}>RAM Size</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('method')}>Method</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('pve_version')}>PVE Version</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('error')}>Error Message</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('created_at')}>Created At</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("status")}>Status</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("type")}>Type</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("nsapp")}>Application</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("os_type")}>OS</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("os_version")}>OS Version</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("disk_size")}>Disk Size</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("core_count")}>Core Count</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("ram_size")}>RAM Size</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("method")}>Method</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("pve_version")}>PVE Version</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("error")}>Error Message</th>
|
||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("created_at")}>Created At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedData.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-4 py-2 border-b">
|
||||
{item.status === "done" ? (
|
||||
"✔️"
|
||||
) : item.status === "failed" ? (
|
||||
"❌"
|
||||
) : item.status === "installing" ? (
|
||||
"🔄"
|
||||
) : (
|
||||
item.status
|
||||
)}
|
||||
{item.status === "done"
|
||||
? (
|
||||
"✔️"
|
||||
)
|
||||
: item.status === "failed"
|
||||
? (
|
||||
"❌"
|
||||
)
|
||||
: item.status === "installing"
|
||||
? (
|
||||
"🔄"
|
||||
)
|
||||
: (
|
||||
item.status
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 border-b">
|
||||
{item.type === "lxc"
|
||||
? (
|
||||
"📦"
|
||||
)
|
||||
: item.type === "vm"
|
||||
? (
|
||||
"🖥️"
|
||||
)
|
||||
: (
|
||||
item.type
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 border-b">{item.type === "lxc" ? (
|
||||
"📦"
|
||||
) : item.type === "vm" ? (
|
||||
"🖥️"
|
||||
) : (
|
||||
item.type
|
||||
)}</td>
|
||||
<td className="px-4 py-2 border-b">{item.nsapp}</td>
|
||||
<td className="px-4 py-2 border-b">{item.os_type}</td>
|
||||
<td className="px-4 py-2 border-b">{item.os_version}</td>
|
||||
|
@ -175,11 +216,14 @@ const DataFetcher: React.FC = () => {
|
|||
</div>
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border">Previous</button>
|
||||
<span>Page {currentPage}</span>
|
||||
<span>
|
||||
Page
|
||||
{currentPage}
|
||||
</span>
|
||||
<button onClick={() => setCurrentPage(prev => prev + 1)} className="p-2 border">Next</button>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => setItemsPerPage(Number(e.target.value))}
|
||||
onChange={e => setItemsPerPage(Number(e.target.value))}
|
||||
className="p-2 border"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
|
|
|
@ -1,150 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AlertColors } from "@/config/siteConfig";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlusCircle, Trash2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { ScriptSchema, type Script } from "../_schemas/schemas";
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
|
||||
type NoteProps = {
|
||||
script: Script;
|
||||
setScript: (script: Script) => void;
|
||||
setIsValid: (isValid: boolean) => void;
|
||||
setZodErrors: (zodErrors: z.ZodError | null) => void;
|
||||
};
|
||||
|
||||
function Note({
|
||||
script,
|
||||
setScript,
|
||||
setIsValid,
|
||||
setZodErrors,
|
||||
}: NoteProps) {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
const addNote = useCallback(() => {
|
||||
setScript({
|
||||
...script,
|
||||
notes: [...script.notes, { text: "", type: "" }],
|
||||
});
|
||||
}, [script, setScript]);
|
||||
|
||||
const updateNote = useCallback((
|
||||
index: number,
|
||||
key: keyof Script["notes"][number],
|
||||
value: string,
|
||||
) => {
|
||||
const updated: Script = {
|
||||
...script,
|
||||
notes: script.notes.map((note, i) =>
|
||||
i === index ? { ...note, [key]: value } : note,
|
||||
),
|
||||
};
|
||||
const result = ScriptSchema.safeParse(updated);
|
||||
setIsValid(result.success);
|
||||
setZodErrors(result.success ? null : result.error);
|
||||
setScript(updated);
|
||||
// Restore focus after state update
|
||||
if (key === "text") {
|
||||
setTimeout(() => {
|
||||
inputRefs.current[index]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [script, setScript, setIsValid, setZodErrors]);
|
||||
|
||||
const removeNote = useCallback((index: number) => {
|
||||
setScript({
|
||||
...script,
|
||||
notes: script.notes.filter((_, i) => i !== index),
|
||||
});
|
||||
}, [script, setScript]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-xl font-semibold">Notes</h3>
|
||||
{script.notes.map((note, index) => (
|
||||
<NoteItem key={index} note={note} index={index} updateNote={updateNote} removeNote={removeNote} />
|
||||
))}
|
||||
<Button type="button" size="sm" onClick={addNote}>
|
||||
<PlusCircle className="mr-2 h-4 w-4" /> Add Note
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const NoteItem = memo(
|
||||
({
|
||||
note,
|
||||
index,
|
||||
updateNote,
|
||||
removeNote,
|
||||
}: {
|
||||
note: Script["notes"][number];
|
||||
index: number;
|
||||
updateNote: (index: number, key: keyof Script["notes"][number], value: string) => void;
|
||||
removeNote: (index: number) => void;
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateNote(index, "text", e.target.value);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}, [index, updateNote]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border p-4 rounded">
|
||||
<Input
|
||||
placeholder="Note Text"
|
||||
value={note.text}
|
||||
onChange={handleTextChange}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Select
|
||||
value={note.type}
|
||||
onValueChange={(value) => updateNote(index, "type", value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(AlertColors).map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<span className="flex items-center gap-2">
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}{" "}
|
||||
<div
|
||||
className={cn(
|
||||
"size-4 rounded-full border",
|
||||
AlertColors[type as keyof typeof AlertColors],
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={() => removeNote(index)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Remove Note
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NoteItem.displayName = 'NoteItem';
|
||||
|
||||
|
||||
export default memo(Note);
|
|
@ -1,4 +1,9 @@
|
|||
import { Label } from "@/components/ui/label";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import type { Category } from "@/lib/types";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
@ -6,11 +11,10 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Category } from "@/lib/types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { type Script } from "../_schemas/schemas";
|
||||
import { memo } from "react";
|
||||
|
||||
import type { Script } from "../_schemas/schemas";
|
||||
|
||||
type CategoryProps = {
|
||||
script: Script;
|
||||
|
@ -20,10 +24,10 @@ type CategoryProps = {
|
|||
categories: Category[];
|
||||
};
|
||||
|
||||
const CategoryTag = memo(({
|
||||
category,
|
||||
onRemove
|
||||
}: {
|
||||
const CategoryTag = memo(({
|
||||
category,
|
||||
onRemove,
|
||||
}: {
|
||||
category: Category;
|
||||
onRemove: () => void;
|
||||
}) => (
|
||||
|
@ -53,7 +57,7 @@ const CategoryTag = memo(({
|
|||
</span>
|
||||
));
|
||||
|
||||
CategoryTag.displayName = 'CategoryTag';
|
||||
CategoryTag.displayName = "CategoryTag";
|
||||
|
||||
function Categories({
|
||||
script,
|
||||
|
@ -79,14 +83,16 @@ function Categories({
|
|||
return (
|
||||
<div>
|
||||
<Label>
|
||||
Category <span className="text-red-500">*</span>
|
||||
Category
|
||||
{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select onValueChange={(value) => addCategory(Number(value))}>
|
||||
<Select onValueChange={value => addCategory(Number(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
{categories.map(category => (
|
||||
<SelectItem key={category.id} value={category.id.toString()}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
|
@ -101,13 +107,15 @@ function Categories({
|
|||
>
|
||||
{script.categories.map((categoryId) => {
|
||||
const category = categoryMap.get(categoryId);
|
||||
return category ? (
|
||||
<CategoryTag
|
||||
key={categoryId}
|
||||
category={category}
|
||||
onRemove={() => removeCategory(categoryId)}
|
||||
/>
|
||||
) : null;
|
||||
return category
|
||||
? (
|
||||
<CategoryTag
|
||||
key={categoryId}
|
||||
category={category}
|
||||
onRemove={() => removeCategory(categoryId)}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
|
@ -1,11 +1,16 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { OperatingSystems } from "@/config/siteConfig";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { PlusCircle, Trash2 } from "lucide-react";
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
import { z } from "zod";
|
||||
import { InstallMethodSchema, ScriptSchema, type Script } from "../_schemas/schemas";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { OperatingSystems } from "@/config/site-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import type { Script } from "../_schemas/schemas";
|
||||
|
||||
import { InstallMethodSchema, ScriptSchema } from "../_schemas/schemas";
|
||||
|
||||
type InstallMethodProps = {
|
||||
script: Script;
|
||||
|
@ -28,9 +33,11 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
|
||||
if (type === "pve") {
|
||||
scriptPath = `tools/pve/${slug}.sh`;
|
||||
} else if (type === "addon") {
|
||||
}
|
||||
else if (type === "addon") {
|
||||
scriptPath = `tools/addon/${slug}.sh`;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
scriptPath = `${type}/${slug}.sh`;
|
||||
}
|
||||
|
||||
|
@ -65,8 +72,8 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
const updatedMethod = { ...method, [key]: value };
|
||||
|
||||
if (key === "type") {
|
||||
updatedMethod.script =
|
||||
value === "alpine" ? `${prev.type}/alpine-${prev.slug}.sh` : `${prev.type}/${prev.slug}.sh`;
|
||||
updatedMethod.script
|
||||
= value === "alpine" ? `${prev.type}/alpine-${prev.slug}.sh` : `${prev.type}/${prev.slug}.sh`;
|
||||
|
||||
// Set OS to Alpine and reset version if type is alpine
|
||||
if (value === "alpine") {
|
||||
|
@ -89,7 +96,8 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
setIsValid(result.success);
|
||||
if (!result.success) {
|
||||
setZodErrors(result.error);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
setZodErrors(null);
|
||||
}
|
||||
return updated;
|
||||
|
@ -100,7 +108,7 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
|
||||
const removeInstallMethod = useCallback(
|
||||
(index: number) => {
|
||||
setScript((prev) => ({
|
||||
setScript(prev => ({
|
||||
...prev,
|
||||
install_methods: prev.install_methods.filter((_, i) => i !== index),
|
||||
}));
|
||||
|
@ -113,7 +121,7 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
<h3 className="text-xl font-semibold">Install Methods</h3>
|
||||
{script.install_methods.map((method, index) => (
|
||||
<div key={index} className="space-y-2 border p-4 rounded">
|
||||
<Select value={method.type} onValueChange={(value) => updateInstallMethod(index, "type", value)}>
|
||||
<Select value={method.type} onValueChange={value => updateInstallMethod(index, "type", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
|
@ -130,12 +138,11 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
placeholder="CPU in Cores"
|
||||
type="number"
|
||||
value={method.resources.cpu || ""}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
cpu: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
|
@ -144,12 +151,11 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
placeholder="RAM in MB"
|
||||
type="number"
|
||||
value={method.resources.ram || ""}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
ram: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
|
@ -158,31 +164,29 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
placeholder="HDD in GB"
|
||||
type="number"
|
||||
value={method.resources.hdd || ""}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
hdd: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={method.resources.os || undefined}
|
||||
onValueChange={(value) =>
|
||||
onValueChange={value =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
os: value || null,
|
||||
version: null, // Reset version when OS changes
|
||||
})
|
||||
}
|
||||
})}
|
||||
disabled={method.type === "alpine"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="OS" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OperatingSystems.map((os) => (
|
||||
{OperatingSystems.map(os => (
|
||||
<SelectItem key={os.name} value={os.name}>
|
||||
{os.name}
|
||||
</SelectItem>
|
||||
|
@ -191,19 +195,18 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
</Select>
|
||||
<Select
|
||||
value={method.resources.version || undefined}
|
||||
onValueChange={(value) =>
|
||||
onValueChange={value =>
|
||||
updateInstallMethod(index, "resources", {
|
||||
...method.resources,
|
||||
version: value || null,
|
||||
})
|
||||
}
|
||||
})}
|
||||
disabled={method.type === "alpine"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OperatingSystems.find((os) => os.name === method.resources.os)?.versions.map((version) => (
|
||||
{OperatingSystems.find(os => os.name === method.resources.os)?.versions.map(version => (
|
||||
<SelectItem key={version.slug} value={version.name}>
|
||||
{version.name}
|
||||
</SelectItem>
|
||||
|
@ -212,12 +215,16 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
|
|||
</Select>
|
||||
</div>
|
||||
<Button variant="destructive" size="sm" type="button" onClick={() => removeInstallMethod(index)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Remove Install Method
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{" "}
|
||||
Remove Install Method
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" size="sm" disabled={script.install_methods.length >= 2} onClick={addInstallMethod}>
|
||||
<PlusCircle className="mr-2 h-4 w-4" /> Add Install Method
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
{" "}
|
||||
Add Install Method
|
||||
</Button>
|
||||
</>
|
||||
);
|
159
frontend/src/app/json-editor/_components/note.tsx
Normal file
159
frontend/src/app/json-editor/_components/note.tsx
Normal file
|
@ -0,0 +1,159 @@
|
|||
import type { z } from "zod";
|
||||
|
||||
import { PlusCircle, Trash2 } from "lucide-react";
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AlertColors } from "@/config/site-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { Script } from "../_schemas/schemas";
|
||||
|
||||
import { ScriptSchema } from "../_schemas/schemas";
|
||||
|
||||
const NoteItem = memo(
|
||||
({
|
||||
note,
|
||||
index,
|
||||
updateNote,
|
||||
removeNote,
|
||||
}: {
|
||||
note: Script["notes"][number];
|
||||
index: number;
|
||||
updateNote: (index: number, key: keyof Script["notes"][number], value: string) => void;
|
||||
removeNote: (index: number) => void;
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateNote(index, "text", e.target.value);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}, [index, updateNote]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border p-4 rounded">
|
||||
<Input
|
||||
placeholder="Note Text"
|
||||
value={note.text}
|
||||
onChange={handleTextChange}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Select
|
||||
value={note.type}
|
||||
onValueChange={value => updateNote(index, "type", value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(AlertColors).map(type => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<span className="flex items-center gap-2">
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
{" "}
|
||||
<div
|
||||
className={cn(
|
||||
"size-4 rounded-full border",
|
||||
AlertColors[type as keyof typeof AlertColors],
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={() => removeNote(index)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{" "}
|
||||
Remove Note
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type NoteProps = {
|
||||
script: Script;
|
||||
setScript: (script: Script) => void;
|
||||
setIsValid: (isValid: boolean) => void;
|
||||
setZodErrors: (zodErrors: z.ZodError | null) => void;
|
||||
};
|
||||
|
||||
function Note({
|
||||
script,
|
||||
setScript,
|
||||
setIsValid,
|
||||
setZodErrors,
|
||||
}: NoteProps) {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
const addNote = useCallback(() => {
|
||||
setScript({
|
||||
...script,
|
||||
notes: [...script.notes, { text: "", type: "" }],
|
||||
});
|
||||
}, [script, setScript]);
|
||||
|
||||
const updateNote = useCallback((
|
||||
index: number,
|
||||
key: keyof Script["notes"][number],
|
||||
value: string,
|
||||
) => {
|
||||
const updated: Script = {
|
||||
...script,
|
||||
notes: script.notes.map((note, i) =>
|
||||
i === index ? { ...note, [key]: value } : note,
|
||||
),
|
||||
};
|
||||
const result = ScriptSchema.safeParse(updated);
|
||||
setIsValid(result.success);
|
||||
setZodErrors(result.success ? null : result.error);
|
||||
setScript(updated);
|
||||
// Restore focus after state update
|
||||
if (key === "text") {
|
||||
setTimeout(() => {
|
||||
inputRefs.current[index]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [script, setScript, setIsValid, setZodErrors]);
|
||||
|
||||
const removeNote = useCallback((index: number) => {
|
||||
setScript({
|
||||
...script,
|
||||
notes: script.notes.filter((_, i) => i !== index),
|
||||
});
|
||||
}, [script, setScript]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-xl font-semibold">Notes</h3>
|
||||
{script.notes.map((note, index) => (
|
||||
<NoteItem key={index} note={note} index={index} updateNote={updateNote} removeNote={removeNote} />
|
||||
))}
|
||||
<Button type="button" size="sm" onClick={addNote}>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
{" "}
|
||||
Add Note
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
NoteItem.displayName = "NoteItem";
|
||||
|
||||
export default memo(Note);
|
|
@ -2,7 +2,7 @@ import { z } from "zod";
|
|||
|
||||
export const InstallMethodSchema = z.object({
|
||||
type: z.enum(["default", "alpine"], {
|
||||
errorMap: () => ({ message: "Type must be either 'default' or 'alpine'" })
|
||||
errorMap: () => ({ message: "Type must be either 'default' or 'alpine'" }),
|
||||
}),
|
||||
script: z.string().min(1, "Script content cannot be empty"),
|
||||
resources: z.object({
|
||||
|
@ -25,7 +25,7 @@ export const ScriptSchema = z.object({
|
|||
categories: z.array(z.number()),
|
||||
date_created: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").min(1, "Date is required"),
|
||||
type: z.enum(["vm", "ct", "pve", "addon", "turnkey"], {
|
||||
errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'pve', 'addon' or 'turnkey'" })
|
||||
errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'pve', 'addon' or 'turnkey'" }),
|
||||
}),
|
||||
updateable: z.boolean(),
|
||||
privileged: z.boolean(),
|
||||
|
|
|
@ -1,26 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { fetchCategories } from "@/lib/data";
|
||||
import { Category } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { format } from "date-fns";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { CalendarIcon, Check, Clipboard, Download } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Categories from "./_components/Categories";
|
||||
import InstallMethod from "./_components/InstallMethod";
|
||||
import Note from "./_components/Note";
|
||||
import { ScriptSchema, type Script } from "./_schemas/schemas";
|
||||
|
||||
import type { Category } from "@/lib/types";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { fetchCategories } from "@/lib/data";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { Script } from "./_schemas/schemas";
|
||||
|
||||
import InstallMethod from "./_components/install-method";
|
||||
import { ScriptSchema } from "./_schemas/schemas";
|
||||
import Categories from "./_components/categories";
|
||||
import Note from "./_components/note";
|
||||
|
||||
const initialScript: Script = {
|
||||
name: "",
|
||||
|
@ -54,7 +60,7 @@ export default function JSONGenerator() {
|
|||
useEffect(() => {
|
||||
fetchCategories()
|
||||
.then(setCategories)
|
||||
.catch((error) => console.error("Error fetching categories:", error));
|
||||
.catch(error => console.error("Error fetching categories:", error));
|
||||
}, []);
|
||||
|
||||
const updateScript = useCallback((key: keyof Script, value: Script[keyof Script]) => {
|
||||
|
@ -67,11 +73,14 @@ export default function JSONGenerator() {
|
|||
|
||||
if (updated.type === "pve") {
|
||||
scriptPath = `tools/pve/${updated.slug}.sh`;
|
||||
} else if (updated.type === "addon") {
|
||||
}
|
||||
else if (updated.type === "addon") {
|
||||
scriptPath = `tools/addon/${updated.slug}.sh`;
|
||||
} else if (method.type === "alpine") {
|
||||
}
|
||||
else if (method.type === "alpine") {
|
||||
scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
scriptPath = `${updated.type}/${updated.slug}.sh`;
|
||||
}
|
||||
|
||||
|
@ -136,7 +145,10 @@ export default function JSONGenerator() {
|
|||
<div className="mt-2 space-y-1">
|
||||
{zodErrors.errors.map((error, index) => (
|
||||
<AlertDescription key={index} className="p-1 text-red-500">
|
||||
{error.path.join(".")} - {error.message}
|
||||
{error.path.join(".")}
|
||||
{" "}
|
||||
-
|
||||
{error.message}
|
||||
</AlertDescription>
|
||||
))}
|
||||
</div>
|
||||
|
@ -154,25 +166,31 @@ export default function JSONGenerator() {
|
|||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>
|
||||
Name <span className="text-red-500">*</span>
|
||||
Name
|
||||
{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input placeholder="Example" value={script.name} onChange={(e) => updateScript("name", e.target.value)} />
|
||||
<Input placeholder="Example" value={script.name} onChange={e => updateScript("name", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Slug <span className="text-red-500">*</span>
|
||||
Slug
|
||||
{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input placeholder="example" value={script.slug} onChange={(e) => updateScript("slug", e.target.value)} />
|
||||
<Input placeholder="example" value={script.slug} onChange={e => updateScript("slug", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Logo <span className="text-red-500">*</span>
|
||||
Logo
|
||||
{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Full logo URL"
|
||||
value={script.logo || ""}
|
||||
onChange={(e) => updateScript("logo", e.target.value || null)}
|
||||
onChange={e => updateScript("logo", e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -180,17 +198,19 @@ export default function JSONGenerator() {
|
|||
<Input
|
||||
placeholder="Path to config file"
|
||||
value={script.config_path || ""}
|
||||
onChange={(e) => updateScript("config_path", e.target.value || null)}
|
||||
onChange={e => updateScript("config_path", e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Description <span className="text-red-500">*</span>
|
||||
Description
|
||||
{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder="Example"
|
||||
value={script.description}
|
||||
onChange={(e) => updateScript("description", e.target.value)}
|
||||
onChange={e => updateScript("description", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Categories script={script} setScript={setScript} categories={categories} />
|
||||
|
@ -200,7 +220,7 @@ export default function JSONGenerator() {
|
|||
<Popover>
|
||||
<PopoverTrigger asChild className="flex-1">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
variant="outline"
|
||||
className={cn("pl-3 text-left font-normal w-full", !script.date_created && "text-muted-foreground")}
|
||||
>
|
||||
{formattedDate || <span>Pick a date</span>}
|
||||
|
@ -219,7 +239,7 @@ export default function JSONGenerator() {
|
|||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Type</Label>
|
||||
<Select value={script.type} onValueChange={(value) => updateScript("type", value)}>
|
||||
<Select value={script.type} onValueChange={value => updateScript("type", value)}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
|
@ -234,11 +254,11 @@ export default function JSONGenerator() {
|
|||
</div>
|
||||
<div className="w-full flex gap-5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch checked={script.updateable} onCheckedChange={(checked) => updateScript("updateable", checked)} />
|
||||
<Switch checked={script.updateable} onCheckedChange={checked => updateScript("updateable", checked)} />
|
||||
<label>Updateable</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch checked={script.privileged} onCheckedChange={(checked) => updateScript("privileged", checked)} />
|
||||
<Switch checked={script.privileged} onCheckedChange={checked => updateScript("privileged", checked)} />
|
||||
<label>Privileged</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -246,18 +266,18 @@ export default function JSONGenerator() {
|
|||
placeholder="Interface Port"
|
||||
type="number"
|
||||
value={script.interface_port || ""}
|
||||
onChange={(e) => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
|
||||
onChange={e => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Website URL"
|
||||
value={script.website || ""}
|
||||
onChange={(e) => updateScript("website", e.target.value || null)}
|
||||
onChange={e => updateScript("website", e.target.value || null)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Documentation URL"
|
||||
value={script.documentation || ""}
|
||||
onChange={(e) => updateScript("documentation", e.target.value || null)}
|
||||
onChange={e => updateScript("documentation", e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
|
||||
|
@ -265,22 +285,20 @@ export default function JSONGenerator() {
|
|||
<Input
|
||||
placeholder="Username"
|
||||
value={script.default_credentials.username || ""}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
updateScript("default_credentials", {
|
||||
...script.default_credentials,
|
||||
username: e.target.value || null,
|
||||
})
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Password"
|
||||
value={script.default_credentials.password || ""}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
updateScript("default_credentials", {
|
||||
...script.default_credentials,
|
||||
password: e.target.value || null,
|
||||
})
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
|
||||
</form>
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import Footer from "@/components/Footer";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import QueryProvider from "@/components/query-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { analytics, basePath } from "@/config/siteConfig";
|
||||
import "@/styles/globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { Inter } from "next/font/google";
|
||||
import React from "react";
|
||||
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { analytics, basePath } from "@/config/site-config";
|
||||
import "@/styles/globals.css";
|
||||
import QueryProvider from "@/components/query-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import Footer from "@/components/footer";
|
||||
import Navbar from "@/components/navbar";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata : Metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: "Proxmox VE Helper-Scripts",
|
||||
description:
|
||||
"The official website for the Proxmox VE Helper-Scripts (Community) Repository. Featuring over 300+ scripts to help you manage your Proxmox VE environment.",
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { basePath } from "@/config/siteConfig";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export const generateStaticParams = () => {
|
||||
import { basePath } from "@/config/site-config";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [];
|
||||
};
|
||||
}
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
import FAQ from "@/components/FAQ";
|
||||
import AnimatedGradientText from "@/components/ui/animated-gradient-text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardFooter } from "@/components/ui/card";
|
||||
import { ArrowRightIcon, ExternalLink } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
@ -11,15 +13,14 @@ import {
|
|||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import Particles from "@/components/ui/particles";
|
||||
import AnimatedGradientText from "@/components/ui/animated-gradient-text";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { basePath } from "@/config/siteConfig";
|
||||
import { CardFooter } from "@/components/ui/card";
|
||||
import Particles from "@/components/ui/particles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { basePath } from "@/config/site-config";
|
||||
import FAQ from "@/components/faq";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowRightIcon, ExternalLink } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
|
||||
function CustomArrowRightIcon() {
|
||||
return <ArrowRightIcon className="h-4 w-4" width={1} />;
|
||||
|
@ -50,7 +51,9 @@ export default function Page() {
|
|||
`p-px ![mask-composite:subtract]`,
|
||||
)}
|
||||
/>
|
||||
❤️ <Separator className="mx-2 h-4" orientation="vertical" />
|
||||
❤️
|
||||
{" "}
|
||||
<Separator className="mx-2 h-4" orientation="vertical" />
|
||||
<span
|
||||
className={cn(
|
||||
`animate-gradient bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
|
||||
|
@ -78,7 +81,9 @@ export default function Page() {
|
|||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<FaGithub className="mr-2 h-4 w-4" /> Tteck's GitHub
|
||||
<FaGithub className="mr-2 h-4 w-4" />
|
||||
{" "}
|
||||
Tteck's GitHub
|
||||
</a>
|
||||
</Button>
|
||||
<Button className="w-full" asChild>
|
||||
|
@ -88,7 +93,9 @@ export default function Page() {
|
|||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" /> Proxmox Helper Scripts
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
{" "}
|
||||
Proxmox Helper Scripts
|
||||
</a>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
@ -104,7 +111,10 @@ export default function Page() {
|
|||
We are a community-driven initiative that simplifies the setup of Proxmox Virtual Environment (VE).
|
||||
</p>
|
||||
<p>
|
||||
With 300+ scripts to help you manage your <b>Proxmox VE environment</b>. Whether you're a seasoned
|
||||
With 300+ scripts to help you manage your
|
||||
{" "}
|
||||
<b>Proxmox VE environment</b>
|
||||
. Whether you're a seasoned
|
||||
user or a newcomer, we've got you covered.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { basePath } from "@/config/siteConfig";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
import { basePath } from "@/config/site-config";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
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";
|
||||
|
||||
export default function InterFaces({ item }: { item: Script }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{item.interface_port !== null ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<h2 className="mr-2 text-end text-lg font-semibold">Default Interface:</h2>
|
||||
<span className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex items-center gap-2")}>
|
||||
{item.interface_port}
|
||||
<ClipboardIcon onClick={() => handleCopy("default interface", String(item.interface_port))} className="size-4 cursor-pointer" />
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,51 +1,84 @@
|
|||
import CodeCopyButton from "@/components/ui/code-copy-button";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import type { Script } from "@/lib/types";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Info } from "lucide-react";
|
||||
import { basePath } from "@/config/siteConfig";
|
||||
import { Script } from "@/lib/types";
|
||||
import { getDisplayValueFromType } from "../ScriptInfoBlocks";
|
||||
import CodeCopyButton from "@/components/ui/code-copy-button";
|
||||
import { basePath } from "@/config/site-config";
|
||||
|
||||
const getInstallCommand = (scriptPath = "", isAlpine = false, useGitea = false) => {
|
||||
import { getDisplayValueFromType } from "../script-info-blocks";
|
||||
|
||||
function getInstallCommand(scriptPath = "", isAlpine = false, useGitea = false) {
|
||||
const githubUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`;
|
||||
const giteaUrl = `https://git.community-scripts.org/community-scripts/${basePath}/raw/branch/main/${scriptPath}`;
|
||||
const url = useGitea ? giteaUrl : githubUrl;
|
||||
return `bash -c "$(curl -fsSL ${url})"`;
|
||||
};
|
||||
|
||||
return isAlpine ? `bash -c "$(curl -fsSL ${url})"` : `bash -c "$(curl -fsSL ${url})"`;
|
||||
}
|
||||
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 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 === "pve" ? (
|
||||
<>
|
||||
To use the {item.name} script, run the command below **only** in the Proxmox VE Shell. This script is
|
||||
intended for managing or enhancing the host system directly.
|
||||
</>
|
||||
) : item.type === "addon" ? (
|
||||
<>
|
||||
This script enhances an existing setup. You can use it inside a running LXC container or directly on the
|
||||
Proxmox VE host to extend functionality with {item.name}.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
To create a new Proxmox VE {item.name} {getDisplayValueFromType(item.type)}, run the command below in the
|
||||
Proxmox VE Shell.
|
||||
</>
|
||||
)}
|
||||
{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 === "pve"
|
||||
? (
|
||||
<>
|
||||
To use the
|
||||
{" "}
|
||||
{item.name}
|
||||
{" "}
|
||||
script, run the command below **only** in the Proxmox VE Shell. This script is
|
||||
intended for managing or enhancing the host system directly.
|
||||
</>
|
||||
)
|
||||
: item.type === "addon"
|
||||
? (
|
||||
<>
|
||||
This script enhances an existing setup. You can use it inside a running LXC container or directly on the
|
||||
Proxmox VE host to extend functionality with
|
||||
{" "}
|
||||
{item.name}
|
||||
.
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
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
|
||||
To create a new Proxmox VE Alpine-
|
||||
{item.name}
|
||||
{" "}
|
||||
{getDisplayValueFromType(item.type)}
|
||||
, run the command below in
|
||||
the Proxmox VE Shell.
|
||||
</p>
|
||||
)}
|
||||
|
@ -56,7 +89,9 @@ export default function InstallCommand({ item }: { item: Script }) {
|
|||
<Alert className="mt-3 mb-3">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<strong>When to use Gitea:</strong> GitHub may have issues including slow connections, delayed updates after bug
|
||||
<strong>When to use Gitea:</strong>
|
||||
{" "}
|
||||
GitHub may have issues including slow connections, delayed updates after bug
|
||||
fixes, no IPv6 support, API rate limits (60/hour). Use our Gitea mirror as a reliable alternative when
|
||||
experiencing these issues.
|
||||
</AlertDescription>
|
||||
|
@ -81,7 +116,8 @@ export default function InstallCommand({ item }: { item: Script }) {
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
} else if (defaultScript?.script) {
|
||||
}
|
||||
else if (defaultScript?.script) {
|
||||
return (
|
||||
<>
|
||||
{renderInstructions()}
|
||||
|
@ -109,4 +145,4 @@ export default function InstallCommand({ item }: { item: Script }) {
|
|||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
"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-[350px] flex-col sm:max-w-[350px]">
|
||||
<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;
|
|
@ -1,17 +1,17 @@
|
|||
import { CPUIcon, HDDIcon, RAMIcon } from "@/components/icons/resource-icons";
|
||||
import { getDisplayValueFromRAM } from "@/lib/utils/resource-utils";
|
||||
|
||||
interface ResourceDisplayProps {
|
||||
type ResourceDisplayProps = {
|
||||
title: string;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
hdd: number | null;
|
||||
}
|
||||
};
|
||||
|
||||
interface IconTextProps {
|
||||
type IconTextProps = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
};
|
||||
|
||||
function IconText({ icon, label }: IconTextProps) {
|
||||
return (
|
||||
|
@ -27,7 +27,8 @@ export function ResourceDisplay({ title, cpu, ram, hdd }: ResourceDisplayProps)
|
|||
const hasRAM = typeof ram === "number" && ram > 0;
|
||||
const hasHDD = typeof hdd === "number" && hdd > 0;
|
||||
|
||||
if (!hasCPU && !hasRAM && !hasHDD) return null;
|
||||
if (!hasCPU && !hasRAM && !hasHDD)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
|
@ -1,18 +1,18 @@
|
|||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import type { Category } from "@/lib/types";
|
||||
|
||||
import { formattedBadge } from "@/components/CommandMenu";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Category } from "@/lib/types";
|
||||
import { formattedBadge } from "@/components/command-menu";
|
||||
import { basePath } from "@/config/site-config";
|
||||
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,
|
||||
|
@ -41,8 +41,8 @@ export default function ScriptAccordion({
|
|||
|
||||
useEffect(() => {
|
||||
if (selectedScript) {
|
||||
const category = items.find((category) =>
|
||||
category.scripts.some((script) => script.slug === selectedScript),
|
||||
const category = items.find(category =>
|
||||
category.scripts.some(script => script.slug === selectedScript),
|
||||
);
|
||||
if (category) {
|
||||
setExpandedItem(category.name);
|
||||
|
@ -58,11 +58,11 @@ export default function ScriptAccordion({
|
|||
collapsible
|
||||
className="overflow-y-scroll max-h-[calc(100vh-225px)] overflow-x-hidden p-2"
|
||||
>
|
||||
{items.map((category) => (
|
||||
{items.map(category => (
|
||||
<AccordionItem
|
||||
key={category.id + ":category"}
|
||||
key={`${category.id}:category`}
|
||||
value={category.name}
|
||||
className={cn("sm:text-md flex flex-col border-none", {
|
||||
className={cn("sm:text-sm flex flex-col border-none", {
|
||||
"rounded-lg bg-accent/30": expandedItem === category.name,
|
||||
})}
|
||||
>
|
||||
|
@ -72,11 +72,15 @@ export default function ScriptAccordion({
|
|||
)}
|
||||
>
|
||||
<div className="mr-2 flex w-full items-center justify-between">
|
||||
<span className="pl-2 text-left">{category.name} </span>
|
||||
<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>{" "}
|
||||
</div>
|
||||
{" "}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
data-state={expandedItem === category.name ? "open" : "closed"}
|
||||
|
@ -109,10 +113,9 @@ export default function ScriptAccordion({
|
|||
height={16}
|
||||
width={16}
|
||||
unoptimized
|
||||
onError={(e) =>
|
||||
((e.currentTarget as HTMLImageElement).src =
|
||||
`/${basePath}/logo.png`)
|
||||
}
|
||||
onError={e =>
|
||||
((e.currentTarget as HTMLImageElement).src
|
||||
= `/${basePath}/logo.png`)}
|
||||
alt={script.name}
|
||||
className="mr-1 w-4 h-4 rounded-full"
|
||||
/>
|
|
@ -1,16 +1,18 @@
|
|||
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 { useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { Category, Script } from "@/lib/types";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { basePath, mostPopularScripts } from "@/config/site-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { extractDate } from "@/lib/time";
|
||||
|
||||
const ITEMS_PER_PAGE = 3;
|
||||
|
||||
export const getDisplayValueFromType = (type: string) => {
|
||||
export function getDisplayValueFromType(type: string) {
|
||||
switch (type) {
|
||||
case "ct":
|
||||
return "LXC";
|
||||
|
@ -22,15 +24,16 @@ export const getDisplayValueFromType = (type: string) => {
|
|||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function LatestScripts({ items }: { items: Category[] }) {
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const latestScripts = useMemo(() => {
|
||||
if (!items) return [];
|
||||
if (!items)
|
||||
return [];
|
||||
|
||||
const scripts = items.flatMap((category) => category.scripts || []);
|
||||
const scripts = items.flatMap(category => category.scripts || []);
|
||||
|
||||
// Filter out duplicates by slug
|
||||
const uniqueScriptsMap = new Map<string, Script>();
|
||||
|
@ -46,11 +49,11 @@ export function LatestScripts({ items }: { items: Category[] }) {
|
|||
}, [items]);
|
||||
|
||||
const goToNextPage = () => {
|
||||
setPage((prevPage) => prevPage + 1);
|
||||
setPage(prevPage => prevPage + 1);
|
||||
};
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
setPage((prevPage) => prevPage - 1);
|
||||
setPage(prevPage => prevPage - 1);
|
||||
};
|
||||
|
||||
const startIndex = (page - 1) * ITEMS_PER_PAGE;
|
||||
|
@ -80,7 +83,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((script) => (
|
||||
{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">
|
||||
|
@ -91,13 +94,15 @@ export function LatestScripts({ items }: { items: Category[] }) {
|
|||
height={64}
|
||||
width={64}
|
||||
alt=""
|
||||
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
|
||||
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)}
|
||||
{script.name}
|
||||
{" "}
|
||||
{getDisplayValueFromType(script.type)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
|
@ -130,7 +135,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
|
|||
|
||||
export function MostViewedScripts({ items }: { items: Category[] }) {
|
||||
const mostViewedScripts = items.reduce((acc: Script[], category) => {
|
||||
const foundScripts = category.scripts.filter((script) => mostPopularScripts.includes(script.slug));
|
||||
const foundScripts = category.scripts.filter(script => mostPopularScripts.includes(script.slug));
|
||||
return acc.concat(foundScripts);
|
||||
}, []);
|
||||
|
||||
|
@ -142,7 +147,7 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
|
|||
</>
|
||||
)}
|
||||
<div className="min-w flex w-full flex-row flex-wrap gap-4">
|
||||
{mostViewedScripts.map((script) => (
|
||||
{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">
|
||||
|
@ -153,13 +158,15 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
|
|||
height={64}
|
||||
width={64}
|
||||
alt=""
|
||||
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
|
||||
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)}
|
||||
{script.name}
|
||||
{" "}
|
||||
{getDisplayValueFromType(script.type)}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<CalendarPlus className="h-4 w-4" />
|
|
@ -1,31 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { extractDate } from "@/lib/time";
|
||||
import { AppVersion, Script } from "@/lib/types";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { basePath } from "@/config/siteConfig";
|
||||
import { useVersions } from "@/hooks/useVersions";
|
||||
import { cleanSlug } from "@/lib/utils/resource-utils";
|
||||
import { Suspense } from "react";
|
||||
import { ResourceDisplay } from "./ResourceDisplay";
|
||||
import { getDisplayValueFromType } from "./ScriptInfoBlocks";
|
||||
import Alerts from "./ScriptItems/Alerts";
|
||||
import Buttons from "./ScriptItems/Buttons";
|
||||
import ConfigFile from "./ScriptItems/ConfigFile";
|
||||
import DefaultPassword from "./ScriptItems/DefaultPassword";
|
||||
import Description from "./ScriptItems/Description";
|
||||
import InstallCommand from "./ScriptItems/InstallCommand";
|
||||
import InterFaces from "./ScriptItems/InterFaces";
|
||||
import Tooltips from "./ScriptItems/Tooltips";
|
||||
import type { AppVersion, Script } from "@/lib/types";
|
||||
|
||||
interface ScriptItemProps {
|
||||
import { cleanSlug } from "@/lib/utils/resource-utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useVersions } from "@/hooks/use-versions";
|
||||
import { basePath } from "@/config/site-config";
|
||||
import { extractDate } from "@/lib/time";
|
||||
|
||||
import { getDisplayValueFromType } from "./script-info-blocks";
|
||||
import DefaultPassword from "./script-items/default-password";
|
||||
import InstallCommand from "./script-items/install-command";
|
||||
import { ResourceDisplay } from "./resource-display";
|
||||
import Description from "./script-items/description";
|
||||
import ConfigFile from "./script-items/config-file";
|
||||
import InterFaces from "./script-items/interfaces";
|
||||
import Tooltips from "./script-items/tool-tips";
|
||||
import Buttons from "./script-items/buttons";
|
||||
import Alerts from "./script-items/alerts";
|
||||
|
||||
type ScriptItemProps = {
|
||||
item: Script;
|
||||
setSelectedScript: (script: string | null) => void;
|
||||
}
|
||||
};
|
||||
|
||||
function ScriptHeader({ item }: { item: Script }) {
|
||||
const defaultInstallMethod = item.install_methods?.[0];
|
||||
|
@ -40,7 +41,7 @@ function ScriptHeader({ item }: { item: Script }) {
|
|||
className="h-32 w-32 rounded-xl bg-gradient-to-br from-accent/40 to-accent/60 object-contain p-3 shadow-lg transition-transform hover:scale-105"
|
||||
src={item.logo || `/${basePath}/logo.png`}
|
||||
width={400}
|
||||
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
|
||||
onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
|
||||
height={400}
|
||||
alt={item.name}
|
||||
unoptimized
|
||||
|
@ -58,10 +59,15 @@ function ScriptHeader({ item }: { item: Script }) {
|
|||
</span>
|
||||
</h1>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>Added {extractDate(item.date_created)}</span>
|
||||
<span>
|
||||
Added
|
||||
{extractDate(item.date_created)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className=" capitalize">
|
||||
{os} {version}
|
||||
{os}
|
||||
{" "}
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -76,10 +82,10 @@ function ScriptHeader({ item }: { item: Script }) {
|
|||
hdd={defaultInstallMethod.resources.hdd}
|
||||
/>
|
||||
)}
|
||||
{item.install_methods.find((method) => method.type === "alpine")?.resources && (
|
||||
{item.install_methods.find(method => method.type === "alpine")?.resources && (
|
||||
<ResourceDisplay
|
||||
title="Alpine"
|
||||
{...item.install_methods.find((method) => method.type === "alpine")!.resources!}
|
||||
{...item.install_methods.find(method => method.type === "alpine")!.resources!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -108,7 +114,8 @@ function VersionInfo({ item }: { item: Script }) {
|
|||
return cleanName === cleanSlug(item.slug) || cleanName.includes(cleanSlug(item.slug));
|
||||
});
|
||||
|
||||
if (!matchedVersion) return null;
|
||||
if (!matchedVersion)
|
||||
return null;
|
||||
|
||||
return <span className="font-medium text-sm">{matchedVersion.version}</span>;
|
||||
}
|
||||
|
@ -132,7 +139,7 @@ export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-gradient-to-b from-card/30 to-background/50 backdrop-blur-sm shadow-sm">
|
||||
<div className="rounded-xl border border-border bg-accent/30 backdrop-blur-sm shadow-sm">
|
||||
<div className="p-6 space-y-6">
|
||||
<Suspense fallback={<div className="animate-pulse h-32 bg-accent/20 rounded-xl" />}>
|
||||
<ScriptHeader item={item} />
|
||||
|
@ -144,7 +151,9 @@ export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
|
|||
<div className="mt-4 rounded-lg border shadow-sm">
|
||||
<div className="flex gap-3 px-4 py-2 bg-accent/25">
|
||||
<h2 className="text-lg font-semibold">
|
||||
How to {item.type === "pve" ? "use" : item.type === "addon" ? "apply" : "install"}
|
||||
How to
|
||||
{" "}
|
||||
{item.type === "pve" ? "use" : item.type === "addon" ? "apply" : "install"}
|
||||
</h2>
|
||||
<Tooltips item={item} />
|
||||
</div>
|
|
@ -1,19 +1,21 @@
|
|||
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";
|
||||
|
||||
import type { Script } from "@/lib/types";
|
||||
|
||||
import TextCopyBlock from "@/components/text-copy-block";
|
||||
import { AlertColors } from "@/config/site-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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) => (
|
||||
{item?.notes?.length > 0
|
||||
&& item.notes.map((note: NoteProps, index: number) => (
|
||||
<div key={index} className="mt-4 flex flex-col shadow-sm gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
|
@ -21,11 +23,13 @@ export default function Alerts({ item }: { item: Script }) {
|
|||
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" />
|
||||
)}
|
||||
{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>
|
|
@ -1,20 +1,22 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { BookOpenText, Code, Globe, LinkIcon, RefreshCcw } from "lucide-react";
|
||||
|
||||
import type { Script } from "@/lib/types";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { basePath } from "@/config/siteConfig";
|
||||
import { Script } from "@/lib/types";
|
||||
import { BookOpenText, Code, Globe, LinkIcon, RefreshCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { basePath } from "@/config/site-config";
|
||||
|
||||
const generateInstallSourceUrl = (slug: string) => {
|
||||
function 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) => {
|
||||
function generateSourceUrl(slug: string, type: string) {
|
||||
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
|
||||
|
||||
switch (type) {
|
||||
|
@ -29,18 +31,18 @@ const generateSourceUrl = (slug: string, type: string) => {
|
|||
default:
|
||||
return `${baseUrl}/ct/${slug}.sh`; // fallback for "ct"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const generateUpdateUrl = (slug: string) => {
|
||||
function generateUpdateUrl(slug: string) {
|
||||
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
|
||||
return `${baseUrl}/ct/${slug}.sh`;
|
||||
};
|
||||
}
|
||||
|
||||
interface LinkItem {
|
||||
type LinkItem = {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Buttons({ item }: { item: Script }) {
|
||||
const isCtOrDefault = ["ct"].includes(item.type);
|
||||
|
@ -76,7 +78,8 @@ export default function Buttons({ item }: { item: Script }) {
|
|||
},
|
||||
].filter(Boolean) as LinkItem[];
|
||||
|
||||
if (links.length === 0) return null;
|
||||
if (links.length === 0)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
|
@ -1,13 +1,15 @@
|
|||
import handleCopy from "@/components/handleCopy";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Script } from "@/lib/types";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Script } from "@/lib/types";
|
||||
import handleCopy from "@/components/handle-copy";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function DefaultPassword({ item }: { item: Script }) {
|
||||
const { username, password } = item.default_credentials;
|
||||
const hasDefaultLogin = username || password;
|
||||
|
||||
if (!hasDefaultLogin) return null;
|
||||
if (!hasDefaultLogin)
|
||||
return null;
|
||||
|
||||
const copyCredential = (type: "username" | "password") => {
|
||||
handleCopy(type, item.default_credentials[type] ?? "");
|
||||
|
@ -21,18 +23,27 @@ export default function DefaultPassword({ item }: { item: Script }) {
|
|||
<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}.
|
||||
You can use the following credentials to login to the
|
||||
{" "}
|
||||
{item.name}
|
||||
{" "}
|
||||
{item.type}
|
||||
.
|
||||
</p>
|
||||
{["username", "password"].map((type) => {
|
||||
const value = item.default_credentials[type as "username" | "password"];
|
||||
return value && value.trim() !== "" ? (
|
||||
<div key={type} className="text-sm">
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}:{" "}
|
||||
<Button variant="secondary" size="null" onClick={() => copyCredential(type as "username" | "password")}>
|
||||
{value}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
return value && value.trim() !== ""
|
||||
? (
|
||||
<div key={type} className="text-sm">
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
:
|
||||
{" "}
|
||||
<Button variant="secondary" size="null" onClick={() => copyCredential(type as "username" | "password")}>
|
||||
{value}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
|
@ -1,4 +1,4 @@
|
|||
import { Script } from "@/lib/types";
|
||||
import type { Script } from "@/lib/types";
|
||||
|
||||
export default function DefaultSettings({ item }: { item: Script }) {
|
||||
const getDisplayValueFromRAM = (ram: number) => (ram >= 1024 ? `${Math.floor(ram / 1024)}GB` : `${ram}MB`);
|
||||
|
@ -8,15 +8,26 @@ export default function DefaultSettings({ item }: { item: Script }) {
|
|||
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>
|
||||
<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 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);
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import TextCopyBlock from "@/components/TextCopyBlock";
|
||||
import { Script } from "@/lib/types";
|
||||
import type { Script } from "@/lib/types";
|
||||
|
||||
import TextCopyBlock from "@/components/text-copy-block";
|
||||
|
||||
export default function Description({ item }: { item: Script }) {
|
||||
return (
|
|
@ -0,0 +1,149 @@
|
|||
import { Info } from "lucide-react";
|
||||
|
||||
import type { Script } from "@/lib/types";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import CodeCopyButton from "@/components/ui/code-copy-button";
|
||||
import { basePath } from "@/config/site-config";
|
||||
|
||||
import { getDisplayValueFromType } from "../script-info-blocks";
|
||||
|
||||
function getInstallCommand(scriptPath = "", isAlpine = false, useGitea = false) {
|
||||
const githubUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`;
|
||||
const giteaUrl = `https://git.community-scripts.org/community-scripts/${basePath}/raw/branch/main/${scriptPath}`;
|
||||
const url = useGitea ? giteaUrl : githubUrl;
|
||||
return isAlpine ? `bash -c "$(curl -fsSL ${url})"` : `bash -c "$(curl -fsSL ${url})"`;
|
||||
}
|
||||
|
||||
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 === "pve"
|
||||
? (
|
||||
<>
|
||||
To use the
|
||||
{" "}
|
||||
{item.name}
|
||||
{" "}
|
||||
script, run the command below **only** in the Proxmox VE Shell. This script is
|
||||
intended for managing or enhancing the host system directly.
|
||||
</>
|
||||
)
|
||||
: item.type === "addon"
|
||||
? (
|
||||
<>
|
||||
This script enhances an existing setup. You can use it inside a running LXC container or directly on the
|
||||
Proxmox VE host to extend functionality with
|
||||
{" "}
|
||||
{item.name}
|
||||
.
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderGiteaInfo = () => (
|
||||
<Alert className="mt-3 mb-3">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<strong>When to use Gitea:</strong>
|
||||
{" "}
|
||||
GitHub may have issues including slow connections, delayed updates after bug
|
||||
fixes, no IPv6 support, API rate limits (60/hour). Use our Gitea mirror as a reliable alternative when
|
||||
experiencing these issues.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
const renderScriptTabs = (useGitea = false) => {
|
||||
if (alpineScript) {
|
||||
return (
|
||||
<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, false, useGitea)}</CodeCopyButton>
|
||||
</TabsContent>
|
||||
<TabsContent value="alpine">
|
||||
{renderInstructions(true)}
|
||||
<CodeCopyButton>{getInstallCommand(alpineScript.script, true, useGitea)}</CodeCopyButton>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
else if (defaultScript?.script) {
|
||||
return (
|
||||
<>
|
||||
{renderInstructions()}
|
||||
<CodeCopyButton>{getInstallCommand(defaultScript.script, false, useGitea)}</CodeCopyButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Tabs defaultValue="github" className="w-full max-w-4xl">
|
||||
<TabsList>
|
||||
<TabsTrigger value="github">GitHub</TabsTrigger>
|
||||
<TabsTrigger value="gitea">Gitea</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="github">
|
||||
{renderScriptTabs(false)}
|
||||
</TabsContent>
|
||||
<TabsContent value="gitea">
|
||||
{renderGiteaInfo()}
|
||||
{renderScriptTabs(true)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { ClipboardIcon } from "lucide-react";
|
||||
|
||||
import type { Script } from "@/lib/types";
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import handleCopy from "@/components/handle-copy";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function InterFaces({ item }: { item: Script }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{item.interface_port !== null
|
||||
? (
|
||||
<div className="flex items-center justify-end">
|
||||
<h2 className="mr-2 text-end text-lg font-semibold">Default Interface:</h2>
|
||||
<span className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex items-center gap-2")}>
|
||||
{item.interface_port}
|
||||
<ClipboardIcon onClick={() => handleCopy("default interface", String(item.interface_port))} className="size-4 cursor-pointer" />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,22 +1,27 @@
|
|||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Script } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
import type { BadgeProps } from "@/components/ui/badge";
|
||||
import type { Script } from "@/lib/types";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TooltipProps = {
|
||||
variant: BadgeProps["variant"];
|
||||
label: string;
|
||||
content?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const TooltipBadge: React.FC<TooltipProps> = ({ variant, label, content }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger className={cn("flex items-center", !content && "cursor-default")}>
|
||||
<Badge variant={variant} className="flex items-center gap-1">
|
||||
{label} {content && <CircleHelp className="size-3" />}
|
||||
{label}
|
||||
{" "}
|
||||
{content && <CircleHelp className="size-3" />}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{content && (
|
46
frontend/src/app/scripts/_components/sidebar.tsx
Normal file
46
frontend/src/app/scripts/_components/sidebar.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import type { Category, Script } from "@/lib/types";
|
||||
|
||||
import ScriptAccordion from "./script-accordion";
|
||||
|
||||
function 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-[350px] flex-col sm:max-w-[350px]">
|
||||
<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;
|
|
@ -1,8 +1,8 @@
|
|||
import { AppVersion } from "@/lib/types";
|
||||
import type { AppVersion } from "@/lib/types";
|
||||
|
||||
interface VersionBadgeProps {
|
||||
type VersionBadgeProps = {
|
||||
version: AppVersion;
|
||||
}
|
||||
};
|
||||
|
||||
export function VersionBadge({ version }: VersionBadgeProps) {
|
||||
return (
|
|
@ -1,18 +1,20 @@
|
|||
"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 { Suspense, useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
|
||||
import type { Category, Script } from "@/lib/types";
|
||||
|
||||
import { ScriptItem } from "@/app/scripts/_components/script-item";
|
||||
import { fetchCategories } from "@/lib/data";
|
||||
|
||||
import {
|
||||
LatestScripts,
|
||||
MostViewedScripts,
|
||||
} from "./_components/ScriptInfoBlocks";
|
||||
import Sidebar from "./_components/Sidebar";
|
||||
} from "./_components/script-info-blocks";
|
||||
import Sidebar from "./_components/sidebar";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
function ScriptContent() {
|
||||
const [selectedScript, setSelectedScript] = useQueryState("id");
|
||||
|
@ -22,9 +24,9 @@ function ScriptContent() {
|
|||
useEffect(() => {
|
||||
if (selectedScript && links.length > 0) {
|
||||
const script = links
|
||||
.map((category) => category.scripts)
|
||||
.map(category => category.scripts)
|
||||
.flat()
|
||||
.find((script) => script.slug === selectedScript);
|
||||
.find(script => script.slug === selectedScript);
|
||||
setItem(script);
|
||||
}
|
||||
}, [selectedScript, links]);
|
||||
|
@ -34,7 +36,7 @@ function ScriptContent() {
|
|||
.then((categories) => {
|
||||
setLinks(categories);
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
.catch(error => console.error(error));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
@ -48,14 +50,16 @@ function ScriptContent() {
|
|||
/>
|
||||
</div>
|
||||
<div className="mx-4 w-full sm:mx-0 sm:ml-4">
|
||||
{selectedScript && item ? (
|
||||
<ScriptItem item={item} setSelectedScript={setSelectedScript} />
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<LatestScripts items={links} />
|
||||
<MostViewedScripts items={links} />
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
|
@ -65,13 +69,13 @@ function ScriptContent() {
|
|||
export default function Page() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
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>
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { basePath } from "@/config/siteConfig";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
import { basePath } from "@/config/site-config";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
let domain = "community-scripts.github.io";
|
||||
let protocol = "https";
|
||||
const domain = "community-scripts.github.io";
|
||||
const protocol = "https";
|
||||
return [
|
||||
{
|
||||
url: `${protocol}://${domain}/${basePath}`,
|
||||
|
@ -18,6 +19,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||
{
|
||||
url: `${protocol}://${domain}/${basePath}/json-editor`,
|
||||
lastModified: new Date(),
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue