Remove npm legacy errors, created single source of truth for ESlint. updated analytics url. updated script background (#5498)

* Update ScriptAccordion and ScriptItem components for improved styling

* Add README.md for Proxmox VE Helper-Scripts Frontend

* Remove testing dependencies and related test files from the frontend project

* Update analytics URL in siteConfig to point to community-scripts.org

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

* Update lint script in package.json to remove npm

* Add 'next' option to ESLint configuration for improved compatibility

* Update package dependencies and versions in package.json and package-lock.json

* Refactor theme provider import and enhance calendar component for dynamic icon rendering

* rename sidebar, alerts and buttons

* rename description and interfaces files

* rename more files

* change folder name

* Refactor tooltip logic to improve updateable condition handling

* Enhance CommandMenu to prevent duplicate scripts across categories

* Remove test step from frontend CI/CD workflow
This commit is contained in:
Bram Suurd
2025-06-28 00:38:09 +02:00
committed by GitHub
parent d60911a063
commit 0067075ed1
91 changed files with 8049 additions and 4043 deletions

View File

@ -44,9 +44,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci --prefer-offline --legacy-peer-deps run: npm ci --prefer-offline --legacy-peer-deps
- name: Run tests
run: npm run test
- name: Configure Next.js for pages - name: Configure Next.js for pages
uses: actions/configure-pages@v5 uses: actions/configure-pages@v5
with: with:

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"
]
}

281
frontend/README.md Normal file
View File

@ -0,0 +1,281 @@
# Proxmox VE Helper-Scripts Frontend
> 🚀 **Modern frontend for the Community-Scripts Proxmox VE Helper-Scripts repository**
A comprehensive, user-friendly interface built with Next.js that provides access to 300+ automation scripts for Proxmox Virtual Environment management. This frontend serves as the official website for the Community-Scripts organization's Proxmox VE Helper-Scripts repository.
![Next.js](https://img.shields.io/badge/Next.js-15.2.4-black?style=flat-square&logo=next.js)
![React](https://img.shields.io/badge/React-19.0.0-blue?style=flat-square&logo=react)
![TypeScript](https://img.shields.io/badge/TypeScript-5.8.2-blue?style=flat-square&logo=typescript)
![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.4.17-06B6D4?style=flat-square&logo=tailwindcss)
![License](https://img.shields.io/badge/License-MIT-green?style=flat-square)
## 🌟 Features
### Core Functionality
- **📜 Script Management**: Browse, search, and filter 300+ Proxmox VE scripts
- **📱 Responsive Design**: Mobile-first approach with modern UI/UX
- **🔍 Advanced Search**: Fuzzy search with category filtering
- **📊 Analytics Integration**: Built-in analytics for usage tracking
- **🌙 Dark/Light Mode**: Theme switching with system preference detection
- **⚡ Performance Optimized**: Static site generation for lightning-fast loading
### Technical Features
- **🎨 Modern UI Components**: Built with Radix UI and shadcn/ui
- **📈 Data Visualization**: Charts and metrics using Chart.js
- **🔄 State Management**: React Query for efficient data fetching
- **📝 Type Safety**: Full TypeScript implementation
- **🚀 Static Export**: Optimized for GitHub Pages deployment
## 🛠️ Tech Stack
### Frontend Framework
- **[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
- **[TypeScript 5.8.2](https://www.typescriptlang.org/)** - Type-safe JavaScript
### Styling & UI
- **[Tailwind CSS 3.4.17](https://tailwindcss.com/)** - Utility-first CSS framework
- **[Radix UI](https://www.radix-ui.com/)** - Unstyled, accessible UI components
- **[shadcn/ui](https://ui.shadcn.com/)** - Re-usable components built on Radix UI
- **[Framer Motion](https://www.framer.com/motion/)** - Animation library
- **[Lucide React](https://lucide.dev/)** - Icon library
### Data & State Management
- **[TanStack Query 5.71.1](https://tanstack.com/query)** - Powerful data synchronization
- **[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
### Development Tools
- **[Vitest 3.1.1](https://vitest.dev/)** - Fast unit testing framework
- **[React Testing Library](https://testing-library.com/react)** - Simple testing utilities
- **[ESLint](https://eslint.org/)** - Code linting and formatting
- **[Prettier](https://prettier.io/)** - Code formatting
### Additional Libraries
- **[Chart.js](https://www.chartjs.org/)** - Data visualization
- **[Fuse.js](https://fusejs.io/)** - Fuzzy search
- **[date-fns](https://date-fns.org/)** - Date utility library
- **[Next Themes](https://github.com/pacocoursey/next-themes)** - Theme management
## 🚀 Getting Started
### Prerequisites
- **Node.js 18+** (recommend using the latest LTS version)
- **npm**, **yarn**, **pnpm**, or **bun** package manager
- **Git** for version control
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/community-scripts/ProxmoxVE.git
cd ProxmoxVE/frontend
```
2. **Install dependencies**
```bash
# Using npm
npm install
# Using yarn
yarn install
# Using pnpm
pnpm install
# Using bun
bun install
```
3. **Start the development server**
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
4. **Open your browser**
Navigate to [http://localhost:3000](http://localhost:3000) to see the application running.
### Environment Configuration
The application uses the following environment variables:
- `BASE_PATH`: Set to "ProxmoxVE" for GitHub Pages deployment
- Analytics configuration is handled in `src/config/siteConfig.tsx`
## 🧪 Development
### Available Scripts
```bash
# Development
npm run dev # Start development server with Turbopack
npm run build # Build for production
npm run start # Start production server (after build)
# Code Quality
npm run lint # Run ESLint
npm run typecheck # Run TypeScript type checking
npm run format:write # Format code with Prettier
npm run format:check # Check code formatting
# Deployment
npm run deploy # Build and deploy to GitHub Pages
```
### Development Workflow
1. **Feature Development**
- Create a new branch for your feature
- Follow the established TypeScript and React patterns
- Use the existing component library (shadcn/ui)
- Ensure responsive design principles
2. **Code Standards**
- Follow TypeScript strict mode
- Use functional components with hooks
- Implement proper error boundaries
- Write descriptive variable and function names
- Use early returns for better readability
3. **Styling Guidelines**
- Use Tailwind CSS utility classes
- Follow mobile-first responsive design
- Implement dark/light mode considerations
- Use CSS variables from the design system
4. **Testing**
- Write unit tests for utility functions
- Test React components with React Testing Library
- Ensure accessibility standards are met
- Run tests before committing
### Component Development
The project uses a component-driven development approach:
```typescript
// Example component structure
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface ComponentProps {
title: string;
className?: string;
}
export const Component = ({ title, className }: ComponentProps) => {
return (
<div className={cn("default-classes", className)}>
<Button>{title}</Button>
</div>
);
};
```
### Configuration for Static Export
The application is configured for static export in `next.config.mjs`:
```javascript
const nextConfig = {
output: "export",
basePath: `/ProxmoxVE`,
images: {
unoptimized: true // Required for static export
}
};
```
## 🤝 Contributing
We welcome contributions from the community! Here's how you can help:
### Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork** locally
3. **Create a new branch** for your feature or bugfix
4. **Make your changes** following our coding standards
5. **Submit a pull request** with a clear description
### Contribution Guidelines
#### Code Style
- Follow the existing TypeScript and React patterns
- Use descriptive variable and function names
- Implement proper error handling
- Write self-documenting code with appropriate comments
#### Component Guidelines
- Use functional components with hooks
- Implement proper TypeScript types
- Follow accessibility best practices
- Ensure responsive design
- Use the existing design system components
#### Pull Request Process
1. Update documentation if needed
2. Update the README if you've added new features
3. Request review from maintainers
### Areas for Contribution
- **🐛 Bug fixes**: Report and fix issues
- **✨ New features**: Enhance functionality
- **📚 Documentation**: Improve guides and examples
- **🎨 UI/UX**: Improve design and user experience
- **♿ Accessibility**: Enhance accessibility features
- **🚀 Performance**: Optimize loading and runtime performance
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- **[tteck](https://github.com/tteck)** - Original creator of the Proxmox VE Helper-Scripts
- **[Community-Scripts Organization](https://github.com/community-scripts)** - Maintaining and expanding the project
- **[Proxmox Community](https://forum.proxmox.com/)** - For continuous feedback and support
- **All Contributors** - Thank you for your valuable contributions!
## 📚 Additional Resources
- **[Proxmox VE Documentation](https://pve.proxmox.com/pve-docs/)**
- **[Community Scripts Repository](https://github.com/community-scripts/ProxmoxVE)**
- **[Discord Community](https://discord.gg/2wvnMDgdnU)**
- **[GitHub Discussions](https://github.com/community-scripts/ProxmoxVE/discussions)**
## 🔗 Links
- **🌐 Live Website**: [https://community-scripts.github.io/ProxmoxVE/](https://community-scripts.github.io/ProxmoxVE/)
- **💬 Discord Server**: [https://discord.gg/2wvnMDgdnU](https://discord.gg/2wvnMDgdnU)
- **📝 Change Log**: [https://github.com/community-scripts/ProxmoxVE/blob/main/CHANGELOG.md](https://github.com/community-scripts/ProxmoxVE/blob/main/CHANGELOG.md)
---
**Made with ❤️ by the Community-Scripts team and contributors**

View File

@ -0,0 +1,41 @@
import antfu from "@antfu/eslint-config";
export default antfu(
{
type: "app",
typescript: true,
formatters: true,
next: 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

30
frontend/package.json generated
View File

@ -1,22 +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": "eslint . --fix",
"test": "vitest",
"deploy": "next build && touch out/.nojekyll && git add out/ && git commit -m \"Deploy\" && git subtree push --prefix out origin gh-pages",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@ -45,7 +41,7 @@
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"next": "15.2.4", "next": "15.2.4",
"next-themes": "^0.3.0", "next-themes": "^0.4.4",
"nuqs": "^2.4.1", "nuqs": "^2.4.1",
"pocketbase": "^0.21.5", "pocketbase": "^0.21.5",
"prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-organize-imports": "^4.1.0",
@ -53,7 +49,7 @@
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-code-blocks": "^0.1.6", "react-code-blocks": "^0.1.6",
"react-datepicker": "^7.6.0", "react-datepicker": "^7.6.0",
"react-day-picker": "8.10.1", "react-day-picker": "^9.4.3",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-simple-typewriter": "^5.0.1", "react-simple-typewriter": "^5.0.1",
@ -64,9 +60,10 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^4.16.1",
"@eslint-react/eslint-plugin": "^1.52.2",
"@next/eslint-plugin-next": "^15.3.4",
"@tanstack/eslint-plugin-query": "^5.68.0", "@tanstack/eslint-plugin-query": "^5.68.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.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",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
@ -75,6 +72,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",
@ -83,11 +83,13 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tailwindcss-animated": "^1.1.2", "tailwindcss-animated": "^1.1.2",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4"
"vitest": "^3.1.1"
}, },
"overrides": { "overrides": {
"@types/react": "npm:types-react@19.0.0-rc.1", "@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"date-fns": "^4.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"
} }
} }

View File

@ -1,11 +0,0 @@
import { screen } from "@testing-library/dom";
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Page from "@/app/page";
describe("Page", () => {
it("should show button to view scripts", () => {
render(<Page />);
expect(screen.getByRole("button", { name: "View Scripts" })).toBeDefined();
});
});

View File

@ -1,56 +0,0 @@
import { describe, it, assert, beforeAll } from "vitest";
import { promises as fs } from "fs";
import path from "path";
import { ScriptSchema, type Script } from "@/app/json-editor/_schemas/schemas";
import { Metadata } from "@/lib/types";
console.log('Current directory: ' + process.cwd());
const jsonDir = "public/json";
const metadataFileName = "metadata.json";
const versionsFileName = "versions.json";
const encoding = "utf-8";
const fileNames = (await fs.readdir(jsonDir))
.filter((fileName) => fileName !== metadataFileName && fileName !== versionsFileName);
describe.each(fileNames)("%s", async (fileName) => {
let script: Script;
beforeAll(async () => {
const filePath = path.resolve(jsonDir, fileName);
const fileContent = await fs.readFile(filePath, encoding)
script = JSON.parse(fileContent);
})
it("should have valid json according to script schema", () => {
ScriptSchema.parse(script);
});
it("should have a corresponding script file", () => {
script.install_methods.forEach((method) => {
const scriptPath = path.resolve("..", method.script)
//FIXME: Dose note account for new dir structure and files in /script/tools
assert(fs.stat(scriptPath), `Script file not found: ${scriptPath}`)
})
});
})
describe(`${metadataFileName}`, async () => {
let metadata: Metadata;
beforeAll(async () => {
const filePath = path.resolve(jsonDir, metadataFileName);
const fileContent = await fs.readFile(filePath, encoding)
metadata = JSON.parse(fileContent);
})
it("should have valid json according to metadata schema", () => {
// TODO: create zod schema for metadata. Move zod schemas to /lib/types.ts
assert(metadata.categories.length > 0);
metadata.categories.forEach((category) => {
assert.isString(category.name)
assert.isNumber(category.id)
assert.isNumber(category.sort_order)
});
});
})

View File

@ -1,4 +0,0 @@
import { vi } from "vitest";
// Mock canvas getContext
HTMLCanvasElement.prototype.getContext = vi.fn();

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,150 +0,0 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AlertColors } from "@/config/siteConfig";
import { cn } from "@/lib/utils";
import { PlusCircle, Trash2 } from "lucide-react";
import { z } from "zod";
import { ScriptSchema, type Script } from "../_schemas/schemas";
import { memo, useCallback, useRef } from "react";
type NoteProps = {
script: Script;
setScript: (script: Script) => void;
setIsValid: (isValid: boolean) => void;
setZodErrors: (zodErrors: z.ZodError | null) => void;
};
function Note({
script,
setScript,
setIsValid,
setZodErrors,
}: NoteProps) {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const addNote = useCallback(() => {
setScript({
...script,
notes: [...script.notes, { text: "", type: "" }],
});
}, [script, setScript]);
const updateNote = useCallback((
index: number,
key: keyof Script["notes"][number],
value: string,
) => {
const updated: Script = {
...script,
notes: script.notes.map((note, i) =>
i === index ? { ...note, [key]: value } : note,
),
};
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
setZodErrors(result.success ? null : result.error);
setScript(updated);
// Restore focus after state update
if (key === "text") {
setTimeout(() => {
inputRefs.current[index]?.focus();
}, 0);
}
}, [script, setScript, setIsValid, setZodErrors]);
const removeNote = useCallback((index: number) => {
setScript({
...script,
notes: script.notes.filter((_, i) => i !== index),
});
}, [script, setScript]);
return (
<>
<h3 className="text-xl font-semibold">Notes</h3>
{script.notes.map((note, index) => (
<NoteItem key={index} note={note} index={index} updateNote={updateNote} removeNote={removeNote} />
))}
<Button type="button" size="sm" onClick={addNote}>
<PlusCircle className="mr-2 h-4 w-4" /> Add Note
</Button>
</>
);
}
const NoteItem = memo(
({
note,
index,
updateNote,
removeNote,
}: {
note: Script["notes"][number];
index: number;
updateNote: (index: number, key: keyof Script["notes"][number], value: string) => void;
removeNote: (index: number) => void;
}) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
updateNote(index, "text", e.target.value);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, [index, updateNote]);
return (
<div className="space-y-2 border p-4 rounded">
<Input
placeholder="Note Text"
value={note.text}
onChange={handleTextChange}
ref={inputRef}
/>
<Select
value={note.type}
onValueChange={(value) => updateNote(index, "type", value)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{Object.keys(AlertColors).map((type) => (
<SelectItem key={type} value={type}>
<span className="flex items-center gap-2">
{type.charAt(0).toUpperCase() + type.slice(1)}{" "}
<div
className={cn(
"size-4 rounded-full border",
AlertColors[type as keyof typeof AlertColors],
)}
/>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="destructive"
type="button"
onClick={() => removeNote(index)}
>
<Trash2 className="mr-2 h-4 w-4" /> Remove Note
</Button>
</div>
);
}
);
NoteItem.displayName = 'NoteItem';
export default memo(Note);

View File

@ -1,4 +1,9 @@
import { Label } from "@/components/ui/label"; import type { z } from "zod";
import { memo } from "react";
import type { Category } from "@/lib/types";
import { 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,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

@ -0,0 +1,159 @@
import type { z } from "zod";
import { PlusCircle, Trash2 } from "lucide-react";
import { memo, useCallback, useRef } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AlertColors } from "@/config/site-config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { Script } from "../_schemas/schemas";
import { ScriptSchema } from "../_schemas/schemas";
const NoteItem = memo(
({
note,
index,
updateNote,
removeNote,
}: {
note: Script["notes"][number];
index: number;
updateNote: (index: number, key: keyof Script["notes"][number], value: string) => void;
removeNote: (index: number) => void;
}) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
updateNote(index, "text", e.target.value);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, [index, updateNote]);
return (
<div className="space-y-2 border p-4 rounded">
<Input
placeholder="Note Text"
value={note.text}
onChange={handleTextChange}
ref={inputRef}
/>
<Select
value={note.type}
onValueChange={value => updateNote(index, "type", value)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{Object.keys(AlertColors).map(type => (
<SelectItem key={type} value={type}>
<span className="flex items-center gap-2">
{type.charAt(0).toUpperCase() + type.slice(1)}
{" "}
<div
className={cn(
"size-4 rounded-full border",
AlertColors[type as keyof typeof AlertColors],
)}
/>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="destructive"
type="button"
onClick={() => removeNote(index)}
>
<Trash2 className="mr-2 h-4 w-4" />
{" "}
Remove Note
</Button>
</div>
);
},
);
type NoteProps = {
script: Script;
setScript: (script: Script) => void;
setIsValid: (isValid: boolean) => void;
setZodErrors: (zodErrors: z.ZodError | null) => void;
};
function Note({
script,
setScript,
setIsValid,
setZodErrors,
}: NoteProps) {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const addNote = useCallback(() => {
setScript({
...script,
notes: [...script.notes, { text: "", type: "" }],
});
}, [script, setScript]);
const updateNote = useCallback((
index: number,
key: keyof Script["notes"][number],
value: string,
) => {
const updated: Script = {
...script,
notes: script.notes.map((note, i) =>
i === index ? { ...note, [key]: value } : note,
),
};
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
setZodErrors(result.success ? null : result.error);
setScript(updated);
// Restore focus after state update
if (key === "text") {
setTimeout(() => {
inputRefs.current[index]?.focus();
}, 0);
}
}, [script, setScript, setIsValid, setZodErrors]);
const removeNote = useCallback((index: number) => {
setScript({
...script,
notes: script.notes.filter((_, i) => i !== index),
});
}, [script, setScript]);
return (
<>
<h3 className="text-xl font-semibold">Notes</h3>
{script.notes.map((note, index) => (
<NoteItem key={index} note={note} index={index} updateNote={updateNote} removeNote={removeNote} />
))}
<Button type="button" size="sm" onClick={addNote}>
<PlusCircle className="mr-2 h-4 w-4" />
{" "}
Add Note
</Button>
</>
);
}
NoteItem.displayName = "NoteItem";
export default memo(Note);

View File

@ -2,7 +2,7 @@ import { z } from "zod";
export const InstallMethodSchema = z.object({ 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,21 +0,0 @@
import handleCopy from "@/components/handleCopy";
import { buttonVariants } from "@/components/ui/button";
import { Script } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ClipboardIcon } from "lucide-react";
export default function InterFaces({ item }: { item: Script }) {
return (
<div className="flex flex-col gap-2 w-full">
{item.interface_port !== null ? (
<div className="flex items-center justify-end">
<h2 className="mr-2 text-end text-lg font-semibold">Default Interface:</h2>
<span className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex items-center gap-2")}>
{item.interface_port}
<ClipboardIcon onClick={() => handleCopy("default interface", String(item.interface_port))} className="size-4 cursor-pointer" />
</span>
</div>
) : null}
</div>
);
}

View File

@ -1,51 +1,84 @@
import CodeCopyButton from "@/components/ui/code-copy-button"; import { Info } from "lucide-react";
import type { Script } from "@/lib/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react"; import CodeCopyButton from "@/components/ui/code-copy-button";
import { basePath } from "@/config/siteConfig"; import { basePath } from "@/config/site-config";
import { Script } from "@/lib/types";
import { getDisplayValueFromType } from "../ScriptInfoBlocks";
const getInstallCommand = (scriptPath = "", isAlpine = false, useGitea = false) => { import { getDisplayValueFromType } from "../script-info-blocks";
function getInstallCommand(scriptPath = "", isAlpine = false, useGitea = false) {
const githubUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`; const githubUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`;
const giteaUrl = `https://git.community-scripts.org/community-scripts/${basePath}/raw/branch/main/${scriptPath}`; const giteaUrl = `https://git.community-scripts.org/community-scripts/${basePath}/raw/branch/main/${scriptPath}`;
const url = useGitea ? giteaUrl : githubUrl; const url = useGitea ? giteaUrl : githubUrl;
return `bash -c "$(curl -fsSL ${url})"`; return isAlpine ? `bash -c "$(curl -fsSL ${url})"` : `bash -c "$(curl -fsSL ${url})"`;
}; }
export default function InstallCommand({ item }: { item: Script }) { export default function InstallCommand({ item }: { item: Script }) {
const alpineScript = item.install_methods.find((method) => method.type === "alpine"); const alpineScript = item.install_methods.find(method => method.type === "alpine");
const defaultScript = item.install_methods.find((method) => method.type === "default"); const defaultScript = item.install_methods.find(method => method.type === "default");
const renderInstructions = (isAlpine = false) => ( const renderInstructions = (isAlpine = false) => (
<> <>
<p className="text-sm mt-2"> <p className="text-sm mt-2">
{isAlpine ? ( {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. As an alternative option, you can use Alpine Linux and the
You are also obliged to adhere to updates provided by the package maintainer. {" "}
</> {item.name}
) : item.type === "pve" ? ( {" "}
<> package to create a
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.name}
</> {" "}
) : item.type === "addon" ? ( {getDisplayValueFromType(item.type)}
<> {" "}
This script enhances an existing setup. You can use it inside a running LXC container or directly on the container with faster creation time and minimal system resource usage.
Proxmox VE host to extend functionality with {item.name}. You are also obliged to adhere to updates provided by the package maintainer.
</> </>
) : ( )
<> : item.type === "pve"
To create a new Proxmox VE {item.name} {getDisplayValueFromType(item.type)}, run the command below in the ? (
Proxmox VE Shell. <>
</> 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> </p>
{isAlpine && ( {isAlpine && (
<p className="mt-2 text-sm"> <p className="mt-2 text-sm">
To create a new Proxmox VE Alpine-{item.name} {getDisplayValueFromType(item.type)}, run the command below in To create a new Proxmox VE Alpine-
{item.name}
{" "}
{getDisplayValueFromType(item.type)}
, run the command below in
the Proxmox VE Shell. the Proxmox VE Shell.
</p> </p>
)} )}
@ -56,7 +89,9 @@ export default function InstallCommand({ item }: { item: Script }) {
<Alert className="mt-3 mb-3"> <Alert className="mt-3 mb-3">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription className="text-sm"> <AlertDescription className="text-sm">
<strong>When to use Gitea:</strong> GitHub may have issues including slow connections, delayed updates after bug <strong>When to use Gitea:</strong>
{" "}
GitHub may have issues including slow connections, delayed updates after bug
fixes, no IPv6 support, API rate limits (60/hour). Use our Gitea mirror as a reliable alternative when fixes, no IPv6 support, API rate limits (60/hour). Use our Gitea mirror as a reliable alternative when
experiencing these issues. experiencing these issues.
</AlertDescription> </AlertDescription>
@ -81,7 +116,8 @@ export default function InstallCommand({ item }: { item: Script }) {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
); );
} else if (defaultScript?.script) { }
else if (defaultScript?.script) {
return ( return (
<> <>
{renderInstructions()} {renderInstructions()}
@ -109,4 +145,4 @@ export default function InstallCommand({ item }: { item: Script }) {
</Tabs> </Tabs>
</div> </div>
); );
} }

View File

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

View File

@ -1,17 +1,17 @@
import { CPUIcon, HDDIcon, RAMIcon } from "@/components/icons/resource-icons"; import { 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,11 +58,11 @@ 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-md 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 "./script-items/default-password";
import InstallCommand from "./script-items/install-command";
import { ResourceDisplay } from "./resource-display";
import Description from "./script-items/description";
import ConfigFile from "./script-items/config-file";
import InterFaces from "./script-items/interfaces";
import Tooltips from "./script-items/tool-tips";
import Buttons from "./script-items/buttons";
import Alerts from "./script-items/alerts";
type ScriptItemProps = {
item: Script; 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>;
} }
@ -132,7 +139,7 @@ export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
</button> </button>
</div> </div>
<div className="rounded-xl border border-border bg-gradient-to-b from-card/30 to-background/50 backdrop-blur-sm shadow-sm"> <div className="rounded-xl border border-border bg-accent/30 backdrop-blur-sm shadow-sm">
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<Suspense fallback={<div className="animate-pulse h-32 bg-accent/20 rounded-xl" />}> <Suspense fallback={<div className="animate-pulse h-32 bg-accent/20 rounded-xl" />}>
<ScriptHeader item={item} /> <ScriptHeader item={item} />
@ -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,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,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

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

@ -0,0 +1,149 @@
import { Info } from "lucide-react";
import type { Script } from "@/lib/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import CodeCopyButton from "@/components/ui/code-copy-button";
import { basePath } from "@/config/site-config";
import { getDisplayValueFromType } from "../script-info-blocks";
function getInstallCommand(scriptPath = "", isAlpine = false, useGitea = false) {
const githubUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`;
const giteaUrl = `https://git.community-scripts.org/community-scripts/${basePath}/raw/branch/main/${scriptPath}`;
const url = useGitea ? giteaUrl : githubUrl;
return isAlpine ? `bash -c "$(curl -fsSL ${url})"` : `bash -c "$(curl -fsSL ${url})"`;
}
export default function InstallCommand({ item }: { item: Script }) {
const alpineScript = item.install_methods.find(method => method.type === "alpine");
const defaultScript = item.install_methods.find(method => method.type === "default");
const renderInstructions = (isAlpine = false) => (
<>
<p className="text-sm mt-2">
{isAlpine
? (
<>
As an alternative option, you can use Alpine Linux and the
{" "}
{item.name}
{" "}
package to create a
{" "}
{item.name}
{" "}
{getDisplayValueFromType(item.type)}
{" "}
container with faster creation time and minimal system resource usage.
You are also obliged to adhere to updates provided by the package maintainer.
</>
)
: item.type === "pve"
? (
<>
To use the
{" "}
{item.name}
{" "}
script, run the command below **only** in the Proxmox VE Shell. This script is
intended for managing or enhancing the host system directly.
</>
)
: item.type === "addon"
? (
<>
This script enhances an existing setup. You can use it inside a running LXC container or directly on the
Proxmox VE host to extend functionality with
{" "}
{item.name}
.
</>
)
: (
<>
To create a new Proxmox VE
{" "}
{item.name}
{" "}
{getDisplayValueFromType(item.type)}
, run the command below in the
Proxmox VE Shell.
</>
)}
</p>
{isAlpine && (
<p className="mt-2 text-sm">
To create a new Proxmox VE Alpine-
{item.name}
{" "}
{getDisplayValueFromType(item.type)}
, run the command below in
the Proxmox VE Shell.
</p>
)}
</>
);
const renderGiteaInfo = () => (
<Alert className="mt-3 mb-3">
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
<strong>When to use Gitea:</strong>
{" "}
GitHub may have issues including slow connections, delayed updates after bug
fixes, no IPv6 support, API rate limits (60/hour). Use our Gitea mirror as a reliable alternative when
experiencing these issues.
</AlertDescription>
</Alert>
);
const renderScriptTabs = (useGitea = false) => {
if (alpineScript) {
return (
<Tabs defaultValue="default" className="mt-2 w-full max-w-4xl">
<TabsList>
<TabsTrigger value="default">Default</TabsTrigger>
<TabsTrigger value="alpine">Alpine Linux</TabsTrigger>
</TabsList>
<TabsContent value="default">
{renderInstructions()}
<CodeCopyButton>{getInstallCommand(defaultScript?.script, false, useGitea)}</CodeCopyButton>
</TabsContent>
<TabsContent value="alpine">
{renderInstructions(true)}
<CodeCopyButton>{getInstallCommand(alpineScript.script, true, useGitea)}</CodeCopyButton>
</TabsContent>
</Tabs>
);
}
else if (defaultScript?.script) {
return (
<>
{renderInstructions()}
<CodeCopyButton>{getInstallCommand(defaultScript.script, false, useGitea)}</CodeCopyButton>
</>
);
}
return null;
};
return (
<div className="p-4">
<Tabs defaultValue="github" className="w-full max-w-4xl">
<TabsList>
<TabsTrigger value="github">GitHub</TabsTrigger>
<TabsTrigger value="gitea">Gitea</TabsTrigger>
</TabsList>
<TabsContent value="github">
{renderScriptTabs(false)}
</TabsContent>
<TabsContent value="gitea">
{renderGiteaInfo()}
{renderScriptTabs(true)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { ClipboardIcon } from "lucide-react";
import type { Script } from "@/lib/types";
import { buttonVariants } from "@/components/ui/button";
import handleCopy from "@/components/handle-copy";
import { cn } from "@/lib/utils";
export default function InterFaces({ item }: { item: Script }) {
return (
<div className="flex flex-col gap-2 w-full">
{item.interface_port !== null
? (
<div className="flex items-center justify-end">
<h2 className="mr-2 text-end text-lg font-semibold">Default Interface:</h2>
<span className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex items-center gap-2")}>
{item.interface_port}
<ClipboardIcon onClick={() => handleCopy("default interface", String(item.interface_port))} className="size-4 cursor-pointer" />
</span>
</div>
)
: null}
</div>
);
}

View File

@ -1,22 +1,27 @@
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Script } from "@/lib/types";
import { cn } from "@/lib/utils";
import { CircleHelp } from "lucide-react"; import { 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

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

View File

@ -1,8 +1,8 @@
import { AppVersion } from "@/lib/types"; import type { AppVersion } from "@/lib/types";
interface VersionBadgeProps { type VersionBadgeProps = {
version: AppVersion; 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,86 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { navbarLinks } from "@/config/siteConfig";
import CommandMenu from "./CommandMenu";
import StarOnGithubButton from "./ui/star-on-github-button";
import { ThemeToggle } from "./ui/theme-toggle";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
export const dynamic = "force-dynamic";
function Navbar() {
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 0);
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return (
<>
<div
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" : ""
}`}
>
<div className="flex h-20 w-full max-w-[1440px] items-center justify-between sm:flex-row">
<Link
href={"/"}
className="flex cursor-pointer w-full justify-center sm:justify-start flex-row-reverse items-center gap-2 font-semibold sm:flex-row"
>
<Image
height={18}
unoptimized
width={18}
alt="logo"
src="/ProxmoxVE/logo.png"
className=""
/>
<span className="hidden md:block">Proxmox VE Helper-Scripts</span>
</Link>
<div className="flex gap-2">
<CommandMenu />
<StarOnGithubButton />
{navbarLinks.map(({ href, event, icon, text, mobileHidden }) => (
<TooltipProvider key={event}>
<Tooltip delayDuration={100}>
<TooltipTrigger
className={mobileHidden ? "hidden lg:block" : ""}
>
<Button variant="ghost" size={"icon"} asChild>
<Link
target="_blank"
href={href}
data-umami-event={event}
>
{icon}
<span className="sr-only">{text}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<ThemeToggle />
</div>
</div>
</div>
</>
);
}
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,54 +139,34 @@ 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 => (
// Track seen scripts globally to avoid duplicates across all categories <CommandGroup key={`category:${category.name}`} heading={category.name}>
const globalSeenScripts = new Set<string>(); {category.scripts.map(script => (
<CommandItem
return links.map((category) => { key={`script:${script.slug}`}
const uniqueScripts = category.scripts.filter((script) => { value={`${script.slug}-${script.name}`}
if (globalSeenScripts.has(script.slug)) { onSelect={() => {
return false; setOpen(false);
} router.push(`/scripts?id=${script.slug}`);
globalSeenScripts.add(script.slug); }}
return true; >
}); <div className="flex gap-2" onClick={() => setOpen(false)}>
<Image
// Only render category if it has unique scripts src={script.logo || `/${basePath}/logo.png`}
if (uniqueScripts.length === 0) { onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
return null; unoptimized
} width={16}
height={16}
return ( alt=""
<CommandGroup key={`category:${category.name}`} heading={category.name}> className="h-5 w-5"
{uniqueScripts.map((script) => ( />
<CommandItem <span>{script.name}</span>
key={`script:${script.slug}`} <span>{formattedBadge(script.type)}</span>
value={`${script.slug}-${script.name}`} </div>
onSelect={() => { </CommandItem>
setOpen(false); ))}
router.push(`/scripts?id=${script.slug}`); </CommandGroup>
}} ))}
>
<div className="flex gap-2" onClick={() => setOpen(false)}>
<Image
src={script.logo || `/${basePath}/logo.png`}
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
unoptimized
width={16}
height={16}
alt=""
className="h-5 w-5"
/>
<span>{script.name}</span>
<span>{formattedBadge(script.type)}</span>
</div>
</CommandItem>
))}
</CommandGroup>
);
});
})()}
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
</> </>

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

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

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";
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:
@ -54,13 +54,17 @@ function Calendar({
...classNames, ...classNames,
}} }}
components={{ components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />, Chevron: ({ ...props }) => {
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />, if (props.orientation === "left") {
return <ChevronLeft className="h-4 w-4" />;
}
return <ChevronRight className="h-4 w-4" />;
},
}} }}
{...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

@ -0,0 +1,72 @@
import { MessagesSquare, Scroll } from "lucide-react";
import { FaDiscord, FaGithub } from "react-icons/fa";
import React from "react";
import type { OperatingSystem } from "@/lib/types";
// eslint-disable-next-line node/no-process-env
export const basePath = process.env.BASE_PATH || "";
export const navbarLinks = [
{
href: `https://github.com/community-scripts/${basePath}`,
event: "Github",
icon: <FaGithub className="h-4 w-4" />,
text: "Github",
},
{
href: `https://discord.gg/2wvnMDgdnU`,
event: "Discord",
icon: <FaDiscord className="h-4 w-4" />,
text: "Discord",
},
{
href: `https://github.com/community-scripts/${basePath}/blob/main/CHANGELOG.md`,
event: "Change Log",
icon: <Scroll className="h-4 w-4" />,
text: "Change Log",
mobileHidden: true,
},
{
href: `https://github.com/community-scripts/${basePath}/discussions`,
event: "Discussions",
icon: <MessagesSquare className="h-4 w-4" />,
text: "Discussions",
mobileHidden: true,
},
].filter(Boolean) as {
href: string;
event: string;
icon: React.ReactNode;
text: string;
mobileHidden?: boolean;
}[];
export const mostPopularScripts = ["post-pve-install", "docker", "homeassistant"];
export const analytics = {
url: "analytics.community-scripts.org",
token: "aefee1b9-2a12-4ac2-9d82-a63113edc62e",
};
export const AlertColors = {
warning: "border-red-500/25 bg-destructive/25",
info: "border-cyan-500/25 bg-cyan-50 dark:border-cyan-900 dark:bg-cyan-900/25",
};
export const OperatingSystems: OperatingSystem[] = [
{
name: "Debian",
versions: [
{ name: "11", slug: "bullseye" },
{ name: "12", slug: "bookworm" },
],
},
{
name: "Ubuntu",
versions: [
{ name: "22.04", slug: "jammy" },
{ name: "24.04", slug: "noble" },
],
},
];

View File

@ -1,72 +0,0 @@
import { OperatingSystem } from "@/lib/types";
import { MessagesSquare, Scroll } from "lucide-react";
import React from "react";
import { FaDiscord, FaGithub } from "react-icons/fa";
export const basePath = process.env.BASE_PATH;
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
export const navbarLinks = [
{
href: `https://github.com/community-scripts/${basePath}`,
event: "Github",
icon: <FaGithub className="h-4 w-4" />,
text: "Github",
},
{
href: `https://discord.gg/2wvnMDgdnU`,
event: "Discord",
icon: <FaDiscord className="h-4 w-4" />,
text: "Discord",
},
{
href: `https://github.com/community-scripts/${basePath}/blob/main/CHANGELOG.md`,
event: "Change Log",
icon: <Scroll className="h-4 w-4" />,
text: "Change Log",
mobileHidden: true,
},
{
href: `https://github.com/community-scripts/${basePath}/discussions`,
event: "Discussions",
icon: <MessagesSquare className="h-4 w-4" />,
text: "Discussions",
mobileHidden: true,
},
].filter(Boolean) as {
href: string;
event: string;
icon: React.ReactNode;
text: string;
mobileHidden?: boolean;
}[];
export const mostPopularScripts = ["post-pve-install", "docker", "homeassistant"];
export const analytics = {
url: "analytics.proxmoxve-scripts.com",
token: "aefee1b9-2a12-4ac2-9d82-a63113edc62e",
};
export const AlertColors = {
warning: "border-red-500/25 bg-destructive/25",
info: "border-cyan-500/25 bg-cyan-50 dark:border-cyan-900 dark:bg-cyan-900/25",
};
export const OperatingSystems: OperatingSystem[] = [
{
name: "Debian",
versions: [
{ name: "11", slug: "bullseye" },
{ name: "12", slug: "bookworm" },
],
},
{
name: "Ubuntu",
versions: [
{ name: "22.04", slug: "jammy" },
{ name: "24.04", slug: "noble" },
],
},
];

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