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:
Bram Suurd 2025-06-28 00:38:09 +02:00 committed by GitHub
parent d60911a063
commit 0067075ed1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 8049 additions and 4043 deletions

View file

@ -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" },

View file

@ -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,
});

View file

@ -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;

View file

@ -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>

View file

@ -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);

View file

@ -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>

View file

@ -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>
</>
);

View 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);

View file

@ -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(),

View file

@ -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>

View file

@ -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.",

View file

@ -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 {

View file

@ -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&apos;s GitHub
<FaGithub className="mr-2 h-4 w-4" />
{" "}
Tteck&apos;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&#39;re a seasoned
With 300+ scripts to help you manage your
{" "}
<b>Proxmox VE environment</b>
. Whether you&#39;re a seasoned
user or a newcomer, we&#39;ve got you covered.
</p>
</div>

View file

@ -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 {

View file

@ -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>
);
}

View file

@ -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>
);
}
}

View file

@ -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;

View file

@ -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">

View file

@ -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"
/>

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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 (

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 && (

View 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;

View file

@ -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 (

View file

@ -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>

View file

@ -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(),
}
},
];
}