Refactor ESLint configuration to have one source of truth and run "npm lint" to apply new changes

This commit is contained in:
Bram Suurd
2025-06-26 19:06:46 +02:00
parent 395a421140
commit fa54f85009
82 changed files with 5149 additions and 1416 deletions

View File

@ -1,5 +0,0 @@
{
"extends": ["next/core-web-vitals"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"]
}

51
frontend/.vscode/settings.json generated vendored Normal file
View File

@ -0,0 +1,51 @@
{
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"json5",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
]
}

View File

@ -13,6 +13,7 @@ A comprehensive, user-friendly interface built with Next.js that provides access
## 🌟 Features ## 🌟 Features
### Core Functionality ### Core Functionality
- **📜 Script Management**: Browse, search, and filter 300+ Proxmox VE scripts - **📜 Script Management**: Browse, search, and filter 300+ Proxmox VE scripts
- **📱 Responsive Design**: Mobile-first approach with modern UI/UX - **📱 Responsive Design**: Mobile-first approach with modern UI/UX
- **🔍 Advanced Search**: Fuzzy search with category filtering - **🔍 Advanced Search**: Fuzzy search with category filtering
@ -21,6 +22,7 @@ A comprehensive, user-friendly interface built with Next.js that provides access
- **⚡ Performance Optimized**: Static site generation for lightning-fast loading - **⚡ Performance Optimized**: Static site generation for lightning-fast loading
### Technical Features ### Technical Features
- **🎨 Modern UI Components**: Built with Radix UI and shadcn/ui - **🎨 Modern UI Components**: Built with Radix UI and shadcn/ui
- **📈 Data Visualization**: Charts and metrics using Chart.js - **📈 Data Visualization**: Charts and metrics using Chart.js
- **🔄 State Management**: React Query for efficient data fetching - **🔄 State Management**: React Query for efficient data fetching
@ -30,11 +32,13 @@ A comprehensive, user-friendly interface built with Next.js that provides access
## 🛠️ Tech Stack ## 🛠️ Tech Stack
### Frontend Framework ### Frontend Framework
- **[Next.js 15.2.4](https://nextjs.org/)** - React framework with App Router - **[Next.js 15.2.4](https://nextjs.org/)** - React framework with App Router
- **[React 19.0.0](https://react.dev/)** - Latest React with concurrent features - **[React 19.0.0](https://react.dev/)** - Latest React with concurrent features
- **[TypeScript 5.8.2](https://www.typescriptlang.org/)** - Type-safe JavaScript - **[TypeScript 5.8.2](https://www.typescriptlang.org/)** - Type-safe JavaScript
### Styling & UI ### Styling & UI
- **[Tailwind CSS 3.4.17](https://tailwindcss.com/)** - Utility-first CSS framework - **[Tailwind CSS 3.4.17](https://tailwindcss.com/)** - Utility-first CSS framework
- **[Radix UI](https://www.radix-ui.com/)** - Unstyled, accessible UI components - **[Radix UI](https://www.radix-ui.com/)** - Unstyled, accessible UI components
- **[shadcn/ui](https://ui.shadcn.com/)** - Re-usable components built on Radix UI - **[shadcn/ui](https://ui.shadcn.com/)** - Re-usable components built on Radix UI
@ -42,17 +46,20 @@ A comprehensive, user-friendly interface built with Next.js that provides access
- **[Lucide React](https://lucide.dev/)** - Icon library - **[Lucide React](https://lucide.dev/)** - Icon library
### Data & State Management ### Data & State Management
- **[TanStack Query 5.71.1](https://tanstack.com/query)** - Powerful data synchronization - **[TanStack Query 5.71.1](https://tanstack.com/query)** - Powerful data synchronization
- **[Zod 3.24.2](https://zod.dev/)** - TypeScript-first schema validation - **[Zod 3.24.2](https://zod.dev/)** - TypeScript-first schema validation
- **[nuqs 2.4.1](https://nuqs.47ng.com/)** - Type-safe search params state manager - **[nuqs 2.4.1](https://nuqs.47ng.com/)** - Type-safe search params state manager
### Development Tools ### Development Tools
- **[Vitest 3.1.1](https://vitest.dev/)** - Fast unit testing framework - **[Vitest 3.1.1](https://vitest.dev/)** - Fast unit testing framework
- **[React Testing Library](https://testing-library.com/react)** - Simple testing utilities - **[React Testing Library](https://testing-library.com/react)** - Simple testing utilities
- **[ESLint](https://eslint.org/)** - Code linting and formatting - **[ESLint](https://eslint.org/)** - Code linting and formatting
- **[Prettier](https://prettier.io/)** - Code formatting - **[Prettier](https://prettier.io/)** - Code formatting
### Additional Libraries ### Additional Libraries
- **[Chart.js](https://www.chartjs.org/)** - Data visualization - **[Chart.js](https://www.chartjs.org/)** - Data visualization
- **[Fuse.js](https://fusejs.io/)** - Fuzzy search - **[Fuse.js](https://fusejs.io/)** - Fuzzy search
- **[date-fns](https://date-fns.org/)** - Date utility library - **[date-fns](https://date-fns.org/)** - Date utility library
@ -69,27 +76,30 @@ A comprehensive, user-friendly interface built with Next.js that provides access
### Installation ### Installation
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone https://github.com/community-scripts/ProxmoxVE.git git clone https://github.com/community-scripts/ProxmoxVE.git
cd ProxmoxVE/frontend cd ProxmoxVE/frontend
``` ```
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
# Using npm # Using npm
npm install npm install
# Using yarn # Using yarn
yarn install yarn install
# Using pnpm # Using pnpm
pnpm install pnpm install
# Using bun # Using bun
bun install bun install
``` ```
3. **Start the development server** 3. **Start the development server**
```bash ```bash
npm run dev npm run dev
# or # or
@ -101,7 +111,7 @@ A comprehensive, user-friendly interface built with Next.js that provides access
``` ```
4. **Open your browser** 4. **Open your browser**
Navigate to [http://localhost:3000](http://localhost:3000) to see the application running. Navigate to [http://localhost:3000](http://localhost:3000) to see the application running.
### Environment Configuration ### Environment Configuration
@ -134,12 +144,14 @@ npm run deploy # Build and deploy to GitHub Pages
### Development Workflow ### Development Workflow
1. **Feature Development** 1. **Feature Development**
- Create a new branch for your feature - Create a new branch for your feature
- Follow the established TypeScript and React patterns - Follow the established TypeScript and React patterns
- Use the existing component library (shadcn/ui) - Use the existing component library (shadcn/ui)
- Ensure responsive design principles - Ensure responsive design principles
2. **Code Standards** 2. **Code Standards**
- Follow TypeScript strict mode - Follow TypeScript strict mode
- Use functional components with hooks - Use functional components with hooks
- Implement proper error boundaries - Implement proper error boundaries
@ -147,6 +159,7 @@ npm run deploy # Build and deploy to GitHub Pages
- Use early returns for better readability - Use early returns for better readability
3. **Styling Guidelines** 3. **Styling Guidelines**
- Use Tailwind CSS utility classes - Use Tailwind CSS utility classes
- Follow mobile-first responsive design - Follow mobile-first responsive design
- Implement dark/light mode considerations - Implement dark/light mode considerations
@ -205,28 +218,30 @@ We welcome contributions from the community! Here's how you can help:
2. **Clone your fork** locally 2. **Clone your fork** locally
3. **Create a new branch** for your feature or bugfix 3. **Create a new branch** for your feature or bugfix
4. **Make your changes** following our coding standards 4. **Make your changes** following our coding standards
6. **Submit a pull request** with a clear description 5. **Submit a pull request** with a clear description
### Contribution Guidelines ### Contribution Guidelines
#### Code Style #### Code Style
- Follow the existing TypeScript and React patterns - Follow the existing TypeScript and React patterns
- Use descriptive variable and function names - Use descriptive variable and function names
- Implement proper error handling - Implement proper error handling
- Write self-documenting code with appropriate comments - Write self-documenting code with appropriate comments
#### Component Guidelines #### Component Guidelines
- Use functional components with hooks - Use functional components with hooks
- Implement proper TypeScript types - Implement proper TypeScript types
- Follow accessibility best practices - Follow accessibility best practices
- Ensure responsive design - Ensure responsive design
- Use the existing design system components - Use the existing design system components
#### Pull Request Process #### Pull Request Process
1. Update documentation if needed 1. Update documentation if needed
4. Update the README if you've added new features 2. Update the README if you've added new features
5. Request review from maintainers 3. Request review from maintainers
### Areas for Contribution ### Areas for Contribution
@ -263,4 +278,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
--- ---
**Made with ❤️ by the Community-Scripts team and contributors** **Made with ❤️ by the Community-Scripts team and contributors**

View File

@ -0,0 +1,40 @@
import antfu from "@antfu/eslint-config";
export default antfu(
{
type: "app",
typescript: true,
formatters: true,
stylistic: {
indent: 2,
semi: true,
quotes: "double",
},
ignores: ["src/components/ui/**", "README.md", "public/json/**"],
},
{
rules: {
"ts/no-redeclare": "off",
"ts/consistent-type-definitions": ["error", "type"],
"no-console": ["warn"],
"antfu/no-top-level-await": ["off"],
"node/prefer-global/process": ["off"],
"node/no-process-env": ["error"],
"perfectionist/sort-imports": [
"error",
{
type: "line-length",
order: "desc",
},
],
"unicorn/filename-case": [
"error",
{
case: "kebabCase",
ignore: ["README.md"],
},
],
},
},
);

File diff suppressed because it is too large Load Diff

13
frontend/package.json generated
View File

@ -1,20 +1,18 @@
{ {
"name": "proxmox-helper-scripts-website", "name": "proxmox-helper-scripts-website",
"type": "module",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT",
"private": true, "private": true,
"author": { "author": {
"name": "Bram Suurd", "name": "Bram Suurd",
"url": "https://github.com/community-scripts" "url": "https://github.com/community-scripts"
}, },
"type": "module", "license": "MIT",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "npm run eslint . --fix",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@ -62,6 +60,8 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^4.16.1",
"@eslint-react/eslint-plugin": "^1.52.2",
"@tanstack/eslint-plugin-query": "^5.68.0", "@tanstack/eslint-plugin-query": "^5.68.0",
"@types/node": "^22.13.16", "@types/node": "^22.13.16",
"@types/react": "npm:types-react@19.0.0-rc.1", "@types/react": "npm:types-react@19.0.0-rc.1",
@ -71,6 +71,9 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.23.0", "eslint": "^9.23.0",
"eslint-config-next": "15.0.2", "eslint-config-next": "15.0.2",
"eslint-plugin-format": "^1.0.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",

View File

@ -1,7 +1,8 @@
import { Metadata, Script } from "@/lib/types";
import { promises as fs } from "fs";
import { NextResponse } from "next/server"; 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"; export const dynamic = "force-static";
@ -10,21 +11,21 @@ const metadataFileName = "metadata.json";
const versionFileName = "version.json"; const versionFileName = "version.json";
const encoding = "utf-8"; const encoding = "utf-8";
const getMetadata = async () => { async function getMetadata() {
const filePath = path.resolve(jsonDir, metadataFileName); const filePath = path.resolve(jsonDir, metadataFileName);
const fileContent = await fs.readFile(filePath, encoding); const fileContent = await fs.readFile(filePath, encoding);
const metadata: Metadata = JSON.parse(fileContent); const metadata: Metadata = JSON.parse(fileContent);
return metadata; return metadata;
}; }
const getScripts = async () => { async function getScripts() {
const filePaths = (await fs.readdir(jsonDir)) const filePaths = (await fs.readdir(jsonDir))
.filter((fileName) => .filter(fileName =>
fileName.endsWith(".json") && fileName.endsWith(".json")
fileName !== metadataFileName && && fileName !== metadataFileName
fileName !== versionFileName && fileName !== versionFileName,
) )
.map((fileName) => path.resolve(jsonDir, fileName)); .map(fileName => path.resolve(jsonDir, fileName));
const scripts = await Promise.all( const scripts = await Promise.all(
filePaths.map(async (filePath) => { filePaths.map(async (filePath) => {
@ -34,7 +35,7 @@ const getScripts = async () => {
}), }),
); );
return scripts; return scripts;
}; }
export async function GET() { export async function GET() {
try { try {
@ -43,7 +44,7 @@ export async function GET() {
const categories = metadata.categories const categories = metadata.categories
.map((category) => { .map((category) => {
category.scripts = scripts.filter((script) => category.scripts = scripts.filter(script =>
script.categories?.includes(category.id), script.categories?.includes(category.id),
); );
return category; return category;
@ -51,7 +52,8 @@ export async function GET() {
.sort((a, b) => a.sort_order - b.sort_order); .sort((a, b) => a.sort_order - b.sort_order);
return NextResponse.json(categories); return NextResponse.json(categories);
} catch (error) { }
catch (error) {
console.error(error as Error); console.error(error as Error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch categories" }, { 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 Error from "next/error";
import { NextResponse } from "next/server"; 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"; export const dynamic = "force-static";
@ -11,33 +11,32 @@ const jsonDir = "public/json";
const versionsFileName = "versions.json"; const versionsFileName = "versions.json";
const encoding = "utf-8"; const encoding = "utf-8";
const getVersions = async () => { async function getVersions() {
const filePath = path.resolve(jsonDir, versionsFileName); const filePath = path.resolve(jsonDir, versionsFileName);
const fileContent = await fs.readFile(filePath, encoding); const fileContent = await fs.readFile(filePath, encoding);
const versions: AppVersion[] = JSON.parse(fileContent); const versions: AppVersion[] = JSON.parse(fileContent);
const modifiedVersions = versions.map(version => { const modifiedVersions = versions.map((version) => {
let newName = version.name; 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 { ...version, name: newName, date: new Date(version.date) };
}); });
return modifiedVersions; return modifiedVersions;
}; }
export async function GET() { export async function GET() {
try { try {
const versions = await getVersions(); const versions = await getVersions();
return NextResponse.json(versions); return NextResponse.json(versions);
}
} catch (error) { catch (error) {
console.error(error); console.error(error);
const err = error as globalThis.Error; const err = error as globalThis.Error;
return NextResponse.json({ return NextResponse.json({
name: err.name, name: err.name,
message: err.message || "An unexpected error occurred", message: err.message || "An unexpected error occurred",
version: "No version found - Error" version: "No version found - Error",
}, { }, {
status: 500, status: 500,
}); });

View File

@ -1,18 +1,20 @@
"use client"; "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 { ChevronLeft, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react"; 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 defaultLogo = "/default-logo.png"; // Fallback logo path
const MAX_DESCRIPTION_LENGTH = 100; // Set max length for description const MAX_DESCRIPTION_LENGTH = 100; // Set max length for description
const MAX_LOGOS = 5; // Max logos to display at once const MAX_LOGOS = 5; // Max logos to display at once
const formattedBadge = (type: string) => { function formattedBadge(type: string) {
switch (type) { switch (type) {
case "vm": case "vm":
return <Badge className="text-blue-500/75 border-blue-500/75 badge">VM</Badge>; 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 <Badge className="text-green-500/75 border-green-500/75 badge">ADDON</Badge>;
} }
return null; return null;
}; }
const CategoryView = () => { function CategoryView() {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategoryIndex, setSelectedCategoryIndex] = useState<number | null>(null); const [selectedCategoryIndex, setSelectedCategoryIndex] = useState<number | null>(null);
const [currentScripts, setCurrentScripts] = useState<any[]>([]); const [currentScripts, setCurrentScripts] = useState<any[]>([]);
@ -36,6 +38,7 @@ const CategoryView = () => {
useEffect(() => { useEffect(() => {
const fetchCategories = async () => { const fetchCategories = async () => {
try { try {
// eslint-disable-next-line node/no-process-env
const basePath = process.env.NODE_ENV === "production" ? "/ProxmoxVE" : ""; const basePath = process.env.NODE_ENV === "production" ? "/ProxmoxVE" : "";
const response = await fetch(`${basePath}/api/categories`); const response = await fetch(`${basePath}/api/categories`);
if (!response.ok) { if (!response.ok) {
@ -50,7 +53,8 @@ const CategoryView = () => {
initialLogoIndices[category.name] = 0; initialLogoIndices[category.name] = 0;
}); });
setLogoIndices(initialLogoIndices); setLogoIndices(initialLogoIndices);
} catch (error) { }
catch (error) {
console.error("Error fetching categories:", error); console.error("Error fetching categories:", error);
} }
}; };
@ -74,8 +78,8 @@ const CategoryView = () => {
const navigateCategory = (direction: "prev" | "next") => { const navigateCategory = (direction: "prev" | "next") => {
if (selectedCategoryIndex !== null) { if (selectedCategoryIndex !== null) {
const newIndex = const newIndex
direction === "prev" = direction === "prev"
? (selectedCategoryIndex - 1 + categories.length) % categories.length ? (selectedCategoryIndex - 1 + categories.length) % categories.length
: (selectedCategoryIndex + 1) % categories.length; : (selectedCategoryIndex + 1) % categories.length;
setSelectedCategoryIndex(newIndex); setSelectedCategoryIndex(newIndex);
@ -86,12 +90,13 @@ const CategoryView = () => {
const switchLogos = (categoryName: string, direction: "prev" | "next") => { const switchLogos = (categoryName: string, direction: "prev" | "next") => {
setLogoIndices((prev) => { setLogoIndices((prev) => {
const currentIndex = prev[categoryName] || 0; const currentIndex = prev[categoryName] || 0;
const category = categories.find((cat) => cat.name === categoryName); const category = categories.find(cat => cat.name === categoryName);
if (!category || !category.scripts) return prev; if (!category || !category.scripts)
return prev;
const totalLogos = category.scripts.length; const totalLogos = category.scripts.length;
const newIndex = const newIndex
direction === "prev" = direction === "prev"
? (currentIndex - MAX_LOGOS + totalLogos) % totalLogos ? (currentIndex - MAX_LOGOS + totalLogos) % totalLogos
: (currentIndex + MAX_LOGOS) % totalLogos; : (currentIndex + MAX_LOGOS) % totalLogos;
@ -109,35 +114,49 @@ const CategoryView = () => {
const hdd = script.install_methods[0]?.resources.hdd; const hdd = script.install_methods[0]?.resources.hdd;
const resourceParts = []; const resourceParts = [];
if (cpu) if (cpu) {
resourceParts.push( resourceParts.push(
<span key="cpu"> <span key="cpu">
<b>CPU:</b> {cpu}vCPU <b>CPU:</b>
{" "}
{cpu}
vCPU
</span>, </span>,
); );
if (ram) }
if (ram) {
resourceParts.push( resourceParts.push(
<span key="ram"> <span key="ram">
<b>RAM:</b> {ram}MB <b>RAM:</b>
{" "}
{ram}
MB
</span>, </span>,
); );
if (hdd) }
if (hdd) {
resourceParts.push( resourceParts.push(
<span key="hdd"> <span key="hdd">
<b>HDD:</b> {hdd}GB <b>HDD:</b>
{" "}
{hdd}
GB
</span>, </span>,
); );
}
return resourceParts.length > 0 ? ( return resourceParts.length > 0
<div className="text-sm text-gray-400"> ? (
{resourceParts.map((part, index) => ( <div className="text-sm text-gray-400">
<React.Fragment key={index}> {resourceParts.map((part, index) => (
{part} <React.Fragment key={index}>
{index < resourceParts.length - 1 && " | "} {part}
</React.Fragment> {index < resourceParts.length - 1 && " | "}
))} </React.Fragment>
</div> ))}
) : null; </div>
)
: null;
}; };
return ( return (
@ -145,145 +164,151 @@ const CategoryView = () => {
{categories.length === 0 && ( {categories.length === 0 && (
<p className="text-center text-gray-500">No categories available. Please check the API endpoint.</p> <p className="text-center text-gray-500">No categories available. Please check the API endpoint.</p>
)} )}
{selectedCategoryIndex !== null ? ( {selectedCategoryIndex !== null
<div> ? (
{/* Header with Navigation */} <div>
<div className="flex items-center justify-between mb-6"> {/* Header with Navigation */}
<Button <div className="flex items-center justify-between mb-6">
variant="ghost" <Button
onClick={() => navigateCategory("prev")} variant="ghost"
className="p-2 transition-transform duration-300 hover:scale-105" 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)}
> >
<CardContent className="flex flex-col gap-4"> <ChevronLeft className="h-6 w-6" />
<h3 className="text-lg font-bold script-text text-center hover:text-blue-600 transition-colors duration-300"> </Button>
{script.name} <h2 className="text-3xl font-semibold transition-opacity duration-300 hover:opacity-90">
</h3> {categories[selectedCategoryIndex].name}
<img </h2>
src={script.logo || defaultLogo} <Button
alt={script.name || "Script logo"} variant="ghost"
className="h-12 w-12 object-contain mx-auto" onClick={() => navigateCategory("next")}
/> className="p-2 transition-transform duration-300 hover:scale-105"
<p className="text-sm text-gray-500 text-center"> >
<b>Created at:</b> {script.date_created || "No date available"} <ChevronRight className="h-6 w-6" />
</p> </Button>
<p </div>
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 */} {/* Scripts Grid */}
<div className="mt-8 text-center"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<Button {currentScripts
variant="default" .sort((a, b) => a.name.localeCompare(b.name))
onClick={handleBackClick} .map(script => (
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" <Card
> key={script.name}
Back to Categories className="p-4 cursor-pointer hover:shadow-md transition-shadow duration-300"
</Button> onClick={() => handleScriptClick(script.slug)}
</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" /> <CardContent className="flex flex-col gap-4">
</Button> <h3 className="text-lg font-bold script-text text-center hover:text-blue-600 transition-colors duration-300">
{category.scripts && {script.name}
category.scripts </h3>
.slice(logoIndices[category.name] || 0, (logoIndices[category.name] || 0) + MAX_LOGOS) <img
.map((script, i) => ( src={script.logo || defaultLogo}
<div key={i} className="flex flex-col items-center"> alt={script.name || "Script logo"}
<img className="h-12 w-12 object-contain mx-auto"
src={script.logo || defaultLogo} />
alt={script.name || "Script logo"} <p className="text-sm text-gray-500 text-center">
title={script.name} <b>Created at:</b>
className="h-8 w-8 object-contain cursor-pointer" {" "}
onClick={(e) => { {script.date_created || "No date available"}
e.stopPropagation(); </p>
handleScriptClick(script.slug); <p
}} className="text-sm text-gray-700 hover:text-gray-900 text-center transition-colors duration-300"
/> title={script.description || "No description available."}
{formattedBadge(script.type)} >
</div> {truncateDescription(script.description || "No description available.")}
))} </p>
<Button {renderResources(script)}
variant="ghost" </CardContent>
onClick={(e) => { </Card>
e.stopPropagation(); ))}
switchLogos(category.name, "next"); </div>
}}
className="p-1 transition-transform duration-300 hover:scale-110" {/* Back to Categories Button */}
> <div className="mt-8 text-center">
<ChevronRight className="h-4 w-4" /> <Button
</Button> variant="default"
</div> onClick={handleBackClick}
<p className="text-sm text-gray-400 text-center"> 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"
{(category as any).description || "No description available."} >
</p> Back to Categories
</CardContent> </Button>
</Card> </div>
))} </div>
</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> </div>
); );
}; }
export default CategoryView; export default CategoryView;

View File

@ -1,11 +1,11 @@
"use client"; "use client";
import React, { JSX, useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import DatePicker from 'react-datepicker'; import "react-datepicker/dist/react-datepicker.css";
import 'react-datepicker/dist/react-datepicker.css';
import ApplicationChart from "../../components/ApplicationChart";
interface DataModel { import ApplicationChart from "../../components/application-chart";
type DataModel = {
id: number; id: number;
ct_type: number; ct_type: number;
disk_size: number; disk_size: number;
@ -22,13 +22,13 @@ interface DataModel {
error: string; error: string;
type: string; type: string;
[key: string]: any; [key: string]: any;
} };
interface SummaryData { type SummaryData = {
total_entries: number; total_entries: number;
status_count: Record<string, number>; status_count: Record<string, number>;
nsapp_count: Record<string, number>; nsapp_count: Record<string, number>;
} };
const DataFetcher: React.FC = () => { const DataFetcher: React.FC = () => {
const [data, setData] = useState<DataModel[]>([]); const [data, setData] = useState<DataModel[]>([]);
@ -37,16 +37,18 @@ const DataFetcher: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(25); 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(() => { useEffect(() => {
const fetchSummary = async () => { const fetchSummary = async () => {
try { try {
const response = await fetch("https://api.htl-braunau.at/data/summary"); 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(); const result: SummaryData = await response.json();
setSummary(result); setSummary(result);
} catch (err) { }
catch (err) {
setError((err as Error).message); setError((err as Error).message);
} }
}; };
@ -58,13 +60,16 @@ const DataFetcher: React.FC = () => {
const fetchPaginatedData = async () => { const fetchPaginatedData = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch(`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage === 0 ? '' : itemsPerPage}`); 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}`); if (!response.ok)
throw new Error(`Failed to fetch data: ${response.statusText}`);
const result: DataModel[] = await response.json(); const result: DataModel[] = await response.json();
setData(result); setData(result);
} catch (err) { }
catch (err) {
setError((err as Error).message); setError((err as Error).message);
} finally { }
finally {
setLoading(false); setLoading(false);
} }
}; };
@ -73,26 +78,35 @@ const DataFetcher: React.FC = () => {
}, [currentPage, itemsPerPage]); }, [currentPage, itemsPerPage]);
const sortedData = React.useMemo(() => { const sortedData = React.useMemo(() => {
if (!sortConfig) return data; if (!sortConfig)
return data;
const sorted = [...data].sort((a, b) => { const sorted = [...data].sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) { 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]) { if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? 1 : -1; return sortConfig.direction === "ascending" ? 1 : -1;
} }
return 0; return 0;
}); });
return sorted; return sorted;
}, [data, sortConfig]); }, [data, sortConfig]);
if (loading) return <p>Loading...</p>; if (loading)
if (error) return <p>Error: {error}</p>; return <p>Loading...</p>;
if (error) {
return (
<p>
Error:
{error}
</p>
);
}
const requestSort = (key: string) => { const requestSort = (key: string) => {
let direction: 'ascending' | 'descending' = 'ascending'; let direction: "ascending" | "descending" = "ascending";
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'ascending') { if (sortConfig && sortConfig.key === key && sortConfig.direction === "ascending") {
direction = 'descending'; direction = "descending";
} }
setSortConfig({ key, direction }); setSortConfig({ key, direction });
}; };
@ -102,8 +116,8 @@ const DataFetcher: React.FC = () => {
const year = date.getFullYear(); const year = date.getFullYear();
const month = date.getMonth() + 1; const month = date.getMonth() + 1;
const day = date.getDate(); const day = date.getDate();
const hours = String(date.getHours()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, "0");
const timezoneOffset = dateString.slice(-6); const timezoneOffset = dateString.slice(-6);
return `${day}.${month}.${year} ${hours}:${minutes} ${timezoneOffset} GMT`; return `${day}.${month}.${year} ${hours}:${minutes} ${timezoneOffset} GMT`;
}; };
@ -114,49 +128,76 @@ const DataFetcher: React.FC = () => {
<ApplicationChart data={summary} /> <ApplicationChart data={summary} />
<p className="text-lg font-bold mt-4"> </p> <p className="text-lg font-bold mt-4"> </p>
<div className="mb-4 flex justify-between items-center"> <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-bold">
<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> {summary?.total_entries}
</div> {" "}
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-x-auto">
<div className="overflow-y-auto lg:overflow-y-visible"> <div className="overflow-y-auto lg:overflow-y-visible">
<table className="min-w-full table-auto border-collapse"> <table className="min-w-full table-auto border-collapse">
<thead> <thead>
<tr> <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("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("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("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_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("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("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("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("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("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("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("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("created_at")}>Created At</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedData.map((item, index) => ( {sortedData.map((item, index) => (
<tr key={index}> <tr key={index}>
<td className="px-4 py-2 border-b"> <td className="px-4 py-2 border-b">
{item.status === "done" ? ( {item.status === "done"
"✔️" ? (
) : item.status === "failed" ? ( "✔️"
"❌" )
) : item.status === "installing" ? ( : item.status === "failed"
"🔄" ? (
) : ( "❌"
item.status )
)} : item.status === "installing"
? (
"🔄"
)
: (
item.status
)}
</td>
<td className="px-4 py-2 border-b">
{item.type === "lxc"
? (
"📦"
)
: item.type === "vm"
? (
"🖥️"
)
: (
item.type
)}
</td> </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.nsapp}</td>
<td className="px-4 py-2 border-b">{item.os_type}</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> <td className="px-4 py-2 border-b">{item.os_version}</td>
@ -175,11 +216,14 @@ const DataFetcher: React.FC = () => {
</div> </div>
<div className="mt-4 flex justify-between items-center"> <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> <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> <button onClick={() => setCurrentPage(prev => prev + 1)} className="p-2 border">Next</button>
<select <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))} onChange={e => setItemsPerPage(Number(e.target.value))}
className="p-2 border" className="p-2 border"
> >
<option value={10}>10</option> <option value={10}>10</option>

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 { import {
Select, Select,
SelectContent, SelectContent,
@ -6,11 +11,10 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Category } from "@/lib/types"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { z } from "zod";
import { type Script } from "../_schemas/schemas"; import type { Script } from "../_schemas/schemas";
import { memo } from "react";
type CategoryProps = { type CategoryProps = {
script: Script; script: Script;
@ -20,10 +24,10 @@ type CategoryProps = {
categories: Category[]; categories: Category[];
}; };
const CategoryTag = memo(({ const CategoryTag = memo(({
category, category,
onRemove onRemove,
}: { }: {
category: Category; category: Category;
onRemove: () => void; onRemove: () => void;
}) => ( }) => (
@ -53,7 +57,7 @@ const CategoryTag = memo(({
</span> </span>
)); ));
CategoryTag.displayName = 'CategoryTag'; CategoryTag.displayName = "CategoryTag";
function Categories({ function Categories({
script, script,
@ -79,14 +83,16 @@ function Categories({
return ( return (
<div> <div>
<Label> <Label>
Category <span className="text-red-500">*</span> Category
{" "}
<span className="text-red-500">*</span>
</Label> </Label>
<Select onValueChange={(value) => addCategory(Number(value))}> <Select onValueChange={value => addCategory(Number(value))}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a category" /> <SelectValue placeholder="Select a category" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{categories.map((category) => ( {categories.map(category => (
<SelectItem key={category.id} value={category.id.toString()}> <SelectItem key={category.id} value={category.id.toString()}>
{category.name} {category.name}
</SelectItem> </SelectItem>
@ -101,13 +107,15 @@ function Categories({
> >
{script.categories.map((categoryId) => { {script.categories.map((categoryId) => {
const category = categoryMap.get(categoryId); const category = categoryMap.get(categoryId);
return category ? ( return category
<CategoryTag ? (
key={categoryId} <CategoryTag
category={category} key={categoryId}
onRemove={() => removeCategory(categoryId)} category={category}
/> onRemove={() => removeCategory(categoryId)}
) : null; />
)
: null;
})} })}
</div> </div>
</div> </div>

View File

@ -1,150 +1,159 @@
import { Button } from "@/components/ui/button"; import type { z } from "zod";
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 { PlusCircle, Trash2 } from "lucide-react";
import { z } from "zod";
import { ScriptSchema, type Script } from "../_schemas/schemas";
import { memo, useCallback, useRef } from "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 = { type NoteProps = {
script: Script; script: Script;
setScript: (script: Script) => void; setScript: (script: Script) => void;
setIsValid: (isValid: boolean) => void; setIsValid: (isValid: boolean) => void;
setZodErrors: (zodErrors: z.ZodError | null) => void; setZodErrors: (zodErrors: z.ZodError | null) => void;
}; };
function Note({ function Note({
script, script,
setScript, setScript,
setIsValid, setIsValid,
setZodErrors, setZodErrors,
}: NoteProps) { }: NoteProps) {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const addNote = useCallback(() => { const addNote = useCallback(() => {
setScript({ setScript({
...script, ...script,
notes: [...script.notes, { text: "", type: "" }], notes: [...script.notes, { text: "", type: "" }],
}); });
}, [script, setScript]); }, [script, setScript]);
const updateNote = useCallback(( const updateNote = useCallback((
index: number, index: number,
key: keyof Script["notes"][number], key: keyof Script["notes"][number],
value: string, value: string,
) => { ) => {
const updated: Script = { const updated: Script = {
...script, ...script,
notes: script.notes.map((note, i) => notes: script.notes.map((note, i) =>
i === index ? { ...note, [key]: value } : note, i === index ? { ...note, [key]: value } : note,
), ),
}; };
const result = ScriptSchema.safeParse(updated); const result = ScriptSchema.safeParse(updated);
setIsValid(result.success); setIsValid(result.success);
setZodErrors(result.success ? null : result.error); setZodErrors(result.success ? null : result.error);
setScript(updated); setScript(updated);
// Restore focus after state update // Restore focus after state update
if (key === "text") { if (key === "text") {
setTimeout(() => { setTimeout(() => {
inputRefs.current[index]?.focus(); inputRefs.current[index]?.focus();
}, 0); }, 0);
} }
}, [script, setScript, setIsValid, setZodErrors]); }, [script, setScript, setIsValid, setZodErrors]);
const removeNote = useCallback((index: number) => { const removeNote = useCallback((index: number) => {
setScript({ setScript({
...script, ...script,
notes: script.notes.filter((_, i) => i !== index), notes: script.notes.filter((_, i) => i !== index),
}); });
}, [script, setScript]); }, [script, setScript]);
return ( return (
<> <>
<h3 className="text-xl font-semibold">Notes</h3> <h3 className="text-xl font-semibold">Notes</h3>
{script.notes.map((note, index) => ( {script.notes.map((note, index) => (
<NoteItem key={index} note={note} index={index} updateNote={updateNote} removeNote={removeNote} /> <NoteItem key={index} note={note} index={index} updateNote={updateNote} removeNote={removeNote} />
))} ))}
<Button type="button" size="sm" onClick={addNote}> <Button type="button" size="sm" onClick={addNote}>
<PlusCircle className="mr-2 h-4 w-4" /> Add Note <PlusCircle className="mr-2 h-4 w-4" />
</Button> {" "}
</> Add Note
); </Button>
</>
);
} }
const NoteItem = memo( NoteItem.displayName = "NoteItem";
({
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>) => { export default memo(Note);
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,11 +1,16 @@
import { Button } from "@/components/ui/button"; import type { z } from "zod";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { OperatingSystems } from "@/config/siteConfig";
import { PlusCircle, Trash2 } from "lucide-react"; import { PlusCircle, Trash2 } from "lucide-react";
import { memo, useCallback, useRef } from "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 = { type InstallMethodProps = {
script: Script; script: Script;
@ -28,9 +33,11 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
if (type === "pve") { if (type === "pve") {
scriptPath = `tools/pve/${slug}.sh`; scriptPath = `tools/pve/${slug}.sh`;
} else if (type === "addon") { }
else if (type === "addon") {
scriptPath = `tools/addon/${slug}.sh`; scriptPath = `tools/addon/${slug}.sh`;
} else { }
else {
scriptPath = `${type}/${slug}.sh`; scriptPath = `${type}/${slug}.sh`;
} }
@ -65,8 +72,8 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
const updatedMethod = { ...method, [key]: value }; const updatedMethod = { ...method, [key]: value };
if (key === "type") { if (key === "type") {
updatedMethod.script = updatedMethod.script
value === "alpine" ? `${prev.type}/alpine-${prev.slug}.sh` : `${prev.type}/${prev.slug}.sh`; = value === "alpine" ? `${prev.type}/alpine-${prev.slug}.sh` : `${prev.type}/${prev.slug}.sh`;
// Set OS to Alpine and reset version if type is alpine // Set OS to Alpine and reset version if type is alpine
if (value === "alpine") { if (value === "alpine") {
@ -89,7 +96,8 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
setIsValid(result.success); setIsValid(result.success);
if (!result.success) { if (!result.success) {
setZodErrors(result.error); setZodErrors(result.error);
} else { }
else {
setZodErrors(null); setZodErrors(null);
} }
return updated; return updated;
@ -100,7 +108,7 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
const removeInstallMethod = useCallback( const removeInstallMethod = useCallback(
(index: number) => { (index: number) => {
setScript((prev) => ({ setScript(prev => ({
...prev, ...prev,
install_methods: prev.install_methods.filter((_, i) => i !== index), 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> <h3 className="text-xl font-semibold">Install Methods</h3>
{script.install_methods.map((method, index) => ( {script.install_methods.map((method, index) => (
<div key={index} className="space-y-2 border p-4 rounded"> <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> <SelectTrigger>
<SelectValue placeholder="Type" /> <SelectValue placeholder="Type" />
</SelectTrigger> </SelectTrigger>
@ -130,12 +138,11 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
placeholder="CPU in Cores" placeholder="CPU in Cores"
type="number" type="number"
value={method.resources.cpu || ""} value={method.resources.cpu || ""}
onChange={(e) => onChange={e =>
updateInstallMethod(index, "resources", { updateInstallMethod(index, "resources", {
...method.resources, ...method.resources,
cpu: e.target.value ? Number(e.target.value) : null, cpu: e.target.value ? Number(e.target.value) : null,
}) })}
}
/> />
<Input <Input
ref={(el) => { ref={(el) => {
@ -144,12 +151,11 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
placeholder="RAM in MB" placeholder="RAM in MB"
type="number" type="number"
value={method.resources.ram || ""} value={method.resources.ram || ""}
onChange={(e) => onChange={e =>
updateInstallMethod(index, "resources", { updateInstallMethod(index, "resources", {
...method.resources, ...method.resources,
ram: e.target.value ? Number(e.target.value) : null, ram: e.target.value ? Number(e.target.value) : null,
}) })}
}
/> />
<Input <Input
ref={(el) => { ref={(el) => {
@ -158,31 +164,29 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
placeholder="HDD in GB" placeholder="HDD in GB"
type="number" type="number"
value={method.resources.hdd || ""} value={method.resources.hdd || ""}
onChange={(e) => onChange={e =>
updateInstallMethod(index, "resources", { updateInstallMethod(index, "resources", {
...method.resources, ...method.resources,
hdd: e.target.value ? Number(e.target.value) : null, hdd: e.target.value ? Number(e.target.value) : null,
}) })}
}
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select
value={method.resources.os || undefined} value={method.resources.os || undefined}
onValueChange={(value) => onValueChange={value =>
updateInstallMethod(index, "resources", { updateInstallMethod(index, "resources", {
...method.resources, ...method.resources,
os: value || null, os: value || null,
version: null, // Reset version when OS changes version: null, // Reset version when OS changes
}) })}
}
disabled={method.type === "alpine"} disabled={method.type === "alpine"}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="OS" /> <SelectValue placeholder="OS" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{OperatingSystems.map((os) => ( {OperatingSystems.map(os => (
<SelectItem key={os.name} value={os.name}> <SelectItem key={os.name} value={os.name}>
{os.name} {os.name}
</SelectItem> </SelectItem>
@ -191,19 +195,18 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
</Select> </Select>
<Select <Select
value={method.resources.version || undefined} value={method.resources.version || undefined}
onValueChange={(value) => onValueChange={value =>
updateInstallMethod(index, "resources", { updateInstallMethod(index, "resources", {
...method.resources, ...method.resources,
version: value || null, version: value || null,
}) })}
}
disabled={method.type === "alpine"} disabled={method.type === "alpine"}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Version" /> <SelectValue placeholder="Version" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <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}> <SelectItem key={version.slug} value={version.name}>
{version.name} {version.name}
</SelectItem> </SelectItem>
@ -212,12 +215,16 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
</Select> </Select>
</div> </div>
<Button variant="destructive" size="sm" type="button" onClick={() => removeInstallMethod(index)}> <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> </Button>
</div> </div>
))} ))}
<Button type="button" size="sm" disabled={script.install_methods.length >= 2} onClick={addInstallMethod}> <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> </Button>
</> </>
); );

View File

@ -2,7 +2,7 @@ import { z } from "zod";
export const InstallMethodSchema = z.object({ export const InstallMethodSchema = z.object({
type: z.enum(["default", "alpine"], { 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"), script: z.string().min(1, "Script content cannot be empty"),
resources: z.object({ resources: z.object({
@ -25,7 +25,7 @@ export const ScriptSchema = z.object({
categories: z.array(z.number()), 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"), 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"], { 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(), updateable: z.boolean(),
privileged: z.boolean(), privileged: z.boolean(),

View File

@ -1,26 +1,32 @@
"use client"; "use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import type { z } from "zod";
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 { CalendarIcon, Check, Clipboard, Download } from "lucide-react"; import { CalendarIcon, Check, Clipboard, Download } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { format } from "date-fns";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
import Categories from "./_components/Categories"; import type { Category } from "@/lib/types";
import InstallMethod from "./_components/InstallMethod";
import Note from "./_components/Note"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScriptSchema, type Script } from "./_schemas/schemas"; 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 = { const initialScript: Script = {
name: "", name: "",
@ -54,7 +60,7 @@ export default function JSONGenerator() {
useEffect(() => { useEffect(() => {
fetchCategories() fetchCategories()
.then(setCategories) .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]) => { const updateScript = useCallback((key: keyof Script, value: Script[keyof Script]) => {
@ -67,11 +73,14 @@ export default function JSONGenerator() {
if (updated.type === "pve") { if (updated.type === "pve") {
scriptPath = `tools/pve/${updated.slug}.sh`; scriptPath = `tools/pve/${updated.slug}.sh`;
} else if (updated.type === "addon") { }
else if (updated.type === "addon") {
scriptPath = `tools/addon/${updated.slug}.sh`; scriptPath = `tools/addon/${updated.slug}.sh`;
} else if (method.type === "alpine") { }
else if (method.type === "alpine") {
scriptPath = `${updated.type}/alpine-${updated.slug}.sh`; scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
} else { }
else {
scriptPath = `${updated.type}/${updated.slug}.sh`; scriptPath = `${updated.type}/${updated.slug}.sh`;
} }
@ -136,7 +145,10 @@ export default function JSONGenerator() {
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{zodErrors.errors.map((error, index) => ( {zodErrors.errors.map((error, index) => (
<AlertDescription key={index} className="p-1 text-red-500"> <AlertDescription key={index} className="p-1 text-red-500">
{error.path.join(".")} - {error.message} {error.path.join(".")}
{" "}
-
{error.message}
</AlertDescription> </AlertDescription>
))} ))}
</div> </div>
@ -154,25 +166,31 @@ export default function JSONGenerator() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label> <Label>
Name <span className="text-red-500">*</span> Name
{" "}
<span className="text-red-500">*</span>
</Label> </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>
<div> <div>
<Label> <Label>
Slug <span className="text-red-500">*</span> Slug
{" "}
<span className="text-red-500">*</span>
</Label> </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> </div>
<div> <div>
<Label> <Label>
Logo <span className="text-red-500">*</span> Logo
{" "}
<span className="text-red-500">*</span>
</Label> </Label>
<Input <Input
placeholder="Full logo URL" placeholder="Full logo URL"
value={script.logo || ""} value={script.logo || ""}
onChange={(e) => updateScript("logo", e.target.value || null)} onChange={e => updateScript("logo", e.target.value || null)}
/> />
</div> </div>
<div> <div>
@ -180,17 +198,19 @@ export default function JSONGenerator() {
<Input <Input
placeholder="Path to config file" placeholder="Path to config file"
value={script.config_path || ""} value={script.config_path || ""}
onChange={(e) => updateScript("config_path", e.target.value || null)} onChange={e => updateScript("config_path", e.target.value || null)}
/> />
</div> </div>
<div> <div>
<Label> <Label>
Description <span className="text-red-500">*</span> Description
{" "}
<span className="text-red-500">*</span>
</Label> </Label>
<Textarea <Textarea
placeholder="Example" placeholder="Example"
value={script.description} value={script.description}
onChange={(e) => updateScript("description", e.target.value)} onChange={e => updateScript("description", e.target.value)}
/> />
</div> </div>
<Categories script={script} setScript={setScript} categories={categories} /> <Categories script={script} setScript={setScript} categories={categories} />
@ -200,7 +220,7 @@ export default function JSONGenerator() {
<Popover> <Popover>
<PopoverTrigger asChild className="flex-1"> <PopoverTrigger asChild className="flex-1">
<Button <Button
variant={"outline"} variant="outline"
className={cn("pl-3 text-left font-normal w-full", !script.date_created && "text-muted-foreground")} className={cn("pl-3 text-left font-normal w-full", !script.date_created && "text-muted-foreground")}
> >
{formattedDate || <span>Pick a date</span>} {formattedDate || <span>Pick a date</span>}
@ -219,7 +239,7 @@ export default function JSONGenerator() {
</div> </div>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<Label>Type</Label> <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"> <SelectTrigger className="flex-1">
<SelectValue placeholder="Type" /> <SelectValue placeholder="Type" />
</SelectTrigger> </SelectTrigger>
@ -234,11 +254,11 @@ export default function JSONGenerator() {
</div> </div>
<div className="w-full flex gap-5"> <div className="w-full flex gap-5">
<div className="flex items-center space-x-2"> <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> <label>Updateable</label>
</div> </div>
<div className="flex items-center space-x-2"> <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> <label>Privileged</label>
</div> </div>
</div> </div>
@ -246,18 +266,18 @@ export default function JSONGenerator() {
placeholder="Interface Port" placeholder="Interface Port"
type="number" type="number"
value={script.interface_port || ""} 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"> <div className="flex gap-2">
<Input <Input
placeholder="Website URL" placeholder="Website URL"
value={script.website || ""} value={script.website || ""}
onChange={(e) => updateScript("website", e.target.value || null)} onChange={e => updateScript("website", e.target.value || null)}
/> />
<Input <Input
placeholder="Documentation URL" placeholder="Documentation URL"
value={script.documentation || ""} value={script.documentation || ""}
onChange={(e) => updateScript("documentation", e.target.value || null)} onChange={e => updateScript("documentation", e.target.value || null)}
/> />
</div> </div>
<InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} /> <InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
@ -265,22 +285,20 @@ export default function JSONGenerator() {
<Input <Input
placeholder="Username" placeholder="Username"
value={script.default_credentials.username || ""} value={script.default_credentials.username || ""}
onChange={(e) => onChange={e =>
updateScript("default_credentials", { updateScript("default_credentials", {
...script.default_credentials, ...script.default_credentials,
username: e.target.value || null, username: e.target.value || null,
}) })}
}
/> />
<Input <Input
placeholder="Password" placeholder="Password"
value={script.default_credentials.password || ""} value={script.default_credentials.password || ""}
onChange={(e) => onChange={e =>
updateScript("default_credentials", { updateScript("default_credentials", {
...script.default_credentials, ...script.default_credentials,
password: e.target.value || null, password: e.target.value || null,
}) })}
}
/> />
<Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} /> <Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
</form> </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 type { Metadata } from "next";
import { Inter } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app"; import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Inter } from "next/font/google";
import React from "react"; 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"] }); const inter = Inter({ subsets: ["latin"] });
export const metadata : Metadata = { export const metadata: Metadata = {
title: "Proxmox VE Helper-Scripts", title: "Proxmox VE Helper-Scripts",
description: description:
"The official website for the Proxmox VE Helper-Scripts (Community) Repository. Featuring over 300+ scripts to help you manage your Proxmox VE environment.", "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"; import type { MetadataRoute } from "next";
export const generateStaticParams = () => { import { basePath } from "@/config/site-config";
export function generateStaticParams() {
return []; return [];
}; }
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {

View File

@ -1,8 +1,10 @@
"use client"; "use client";
import FAQ from "@/components/FAQ"; import { ArrowRightIcon, ExternalLink } from "lucide-react";
import AnimatedGradientText from "@/components/ui/animated-gradient-text"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { FaGithub } from "react-icons/fa";
import { CardFooter } from "@/components/ui/card"; import { useTheme } from "next-themes";
import Link from "next/link";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -11,15 +13,14 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } 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 { 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 { 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() { function CustomArrowRightIcon() {
return <ArrowRightIcon className="h-4 w-4" width={1} />; return <ArrowRightIcon className="h-4 w-4" width={1} />;
@ -50,7 +51,9 @@ export default function Page() {
`p-px ![mask-composite:subtract]`, `p-px ![mask-composite:subtract]`,
)} )}
/> />
<Separator className="mx-2 h-4" orientation="vertical" />
{" "}
<Separator className="mx-2 h-4" orientation="vertical" />
<span <span
className={cn( 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`, `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" rel="noopener noreferrer"
className="flex items-center justify-center" 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> </a>
</Button> </Button>
<Button className="w-full" asChild> <Button className="w-full" asChild>
@ -88,7 +93,9 @@ export default function Page() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center" 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> </a>
</Button> </Button>
</CardFooter> </CardFooter>
@ -104,7 +111,10 @@ export default function Page() {
We are a community-driven initiative that simplifies the setup of Proxmox Virtual Environment (VE). We are a community-driven initiative that simplifies the setup of Proxmox Virtual Environment (VE).
</p> </p>
<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. user or a newcomer, we&#39;ve got you covered.
</p> </p>
</div> </div>

View File

@ -1,6 +1,7 @@
import { basePath } from "@/config/siteConfig";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { basePath } from "@/config/site-config";
export const dynamic = "force-static"; export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {

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 { 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 = { type NoteProps = {
text: string; text: string;
type: keyof typeof AlertColors; type: keyof typeof AlertColors;
} };
export default function Alerts({ item }: { item: Script }) { export default function Alerts({ item }: { item: Script }) {
return ( return (
<> <>
{item?.notes?.length > 0 && {item?.notes?.length > 0
item.notes.map((note: NoteProps, index: number) => ( && item.notes.map((note: NoteProps, index: number) => (
<div key={index} className="mt-4 flex flex-col shadow-sm gap-2"> <div key={index} className="mt-4 flex flex-col shadow-sm gap-2">
<p <p
className={cn( className={cn(
@ -21,11 +23,13 @@ export default function Alerts({ item }: { item: Script }) {
AlertColors[note.type], AlertColors[note.type],
)} )}
> >
{note.type == "info" ? ( {note.type === "info"
<NotepadText className="h-4 min-h-4 w-4 min-w-4" /> ? (
) : ( <NotepadText className="h-4 min-h-4 w-4 min-w-4" />
<AlertCircle 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> <span>{TextCopyBlock(note.text)}</span>
</p> </p>
</div> </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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { basePath } from "@/config/siteConfig"; import { Button } from "@/components/ui/button";
import { Script } from "@/lib/types"; import { basePath } from "@/config/site-config";
import { BookOpenText, Code, Globe, LinkIcon, RefreshCcw } from "lucide-react";
const generateInstallSourceUrl = (slug: string) => { function generateInstallSourceUrl(slug: string) {
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`; const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
return `${baseUrl}/install/${slug}-install.sh`; 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`; const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
switch (type) { switch (type) {
@ -29,18 +31,18 @@ const generateSourceUrl = (slug: string, type: string) => {
default: default:
return `${baseUrl}/ct/${slug}.sh`; // fallback for "ct" 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`; const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
return `${baseUrl}/ct/${slug}.sh`; return `${baseUrl}/ct/${slug}.sh`;
}; }
interface LinkItem { type LinkItem = {
href: string; href: string;
icon: React.ReactNode; icon: React.ReactNode;
text: string; text: string;
} };
export default function Buttons({ item }: { item: Script }) { export default function Buttons({ item }: { item: Script }) {
const isCtOrDefault = ["ct"].includes(item.type); const isCtOrDefault = ["ct"].includes(item.type);
@ -76,7 +78,8 @@ export default function Buttons({ item }: { item: Script }) {
}, },
].filter(Boolean) as LinkItem[]; ].filter(Boolean) as LinkItem[];
if (links.length === 0) return null; if (links.length === 0)
return null;
return ( return (
<DropdownMenu> <DropdownMenu>

View File

@ -1,5 +1,6 @@
import TextCopyBlock from "@/components/TextCopyBlock"; import type { Script } from "@/lib/types";
import { Script } from "@/lib/types";
import TextCopyBlock from "@/components/text-copy-block";
export default function Description({ item }: { item: Script }) { export default function Description({ item }: { item: Script }) {
return ( return (

View File

@ -1,77 +0,0 @@
import CodeCopyButton from "@/components/ui/code-copy-button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { basePath } from "@/config/siteConfig";
import { Script } from "@/lib/types";
import { getDisplayValueFromType } from "../ScriptInfoBlocks";
const getInstallCommand = (scriptPath = "", isAlpine = false) => {
const url = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`;
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>
)}
</>
);
return (
<div className="p-4">
{alpineScript ? (
<Tabs defaultValue="default" className="mt-2 w-full max-w-4xl">
<TabsList>
<TabsTrigger value="default">Default</TabsTrigger>
<TabsTrigger value="alpine">Alpine Linux</TabsTrigger>
</TabsList>
<TabsContent value="default">
{renderInstructions()}
<CodeCopyButton>{getInstallCommand(defaultScript?.script)}</CodeCopyButton>
</TabsContent>
<TabsContent value="alpine">
{renderInstructions(true)}
<CodeCopyButton>{getInstallCommand(alpineScript.script, true)}</CodeCopyButton>
</TabsContent>
</Tabs>
) : defaultScript?.script ? (
<>
{renderInstructions()}
<CodeCopyButton>{getInstallCommand(defaultScript.script)}</CodeCopyButton>
</>
) : null}
</div>
);
}

View File

@ -1,21 +1,25 @@
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"; 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 }) { export default function InterFaces({ item }: { item: Script }) {
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{item.interface_port !== null ? ( {item.interface_port !== null
<div className="flex items-center justify-end"> ? (
<h2 className="mr-2 text-end text-lg font-semibold">Default Interface:</h2> <div className="flex items-center justify-end">
<span className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex items-center gap-2")}> <h2 className="mr-2 text-end text-lg font-semibold">Default Interface:</h2>
{item.interface_port} <span className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex items-center gap-2")}>
<ClipboardIcon onClick={() => handleCopy("default interface", String(item.interface_port))} className="size-4 cursor-pointer" /> {item.interface_port}
</span> <ClipboardIcon onClick={() => handleCopy("default interface", String(item.interface_port))} className="size-4 cursor-pointer" />
</div> </span>
) : null} </div>
)
: null}
</div> </div>
); );
} }

View File

@ -1,13 +1,15 @@
import handleCopy from "@/components/handleCopy"; import type { Script } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; 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 }) { export default function DefaultPassword({ item }: { item: Script }) {
const { username, password } = item.default_credentials; const { username, password } = item.default_credentials;
const hasDefaultLogin = username || password; const hasDefaultLogin = username || password;
if (!hasDefaultLogin) return null; if (!hasDefaultLogin)
return null;
const copyCredential = (type: "username" | "password") => { const copyCredential = (type: "username" | "password") => {
handleCopy(type, item.default_credentials[type] ?? ""); handleCopy(type, item.default_credentials[type] ?? "");
@ -21,18 +23,27 @@ export default function DefaultPassword({ item }: { item: Script }) {
<Separator className="w-full" /> <Separator className="w-full" />
<div className="flex flex-col gap-2 p-4"> <div className="flex flex-col gap-2 p-4">
<p className="mb-2 text-sm"> <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> </p>
{["username", "password"].map((type) => { {["username", "password"].map((type) => {
const value = item.default_credentials[type as "username" | "password"]; const value = item.default_credentials[type as "username" | "password"];
return value && value.trim() !== "" ? ( return value && value.trim() !== ""
<div key={type} className="text-sm"> ? (
{type.charAt(0).toUpperCase() + type.slice(1)}:{" "} <div key={type} className="text-sm">
<Button variant="secondary" size="null" onClick={() => copyCredential(type as "username" | "password")}> {type.charAt(0).toUpperCase() + type.slice(1)}
{value} :
</Button> {" "}
</div> <Button variant="secondary" size="null" onClick={() => copyCredential(type as "username" | "password")}>
) : null; {value}
</Button>
</div>
)
: null;
})} })}
</div> </div>
</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 }) { export default function DefaultSettings({ item }: { item: Script }) {
const getDisplayValueFromRAM = (ram: number) => (ram >= 1024 ? `${Math.floor(ram / 1024)}GB` : `${ram}MB`); 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 ( return (
<div> <div>
<h2 className="text-md font-semibold">{title}</h2> <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">
<p className="text-sm text-muted-foreground">RAM: {getDisplayValueFromRAM(ram ?? 0)}</p> CPU:
<p className="text-sm text-muted-foreground">HDD: {hdd}GB</p> {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> </div>
); );
}; };
const defaultSettings = item.install_methods.find((method) => method.type === "default"); const defaultSettings = item.install_methods.find(method => method.type === "default");
const defaultAlpineSettings = item.install_methods.find((method) => method.type === "alpine"); const defaultAlpineSettings = item.install_methods.find(method => method.type === "alpine");
const hasDefaultSettings = defaultSettings?.resources && Object.values(defaultSettings.resources).some(Boolean); const hasDefaultSettings = defaultSettings?.resources && Object.values(defaultSettings.resources).some(Boolean);

View File

@ -0,0 +1,114 @@
import type { Script } from "@/lib/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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) {
const url = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`;
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>
)}
</>
);
return (
<div className="p-4">
{alpineScript
? (
<Tabs defaultValue="default" className="mt-2 w-full max-w-4xl">
<TabsList>
<TabsTrigger value="default">Default</TabsTrigger>
<TabsTrigger value="alpine">Alpine Linux</TabsTrigger>
</TabsList>
<TabsContent value="default">
{renderInstructions()}
<CodeCopyButton>{getInstallCommand(defaultScript?.script)}</CodeCopyButton>
</TabsContent>
<TabsContent value="alpine">
{renderInstructions(true)}
<CodeCopyButton>{getInstallCommand(alpineScript.script, true)}</CodeCopyButton>
</TabsContent>
</Tabs>
)
: defaultScript?.script
? (
<>
{renderInstructions()}
<CodeCopyButton>{getInstallCommand(defaultScript.script)}</CodeCopyButton>
</>
)
: null}
</div>
);
}

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 { CircleHelp } from "lucide-react";
import React from "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"]; variant: BadgeProps["variant"];
label: string; label: string;
content?: string; content?: string;
} };
const TooltipBadge: React.FC<TooltipProps> = ({ variant, label, content }) => ( const TooltipBadge: React.FC<TooltipProps> = ({ variant, label, content }) => (
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={100}> <Tooltip delayDuration={100}>
<TooltipTrigger className={cn("flex items-center", !content && "cursor-default")}> <TooltipTrigger className={cn("flex items-center", !content && "cursor-default")}>
<Badge variant={variant} className="flex items-center gap-1"> <Badge variant={variant} className="flex items-center gap-1">
{label} {content && <CircleHelp className="size-3" />} {label}
{" "}
{content && <CircleHelp className="size-3" />}
</Badge> </Badge>
</TooltipTrigger> </TooltipTrigger>
{content && ( {content && (

View File

@ -1,43 +1,46 @@
"use client"; "use client";
import type { Category, Script } from "@/lib/types"; import type { Category, Script } from "@/lib/types";
import ScriptAccordion from "./ScriptAccordion";
const Sidebar = ({ import ScriptAccordion from "./script-accordion";
items,
selectedScript, function Sidebar({
setSelectedScript, items,
selectedScript,
setSelectedScript,
}: { }: {
items: Category[]; items: Category[];
selectedScript: string | null; selectedScript: string | null;
setSelectedScript: (script: string | null) => void; setSelectedScript: (script: string | null) => void;
}) => { }) {
const uniqueScripts = items.reduce((acc, category) => { const uniqueScripts = items.reduce((acc, category) => {
for (const script of category.scripts) { for (const script of category.scripts) {
if (!acc.some((s) => s.name === script.name)) { if (!acc.some(s => s.name === script.name)) {
acc.push(script); acc.push(script);
} }
} }
return acc; return acc;
}, [] as Script[]); }, [] as Script[]);
return ( return (
<div className="flex min-w-[350px] flex-col sm:max-w-[350px]"> <div className="flex min-w-[350px] flex-col sm:max-w-[350px]">
<div className="flex items-end justify-between pb-4"> <div className="flex items-end justify-between pb-4">
<h1 className="text-xl font-bold">Categories</h1> <h1 className="text-xl font-bold">Categories</h1>
<p className="text-xs italic text-muted-foreground"> <p className="text-xs italic text-muted-foreground">
{uniqueScripts.length} Total scripts {uniqueScripts.length}
</p> {" "}
</div> Total scripts
<div className="rounded-lg"> </p>
<ScriptAccordion </div>
items={items} <div className="rounded-lg">
selectedScript={selectedScript} <ScriptAccordion
setSelectedScript={setSelectedScript} items={items}
/> selectedScript={selectedScript}
</div> setSelectedScript={setSelectedScript}
</div> />
); </div>
}; </div>
);
}
export default Sidebar; export default Sidebar;

View File

@ -1,17 +1,17 @@
import { CPUIcon, HDDIcon, RAMIcon } from "@/components/icons/resource-icons"; import { CPUIcon, HDDIcon, RAMIcon } from "@/components/icons/resource-icons";
import { getDisplayValueFromRAM } from "@/lib/utils/resource-utils"; import { getDisplayValueFromRAM } from "@/lib/utils/resource-utils";
interface ResourceDisplayProps { type ResourceDisplayProps = {
title: string; title: string;
cpu: number | null; cpu: number | null;
ram: number | null; ram: number | null;
hdd: number | null; hdd: number | null;
} };
interface IconTextProps { type IconTextProps = {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
} };
function IconText({ icon, label }: IconTextProps) { function IconText({ icon, label }: IconTextProps) {
return ( return (
@ -27,7 +27,8 @@ export function ResourceDisplay({ title, cpu, ram, hdd }: ResourceDisplayProps)
const hasRAM = typeof ram === "number" && ram > 0; const hasRAM = typeof ram === "number" && ram > 0;
const hasHDD = typeof hdd === "number" && hdd > 0; const hasHDD = typeof hdd === "number" && hdd > 0;
if (!hasCPU && !hasRAM && !hasHDD) return null; if (!hasCPU && !hasRAM && !hasHDD)
return null;
return ( return (
<div className="flex flex-wrap items-center gap-2"> <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 { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } 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 { 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({ export default function ScriptAccordion({
items, items,
@ -41,8 +41,8 @@ export default function ScriptAccordion({
useEffect(() => { useEffect(() => {
if (selectedScript) { if (selectedScript) {
const category = items.find((category) => const category = items.find(category =>
category.scripts.some((script) => script.slug === selectedScript), category.scripts.some(script => script.slug === selectedScript),
); );
if (category) { if (category) {
setExpandedItem(category.name); setExpandedItem(category.name);
@ -58,9 +58,9 @@ export default function ScriptAccordion({
collapsible collapsible
className="overflow-y-scroll max-h-[calc(100vh-225px)] overflow-x-hidden p-2" className="overflow-y-scroll max-h-[calc(100vh-225px)] overflow-x-hidden p-2"
> >
{items.map((category) => ( {items.map(category => (
<AccordionItem <AccordionItem
key={category.id + ":category"} key={`${category.id}:category`}
value={category.name} value={category.name}
className={cn("sm:text-sm flex flex-col border-none", { className={cn("sm:text-sm flex flex-col border-none", {
"rounded-lg bg-accent/30": expandedItem === category.name, "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"> <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"> <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} {category.scripts.length}
</span> </span>
</div>{" "} </div>
{" "}
</AccordionTrigger> </AccordionTrigger>
<AccordionContent <AccordionContent
data-state={expandedItem === category.name ? "open" : "closed"} data-state={expandedItem === category.name ? "open" : "closed"}
@ -109,10 +113,9 @@ export default function ScriptAccordion({
height={16} height={16}
width={16} width={16}
unoptimized unoptimized
onError={(e) => onError={e =>
((e.currentTarget as HTMLImageElement).src = ((e.currentTarget as HTMLImageElement).src
`/${basePath}/logo.png`) = `/${basePath}/logo.png`)}
}
alt={script.name} alt={script.name}
className="mr-1 w-4 h-4 rounded-full" 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 { CalendarPlus } from "lucide-react";
import { useMemo, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; 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; const ITEMS_PER_PAGE = 3;
export const getDisplayValueFromType = (type: string) => { export function getDisplayValueFromType(type: string) {
switch (type) { switch (type) {
case "ct": case "ct":
return "LXC"; return "LXC";
@ -22,15 +24,16 @@ export const getDisplayValueFromType = (type: string) => {
default: default:
return ""; return "";
} }
}; }
export function LatestScripts({ items }: { items: Category[] }) { export function LatestScripts({ items }: { items: Category[] }) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const latestScripts = useMemo(() => { 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 // Filter out duplicates by slug
const uniqueScriptsMap = new Map<string, Script>(); const uniqueScriptsMap = new Map<string, Script>();
@ -46,11 +49,11 @@ export function LatestScripts({ items }: { items: Category[] }) {
}, [items]); }, [items]);
const goToNextPage = () => { const goToNextPage = () => {
setPage((prevPage) => prevPage + 1); setPage(prevPage => prevPage + 1);
}; };
const goToPreviousPage = () => { const goToPreviousPage = () => {
setPage((prevPage) => prevPage - 1); setPage(prevPage => prevPage - 1);
}; };
const startIndex = (page - 1) * ITEMS_PER_PAGE; const startIndex = (page - 1) * ITEMS_PER_PAGE;
@ -80,7 +83,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
</div> </div>
)} )}
<div className="min-w flex w-full flex-row flex-wrap gap-4"> <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"> <Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-3"> <CardTitle className="flex items-center gap-3">
@ -91,13 +94,15 @@ export function LatestScripts({ items }: { items: Category[] }) {
height={64} height={64}
width={64} width={64}
alt="" 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" className="h-11 w-11 object-contain"
/> />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-lg line-clamp-1"> <p className="text-lg line-clamp-1">
{script.name} {getDisplayValueFromType(script.type)} {script.name}
{" "}
{getDisplayValueFromType(script.type)}
</p> </p>
<p className="text-sm text-muted-foreground flex items-center gap-1"> <p className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarPlus className="h-4 w-4" /> <CalendarPlus className="h-4 w-4" />
@ -130,7 +135,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
export function MostViewedScripts({ items }: { items: Category[] }) { export function MostViewedScripts({ items }: { items: Category[] }) {
const mostViewedScripts = items.reduce((acc: Script[], 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); 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"> <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"> <Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-3"> <CardTitle className="flex items-center gap-3">
@ -153,13 +158,15 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
height={64} height={64}
width={64} width={64}
alt="" 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" className="h-11 w-11 object-contain"
/> />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="line-clamp-1 text-lg"> <p className="line-clamp-1 text-lg">
{script.name} {getDisplayValueFromType(script.type)} {script.name}
{" "}
{getDisplayValueFromType(script.type)}
</p> </p>
<p className="flex items-center gap-1 text-sm text-muted-foreground"> <p className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarPlus className="h-4 w-4" /> <CalendarPlus className="h-4 w-4" />

View File

@ -1,31 +1,32 @@
"use client"; "use client";
import { extractDate } from "@/lib/time";
import { AppVersion, Script } from "@/lib/types";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Suspense } from "react";
import Image from "next/image"; import Image from "next/image";
import { Separator } from "@/components/ui/separator"; import type { AppVersion, Script } from "@/lib/types";
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";
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 "./ScriptItems/default-password";
import InstallCommand from "./ScriptItems/install-command";
import { ResourceDisplay } from "./resource-display";
import Description from "./ScriptItems/description";
import ConfigFile from "./ScriptItems/config-file";
import InterFaces from "./ScriptItems/interfaces";
import Tooltips from "./ScriptItems/tool-tips";
import Buttons from "./ScriptItems/buttons";
import Alerts from "./ScriptItems/alerts";
type ScriptItemProps = {
item: Script; item: Script;
setSelectedScript: (script: string | null) => void; setSelectedScript: (script: string | null) => void;
} };
function ScriptHeader({ item }: { item: Script }) { function ScriptHeader({ item }: { item: Script }) {
const defaultInstallMethod = item.install_methods?.[0]; 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" 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`} src={item.logo || `/${basePath}/logo.png`}
width={400} width={400}
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)} onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
height={400} height={400}
alt={item.name} alt={item.name}
unoptimized unoptimized
@ -58,10 +59,15 @@ function ScriptHeader({ item }: { item: Script }) {
</span> </span>
</h1> </h1>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground"> <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></span>
<span className=" capitalize"> <span className=" capitalize">
{os} {version} {os}
{" "}
{version}
</span> </span>
</div> </div>
</div> </div>
@ -76,10 +82,10 @@ function ScriptHeader({ item }: { item: Script }) {
hdd={defaultInstallMethod.resources.hdd} hdd={defaultInstallMethod.resources.hdd}
/> />
)} )}
{item.install_methods.find((method) => method.type === "alpine")?.resources && ( {item.install_methods.find(method => method.type === "alpine")?.resources && (
<ResourceDisplay <ResourceDisplay
title="Alpine" title="Alpine"
{...item.install_methods.find((method) => method.type === "alpine")!.resources!} {...item.install_methods.find(method => method.type === "alpine")!.resources!}
/> />
)} )}
</div> </div>
@ -108,7 +114,8 @@ function VersionInfo({ item }: { item: Script }) {
return cleanName === cleanSlug(item.slug) || cleanName.includes(cleanSlug(item.slug)); 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>; return <span className="font-medium text-sm">{matchedVersion.version}</span>;
} }
@ -144,7 +151,9 @@ export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
<div className="mt-4 rounded-lg border shadow-sm"> <div className="mt-4 rounded-lg border shadow-sm">
<div className="flex gap-3 px-4 py-2 bg-accent/25"> <div className="flex gap-3 px-4 py-2 bg-accent/25">
<h2 className="text-lg font-semibold"> <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> </h2>
<Tooltips item={item} /> <Tooltips item={item} />
</div> </div>

View File

@ -1,8 +1,8 @@
import { AppVersion } from "@/lib/types"; import type { AppVersion } from "@/lib/types";
interface VersionBadgeProps { type VersionBadgeProps = {
version: AppVersion; version: AppVersion;
} };
export function VersionBadge({ version }: VersionBadgeProps) { export function VersionBadge({ version }: VersionBadgeProps) {
return ( return (

View File

@ -1,18 +1,20 @@
"use client"; "use client";
import { Suspense, useEffect, useState } from "react";
export const dynamic = "force-static";
import { ScriptItem } from "@/app/scripts/_components/ScriptItem";
import { fetchCategories } from "@/lib/data";
import { Category, Script } from "@/lib/types";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useQueryState } from "nuqs"; 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 { import {
LatestScripts, LatestScripts,
MostViewedScripts, MostViewedScripts,
} from "./_components/ScriptInfoBlocks"; } from "./_components/script-info-blocks";
import Sidebar from "./_components/Sidebar"; import Sidebar from "./_components/sidebar";
export const dynamic = "force-static";
function ScriptContent() { function ScriptContent() {
const [selectedScript, setSelectedScript] = useQueryState("id"); const [selectedScript, setSelectedScript] = useQueryState("id");
@ -22,9 +24,9 @@ function ScriptContent() {
useEffect(() => { useEffect(() => {
if (selectedScript && links.length > 0) { if (selectedScript && links.length > 0) {
const script = links const script = links
.map((category) => category.scripts) .map(category => category.scripts)
.flat() .flat()
.find((script) => script.slug === selectedScript); .find(script => script.slug === selectedScript);
setItem(script); setItem(script);
} }
}, [selectedScript, links]); }, [selectedScript, links]);
@ -34,7 +36,7 @@ function ScriptContent() {
.then((categories) => { .then((categories) => {
setLinks(categories); setLinks(categories);
}) })
.catch((error) => console.error(error)); .catch(error => console.error(error));
}, []); }, []);
return ( return (
@ -48,14 +50,16 @@ function ScriptContent() {
/> />
</div> </div>
<div className="mx-4 w-full sm:mx-0 sm:ml-4"> <div className="mx-4 w-full sm:mx-0 sm:ml-4">
{selectedScript && item ? ( {selectedScript && item
<ScriptItem item={item} setSelectedScript={setSelectedScript} /> ? (
) : ( <ScriptItem item={item} setSelectedScript={setSelectedScript} />
<div className="flex w-full flex-col gap-5"> )
<LatestScripts items={links} /> : (
<MostViewedScripts items={links} /> <div className="flex w-full flex-col gap-5">
</div> <LatestScripts items={links} />
)} <MostViewedScripts items={links} />
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@ -65,13 +69,13 @@ function ScriptContent() {
export default function Page() { export default function Page() {
return ( return (
<Suspense <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="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"> <div className="space-y-2 text-center">
<Loader2 className="h-10 w-10 animate-spin" /> <Loader2 className="h-10 w-10 animate-spin" />
</div> </div>
</div> </div>
} )}
> >
<ScriptContent /> <ScriptContent />
</Suspense> </Suspense>

View File

@ -1,11 +1,12 @@
import { basePath } from "@/config/siteConfig";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { basePath } from "@/config/site-config";
export const dynamic = "force-static"; export const dynamic = "force-static";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
let domain = "community-scripts.github.io"; const domain = "community-scripts.github.io";
let protocol = "https"; const protocol = "https";
return [ return [
{ {
url: `${protocol}://${domain}/${basePath}`, url: `${protocol}://${domain}/${basePath}`,
@ -18,6 +19,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ {
url: `${protocol}://${domain}/${basePath}/json-editor`, url: `${protocol}://${domain}/${basePath}/json-editor`,
lastModified: new Date(), lastModified: new Date(),
} },
]; ];
} }

View File

@ -1,7 +1,8 @@
import * as AccordionPrimitive from "@radix-ui/react-accordion"; import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { FAQ_Items } from "../config/faqConfig";
import { Accordion, AccordionContent, AccordionItem } from "./ui/accordion"; import { Accordion, AccordionContent, AccordionItem } from "./ui/accordion";
import { FAQ_Items } from "../config/faq-config";
export default function FAQ() { export default function FAQ() {
return ( return (

View File

@ -1,16 +1,19 @@
import { basePath } from "@/config/siteConfig"; import { FileJson, Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { FileJson, Server, ExternalLink } from "lucide-react";
import { buttonVariants } from "./ui/button"; import { basePath } from "@/config/site-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buttonVariants } from "./ui/button";
export default function Footer() { export default function Footer() {
return ( return (
<div className="supports-backdrop-blur:bg-background/90 mt-auto border-t w-full flex justify-between border-border bg-background/40 py-4 backdrop-blur-lg"> <div className="supports-backdrop-blur:bg-background/90 mt-auto border-t w-full flex justify-between border-border bg-background/40 py-4 backdrop-blur-lg">
<div className="mx-6 w-full flex justify-between text-xs sm:text-sm text-muted-foreground"> <div className="mx-6 w-full flex justify-between text-xs sm:text-sm text-muted-foreground">
<div className="flex items-center"> <div className="flex items-center">
<p> <p>
Website built by the community. The source code is available on{" "} Website built by the community. The source code is available on
{" "}
<Link <Link
href={`https://github.com/community-scripts/${basePath}/tree/main/frontend`} href={`https://github.com/community-scripts/${basePath}/tree/main/frontend`}
target="_blank" target="_blank"
@ -28,13 +31,17 @@ export default function Footer() {
href="/json-editor" href="/json-editor"
className={cn(buttonVariants({ variant: "link" }), "text-muted-foreground flex items-center gap-2")} className={cn(buttonVariants({ variant: "link" }), "text-muted-foreground flex items-center gap-2")}
> >
<FileJson className="h-4 w-4" /> JSON Editor <FileJson className="h-4 w-4" />
{" "}
JSON Editor
</Link> </Link>
<Link <Link
href="/data" href="/data"
className={cn(buttonVariants({ variant: "link" }), "text-muted-foreground flex items-center gap-2")} className={cn(buttonVariants({ variant: "link" }), "text-muted-foreground flex items-center gap-2")}
> >
<Server className="h-4 w-4" /> API Data <Server className="h-4 w-4" />
{" "}
API Data
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -2,14 +2,15 @@
import React from "react"; import React from "react";
interface ModalProps { type ModalProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
} };
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => { const Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
if (!isOpen) return null; if (!isOpen)
return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">

View File

@ -1,15 +1,15 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react";
import { navbarLinks } from "@/config/siteConfig"; import { navbarLinks } from "@/config/site-config";
import { Button } from "@/components/ui/button";
import CommandMenu from "./CommandMenu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import StarOnGithubButton from "./ui/star-on-github-button"; import StarOnGithubButton from "./ui/star-on-github-button";
import { ThemeToggle } from "./ui/theme-toggle"; import { ThemeToggle } from "./ui/theme-toggle";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import CommandMenu from "./command-menu";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -28,59 +28,59 @@ function Navbar() {
}; };
}, []); }, []);
return ( return (
<> <>
<div <div
className={`fixed left-0 top-0 z-50 flex w-screen justify-center px-4 xl:px-0 ${ className={`fixed left-0 top-0 z-50 flex w-screen justify-center px-4 xl:px-0 ${
isScrolled ? "glass border-b bg-background/50" : "" isScrolled ? "glass border-b bg-background/50" : ""
}`} }`}
> >
<div className="flex h-20 w-full max-w-[1440px] items-center justify-between sm:flex-row"> <div className="flex h-20 w-full max-w-[1440px] items-center justify-between sm:flex-row">
<Link <Link
href={"/"} href="/"
className="flex cursor-pointer w-full justify-center sm:justify-start flex-row-reverse items-center gap-2 font-semibold sm:flex-row" className="flex cursor-pointer w-full justify-center sm:justify-start flex-row-reverse items-center gap-2 font-semibold sm:flex-row"
> >
<Image <Image
height={18} height={18}
unoptimized unoptimized
width={18} width={18}
alt="logo" alt="logo"
src="/ProxmoxVE/logo.png" src="/ProxmoxVE/logo.png"
className="" className=""
/> />
<span className="hidden md:block">Proxmox VE Helper-Scripts</span> <span className="hidden md:block">Proxmox VE Helper-Scripts</span>
</Link> </Link>
<div className="flex gap-2"> <div className="flex gap-2">
<CommandMenu /> <CommandMenu />
<StarOnGithubButton /> <StarOnGithubButton />
{navbarLinks.map(({ href, event, icon, text, mobileHidden }) => ( {navbarLinks.map(({ href, event, icon, text, mobileHidden }) => (
<TooltipProvider key={event}> <TooltipProvider key={event}>
<Tooltip delayDuration={100}> <Tooltip delayDuration={100}>
<TooltipTrigger <TooltipTrigger
className={mobileHidden ? "hidden lg:block" : ""} className={mobileHidden ? "hidden lg:block" : ""}
> >
<Button variant="ghost" size={"icon"} asChild> <Button variant="ghost" size="icon" asChild>
<Link <Link
target="_blank" target="_blank"
href={href} href={href}
data-umami-event={event} data-umami-event={event}
> >
{icon} {icon}
<span className="sr-only">{text}</span> <span className="sr-only">{text}</span>
</Link> </Link>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="text-xs"> <TooltipContent side="bottom" className="text-xs">
{text} {text}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
))} ))}
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
</div> </div>
</> </>
); );
} }
export default Navbar; export default Navbar;

View File

@ -1,12 +1,11 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { ArcElement, Chart as ChartJS, Tooltip as ChartTooltip, Legend } from "chart.js";
import { import ChartDataLabels from "chartjs-plugin-datalabels";
Dialog, import { BarChart3, PieChart } from "lucide-react";
DialogContent, import React, { useState } from "react";
DialogHeader, import { Pie } from "react-chartjs-2";
DialogTitle,
} from "@/components/ui/dialog";
import { import {
Table, Table,
TableBody, TableBody,
@ -21,21 +20,23 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from "chart.js"; import {
import ChartDataLabels from "chartjs-plugin-datalabels"; Dialog,
import { BarChart3, PieChart } from "lucide-react"; DialogContent,
import React, { useState } from "react"; DialogHeader,
import { Pie, Bar } from "react-chartjs-2"; DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
ChartJS.register(ArcElement, ChartTooltip, Legend, ChartDataLabels); ChartJS.register(ArcElement, ChartTooltip, Legend, ChartDataLabels);
interface SummaryData { type SummaryData = {
nsapp_count: Record<string, number>; nsapp_count: Record<string, number>;
} };
interface ApplicationChartProps { type ApplicationChartProps = {
data: SummaryData | null; data: SummaryData | null;
} };
const ITEMS_PER_PAGE = 20; const ITEMS_PER_PAGE = 20;
const CHART_COLORS = [ const CHART_COLORS = [
@ -61,14 +62,15 @@ export default function ApplicationChart({ data }: ApplicationChartProps) {
const [chartStartIndex, setChartStartIndex] = useState(0); const [chartStartIndex, setChartStartIndex] = useState(0);
const [tableLimit, setTableLimit] = useState(ITEMS_PER_PAGE); const [tableLimit, setTableLimit] = useState(ITEMS_PER_PAGE);
if (!data) return null; if (!data)
return null;
const sortedApps = Object.entries(data.nsapp_count) const sortedApps = Object.entries(data.nsapp_count)
.sort(([, a], [, b]) => b - a); .sort(([, a], [, b]) => b - a);
const chartApps = sortedApps.slice( const chartApps = sortedApps.slice(
chartStartIndex, chartStartIndex,
chartStartIndex + ITEMS_PER_PAGE chartStartIndex + ITEMS_PER_PAGE,
); );
const chartData = { const chartData = {
@ -141,14 +143,18 @@ export default function ApplicationChart({ data }: ApplicationChartProps) {
onClick={() => setChartStartIndex(Math.max(0, chartStartIndex - ITEMS_PER_PAGE))} onClick={() => setChartStartIndex(Math.max(0, chartStartIndex - ITEMS_PER_PAGE))}
disabled={chartStartIndex === 0} disabled={chartStartIndex === 0}
> >
Previous {ITEMS_PER_PAGE} Previous
{" "}
{ITEMS_PER_PAGE}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => setChartStartIndex(chartStartIndex + ITEMS_PER_PAGE)} onClick={() => setChartStartIndex(chartStartIndex + ITEMS_PER_PAGE)}
disabled={chartStartIndex + ITEMS_PER_PAGE >= sortedApps.length} disabled={chartStartIndex + ITEMS_PER_PAGE >= sortedApps.length}
> >
Next {ITEMS_PER_PAGE} Next
{" "}
{ITEMS_PER_PAGE}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@ -190,4 +196,4 @@ export default function ApplicationChart({ data }: ApplicationChartProps) {
</Dialog> </Dialog>
</div> </div>
); );
} }

View File

@ -1,3 +1,10 @@
import { useRouter } from "next/navigation";
import { Sparkles } from "lucide-react";
import Image from "next/image";
import React from "react";
import type { Category, Script } from "@/lib/types";
import { import {
CommandDialog, CommandDialog,
CommandEmpty, CommandEmpty,
@ -6,22 +13,16 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { basePath } from "@/config/siteConfig"; import { basePath } from "@/config/site-config";
import { fetchCategories } from "@/lib/data"; import { fetchCategories } from "@/lib/data";
import { Category, Script } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React from "react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { DialogTitle } from "./ui/dialog";
import { Sparkles } from "lucide-react";
import { TooltipContent, TooltipProvider } from "./ui/tooltip";
import { TooltipTrigger } from "./ui/tooltip";
import { Tooltip } from "./ui/tooltip";
export const formattedBadge = (type: string) => { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { DialogTitle } from "./ui/dialog";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
export function formattedBadge(type: string) {
switch (type) { switch (type) {
case "vm": case "vm":
return <Badge className="text-blue-500/75 border-blue-500/75">VM</Badge>; return <Badge className="text-blue-500/75 border-blue-500/75">VM</Badge>;
@ -33,12 +34,13 @@ export const formattedBadge = (type: string) => {
return <Badge className="text-green-500/75 border-green-500/75">ADDON</Badge>; return <Badge className="text-green-500/75 border-green-500/75">ADDON</Badge>;
} }
return null; return null;
}; }
// random Script // random Script
function getRandomScript(categories: Category[]): Script | null { function getRandomScript(categories: Category[]): Script | null {
const allScripts = categories.flatMap((cat) => cat.scripts || []); const allScripts = categories.flatMap(cat => cat.scripts || []);
if (allScripts.length === 0) return null; if (allScripts.length === 0)
return null;
const idx = Math.floor(Math.random() * allScripts.length); const idx = Math.floor(Math.random() * allScripts.length);
return allScripts[idx]; return allScripts[idx];
} }
@ -49,18 +51,6 @@ export default function CommandMenu() {
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const router = useRouter(); const router = useRouter();
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
fetchSortedCategories();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const fetchSortedCategories = () => { const fetchSortedCategories = () => {
setIsLoading(true); setIsLoading(true);
fetchCategories() fetchCategories()
@ -74,6 +64,18 @@ export default function CommandMenu() {
}); });
}; };
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
fetchSortedCategories();
setOpen(open => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const openRandomScript = async () => { const openRandomScript = async () => {
if (links.length === 0) { if (links.length === 0) {
setIsLoading(true); setIsLoading(true);
@ -84,10 +86,12 @@ export default function CommandMenu() {
if (randomScript) { if (randomScript) {
router.push(`/scripts?id=${randomScript.slug}`); router.push(`/scripts?id=${randomScript.slug}`);
} }
} finally { }
finally {
setIsLoading(false); setIsLoading(false);
} }
} else { }
else {
const randomScript = getRandomScript(links); const randomScript = getRandomScript(links);
if (randomScript) { if (randomScript) {
router.push(`/scripts?id=${randomScript.slug}`); router.push(`/scripts?id=${randomScript.slug}`);
@ -110,7 +114,8 @@ export default function CommandMenu() {
> >
<span className="inline-flex">Search scripts...</span> <span className="inline-flex">Search scripts...</span>
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.45rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex"> <kbd className="pointer-events-none absolute right-[0.3rem] top-[0.45rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>K <span className="text-xs"></span>
K
</kbd> </kbd>
</Button> </Button>
@ -134,9 +139,9 @@ export default function CommandMenu() {
<CommandInput placeholder="Search for a script..." /> <CommandInput placeholder="Search for a script..." />
<CommandList> <CommandList>
<CommandEmpty>{isLoading ? "Loading..." : "No scripts found."}</CommandEmpty> <CommandEmpty>{isLoading ? "Loading..." : "No scripts found."}</CommandEmpty>
{links.map((category) => ( {links.map(category => (
<CommandGroup key={`category:${category.name}`} heading={category.name}> <CommandGroup key={`category:${category.name}`} heading={category.name}>
{category.scripts.map((script) => ( {category.scripts.map(script => (
<CommandItem <CommandItem
key={`script:${script.slug}`} key={`script:${script.slug}`}
value={`${script.slug}-${script.name}`} value={`${script.slug}-${script.name}`}
@ -148,7 +153,7 @@ export default function CommandMenu() {
<div className="flex gap-2" onClick={() => setOpen(false)}> <div className="flex gap-2" onClick={() => setOpen(false)}>
<Image <Image
src={script.logo || `/${basePath}/logo.png`} src={script.logo || `/${basePath}/logo.png`}
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)} onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
unoptimized unoptimized
width={16} width={16}
height={16} height={16}

View File

@ -1,5 +1,6 @@
import { ClipboardIcon } from "lucide-react"; import { ClipboardIcon } from "lucide-react";
import handleCopy from "./handleCopy";
import handleCopy from "./handle-copy";
export default function TextCopyBlock(description: string) { export default function TextCopyBlock(description: string) {
const pattern = /`([^`]*)`/g; const pattern = /`([^`]*)`/g;
@ -19,7 +20,8 @@ export default function TextCopyBlock(description: string) {
/> />
</span> </span>
); );
} else { }
else {
return part; return part;
} }
}); });

View File

@ -1,7 +1,8 @@
"use client"; "use client";
import type { ThemeProviderProps } from "next-themes/dist/types";
import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View File

@ -1,7 +1,9 @@
import * as React from "react" import type { VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
@ -16,8 +18,8 @@ const alertVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
const Alert = React.forwardRef< const Alert = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -29,8 +31,8 @@ const Alert = React.forwardRef<
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
)) ));
Alert.displayName = "Alert" Alert.displayName = "Alert";
const AlertTitle = React.forwardRef< const AlertTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -41,8 +43,8 @@ const AlertTitle = React.forwardRef<
className={cn("mb-1 font-medium leading-none tracking-tight", className)} className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} {...props}
/> />
)) ));
AlertTitle.displayName = "AlertTitle" AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef< const AlertDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -53,7 +55,7 @@ const AlertDescription = React.forwardRef<
className={cn("text-sm [&_p]:leading-relaxed", className)} className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} {...props}
/> />
)) ));
AlertDescription.displayName = "AlertDescription" AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertDescription, AlertTitle };

View File

@ -1,4 +1,4 @@
import { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -17,7 +17,7 @@ export default function AnimatedGradientText({
)} )}
> >
<div <div
className={`absolute inset-0 block h-full w-full animate-gradient bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:var(--bg-size)_100%] p-[1px] [border-radius:inherit] ![mask-composite:subtract] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]`} className="absolute inset-0 block h-full w-full animate-gradient bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:var(--bg-size)_100%] p-[1px] [border-radius:inherit] ![mask-composite:subtract] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]"
/> />
{children} {children}

View File

@ -1,4 +1,6 @@
import { cva, type VariantProps } from "class-variance-authority"; import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -26,9 +28,7 @@ const badgeVariants = cva(
}, },
); );
export interface BadgeProps export type BadgeProps = {} & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (

View File

@ -1,8 +1,11 @@
import { cn } from "@/lib/utils"; import type { VariantProps } from "class-variance-authority";
import { Slot, Slottable } from "@radix-ui/react-slot"; import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{ {
@ -47,21 +50,19 @@ const buttonVariants = cva(
}, },
); );
interface IconProps { type IconProps = {
Icon: React.ElementType; Icon: React.ElementType;
iconPlacement: "left" | "right"; iconPlacement: "left" | "right";
} };
interface IconRefProps { type IconRefProps = {
Icon?: never; Icon?: never;
iconPlacement?: undefined; iconPlacement?: undefined;
} };
export interface ButtonProps export type ButtonProps = {
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
} } & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
export type ButtonIconProps = IconProps | IconRefProps; export type ButtonIconProps = IconProps | IconRefProps;

View File

@ -1,13 +1,13 @@
"use client" "use client";
import * as React from "react" import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, ChevronRight } from "lucide-react" import { DayPicker } from "react-day-picker";
import { DayPicker } from "react-day-picker" import * as React from "react";
import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button" import { cn } from "@/lib/utils";
export type CalendarProps = React.ComponentProps<typeof DayPicker> export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ function Calendar({
className, className,
@ -27,7 +27,7 @@ function Calendar({
nav: "space-x-1 flex items-center", nav: "space-x-1 flex items-center",
nav_button: cn( nav_button: cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
), ),
nav_button_previous: "absolute left-1", nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1", nav_button_next: "absolute right-1",
@ -39,7 +39,7 @@ function Calendar({
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn( day: cn(
buttonVariants({ variant: "ghost" }), buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100" "h-9 w-9 p-0 font-normal aria-selected:opacity-100",
), ),
day_range_end: "day-range-end", day_range_end: "day-range-end",
day_selected: day_selected:
@ -59,8 +59,8 @@ function Calendar({
}} }}
{...props} {...props}
/> />
) );
} }
Calendar.displayName = "Calendar" Calendar.displayName = "Calendar";
export { Calendar } export { Calendar };

View File

@ -1,8 +1,10 @@
"use client"; "use client";
import { cn } from "@/lib/utils";
import { CheckIcon, ClipboardIcon } from "lucide-react"; import { CheckIcon, ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card } from "./card"; import { Card } from "./card";
export default function CodeCopyButton({ export default function CodeCopyButton({
@ -26,7 +28,7 @@ export default function CodeCopyButton({
setHasCopied(true); setHasCopied(true);
let warning = localStorage.getItem("warning"); const warning = localStorage.getItem("warning");
if (warning === null) { if (warning === null) {
localStorage.setItem("warning", "1"); localStorage.setItem("warning", "1");
@ -50,11 +52,13 @@ export default function CodeCopyButton({
className={cn("bg-muted px-3 py-4")} className={cn("bg-muted px-3 py-4")}
title="Copy" title="Copy"
> >
{hasCopied ? ( {hasCopied
<CheckIcon className="h-4 w-4" /> ? (
) : ( <CheckIcon className="h-4 w-4" />
<ClipboardIcon className="h-4 w-4" /> )
)} : (
<ClipboardIcon className="h-4 w-4" />
)}
</button> </button>
</Card> </Card>
</div> </div>

View File

@ -1,14 +1,18 @@
"use client"; "use client";
import { basePath } from "@/config/siteConfig"; import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { Clipboard, Copy } from "lucide-react"; import { Clipboard, Copy } from "lucide-react";
import Link from "next/link";
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "./button"; import Link from "next/link";
import { basePath } from "@/config/site-config";
import { cn } from "@/lib/utils";
import { Separator } from "./separator"; import { Separator } from "./separator";
import { Button } from "./button";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
@ -40,23 +44,24 @@ const buttonVariants = cva(
}, },
); );
const handleCopy = (type: string, value: string) => { function handleCopy(type: string, value: string) {
navigator.clipboard.writeText(value); navigator.clipboard.writeText(value);
let amountOfScriptsCopied = localStorage.getItem("amountOfScriptsCopied"); let amountOfScriptsCopied = localStorage.getItem("amountOfScriptsCopied");
if (amountOfScriptsCopied === null) { if (amountOfScriptsCopied === null) {
localStorage.setItem("amountOfScriptsCopied", "1"); localStorage.setItem("amountOfScriptsCopied", "1");
} else { }
amountOfScriptsCopied = (parseInt(amountOfScriptsCopied) + 1).toString(); else {
amountOfScriptsCopied = (Number.parseInt(amountOfScriptsCopied) + 1).toString();
localStorage.setItem("amountOfScriptsCopied", amountOfScriptsCopied); localStorage.setItem("amountOfScriptsCopied", amountOfScriptsCopied);
if ( if (
parseInt(amountOfScriptsCopied) === 3 || Number.parseInt(amountOfScriptsCopied) === 3
parseInt(amountOfScriptsCopied) === 10 || || Number.parseInt(amountOfScriptsCopied) === 10
parseInt(amountOfScriptsCopied) === 25 || || Number.parseInt(amountOfScriptsCopied) === 25
parseInt(amountOfScriptsCopied) === 50 || || Number.parseInt(amountOfScriptsCopied) === 50
parseInt(amountOfScriptsCopied) === 100 || Number.parseInt(amountOfScriptsCopied) === 100
) { ) {
setTimeout(() => { setTimeout(() => {
toast.info( toast.info(
@ -86,17 +91,20 @@ const handleCopy = (type: string, value: string) => {
toast.success( toast.success(
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clipboard className="h-4 w-4" /> <Clipboard className="h-4 w-4" />
<span>Copied {type} to clipboard</span> <span>
Copied
{type}
{" "}
to clipboard
</span>
</div>, </div>,
); );
}; }
export interface CodeBlockProps export type CodeBlockProps = {
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
code: string; code: string;
} } & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>( const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
({ className, variant, size, asChild = false, code }, ref) => { ({ className, variant, size, asChild = false, code }, ref) => {
@ -121,7 +129,10 @@ const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
)} )}
> >
<p className="flex items-center gap-2"> <p className="flex items-center gap-2">
{code} <Separator orientation="vertical" />{" "} {code}
{" "}
<Separator orientation="vertical" />
{" "}
<Copy <Copy
className="cursor-pointer" className="cursor-pointer"
size={16} size={16}

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { type DialogProps } from "@radix-ui/react-dialog"; import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk"; import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import * as React from "react"; import * as React from "react";
@ -23,9 +24,9 @@ const Command = React.forwardRef<
)); ));
Command.displayName = CommandPrimitive.displayName; Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {} type CommandDialogProps = {} & DialogProps;
const CommandDialog = ({ children, ...props }: CommandDialogProps) => { function CommandDialog({ children, ...props }: CommandDialogProps) {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg"> <DialogContent className="overflow-hidden p-0 shadow-lg">
@ -35,7 +36,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; }
const CommandInput = React.forwardRef< const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>, React.ElementRef<typeof CommandPrimitive.Input>,
@ -126,10 +127,10 @@ const CommandItem = React.forwardRef<
CommandItem.displayName = CommandPrimitive.Item.displayName; CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ function CommandShortcut({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) {
return ( return (
<span <span
className={cn( className={cn(
@ -139,7 +140,7 @@ const CommandShortcut = ({
{...props} {...props}
/> />
); );
}; }
CommandShortcut.displayName = "CommandShortcut"; CommandShortcut.displayName = "CommandShortcut";
export { export {

View File

@ -1,8 +1,9 @@
"use client"; "use client";
import { cn } from "@/lib/utils";
import { CheckIcon, ClipboardIcon } from "lucide-react"; import { CheckIcon, ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card } from "./card"; import { Card } from "./card";
export default function CodeCopyButton({ export default function CodeCopyButton({
@ -26,7 +27,6 @@ export default function CodeCopyButton({
setHasCopied(true); setHasCopied(true);
// toast.success(`copied ${type} to clipboard`, { // toast.success(`copied ${type} to clipboard`, {
// icon: <ClipboardCheck className="h-4 w-4" />, // icon: <ClipboardCheck className="h-4 w-4" />,
// }); // });
@ -42,11 +42,13 @@ export default function CodeCopyButton({
className={cn(" right-0 cursor-pointer bg-muted px-3 py-4")} className={cn(" right-0 cursor-pointer bg-muted px-3 py-4")}
onClick={() => handleCopy("install command", children)} onClick={() => handleCopy("install command", children)}
> >
{hasCopied ? ( {hasCopied
<CheckIcon className="h-4 w-4" /> ? (
) : ( <CheckIcon className="h-4 w-4" />
<ClipboardIcon className="h-4 w-4" /> )
)} : (
<ClipboardIcon className="h-4 w-4" />
)}
<span className="sr-only">Copy</span> <span className="sr-only">Copy</span>
</div> </div>
</Card> </Card>

View File

@ -53,32 +53,36 @@ const DialogContent = React.forwardRef<
)); ));
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ function DialogHeader({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) {
<div return (
className={cn( <div
"flex flex-col space-y-1.5 text-center sm:text-left", className={cn(
className, "flex flex-col space-y-1.5 text-center sm:text-left",
)} className,
{...props} )}
/> {...props}
); />
);
}
DialogHeader.displayName = "DialogHeader"; DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ function DialogFooter({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) {
<div return (
className={cn( <div
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className={cn(
className, "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
)} className,
{...props} )}
/> {...props}
); />
);
}
DialogFooter.displayName = "DialogFooter"; DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<

View File

@ -37,8 +37,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)); ));
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName; = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -53,8 +53,8 @@ const DropdownMenuSubContent = React.forwardRef<
{...props} {...props}
/> />
)); ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName
DropdownMenuPrimitive.SubContent.displayName; = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -113,8 +113,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)); ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName; = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -168,17 +168,17 @@ const DropdownMenuSeparator = React.forwardRef<
)); ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ function DropdownMenuShortcut({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) {
return ( return (
<span <span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} {...props}
/> />
); );
}; }
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export { export {

View File

@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface InputProps export type InputProps = {} & React.InputHTMLAttributes<HTMLInputElement>;
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {

View File

@ -1,26 +1,28 @@
"use client" "use client";
import * as React from "react" import type { VariantProps } from "class-variance-authority";
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import * as LabelPrimitive from "@radix-ui/react-label";
import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const labelVariants = cva( const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
) );
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
VariantProps<typeof labelVariants> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root
ref={ref} ref={ref}
className={cn(labelVariants(), className)} className={cn(labelVariants(), className)}
{...props} {...props}
/> />
)) ));
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName;
export { Label } export { Label };

View File

@ -53,7 +53,8 @@ const NavigationMenuTrigger = React.forwardRef<
className={cn(navigationMenuTriggerStyle(), "group", className)} className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props} {...props}
> >
{children}{" "} {children}
{" "}
<ChevronDown <ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180" className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true" aria-hidden="true"
@ -94,8 +95,8 @@ const NavigationMenuViewport = React.forwardRef<
/> />
</div> </div>
)); ));
NavigationMenuViewport.displayName = NavigationMenuViewport.displayName
NavigationMenuPrimitive.Viewport.displayName; = NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef< const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
@ -112,8 +113,8 @@ const NavigationMenuIndicator = React.forwardRef<
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator> </NavigationMenuPrimitive.Indicator>
)); ));
NavigationMenuIndicator.displayName = NavigationMenuIndicator.displayName
NavigationMenuPrimitive.Indicator.displayName; = NavigationMenuPrimitive.Indicator.displayName;
export { export {
NavigationMenu, NavigationMenu,

View File

@ -30,10 +30,10 @@ export default function NumberTicker({
}); });
useEffect(() => { useEffect(() => {
isInView && isInView
setTimeout(() => { && setTimeout(() => {
motionValue.set(direction === "down" ? 0 : value); motionValue.set(direction === "down" ? 0 : value);
}, delay * 1000); }, delay * 1000);
}, [motionValue, isInView, delay, value, direction]); }, [motionValue, isInView, delay, value, direction]);
useEffect( useEffect(

View File

@ -1,12 +1,13 @@
"use client"; "use client";
import { cn } from "@/lib/utils";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
interface MousePosition { import { cn } from "@/lib/utils";
type MousePosition = {
x: number; x: number;
y: number; y: number;
} };
function MousePosition(): MousePosition { function MousePosition(): MousePosition {
const [mousePosition, setMousePosition] = useState<MousePosition>({ const [mousePosition, setMousePosition] = useState<MousePosition>({
@ -29,7 +30,7 @@ function MousePosition(): MousePosition {
return mousePosition; return mousePosition;
} }
interface ParticlesProps { type ParticlesProps = {
className?: string; className?: string;
quantity?: number; quantity?: number;
staticity?: number; staticity?: number;
@ -39,18 +40,18 @@ interface ParticlesProps {
color?: string; color?: string;
vx?: number; vx?: number;
vy?: number; vy?: number;
} };
function hexToRgb(hex: string): number[] { function hexToRgb(hex: string): number[] {
hex = hex.replace("#", ""); hex = hex.replace("#", "");
if (hex.length === 3) { if (hex.length === 3) {
hex = hex hex = hex
.split("") .split("")
.map((char) => char + char) .map(char => char + char)
.join(""); .join("");
} }
const hexInt = parseInt(hex, 16); const hexInt = Number.parseInt(hex, 16);
const red = (hexInt >> 16) & 255; const red = (hexInt >> 16) & 255;
const green = (hexInt >> 8) & 255; const green = (hexInt >> 8) & 255;
const blue = hexInt & 255; const blue = hexInt & 255;
@ -150,7 +151,7 @@ const Particles: React.FC<ParticlesProps> = ({
const translateY = 0; const translateY = 0;
const pSize = Math.floor(Math.random() * 2) + size; const pSize = Math.floor(Math.random() * 2) + size;
const alpha = 0; const alpha = 0;
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)); const targetAlpha = Number.parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
const dx = (Math.random() - 0.5) * 0.1; const dx = (Math.random() - 0.5) * 0.1;
const dy = (Math.random() - 0.5) * 0.1; const dy = (Math.random() - 0.5) * 0.1;
const magnetism = 0.1 + Math.random() * 4; const magnetism = 0.1 + Math.random() * 4;
@ -213,8 +214,8 @@ const Particles: React.FC<ParticlesProps> = ({
start2: number, start2: number,
end2: number, end2: number,
): number => { ): number => {
const remapped = const remapped
((value - start1) * (end2 - start2)) / (end1 - start1) + start2; = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
return remapped > 0 ? remapped : 0; return remapped > 0 ? remapped : 0;
}; };
@ -229,7 +230,7 @@ const Particles: React.FC<ParticlesProps> = ({
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
]; ];
const closestEdge = edge.reduce((a, b) => Math.min(a, b)); const closestEdge = edge.reduce((a, b) => Math.min(a, b));
const remapClosestEdge = parseFloat( const remapClosestEdge = Number.parseFloat(
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2), remapValue(closestEdge, 0, 20, 0, 1).toFixed(2),
); );
if (remapClosestEdge > 1) { if (remapClosestEdge > 1) {
@ -237,26 +238,27 @@ const Particles: React.FC<ParticlesProps> = ({
if (circle.alpha > circle.targetAlpha) { if (circle.alpha > circle.targetAlpha) {
circle.alpha = circle.targetAlpha; circle.alpha = circle.targetAlpha;
} }
} else { }
else {
circle.alpha = circle.targetAlpha * remapClosestEdge; circle.alpha = circle.targetAlpha * remapClosestEdge;
} }
circle.x += circle.dx + vx; circle.x += circle.dx + vx;
circle.y += circle.dy + vy; circle.y += circle.dy + vy;
circle.translateX += circle.translateX
(mouse.current.x / (staticity / circle.magnetism) - circle.translateX) / += (mouse.current.x / (staticity / circle.magnetism) - circle.translateX)
ease; / ease;
circle.translateY += circle.translateY
(mouse.current.y / (staticity / circle.magnetism) - circle.translateY) / += (mouse.current.y / (staticity / circle.magnetism) - circle.translateY)
ease; / ease;
drawCircle(circle, true); drawCircle(circle, true);
// circle gets out of the canvas // circle gets out of the canvas
if ( if (
circle.x < -circle.size || circle.x < -circle.size
circle.x > canvasSize.current.w + circle.size || || circle.x > canvasSize.current.w + circle.size
circle.y < -circle.size || || circle.y < -circle.size
circle.y > canvasSize.current.h + circle.size || circle.y > canvasSize.current.h + circle.size
) { ) {
// remove the circle from the array // remove the circle from the array
circles.current.splice(i, 1); circles.current.splice(i, 1);

View File

@ -1,13 +1,13 @@
"use client" "use client";
import * as React from "react" import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
@ -20,12 +20,12 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
)) ));
PopoverContent.displayName = PopoverPrimitive.Content.displayName PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent } export { Popover, PopoverContent, PopoverTrigger };

View File

@ -1,16 +1,16 @@
"use client" "use client";
import * as React from "react" import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef< const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>, React.ElementRef<typeof SelectPrimitive.Trigger>,
@ -20,7 +20,7 @@ const SelectTrigger = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className,
)} )}
{...props} {...props}
> >
@ -29,8 +29,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)) ));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef< const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@ -40,14 +40,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
)) ));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef< const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@ -57,15 +57,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
)) ));
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName
SelectPrimitive.ScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
@ -76,9 +76,9 @@ const SelectContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" && position === "popper"
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
{...props} {...props}
@ -87,8 +87,8 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper"
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)} )}
> >
{children} {children}
@ -96,8 +96,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
)) ));
SelectContent.displayName = SelectPrimitive.Content.displayName SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef< const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>, React.ElementRef<typeof SelectPrimitive.Label>,
@ -108,8 +108,8 @@ const SelectLabel = React.forwardRef<
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ));
SelectLabel.displayName = SelectPrimitive.Label.displayName SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef< const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>, React.ElementRef<typeof SelectPrimitive.Item>,
@ -119,7 +119,7 @@ const SelectItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@ -131,8 +131,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ));
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef< const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>, React.ElementRef<typeof SelectPrimitive.Separator>,
@ -143,18 +143,18 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export { export {
Select, Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent, SelectContent,
SelectLabel, SelectGroup,
SelectItem, SelectItem,
SelectSeparator, SelectLabel,
SelectScrollUpButton,
SelectScrollDownButton, SelectScrollDownButton,
} SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -1,7 +1,9 @@
"use client"; "use client";
import type { VariantProps } from "class-variance-authority";
import * as SheetPrimitive from "@radix-ui/react-dialog"; import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { X } from "lucide-react"; import { X } from "lucide-react";
import * as React from "react"; import * as React from "react";
@ -49,9 +51,7 @@ const sheetVariants = cva(
}, },
); );
interface SheetContentProps type SheetContentProps = {} & React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & VariantProps<typeof sheetVariants>;
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef< const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>, React.ElementRef<typeof SheetPrimitive.Content>,
@ -74,32 +74,36 @@ const SheetContent = React.forwardRef<
)); ));
SheetContent.displayName = SheetPrimitive.Content.displayName; SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ function SheetHeader({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) {
<div return (
className={cn( <div
"flex flex-col space-y-2 text-center sm:text-left", className={cn(
className, "flex flex-col space-y-2 text-center sm:text-left",
)} className,
{...props} )}
/> {...props}
); />
);
}
SheetHeader.displayName = "SheetHeader"; SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ function SheetFooter({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) {
<div return (
className={cn( <div
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className={cn(
className, "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
)} className,
{...props} )}
/> {...props}
); />
);
}
SheetFooter.displayName = "SheetFooter"; SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef< const SheetTitle = React.forwardRef<

View File

@ -1,11 +1,11 @@
"use client"; "use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner"; import { Toaster as Sonner } from "sonner";
import { useTheme } from "next-themes";
type ToasterProps = React.ComponentProps<typeof Sonner>; type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => { function Toaster({ ...props }: ToasterProps) {
const { theme = "system" } = useTheme(); const { theme = "system" } = useTheme();
return ( return (
@ -26,6 +26,6 @@ const Toaster = ({ ...props }: ToasterProps) => {
{...props} {...props}
/> />
); );
}; }
export { Toaster }; export { Toaster };

View File

@ -1,10 +1,12 @@
import { basePath } from "@/config/siteConfig";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { useEffect, useState } from "react";
import { FaGithub, FaStar } from "react-icons/fa"; import { FaGithub, FaStar } from "react-icons/fa";
import { buttonVariants } from "./button"; import { useEffect, useState } from "react";
import Link from "next/link";
import { basePath } from "@/config/site-config";
import { cn } from "@/lib/utils";
import NumberTicker from "./number-ticker"; import NumberTicker from "./number-ticker";
import { buttonVariants } from "./button";
export default function StarOnGithubButton() { export default function StarOnGithubButton() {
const [stars, setStars] = useState(0); const [stars, setStars] = useState(0);
@ -23,7 +25,8 @@ export default function StarOnGithubButton() {
const data = await res.json(); const data = await res.json();
setStars(data.stargazers_count || stars); setStars(data.stargazers_count || stars);
} }
} catch (error) { }
catch (error) {
console.error("Error fetching stars:", error); console.error("Error fetching stars:", error);
} }
}; };
@ -43,7 +46,8 @@ export default function StarOnGithubButton() {
<span className="absolute right-0 -mt-12 h-32 translate-x-12 rotate-12 bg-white opacity-10 transition-all duration-1000 ease-out group-hover:-translate-x-40" /> <span className="absolute right-0 -mt-12 h-32 translate-x-12 rotate-12 bg-white opacity-10 transition-all duration-1000 ease-out group-hover:-translate-x-40" />
<div className="flex items-center"> <div className="flex items-center">
<FaGithub className="size-4" /> <FaGithub className="size-4" />
<span className="ml-1">Star on GitHub</span>{" "} <span className="ml-1">Star on GitHub</span>
{" "}
</div> </div>
<div className="ml-2 flex items-center gap-1 text-sm md:flex"> <div className="ml-2 flex items-center gap-1 text-sm md:flex">
<FaStar className="size-4 text-gray-500 transition-all duration-300 group-hover:text-yellow-300" /> <FaStar className="size-4 text-gray-500 transition-all duration-300 group-hover:text-yellow-300" />

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as SwitchPrimitives from "@radix-ui/react-switch" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Switch = React.forwardRef< const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>, React.ElementRef<typeof SwitchPrimitives.Root>,
@ -12,18 +12,18 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className className,
)} )}
{...props} {...props}
ref={ref} ref={ref}
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>
)) ));
Switch.displayName = SwitchPrimitives.Root.displayName Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch } export { Switch };

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Table = React.forwardRef< const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
@ -13,16 +13,16 @@ const Table = React.forwardRef<
{...props} {...props}
/> />
</div> </div>
)) ));
Table.displayName = "Table" Table.displayName = "Table";
const TableHeader = React.forwardRef< const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
)) ));
TableHeader.displayName = "TableHeader" TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef< const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@ -33,8 +33,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
)) ));
TableBody.displayName = "TableBody" TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef< const TableFooter = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@ -44,12 +44,12 @@ const TableFooter = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableFooter.displayName = "TableFooter" TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef< const TableRow = React.forwardRef<
HTMLTableRowElement, HTMLTableRowElement,
@ -59,12 +59,12 @@ const TableRow = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableRow.displayName = "TableRow" TableRow.displayName = "TableRow";
const TableHead = React.forwardRef< const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@ -74,12 +74,12 @@ const TableHead = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableHead.displayName = "TableHead" TableHead.displayName = "TableHead";
const TableCell = React.forwardRef< const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@ -89,12 +89,12 @@ const TableCell = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TableCell.displayName = "TableCell" TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef< const TableCaption = React.forwardRef<
HTMLTableCaptionElement, HTMLTableCaptionElement,
@ -105,16 +105,16 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
TableCaption.displayName = "TableCaption" TableCaption.displayName = "TableCaption";
export { export {
Table, Table,
TableHeader,
TableBody, TableBody,
TableCaption,
TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader,
TableRow, TableRow,
TableCell, };
TableCaption,
}

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Textarea = React.forwardRef< const Textarea = React.forwardRef<
HTMLTextAreaElement, HTMLTextAreaElement,
@ -10,13 +10,13 @@ const Textarea = React.forwardRef<
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
}) });
Textarea.displayName = "Textarea" Textarea.displayName = "Textarea";
export { Textarea } export { Textarea };

View File

@ -2,21 +2,24 @@
import { MoonIcon, SunIcon } from "@radix-ui/react-icons"; import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Button } from "./button";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "./tooltip"; } from "./tooltip";
import { Button } from "./button";
export function ThemeToggle() { export function ThemeToggle() {
const { setTheme, theme: currentTheme } = useTheme(); const { setTheme, theme: currentTheme } = useTheme();
const handleChangeTheme = (theme: "light" | "dark") => { const handleChangeTheme = (theme: "light" | "dark") => {
if (theme === currentTheme) return; if (theme === currentTheme)
return;
if (!document.startViewTransition) return setTheme(theme); if (!document.startViewTransition)
return setTheme(theme);
document.startViewTransition(() => setTheme(theme)); document.startViewTransition(() => setTheme(theme));
}; };
@ -31,8 +34,7 @@ export function ThemeToggle() {
className="px-2" className="px-2"
aria-label="Toggle theme" aria-label="Toggle theme"
onClick={() => onClick={() =>
handleChangeTheme(currentTheme === "dark" ? "light" : "dark") handleChangeTheme(currentTheme === "dark" ? "light" : "dark")}
}
> >
<SunIcon className="size-[1.2rem] text-neutral-800 dark:hidden dark:text-neutral-200" /> <SunIcon className="size-[1.2rem] text-neutral-800 dark:hidden dark:text-neutral-200" />
<MoonIcon className="hidden size-[1.2rem] text-neutral-800 dark:block dark:text-neutral-200" /> <MoonIcon className="hidden size-[1.2rem] text-neutral-800 dark:block dark:text-neutral-200" />

View File

@ -1,15 +1,15 @@
"use client" "use client";
import * as React from "react" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef< const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>, React.ElementRef<typeof TooltipPrimitive.Content>,
@ -20,11 +20,11 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]", "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className className,
)} )}
{...props} {...props}
/> />
)) ));
TooltipContent.displayName = TooltipPrimitive.Content.displayName TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@ -25,9 +25,9 @@ export const FAQ_Items = [
"Updates via our LXC scripts might not pull the absolute latest version for a few reasons:\n- A bug in the application's release naming on GitHub.\n- A bug in our update script.\n- We intentionally pinned the version. This happens if a newer version has breaking changes or serious bugs that could affect your data or LXC stability. We wait for fixes before allowing the update.", "Updates via our LXC scripts might not pull the absolute latest version for a few reasons:\n- A bug in the application's release naming on GitHub.\n- A bug in our update script.\n- We intentionally pinned the version. This happens if a newer version has breaking changes or serious bugs that could affect your data or LXC stability. We wait for fixes before allowing the update.",
}, },
{ {
title: 'Why am I getting a "502 Bad Gateway" error?', title: "Why am I getting a \"502 Bad Gateway\" error?",
content: content:
'A "502 Bad Gateway" error usually means the application inside the LXC is not running or responding correctly. Check the application\'s logs first. If you use a reverse proxy, check its logs too. If you still have problems after checking the logs, report the issue, providing details from the logs.', "A \"502 Bad Gateway\" error usually means the application inside the LXC is not running or responding correctly. Check the application's logs first. If you use a reverse proxy, check its logs too. If you still have problems after checking the logs, report the issue, providing details from the logs.",
}, },
{ {
title: "What should I do if a script fails during execution?", title: "What should I do if a script fails during execution?",

View File

@ -1,45 +1,45 @@
import { OperatingSystem } from "@/lib/types";
import { MessagesSquare, Scroll } from "lucide-react"; import { MessagesSquare, Scroll } from "lucide-react";
import React from "react";
import { FaDiscord, FaGithub } from "react-icons/fa"; import { FaDiscord, FaGithub } from "react-icons/fa";
import React from "react";
export const basePath = process.env.BASE_PATH; import type { OperatingSystem } from "@/lib/types";
const isMobile = typeof window !== "undefined" && window.innerWidth < 640; // eslint-disable-next-line node/no-process-env
export const basePath = process.env.BASE_PATH || "";
export const navbarLinks = [ export const navbarLinks = [
{ {
href: `https://github.com/community-scripts/${basePath}`, href: `https://github.com/community-scripts/${basePath}`,
event: "Github", event: "Github",
icon: <FaGithub className="h-4 w-4" />, icon: <FaGithub className="h-4 w-4" />,
text: "Github", text: "Github",
}, },
{ {
href: `https://discord.gg/2wvnMDgdnU`, href: `https://discord.gg/2wvnMDgdnU`,
event: "Discord", event: "Discord",
icon: <FaDiscord className="h-4 w-4" />, icon: <FaDiscord className="h-4 w-4" />,
text: "Discord", text: "Discord",
}, },
{ {
href: `https://github.com/community-scripts/${basePath}/blob/main/CHANGELOG.md`, href: `https://github.com/community-scripts/${basePath}/blob/main/CHANGELOG.md`,
event: "Change Log", event: "Change Log",
icon: <Scroll className="h-4 w-4" />, icon: <Scroll className="h-4 w-4" />,
text: "Change Log", text: "Change Log",
mobileHidden: true, mobileHidden: true,
}, },
{ {
href: `https://github.com/community-scripts/${basePath}/discussions`, href: `https://github.com/community-scripts/${basePath}/discussions`,
event: "Discussions", event: "Discussions",
icon: <MessagesSquare className="h-4 w-4" />, icon: <MessagesSquare className="h-4 w-4" />,
text: "Discussions", text: "Discussions",
mobileHidden: true, mobileHidden: true,
}, },
].filter(Boolean) as { ].filter(Boolean) as {
href: string; href: string;
event: string; event: string;
icon: React.ReactNode; icon: React.ReactNode;
text: string; text: string;
mobileHidden?: boolean; mobileHidden?: boolean;
}[]; }[];
export const mostPopularScripts = ["post-pve-install", "docker", "homeassistant"]; export const mostPopularScripts = ["post-pve-install", "docker", "homeassistant"];

View File

@ -1,9 +1,11 @@
"use client"; "use client";
import { fetchVersions } from "@/lib/data";
import { AppVersion } from "@/lib/types";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { AppVersion } from "@/lib/types";
import { fetchVersions } from "@/lib/data";
export function useVersions() { export function useVersions() {
return useQuery<AppVersion[]>({ return useQuery<AppVersion[]>({
queryKey: ["versions"], queryKey: ["versions"],

View File

@ -1,18 +1,18 @@
import { Category } from "./types"; import type { Category } from "./types";
export const fetchCategories = async () => { export async function fetchCategories() {
const response = await fetch("api/categories"); const response = await fetch("api/categories");
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch categories: ${response.statusText}`); throw new Error(`Failed to fetch categories: ${response.statusText}`);
} }
const categories: Category[] = await response.json(); const categories: Category[] = await response.json();
return categories; return categories;
}; }
export const fetchVersions = async () => { export async function fetchVersions() {
const response = await fetch(`api/versions`); const response = await fetch(`api/versions`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch versions: ${response.statusText}`); throw new Error(`Failed to fetch versions: ${response.statusText}`);
} }
return response.json(); return response.json();
}; }

View File

@ -1,4 +1,4 @@
import { AlertColors } from "@/config/siteConfig"; import type { AlertColors } from "@/config/site-config";
export type Script = { export type Script = {
name: string; name: string;
@ -48,18 +48,18 @@ export type Metadata = {
categories: Category[]; categories: Category[];
}; };
export interface Version { export type Version = {
name: string; name: string;
slug: string; slug: string;
} };
export interface OperatingSystem { export type OperatingSystem = {
name: string; name: string;
versions: Version[]; versions: Version[];
} };
export interface AppVersion { export type AppVersion = {
name: string; name: string;
version: string; version: string;
date: Date; date: Date;
} };

View File

@ -1,5 +1,7 @@
import { clsx, type ClassValue } from "clsx"; import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { clsx } from "clsx";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));

View File

@ -2,7 +2,6 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
@ -92,4 +91,4 @@
.glass { .glass {
backdrop-filter: blur(15px) saturate(100%); backdrop-filter: blur(15px) saturate(100%);
-webkit-backdrop-filter: blur(15px) saturate(100%); -webkit-backdrop-filter: blur(15px) saturate(100%);
} }

View File

@ -1,10 +1,11 @@
/* eslint-disable ts/no-require-imports */
//
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
const svgToDataUri = require("mini-svg-data-uri");
const { const {
default: flattenColorPalette, default: flattenColorPalette,
} = require("tailwindcss/lib/util/flattenColorPalette"); } = require("tailwindcss/lib/util/flattenColorPalette");
const svgToDataUri = require("mini-svg-data-uri");
const config = { const config = {
darkMode: ["class"], darkMode: ["class"],
@ -73,11 +74,11 @@ const config = {
from: { height: "var(--radix-accordion-content-height)" }, from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" }, to: { height: "0" },
}, },
shine: { "shine": {
from: { backgroundPosition: "200% 0" }, from: { backgroundPosition: "200% 0" },
to: { backgroundPosition: "-200% 0" }, to: { backgroundPosition: "-200% 0" },
}, },
gradient: { "gradient": {
to: { to: {
backgroundPosition: "var(--bg-size) 0", backgroundPosition: "var(--bg-size) 0",
}, },
@ -89,11 +90,11 @@ const config = {
"50%": { "50%": {
"background-position": "100% 100%", "background-position": "100% 100%",
}, },
to: { "to": {
"background-position": "0% 0%", "background-position": "0% 0%",
}, },
}, },
moveHorizontal: { "moveHorizontal": {
"0%": { "0%": {
transform: "translateX(-50%) translateY(-10%)", transform: "translateX(-50%) translateY(-10%)",
}, },
@ -104,7 +105,7 @@ const config = {
transform: "translateX(-50%) translateY(-10%)", transform: "translateX(-50%) translateY(-10%)",
}, },
}, },
moveInCircle: { "moveInCircle": {
"0%": { "0%": {
transform: "rotate(0deg)", transform: "rotate(0deg)",
}, },
@ -115,7 +116,7 @@ const config = {
transform: "rotate(360deg)", transform: "rotate(360deg)",
}, },
}, },
moveVertical: { "moveVertical": {
"0%": { "0%": {
transform: "translateY(-50%)", transform: "translateY(-50%)",
}, },
@ -130,8 +131,8 @@ const config = {
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
shine: "shine 8s ease-in-out infinite", "shine": "shine 8s ease-in-out infinite",
gradient: "gradient 8s linear infinite", "gradient": "gradient 8s linear infinite",
}, },
}, },
}, },
@ -168,8 +169,8 @@ const config = {
} satisfies Config; } satisfies Config;
function addVariablesForColors({ addBase, theme }: any) { function addVariablesForColors({ addBase, theme }: any) {
let allColors = flattenColorPalette(theme("colors")); const allColors = flattenColorPalette(theme("colors"));
let newVars = Object.fromEntries( const newVars = Object.fromEntries(
Object.entries(allColors).map(([key, val]) => [`--${key}`, val]), Object.entries(allColors).map(([key, val]) => [`--${key}`, val]),
); );
addBase({ addBase({

30
frontend/tsconfig.json generated
View File

@ -1,32 +1,32 @@
{ {
"compilerOptions": { "compilerOptions": {
"incremental": true,
"target": "ES2017",
"jsx": "preserve",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": { "paths": {
"@/*": [ "@/*": [
"./src/*" "./src/*"
] ]
}, },
"target": "ES2017" "resolveJsonModule": true,
"allowJs": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,
"plugins": [
{
"name": "next"
}
]
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",

View File

@ -1,11 +1,11 @@
import { defineConfig } from 'vitest/config' import tsconfigPaths from "vite-tsconfig-paths";
import react from '@vitejs/plugin-react' import { defineConfig } from "vitest/config";
import tsconfigPaths from 'vite-tsconfig-paths' import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [tsconfigPaths(), react()], plugins: [tsconfigPaths(), react()],
test: { test: {
environment: "jsdom", environment: "jsdom",
setupFiles: ["src/__tests__/setupTests.ts"] setupFiles: ["src/__tests__/setupTests.ts"],
}, },
}) });