mirror of
				https://github.com/community-scripts/ProxmoxVE.git
				synced 2025-11-04 02:12:49 +00:00 
			
		
		
		
	Remove npm legacy errors, created single source of truth for ESlint. updated analytics url. updated script background (#5498)
* Update ScriptAccordion and ScriptItem components for improved styling * Add README.md for Proxmox VE Helper-Scripts Frontend * Remove testing dependencies and related test files from the frontend project * Update analytics URL in siteConfig to point to community-scripts.org * Refactor ESLint configuration to have one source of truth and run "npm lint" to apply new changes * Update lint script in package.json to remove npm * Add 'next' option to ESLint configuration for improved compatibility * Update package dependencies and versions in package.json and package-lock.json * Refactor theme provider import and enhance calendar component for dynamic icon rendering * rename sidebar, alerts and buttons * rename description and interfaces files * rename more files * change folder name * Refactor tooltip logic to improve updateable condition handling * Enhance CommandMenu to prevent duplicate scripts across categories * Remove test step from frontend CI/CD workflow
This commit is contained in:
		
							
								
								
									
										3
									
								
								.github/workflows/frontend-cicd.yml
									
									
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/frontend-cicd.yml
									
									
									
										generated
									
									
										vendored
									
									
								
							@@ -44,9 +44,6 @@ jobs:
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: npm ci --prefer-offline --legacy-peer-deps
 | 
			
		||||
 | 
			
		||||
      - name: Run tests
 | 
			
		||||
        run: npm run test
 | 
			
		||||
 | 
			
		||||
      - name: Configure Next.js for pages
 | 
			
		||||
        uses: actions/configure-pages@v5
 | 
			
		||||
        with:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								frontend/.eslintrc.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								frontend/.eslintrc.json
									
									
									
										generated
									
									
									
								
							@@ -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
									
								
							
							
						
						
									
										51
									
								
								frontend/.vscode/settings.json
									
									
									
										generated
									
									
										vendored
									
									
										Normal 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
									
								
							
							
						
						
									
										281
									
								
								frontend/README.md
									
									
									
									
									
										Normal 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.
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
## 🌟 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**
 | 
			
		||||
							
								
								
									
										41
									
								
								frontend/eslint.config.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								frontend/eslint.config.mjs
									
									
									
									
									
										Normal 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"],
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										8376
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8376
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										30
									
								
								frontend/package.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										30
									
								
								frontend/package.json
									
									
									
										generated
									
									
									
								
							@@ -1,22 +1,18 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "proxmox-helper-scripts-website",
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "version": "1.0.0",
 | 
			
		||||
  "license": "MIT",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "author": {
 | 
			
		||||
    "name": "Bram Suurd",
 | 
			
		||||
    "url": "https://github.com/community-scripts"
 | 
			
		||||
  },
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "license": "MIT",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "next dev --turbopack",
 | 
			
		||||
    "build": "next build",
 | 
			
		||||
    "start": "next start",
 | 
			
		||||
    "lint": "next lint",
 | 
			
		||||
    "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",
 | 
			
		||||
    "lint": "eslint . --fix",
 | 
			
		||||
    "typecheck": "tsc --noEmit"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
@@ -45,7 +41,7 @@
 | 
			
		||||
    "lucide-react": "^0.453.0",
 | 
			
		||||
    "mini-svg-data-uri": "^1.4.4",
 | 
			
		||||
    "next": "15.2.4",
 | 
			
		||||
    "next-themes": "^0.3.0",
 | 
			
		||||
    "next-themes": "^0.4.4",
 | 
			
		||||
    "nuqs": "^2.4.1",
 | 
			
		||||
    "pocketbase": "^0.21.5",
 | 
			
		||||
    "prettier-plugin-organize-imports": "^4.1.0",
 | 
			
		||||
@@ -53,7 +49,7 @@
 | 
			
		||||
    "react-chartjs-2": "^5.3.0",
 | 
			
		||||
    "react-code-blocks": "^0.1.6",
 | 
			
		||||
    "react-datepicker": "^7.6.0",
 | 
			
		||||
    "react-day-picker": "8.10.1",
 | 
			
		||||
    "react-day-picker": "^9.4.3",
 | 
			
		||||
    "react-dom": "19.0.0",
 | 
			
		||||
    "react-icons": "^5.5.0",
 | 
			
		||||
    "react-simple-typewriter": "^5.0.1",
 | 
			
		||||
@@ -64,9 +60,10 @@
 | 
			
		||||
    "zod": "^3.24.2"
 | 
			
		||||
  },
 | 
			
		||||
  "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",
 | 
			
		||||
    "@testing-library/dom": "^10.4.0",
 | 
			
		||||
    "@testing-library/react": "^16.2.0",
 | 
			
		||||
    "@types/node": "^22.13.16",
 | 
			
		||||
    "@types/react": "npm:types-react@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",
 | 
			
		||||
    "eslint": "^9.23.0",
 | 
			
		||||
    "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",
 | 
			
		||||
    "postcss": "^8.5.3",
 | 
			
		||||
    "prettier": "^3.5.3",
 | 
			
		||||
@@ -83,11 +83,13 @@
 | 
			
		||||
    "tailwindcss-animate": "^1.0.7",
 | 
			
		||||
    "tailwindcss-animated": "^1.1.2",
 | 
			
		||||
    "typescript": "^5.8.2",
 | 
			
		||||
    "vite-tsconfig-paths": "^5.1.4",
 | 
			
		||||
    "vitest": "^3.1.1"
 | 
			
		||||
    "vite-tsconfig-paths": "^5.1.4"
 | 
			
		||||
  },
 | 
			
		||||
  "overrides": {
 | 
			
		||||
    "@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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -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)
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
})
 | 
			
		||||
@@ -1,4 +0,0 @@
 | 
			
		||||
import { vi } from "vitest";
 | 
			
		||||
 | 
			
		||||
// Mock canvas getContext
 | 
			
		||||
HTMLCanvasElement.prototype.getContext = vi.fn();
 | 
			
		||||
@@ -1,7 +1,8 @@
 | 
			
		||||
import { Metadata, Script } from "@/lib/types";
 | 
			
		||||
import { promises as fs } from "fs";
 | 
			
		||||
import { NextResponse } from "next/server";
 | 
			
		||||
import path from "path";
 | 
			
		||||
import { promises as fs } from "node:fs";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
 | 
			
		||||
import type { Metadata, Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
export const dynamic = "force-static";
 | 
			
		||||
 | 
			
		||||
@@ -10,21 +11,21 @@ const metadataFileName = "metadata.json";
 | 
			
		||||
const versionFileName = "version.json";
 | 
			
		||||
const encoding = "utf-8";
 | 
			
		||||
 | 
			
		||||
const getMetadata = async () => {
 | 
			
		||||
async function getMetadata() {
 | 
			
		||||
  const filePath = path.resolve(jsonDir, metadataFileName);
 | 
			
		||||
  const fileContent = await fs.readFile(filePath, encoding);
 | 
			
		||||
  const metadata: Metadata = JSON.parse(fileContent);
 | 
			
		||||
  return metadata;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getScripts = async () => {
 | 
			
		||||
async function getScripts() {
 | 
			
		||||
  const filePaths = (await fs.readdir(jsonDir))
 | 
			
		||||
    .filter((fileName) => 
 | 
			
		||||
      fileName.endsWith(".json") && 
 | 
			
		||||
      fileName !== metadataFileName && 
 | 
			
		||||
      fileName !== versionFileName
 | 
			
		||||
    .filter(fileName =>
 | 
			
		||||
      fileName.endsWith(".json")
 | 
			
		||||
      && fileName !== metadataFileName
 | 
			
		||||
      && fileName !== versionFileName,
 | 
			
		||||
    )
 | 
			
		||||
    .map((fileName) => path.resolve(jsonDir, fileName));
 | 
			
		||||
    .map(fileName => path.resolve(jsonDir, fileName));
 | 
			
		||||
 | 
			
		||||
  const scripts = await Promise.all(
 | 
			
		||||
    filePaths.map(async (filePath) => {
 | 
			
		||||
@@ -34,7 +35,7 @@ const getScripts = async () => {
 | 
			
		||||
    }),
 | 
			
		||||
  );
 | 
			
		||||
  return scripts;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function GET() {
 | 
			
		||||
  try {
 | 
			
		||||
@@ -43,7 +44,7 @@ export async function GET() {
 | 
			
		||||
 | 
			
		||||
    const categories = metadata.categories
 | 
			
		||||
      .map((category) => {
 | 
			
		||||
        category.scripts = scripts.filter((script) =>
 | 
			
		||||
        category.scripts = scripts.filter(script =>
 | 
			
		||||
          script.categories?.includes(category.id),
 | 
			
		||||
        );
 | 
			
		||||
        return category;
 | 
			
		||||
@@ -51,7 +52,8 @@ export async function GET() {
 | 
			
		||||
      .sort((a, b) => a.sort_order - b.sort_order);
 | 
			
		||||
 | 
			
		||||
    return NextResponse.json(categories);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
  }
 | 
			
		||||
  catch (error) {
 | 
			
		||||
    console.error(error as Error);
 | 
			
		||||
    return NextResponse.json(
 | 
			
		||||
      { error: "Failed to fetch categories" },
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
import { AppVersion } from "@/lib/types";
 | 
			
		||||
import { error } from "console";
 | 
			
		||||
import { promises as fs } from "fs";
 | 
			
		||||
// import Error from "next/error";
 | 
			
		||||
import { NextResponse } from "next/server";
 | 
			
		||||
import path from "path";
 | 
			
		||||
import { promises as fs } from "node:fs";
 | 
			
		||||
import path from "node:path";
 | 
			
		||||
 | 
			
		||||
import type { AppVersion } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
export const dynamic = "force-static";
 | 
			
		||||
 | 
			
		||||
@@ -11,33 +11,32 @@ const jsonDir = "public/json";
 | 
			
		||||
const versionsFileName = "versions.json";
 | 
			
		||||
const encoding = "utf-8";
 | 
			
		||||
 | 
			
		||||
const getVersions = async () => {
 | 
			
		||||
async function getVersions() {
 | 
			
		||||
  const filePath = path.resolve(jsonDir, versionsFileName);
 | 
			
		||||
  const fileContent = await fs.readFile(filePath, encoding);
 | 
			
		||||
  const versions: AppVersion[] = JSON.parse(fileContent);
 | 
			
		||||
 | 
			
		||||
  const modifiedVersions = versions.map(version => {
 | 
			
		||||
  const modifiedVersions = versions.map((version) => {
 | 
			
		||||
    let newName = version.name;
 | 
			
		||||
    newName = newName.toLowerCase().replace(/[^a-z0-9/]/g, '');
 | 
			
		||||
    newName = newName.toLowerCase().replace(/[^a-z0-9/]/g, "");
 | 
			
		||||
    return { ...version, name: newName, date: new Date(version.date) };
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return modifiedVersions;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function GET() {
 | 
			
		||||
  try {
 | 
			
		||||
 | 
			
		||||
    const versions = await getVersions();
 | 
			
		||||
    return NextResponse.json(versions);
 | 
			
		||||
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
  }
 | 
			
		||||
  catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    const err = error as globalThis.Error;
 | 
			
		||||
    return NextResponse.json({
 | 
			
		||||
      name: err.name,
 | 
			
		||||
      message: err.message || "An unexpected error occurred",
 | 
			
		||||
      version: "No version found - Error"
 | 
			
		||||
      version: "No version found - Error",
 | 
			
		||||
    }, {
 | 
			
		||||
      status: 500,
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,20 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { Badge } from "@/components/ui/badge";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Card, CardContent } from "@/components/ui/card";
 | 
			
		||||
import { Category } from "@/lib/types";
 | 
			
		||||
import { ChevronLeft, ChevronRight } from "lucide-react";
 | 
			
		||||
import { useRouter } from "next/navigation";
 | 
			
		||||
import React, { useEffect, useState } from "react";
 | 
			
		||||
import { useRouter } from "next/navigation";
 | 
			
		||||
 | 
			
		||||
import type { Category } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { Card, CardContent } from "@/components/ui/card";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Badge } from "@/components/ui/badge";
 | 
			
		||||
 | 
			
		||||
const defaultLogo = "/default-logo.png"; // Fallback logo path
 | 
			
		||||
const MAX_DESCRIPTION_LENGTH = 100; // Set max length for description
 | 
			
		||||
const MAX_LOGOS = 5; // Max logos to display at once
 | 
			
		||||
 | 
			
		||||
const formattedBadge = (type: string) => {
 | 
			
		||||
function formattedBadge(type: string) {
 | 
			
		||||
  switch (type) {
 | 
			
		||||
    case "vm":
 | 
			
		||||
      return <Badge className="text-blue-500/75 border-blue-500/75 badge">VM</Badge>;
 | 
			
		||||
@@ -24,9 +26,9 @@ const formattedBadge = (type: string) => {
 | 
			
		||||
      return <Badge className="text-green-500/75 border-green-500/75 badge">ADDON</Badge>;
 | 
			
		||||
  }
 | 
			
		||||
  return null;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const CategoryView = () => {
 | 
			
		||||
function CategoryView() {
 | 
			
		||||
  const [categories, setCategories] = useState<Category[]>([]);
 | 
			
		||||
  const [selectedCategoryIndex, setSelectedCategoryIndex] = useState<number | null>(null);
 | 
			
		||||
  const [currentScripts, setCurrentScripts] = useState<any[]>([]);
 | 
			
		||||
@@ -36,6 +38,7 @@ const CategoryView = () => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const fetchCategories = async () => {
 | 
			
		||||
      try {
 | 
			
		||||
        // eslint-disable-next-line node/no-process-env
 | 
			
		||||
        const basePath = process.env.NODE_ENV === "production" ? "/ProxmoxVE" : "";
 | 
			
		||||
        const response = await fetch(`${basePath}/api/categories`);
 | 
			
		||||
        if (!response.ok) {
 | 
			
		||||
@@ -50,7 +53,8 @@ const CategoryView = () => {
 | 
			
		||||
          initialLogoIndices[category.name] = 0;
 | 
			
		||||
        });
 | 
			
		||||
        setLogoIndices(initialLogoIndices);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
      }
 | 
			
		||||
      catch (error) {
 | 
			
		||||
        console.error("Error fetching categories:", error);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
@@ -74,8 +78,8 @@ const CategoryView = () => {
 | 
			
		||||
 | 
			
		||||
  const navigateCategory = (direction: "prev" | "next") => {
 | 
			
		||||
    if (selectedCategoryIndex !== null) {
 | 
			
		||||
      const newIndex =
 | 
			
		||||
        direction === "prev"
 | 
			
		||||
      const newIndex
 | 
			
		||||
        = direction === "prev"
 | 
			
		||||
          ? (selectedCategoryIndex - 1 + categories.length) % categories.length
 | 
			
		||||
          : (selectedCategoryIndex + 1) % categories.length;
 | 
			
		||||
      setSelectedCategoryIndex(newIndex);
 | 
			
		||||
@@ -86,12 +90,13 @@ const CategoryView = () => {
 | 
			
		||||
  const switchLogos = (categoryName: string, direction: "prev" | "next") => {
 | 
			
		||||
    setLogoIndices((prev) => {
 | 
			
		||||
      const currentIndex = prev[categoryName] || 0;
 | 
			
		||||
      const category = categories.find((cat) => cat.name === categoryName);
 | 
			
		||||
      if (!category || !category.scripts) return prev;
 | 
			
		||||
      const category = categories.find(cat => cat.name === categoryName);
 | 
			
		||||
      if (!category || !category.scripts)
 | 
			
		||||
        return prev;
 | 
			
		||||
 | 
			
		||||
      const totalLogos = category.scripts.length;
 | 
			
		||||
      const newIndex =
 | 
			
		||||
        direction === "prev"
 | 
			
		||||
      const newIndex
 | 
			
		||||
        = direction === "prev"
 | 
			
		||||
          ? (currentIndex - MAX_LOGOS + totalLogos) % totalLogos
 | 
			
		||||
          : (currentIndex + MAX_LOGOS) % totalLogos;
 | 
			
		||||
 | 
			
		||||
@@ -109,35 +114,49 @@ const CategoryView = () => {
 | 
			
		||||
    const hdd = script.install_methods[0]?.resources.hdd;
 | 
			
		||||
 | 
			
		||||
    const resourceParts = [];
 | 
			
		||||
    if (cpu)
 | 
			
		||||
    if (cpu) {
 | 
			
		||||
      resourceParts.push(
 | 
			
		||||
        <span key="cpu">
 | 
			
		||||
          <b>CPU:</b> {cpu}vCPU
 | 
			
		||||
          <b>CPU:</b>
 | 
			
		||||
          {" "}
 | 
			
		||||
          {cpu}
 | 
			
		||||
          vCPU
 | 
			
		||||
        </span>,
 | 
			
		||||
      );
 | 
			
		||||
    if (ram)
 | 
			
		||||
    }
 | 
			
		||||
    if (ram) {
 | 
			
		||||
      resourceParts.push(
 | 
			
		||||
        <span key="ram">
 | 
			
		||||
          <b>RAM:</b> {ram}MB
 | 
			
		||||
          <b>RAM:</b>
 | 
			
		||||
          {" "}
 | 
			
		||||
          {ram}
 | 
			
		||||
          MB
 | 
			
		||||
        </span>,
 | 
			
		||||
      );
 | 
			
		||||
    if (hdd)
 | 
			
		||||
    }
 | 
			
		||||
    if (hdd) {
 | 
			
		||||
      resourceParts.push(
 | 
			
		||||
        <span key="hdd">
 | 
			
		||||
          <b>HDD:</b> {hdd}GB
 | 
			
		||||
          <b>HDD:</b>
 | 
			
		||||
          {" "}
 | 
			
		||||
          {hdd}
 | 
			
		||||
          GB
 | 
			
		||||
        </span>,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return resourceParts.length > 0 ? (
 | 
			
		||||
      <div className="text-sm text-gray-400">
 | 
			
		||||
        {resourceParts.map((part, index) => (
 | 
			
		||||
          <React.Fragment key={index}>
 | 
			
		||||
            {part}
 | 
			
		||||
            {index < resourceParts.length - 1 && " | "}
 | 
			
		||||
          </React.Fragment>
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
    ) : null;
 | 
			
		||||
    return resourceParts.length > 0
 | 
			
		||||
      ? (
 | 
			
		||||
          <div className="text-sm text-gray-400">
 | 
			
		||||
            {resourceParts.map((part, index) => (
 | 
			
		||||
              <React.Fragment key={index}>
 | 
			
		||||
                {part}
 | 
			
		||||
                {index < resourceParts.length - 1 && " | "}
 | 
			
		||||
              </React.Fragment>
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        )
 | 
			
		||||
      : null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@@ -145,145 +164,151 @@ const CategoryView = () => {
 | 
			
		||||
      {categories.length === 0 && (
 | 
			
		||||
        <p className="text-center text-gray-500">No categories available. Please check the API endpoint.</p>
 | 
			
		||||
      )}
 | 
			
		||||
      {selectedCategoryIndex !== null ? (
 | 
			
		||||
        <div>
 | 
			
		||||
          {/* Header with Navigation */}
 | 
			
		||||
          <div className="flex items-center justify-between mb-6">
 | 
			
		||||
            <Button
 | 
			
		||||
              variant="ghost"
 | 
			
		||||
              onClick={() => navigateCategory("prev")}
 | 
			
		||||
              className="p-2 transition-transform duration-300 hover:scale-105"
 | 
			
		||||
            >
 | 
			
		||||
              <ChevronLeft className="h-6 w-6" />
 | 
			
		||||
            </Button>
 | 
			
		||||
            <h2 className="text-3xl font-semibold transition-opacity duration-300 hover:opacity-90">
 | 
			
		||||
              {categories[selectedCategoryIndex].name}
 | 
			
		||||
            </h2>
 | 
			
		||||
            <Button
 | 
			
		||||
              variant="ghost"
 | 
			
		||||
              onClick={() => navigateCategory("next")}
 | 
			
		||||
              className="p-2 transition-transform duration-300 hover:scale-105"
 | 
			
		||||
            >
 | 
			
		||||
              <ChevronRight className="h-6 w-6" />
 | 
			
		||||
            </Button>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {/* Scripts Grid */}
 | 
			
		||||
          <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
 | 
			
		||||
            {currentScripts
 | 
			
		||||
              .sort((a, b) => a.name.localeCompare(b.name))
 | 
			
		||||
              .map((script) => (
 | 
			
		||||
                <Card
 | 
			
		||||
                  key={script.name}
 | 
			
		||||
                  className="p-4 cursor-pointer hover:shadow-md transition-shadow duration-300"
 | 
			
		||||
                  onClick={() => handleScriptClick(script.slug)}
 | 
			
		||||
      {selectedCategoryIndex !== null
 | 
			
		||||
        ? (
 | 
			
		||||
            <div>
 | 
			
		||||
              {/* Header with Navigation */}
 | 
			
		||||
              <div className="flex items-center justify-between mb-6">
 | 
			
		||||
                <Button
 | 
			
		||||
                  variant="ghost"
 | 
			
		||||
                  onClick={() => navigateCategory("prev")}
 | 
			
		||||
                  className="p-2 transition-transform duration-300 hover:scale-105"
 | 
			
		||||
                >
 | 
			
		||||
                  <CardContent className="flex flex-col gap-4">
 | 
			
		||||
                    <h3 className="text-lg font-bold script-text text-center hover:text-blue-600 transition-colors duration-300">
 | 
			
		||||
                      {script.name}
 | 
			
		||||
                    </h3>
 | 
			
		||||
                    <img
 | 
			
		||||
                      src={script.logo || defaultLogo}
 | 
			
		||||
                      alt={script.name || "Script logo"}
 | 
			
		||||
                      className="h-12 w-12 object-contain mx-auto"
 | 
			
		||||
                    />
 | 
			
		||||
                    <p className="text-sm text-gray-500 text-center">
 | 
			
		||||
                      <b>Created at:</b> {script.date_created || "No date available"}
 | 
			
		||||
                    </p>
 | 
			
		||||
                    <p
 | 
			
		||||
                      className="text-sm text-gray-700 hover:text-gray-900 text-center transition-colors duration-300"
 | 
			
		||||
                      title={script.description || "No description available."}
 | 
			
		||||
                    >
 | 
			
		||||
                      {truncateDescription(script.description || "No description available.")}
 | 
			
		||||
                    </p>
 | 
			
		||||
                    {renderResources(script)}
 | 
			
		||||
                  </CardContent>
 | 
			
		||||
                </Card>
 | 
			
		||||
              ))}
 | 
			
		||||
          </div>
 | 
			
		||||
                  <ChevronLeft className="h-6 w-6" />
 | 
			
		||||
                </Button>
 | 
			
		||||
                <h2 className="text-3xl font-semibold transition-opacity duration-300 hover:opacity-90">
 | 
			
		||||
                  {categories[selectedCategoryIndex].name}
 | 
			
		||||
                </h2>
 | 
			
		||||
                <Button
 | 
			
		||||
                  variant="ghost"
 | 
			
		||||
                  onClick={() => navigateCategory("next")}
 | 
			
		||||
                  className="p-2 transition-transform duration-300 hover:scale-105"
 | 
			
		||||
                >
 | 
			
		||||
                  <ChevronRight className="h-6 w-6" />
 | 
			
		||||
                </Button>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
          {/* Back to Categories Button */}
 | 
			
		||||
          <div className="mt-8 text-center">
 | 
			
		||||
            <Button
 | 
			
		||||
              variant="default"
 | 
			
		||||
              onClick={handleBackClick}
 | 
			
		||||
              className="px-6 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-md transition-transform duration-300 hover:scale-105"
 | 
			
		||||
            >
 | 
			
		||||
              Back to Categories
 | 
			
		||||
            </Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <div>
 | 
			
		||||
          {/* Categories Grid */}
 | 
			
		||||
          <div className="flex justify-between items-center mb-8">
 | 
			
		||||
            <h1 className="text-3xl font-semibold mb-4">Categories</h1>
 | 
			
		||||
            <p className="text-sm text-gray-500">
 | 
			
		||||
              {categories.reduce((total, category) => total + (category.scripts?.length || 0), 0)} Total scripts
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
 | 
			
		||||
            {categories.map((category, index) => (
 | 
			
		||||
              <Card
 | 
			
		||||
                key={category.name}
 | 
			
		||||
                onClick={() => handleCategoryClick(index)}
 | 
			
		||||
                className="cursor-pointer hover:shadow-lg flex flex-col items-center justify-center py-6 transition-shadow duration-300"
 | 
			
		||||
              >
 | 
			
		||||
                <CardContent className="flex flex-col items-center">
 | 
			
		||||
                  <h3 className="text-xl font-bold mb-4 category-title transition-colors duration-300 hover:text-blue-600">
 | 
			
		||||
                    {category.name}
 | 
			
		||||
                  </h3>
 | 
			
		||||
                  <div className="flex justify-center items-center gap-2 mb-4">
 | 
			
		||||
                    <Button
 | 
			
		||||
                      variant="ghost"
 | 
			
		||||
                      onClick={(e) => {
 | 
			
		||||
                        e.stopPropagation();
 | 
			
		||||
                        switchLogos(category.name, "prev");
 | 
			
		||||
                      }}
 | 
			
		||||
                      className="p-1 transition-transform duration-300 hover:scale-110"
 | 
			
		||||
              {/* Scripts Grid */}
 | 
			
		||||
              <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
 | 
			
		||||
                {currentScripts
 | 
			
		||||
                  .sort((a, b) => a.name.localeCompare(b.name))
 | 
			
		||||
                  .map(script => (
 | 
			
		||||
                    <Card
 | 
			
		||||
                      key={script.name}
 | 
			
		||||
                      className="p-4 cursor-pointer hover:shadow-md transition-shadow duration-300"
 | 
			
		||||
                      onClick={() => handleScriptClick(script.slug)}
 | 
			
		||||
                    >
 | 
			
		||||
                      <ChevronLeft className="h-4 w-4" />
 | 
			
		||||
                    </Button>
 | 
			
		||||
                    {category.scripts &&
 | 
			
		||||
                      category.scripts
 | 
			
		||||
                        .slice(logoIndices[category.name] || 0, (logoIndices[category.name] || 0) + MAX_LOGOS)
 | 
			
		||||
                        .map((script, i) => (
 | 
			
		||||
                          <div key={i} className="flex flex-col items-center">
 | 
			
		||||
                            <img
 | 
			
		||||
                              src={script.logo || defaultLogo}
 | 
			
		||||
                              alt={script.name || "Script logo"}
 | 
			
		||||
                              title={script.name}
 | 
			
		||||
                              className="h-8 w-8 object-contain cursor-pointer"
 | 
			
		||||
                              onClick={(e) => {
 | 
			
		||||
                                e.stopPropagation();
 | 
			
		||||
                                handleScriptClick(script.slug);
 | 
			
		||||
                              }}
 | 
			
		||||
                            />
 | 
			
		||||
                            {formattedBadge(script.type)}
 | 
			
		||||
                          </div>
 | 
			
		||||
                        ))}
 | 
			
		||||
                    <Button
 | 
			
		||||
                      variant="ghost"
 | 
			
		||||
                      onClick={(e) => {
 | 
			
		||||
                        e.stopPropagation();
 | 
			
		||||
                        switchLogos(category.name, "next");
 | 
			
		||||
                      }}
 | 
			
		||||
                      className="p-1 transition-transform duration-300 hover:scale-110"
 | 
			
		||||
                    >
 | 
			
		||||
                      <ChevronRight className="h-4 w-4" />
 | 
			
		||||
                    </Button>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <p className="text-sm text-gray-400 text-center">
 | 
			
		||||
                    {(category as any).description || "No description available."}
 | 
			
		||||
                  </p>
 | 
			
		||||
                </CardContent>
 | 
			
		||||
              </Card>
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
                      <CardContent className="flex flex-col gap-4">
 | 
			
		||||
                        <h3 className="text-lg font-bold script-text text-center hover:text-blue-600 transition-colors duration-300">
 | 
			
		||||
                          {script.name}
 | 
			
		||||
                        </h3>
 | 
			
		||||
                        <img
 | 
			
		||||
                          src={script.logo || defaultLogo}
 | 
			
		||||
                          alt={script.name || "Script logo"}
 | 
			
		||||
                          className="h-12 w-12 object-contain mx-auto"
 | 
			
		||||
                        />
 | 
			
		||||
                        <p className="text-sm text-gray-500 text-center">
 | 
			
		||||
                          <b>Created at:</b>
 | 
			
		||||
                          {" "}
 | 
			
		||||
                          {script.date_created || "No date available"}
 | 
			
		||||
                        </p>
 | 
			
		||||
                        <p
 | 
			
		||||
                          className="text-sm text-gray-700 hover:text-gray-900 text-center transition-colors duration-300"
 | 
			
		||||
                          title={script.description || "No description available."}
 | 
			
		||||
                        >
 | 
			
		||||
                          {truncateDescription(script.description || "No description available.")}
 | 
			
		||||
                        </p>
 | 
			
		||||
                        {renderResources(script)}
 | 
			
		||||
                      </CardContent>
 | 
			
		||||
                    </Card>
 | 
			
		||||
                  ))}
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              {/* Back to Categories Button */}
 | 
			
		||||
              <div className="mt-8 text-center">
 | 
			
		||||
                <Button
 | 
			
		||||
                  variant="default"
 | 
			
		||||
                  onClick={handleBackClick}
 | 
			
		||||
                  className="px-6 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-md transition-transform duration-300 hover:scale-105"
 | 
			
		||||
                >
 | 
			
		||||
                  Back to Categories
 | 
			
		||||
                </Button>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          )
 | 
			
		||||
        : (
 | 
			
		||||
            <div>
 | 
			
		||||
              {/* Categories Grid */}
 | 
			
		||||
              <div className="flex justify-between items-center mb-8">
 | 
			
		||||
                <h1 className="text-3xl font-semibold mb-4">Categories</h1>
 | 
			
		||||
                <p className="text-sm text-gray-500">
 | 
			
		||||
                  {categories.reduce((total, category) => total + (category.scripts?.length || 0), 0)}
 | 
			
		||||
                  {" "}
 | 
			
		||||
                  Total scripts
 | 
			
		||||
                </p>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
 | 
			
		||||
                {categories.map((category, index) => (
 | 
			
		||||
                  <Card
 | 
			
		||||
                    key={category.name}
 | 
			
		||||
                    onClick={() => handleCategoryClick(index)}
 | 
			
		||||
                    className="cursor-pointer hover:shadow-lg flex flex-col items-center justify-center py-6 transition-shadow duration-300"
 | 
			
		||||
                  >
 | 
			
		||||
                    <CardContent className="flex flex-col items-center">
 | 
			
		||||
                      <h3 className="text-xl font-bold mb-4 category-title transition-colors duration-300 hover:text-blue-600">
 | 
			
		||||
                        {category.name}
 | 
			
		||||
                      </h3>
 | 
			
		||||
                      <div className="flex justify-center items-center gap-2 mb-4">
 | 
			
		||||
                        <Button
 | 
			
		||||
                          variant="ghost"
 | 
			
		||||
                          onClick={(e) => {
 | 
			
		||||
                            e.stopPropagation();
 | 
			
		||||
                            switchLogos(category.name, "prev");
 | 
			
		||||
                          }}
 | 
			
		||||
                          className="p-1 transition-transform duration-300 hover:scale-110"
 | 
			
		||||
                        >
 | 
			
		||||
                          <ChevronLeft className="h-4 w-4" />
 | 
			
		||||
                        </Button>
 | 
			
		||||
                        {category.scripts
 | 
			
		||||
                          && category.scripts
 | 
			
		||||
                            .slice(logoIndices[category.name] || 0, (logoIndices[category.name] || 0) + MAX_LOGOS)
 | 
			
		||||
                            .map((script, i) => (
 | 
			
		||||
                              <div key={i} className="flex flex-col items-center">
 | 
			
		||||
                                <img
 | 
			
		||||
                                  src={script.logo || defaultLogo}
 | 
			
		||||
                                  alt={script.name || "Script logo"}
 | 
			
		||||
                                  title={script.name}
 | 
			
		||||
                                  className="h-8 w-8 object-contain cursor-pointer"
 | 
			
		||||
                                  onClick={(e) => {
 | 
			
		||||
                                    e.stopPropagation();
 | 
			
		||||
                                    handleScriptClick(script.slug);
 | 
			
		||||
                                  }}
 | 
			
		||||
                                />
 | 
			
		||||
                                {formattedBadge(script.type)}
 | 
			
		||||
                              </div>
 | 
			
		||||
                            ))}
 | 
			
		||||
                        <Button
 | 
			
		||||
                          variant="ghost"
 | 
			
		||||
                          onClick={(e) => {
 | 
			
		||||
                            e.stopPropagation();
 | 
			
		||||
                            switchLogos(category.name, "next");
 | 
			
		||||
                          }}
 | 
			
		||||
                          className="p-1 transition-transform duration-300 hover:scale-110"
 | 
			
		||||
                        >
 | 
			
		||||
                          <ChevronRight className="h-4 w-4" />
 | 
			
		||||
                        </Button>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <p className="text-sm text-gray-400 text-center">
 | 
			
		||||
                        {(category as any).description || "No description available."}
 | 
			
		||||
                      </p>
 | 
			
		||||
                    </CardContent>
 | 
			
		||||
                  </Card>
 | 
			
		||||
                ))}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default CategoryView;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import React, { JSX, useEffect, useState } from "react";
 | 
			
		||||
import DatePicker from 'react-datepicker';
 | 
			
		||||
import 'react-datepicker/dist/react-datepicker.css';
 | 
			
		||||
import ApplicationChart from "../../components/ApplicationChart";
 | 
			
		||||
import React, { useEffect, useState } from "react";
 | 
			
		||||
import "react-datepicker/dist/react-datepicker.css";
 | 
			
		||||
 | 
			
		||||
interface DataModel {
 | 
			
		||||
import ApplicationChart from "../../components/application-chart";
 | 
			
		||||
 | 
			
		||||
type DataModel = {
 | 
			
		||||
  id: number;
 | 
			
		||||
  ct_type: number;
 | 
			
		||||
  disk_size: number;
 | 
			
		||||
@@ -22,13 +22,13 @@ interface DataModel {
 | 
			
		||||
  error: string;
 | 
			
		||||
  type: string;
 | 
			
		||||
  [key: string]: any;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface SummaryData {
 | 
			
		||||
type SummaryData = {
 | 
			
		||||
  total_entries: number;
 | 
			
		||||
  status_count: Record<string, number>;
 | 
			
		||||
  nsapp_count: Record<string, number>;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const DataFetcher: React.FC = () => {
 | 
			
		||||
  const [data, setData] = useState<DataModel[]>([]);
 | 
			
		||||
@@ -37,16 +37,18 @@ const DataFetcher: React.FC = () => {
 | 
			
		||||
  const [error, setError] = useState<string | null>(null);
 | 
			
		||||
  const [currentPage, setCurrentPage] = useState(1);
 | 
			
		||||
  const [itemsPerPage, setItemsPerPage] = useState(25);
 | 
			
		||||
  const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'ascending' | 'descending' } | null>(null);
 | 
			
		||||
  const [sortConfig, setSortConfig] = useState<{ key: string; direction: "ascending" | "descending" } | null>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const fetchSummary = async () => {
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch("https://api.htl-braunau.at/data/summary");
 | 
			
		||||
        if (!response.ok) throw new Error(`Failed to fetch summary: ${response.statusText}`);
 | 
			
		||||
        if (!response.ok)
 | 
			
		||||
          throw new Error(`Failed to fetch summary: ${response.statusText}`);
 | 
			
		||||
        const result: SummaryData = await response.json();
 | 
			
		||||
        setSummary(result);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
      }
 | 
			
		||||
      catch (err) {
 | 
			
		||||
        setError((err as Error).message);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
@@ -58,13 +60,16 @@ const DataFetcher: React.FC = () => {
 | 
			
		||||
    const fetchPaginatedData = async () => {
 | 
			
		||||
      setLoading(true);
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch(`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage === 0 ? '' : itemsPerPage}`);
 | 
			
		||||
        if (!response.ok) throw new Error(`Failed to fetch data: ${response.statusText}`);
 | 
			
		||||
        const response = await fetch(`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage === 0 ? "" : itemsPerPage}`);
 | 
			
		||||
        if (!response.ok)
 | 
			
		||||
          throw new Error(`Failed to fetch data: ${response.statusText}`);
 | 
			
		||||
        const result: DataModel[] = await response.json();
 | 
			
		||||
        setData(result);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
      }
 | 
			
		||||
      catch (err) {
 | 
			
		||||
        setError((err as Error).message);
 | 
			
		||||
      } finally {
 | 
			
		||||
      }
 | 
			
		||||
      finally {
 | 
			
		||||
        setLoading(false);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
@@ -73,26 +78,35 @@ const DataFetcher: React.FC = () => {
 | 
			
		||||
  }, [currentPage, itemsPerPage]);
 | 
			
		||||
 | 
			
		||||
  const sortedData = React.useMemo(() => {
 | 
			
		||||
    if (!sortConfig) return data;
 | 
			
		||||
    if (!sortConfig)
 | 
			
		||||
      return data;
 | 
			
		||||
    const sorted = [...data].sort((a, b) => {
 | 
			
		||||
      if (a[sortConfig.key] < b[sortConfig.key]) {
 | 
			
		||||
        return sortConfig.direction === 'ascending' ? -1 : 1;
 | 
			
		||||
        return sortConfig.direction === "ascending" ? -1 : 1;
 | 
			
		||||
      }
 | 
			
		||||
      if (a[sortConfig.key] > b[sortConfig.key]) {
 | 
			
		||||
        return sortConfig.direction === 'ascending' ? 1 : -1;
 | 
			
		||||
        return sortConfig.direction === "ascending" ? 1 : -1;
 | 
			
		||||
      }
 | 
			
		||||
      return 0;
 | 
			
		||||
    });
 | 
			
		||||
    return sorted;
 | 
			
		||||
  }, [data, sortConfig]);
 | 
			
		||||
 | 
			
		||||
  if (loading) return <p>Loading...</p>;
 | 
			
		||||
  if (error) return <p>Error: {error}</p>;
 | 
			
		||||
  if (loading)
 | 
			
		||||
    return <p>Loading...</p>;
 | 
			
		||||
  if (error) {
 | 
			
		||||
    return (
 | 
			
		||||
      <p>
 | 
			
		||||
        Error:
 | 
			
		||||
        {error}
 | 
			
		||||
      </p>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const requestSort = (key: string) => {
 | 
			
		||||
    let direction: 'ascending' | 'descending' = 'ascending';
 | 
			
		||||
    if (sortConfig && sortConfig.key === key && sortConfig.direction === 'ascending') {
 | 
			
		||||
      direction = 'descending';
 | 
			
		||||
    let direction: "ascending" | "descending" = "ascending";
 | 
			
		||||
    if (sortConfig && sortConfig.key === key && sortConfig.direction === "ascending") {
 | 
			
		||||
      direction = "descending";
 | 
			
		||||
    }
 | 
			
		||||
    setSortConfig({ key, direction });
 | 
			
		||||
  };
 | 
			
		||||
@@ -102,8 +116,8 @@ const DataFetcher: React.FC = () => {
 | 
			
		||||
    const year = date.getFullYear();
 | 
			
		||||
    const month = date.getMonth() + 1;
 | 
			
		||||
    const day = date.getDate();
 | 
			
		||||
    const hours = String(date.getHours()).padStart(2, '0');
 | 
			
		||||
    const minutes = String(date.getMinutes()).padStart(2, '0');
 | 
			
		||||
    const hours = String(date.getHours()).padStart(2, "0");
 | 
			
		||||
    const minutes = String(date.getMinutes()).padStart(2, "0");
 | 
			
		||||
    const timezoneOffset = dateString.slice(-6);
 | 
			
		||||
    return `${day}.${month}.${year} ${hours}:${minutes} ${timezoneOffset} GMT`;
 | 
			
		||||
  };
 | 
			
		||||
@@ -114,49 +128,76 @@ const DataFetcher: React.FC = () => {
 | 
			
		||||
      <ApplicationChart data={summary} />
 | 
			
		||||
      <p className="text-lg font-bold mt-4"> </p>
 | 
			
		||||
      <div className="mb-4 flex justify-between items-center">
 | 
			
		||||
        <p className="text-lg font-bold">{summary?.total_entries} results found</p>
 | 
			
		||||
        <p className="text-lg font">Status Legend: 🔄 installing {summary?.status_count["installing"] ?? 0} | ✔️ completed {summary?.status_count["done"] ?? 0} | ❌ failed {summary?.status_count["failed"] ?? 0} | ❓ unknown</p>
 | 
			
		||||
      </div>      
 | 
			
		||||
        <p className="text-lg font-bold">
 | 
			
		||||
          {summary?.total_entries}
 | 
			
		||||
          {" "}
 | 
			
		||||
          results found
 | 
			
		||||
        </p>
 | 
			
		||||
        <p className="text-lg font">
 | 
			
		||||
          Status Legend: 🔄 installing
 | 
			
		||||
          {summary?.status_count.installing ?? 0}
 | 
			
		||||
          {" "}
 | 
			
		||||
          | ✔️ completed
 | 
			
		||||
          {summary?.status_count.done ?? 0}
 | 
			
		||||
          {" "}
 | 
			
		||||
          | ❌ failed
 | 
			
		||||
          {summary?.status_count.failed ?? 0}
 | 
			
		||||
          {" "}
 | 
			
		||||
          | ❓ unknown
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="overflow-x-auto">
 | 
			
		||||
        <div className="overflow-y-auto lg:overflow-y-visible">
 | 
			
		||||
          <table className="min-w-full table-auto border-collapse">
 | 
			
		||||
            <thead>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('status')}>Status</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('type')}>Type</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('nsapp')}>Application</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('os_type')}>OS</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('os_version')}>OS Version</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('disk_size')}>Disk Size</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('core_count')}>Core Count</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('ram_size')}>RAM Size</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('method')}>Method</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('pve_version')}>PVE Version</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('error')}>Error Message</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort('created_at')}>Created At</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("status")}>Status</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("type")}>Type</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("nsapp")}>Application</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("os_type")}>OS</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("os_version")}>OS Version</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("disk_size")}>Disk Size</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("core_count")}>Core Count</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("ram_size")}>RAM Size</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("method")}>Method</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("pve_version")}>PVE Version</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("error")}>Error Message</th>
 | 
			
		||||
                <th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("created_at")}>Created At</th>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
            <tbody>
 | 
			
		||||
              {sortedData.map((item, index) => (
 | 
			
		||||
                <tr key={index}>
 | 
			
		||||
                  <td className="px-4 py-2 border-b">
 | 
			
		||||
                    {item.status === "done" ? (
 | 
			
		||||
                      "✔️"
 | 
			
		||||
                    ) : item.status === "failed" ? (
 | 
			
		||||
                      "❌"
 | 
			
		||||
                    ) : item.status === "installing" ? (
 | 
			
		||||
                      "🔄"
 | 
			
		||||
                    ) : (
 | 
			
		||||
                      item.status
 | 
			
		||||
                    )}
 | 
			
		||||
                    {item.status === "done"
 | 
			
		||||
                      ? (
 | 
			
		||||
                          "✔️"
 | 
			
		||||
                        )
 | 
			
		||||
                      : item.status === "failed"
 | 
			
		||||
                        ? (
 | 
			
		||||
                            "❌"
 | 
			
		||||
                          )
 | 
			
		||||
                        : item.status === "installing"
 | 
			
		||||
                          ? (
 | 
			
		||||
                              "🔄"
 | 
			
		||||
                            )
 | 
			
		||||
                          : (
 | 
			
		||||
                              item.status
 | 
			
		||||
                            )}
 | 
			
		||||
                  </td>
 | 
			
		||||
                  <td className="px-4 py-2 border-b">
 | 
			
		||||
                    {item.type === "lxc"
 | 
			
		||||
                      ? (
 | 
			
		||||
                          "📦"
 | 
			
		||||
                        )
 | 
			
		||||
                      : item.type === "vm"
 | 
			
		||||
                        ? (
 | 
			
		||||
                            "🖥️"
 | 
			
		||||
                          )
 | 
			
		||||
                        : (
 | 
			
		||||
                            item.type
 | 
			
		||||
                          )}
 | 
			
		||||
                  </td>
 | 
			
		||||
                  <td className="px-4 py-2 border-b">{item.type === "lxc" ? (
 | 
			
		||||
                    "📦"
 | 
			
		||||
                  ) : item.type === "vm" ? (
 | 
			
		||||
                    "🖥️"
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    item.type
 | 
			
		||||
                  )}</td>
 | 
			
		||||
                  <td className="px-4 py-2 border-b">{item.nsapp}</td>
 | 
			
		||||
                  <td className="px-4 py-2 border-b">{item.os_type}</td>
 | 
			
		||||
                  <td className="px-4 py-2 border-b">{item.os_version}</td>
 | 
			
		||||
@@ -175,11 +216,14 @@ const DataFetcher: React.FC = () => {
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="mt-4 flex justify-between items-center">
 | 
			
		||||
        <button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border">Previous</button>
 | 
			
		||||
        <span>Page {currentPage}</span>
 | 
			
		||||
        <span>
 | 
			
		||||
          Page
 | 
			
		||||
          {currentPage}
 | 
			
		||||
        </span>
 | 
			
		||||
        <button onClick={() => setCurrentPage(prev => prev + 1)} className="p-2 border">Next</button>
 | 
			
		||||
        <select
 | 
			
		||||
          value={itemsPerPage}
 | 
			
		||||
          onChange={(e) => setItemsPerPage(Number(e.target.value))}
 | 
			
		||||
          onChange={e => setItemsPerPage(Number(e.target.value))}
 | 
			
		||||
          className="p-2 border"
 | 
			
		||||
        >
 | 
			
		||||
          <option value={10}>10</option>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,150 +0,0 @@
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import {
 | 
			
		||||
    Select,
 | 
			
		||||
    SelectContent,
 | 
			
		||||
    SelectItem,
 | 
			
		||||
    SelectTrigger,
 | 
			
		||||
    SelectValue,
 | 
			
		||||
} from "@/components/ui/select";
 | 
			
		||||
import { AlertColors } from "@/config/siteConfig";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { PlusCircle, Trash2 } from "lucide-react";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { ScriptSchema, type Script } from "../_schemas/schemas";
 | 
			
		||||
import { memo, useCallback, useRef } from "react";
 | 
			
		||||
 | 
			
		||||
type NoteProps = {
 | 
			
		||||
    script: Script;
 | 
			
		||||
    setScript: (script: Script) => void;
 | 
			
		||||
    setIsValid: (isValid: boolean) => void;
 | 
			
		||||
    setZodErrors: (zodErrors: z.ZodError | null) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function Note({
 | 
			
		||||
    script,
 | 
			
		||||
    setScript,
 | 
			
		||||
    setIsValid,
 | 
			
		||||
    setZodErrors,
 | 
			
		||||
}: NoteProps) {
 | 
			
		||||
    const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
 | 
			
		||||
 | 
			
		||||
    const addNote = useCallback(() => {
 | 
			
		||||
        setScript({
 | 
			
		||||
            ...script,
 | 
			
		||||
            notes: [...script.notes, { text: "", type: "" }],
 | 
			
		||||
        });
 | 
			
		||||
    }, [script, setScript]);
 | 
			
		||||
 | 
			
		||||
    const updateNote = useCallback((
 | 
			
		||||
        index: number,
 | 
			
		||||
        key: keyof Script["notes"][number],
 | 
			
		||||
        value: string,
 | 
			
		||||
    ) => {
 | 
			
		||||
        const updated: Script = {
 | 
			
		||||
            ...script,
 | 
			
		||||
            notes: script.notes.map((note, i) =>
 | 
			
		||||
                i === index ? { ...note, [key]: value } : note,
 | 
			
		||||
            ),
 | 
			
		||||
        };
 | 
			
		||||
        const result = ScriptSchema.safeParse(updated);
 | 
			
		||||
        setIsValid(result.success);
 | 
			
		||||
        setZodErrors(result.success ? null : result.error);
 | 
			
		||||
        setScript(updated);
 | 
			
		||||
        // Restore focus after state update
 | 
			
		||||
        if (key === "text") {
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
                inputRefs.current[index]?.focus();
 | 
			
		||||
            }, 0);
 | 
			
		||||
        }
 | 
			
		||||
    }, [script, setScript, setIsValid, setZodErrors]);
 | 
			
		||||
 | 
			
		||||
    const removeNote = useCallback((index: number) => {
 | 
			
		||||
        setScript({
 | 
			
		||||
            ...script,
 | 
			
		||||
            notes: script.notes.filter((_, i) => i !== index),
 | 
			
		||||
        });
 | 
			
		||||
    }, [script, setScript]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <>
 | 
			
		||||
            <h3 className="text-xl font-semibold">Notes</h3>
 | 
			
		||||
            {script.notes.map((note, index) => (
 | 
			
		||||
                <NoteItem key={index} note={note} index={index} updateNote={updateNote} removeNote={removeNote} />
 | 
			
		||||
            ))}
 | 
			
		||||
            <Button type="button" size="sm" onClick={addNote}>
 | 
			
		||||
                <PlusCircle className="mr-2 h-4 w-4" /> Add Note
 | 
			
		||||
            </Button>
 | 
			
		||||
        </>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const NoteItem = memo(
 | 
			
		||||
    ({
 | 
			
		||||
        note,
 | 
			
		||||
        index,
 | 
			
		||||
        updateNote,
 | 
			
		||||
        removeNote,
 | 
			
		||||
    }: {
 | 
			
		||||
        note: Script["notes"][number];
 | 
			
		||||
        index: number;
 | 
			
		||||
        updateNote: (index: number, key: keyof Script["notes"][number], value: string) => void;
 | 
			
		||||
        removeNote: (index: number) => void;
 | 
			
		||||
    }) => {
 | 
			
		||||
        const inputRef = useRef<HTMLInputElement | null>(null);
 | 
			
		||||
 | 
			
		||||
        const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
 | 
			
		||||
            updateNote(index, "text", e.target.value);
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
                inputRef.current?.focus();
 | 
			
		||||
            }, 0);
 | 
			
		||||
        }, [index, updateNote]);
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
            <div className="space-y-2 border p-4 rounded">
 | 
			
		||||
                <Input
 | 
			
		||||
                    placeholder="Note Text"
 | 
			
		||||
                    value={note.text}
 | 
			
		||||
                    onChange={handleTextChange}
 | 
			
		||||
                    ref={inputRef}
 | 
			
		||||
                />
 | 
			
		||||
                <Select
 | 
			
		||||
                    value={note.type}
 | 
			
		||||
                    onValueChange={(value) => updateNote(index, "type", value)}
 | 
			
		||||
                >
 | 
			
		||||
                    <SelectTrigger className="flex-1">
 | 
			
		||||
                        <SelectValue placeholder="Type" />
 | 
			
		||||
                    </SelectTrigger>
 | 
			
		||||
                    <SelectContent>
 | 
			
		||||
                        {Object.keys(AlertColors).map((type) => (
 | 
			
		||||
                            <SelectItem key={type} value={type}>
 | 
			
		||||
                                <span className="flex items-center gap-2">
 | 
			
		||||
                                    {type.charAt(0).toUpperCase() + type.slice(1)}{" "}
 | 
			
		||||
                                    <div
 | 
			
		||||
                                        className={cn(
 | 
			
		||||
                                            "size-4 rounded-full border",
 | 
			
		||||
                                            AlertColors[type as keyof typeof AlertColors],
 | 
			
		||||
                                        )}
 | 
			
		||||
                                    />
 | 
			
		||||
                                </span>
 | 
			
		||||
                            </SelectItem>
 | 
			
		||||
                        ))}
 | 
			
		||||
                    </SelectContent>
 | 
			
		||||
                </Select>
 | 
			
		||||
                <Button
 | 
			
		||||
                    size="sm"
 | 
			
		||||
                    variant="destructive"
 | 
			
		||||
                    type="button"
 | 
			
		||||
                    onClick={() => removeNote(index)}
 | 
			
		||||
                >
 | 
			
		||||
                    <Trash2 className="mr-2 h-4 w-4" /> Remove Note
 | 
			
		||||
                </Button>
 | 
			
		||||
            </div>
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
NoteItem.displayName = 'NoteItem';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export default memo(Note);
 | 
			
		||||
@@ -1,4 +1,9 @@
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
import type { z } from "zod";
 | 
			
		||||
 | 
			
		||||
import { memo } from "react";
 | 
			
		||||
 | 
			
		||||
import type { Category } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  Select,
 | 
			
		||||
  SelectContent,
 | 
			
		||||
@@ -6,11 +11,10 @@ import {
 | 
			
		||||
  SelectTrigger,
 | 
			
		||||
  SelectValue,
 | 
			
		||||
} from "@/components/ui/select";
 | 
			
		||||
import { Category } from "@/lib/types";
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { type Script } from "../_schemas/schemas";
 | 
			
		||||
import { memo } from "react";
 | 
			
		||||
 | 
			
		||||
import type { Script } from "../_schemas/schemas";
 | 
			
		||||
 | 
			
		||||
type CategoryProps = {
 | 
			
		||||
  script: Script;
 | 
			
		||||
@@ -20,10 +24,10 @@ type CategoryProps = {
 | 
			
		||||
  categories: Category[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const CategoryTag = memo(({ 
 | 
			
		||||
  category, 
 | 
			
		||||
  onRemove 
 | 
			
		||||
}: { 
 | 
			
		||||
const CategoryTag = memo(({
 | 
			
		||||
  category,
 | 
			
		||||
  onRemove,
 | 
			
		||||
}: {
 | 
			
		||||
  category: Category;
 | 
			
		||||
  onRemove: () => void;
 | 
			
		||||
}) => (
 | 
			
		||||
@@ -53,7 +57,7 @@ const CategoryTag = memo(({
 | 
			
		||||
  </span>
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
CategoryTag.displayName = 'CategoryTag';
 | 
			
		||||
CategoryTag.displayName = "CategoryTag";
 | 
			
		||||
 | 
			
		||||
function Categories({
 | 
			
		||||
  script,
 | 
			
		||||
@@ -79,14 +83,16 @@ function Categories({
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <Label>
 | 
			
		||||
        Category <span className="text-red-500">*</span>
 | 
			
		||||
        Category
 | 
			
		||||
        {" "}
 | 
			
		||||
        <span className="text-red-500">*</span>
 | 
			
		||||
      </Label>
 | 
			
		||||
      <Select onValueChange={(value) => addCategory(Number(value))}>
 | 
			
		||||
      <Select onValueChange={value => addCategory(Number(value))}>
 | 
			
		||||
        <SelectTrigger>
 | 
			
		||||
          <SelectValue placeholder="Select a category" />
 | 
			
		||||
        </SelectTrigger>
 | 
			
		||||
        <SelectContent>
 | 
			
		||||
          {categories.map((category) => (
 | 
			
		||||
          {categories.map(category => (
 | 
			
		||||
            <SelectItem key={category.id} value={category.id.toString()}>
 | 
			
		||||
              {category.name}
 | 
			
		||||
            </SelectItem>
 | 
			
		||||
@@ -101,13 +107,15 @@ function Categories({
 | 
			
		||||
      >
 | 
			
		||||
        {script.categories.map((categoryId) => {
 | 
			
		||||
          const category = categoryMap.get(categoryId);
 | 
			
		||||
          return category ? (
 | 
			
		||||
            <CategoryTag
 | 
			
		||||
              key={categoryId}
 | 
			
		||||
              category={category}
 | 
			
		||||
              onRemove={() => removeCategory(categoryId)}
 | 
			
		||||
            />
 | 
			
		||||
          ) : null;
 | 
			
		||||
          return category
 | 
			
		||||
            ? (
 | 
			
		||||
                <CategoryTag
 | 
			
		||||
                  key={categoryId}
 | 
			
		||||
                  category={category}
 | 
			
		||||
                  onRemove={() => removeCategory(categoryId)}
 | 
			
		||||
                />
 | 
			
		||||
              )
 | 
			
		||||
            : null;
 | 
			
		||||
        })}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -1,11 +1,16 @@
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
 | 
			
		||||
import { OperatingSystems } from "@/config/siteConfig";
 | 
			
		||||
import type { z } from "zod";
 | 
			
		||||
 | 
			
		||||
import { PlusCircle, Trash2 } from "lucide-react";
 | 
			
		||||
import { memo, useCallback, useRef } from "react";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import { InstallMethodSchema, ScriptSchema, type Script } from "../_schemas/schemas";
 | 
			
		||||
 | 
			
		||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
 | 
			
		||||
import { OperatingSystems } from "@/config/site-config";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
 | 
			
		||||
import type { Script } from "../_schemas/schemas";
 | 
			
		||||
 | 
			
		||||
import { InstallMethodSchema, ScriptSchema } from "../_schemas/schemas";
 | 
			
		||||
 | 
			
		||||
type InstallMethodProps = {
 | 
			
		||||
  script: Script;
 | 
			
		||||
@@ -28,9 +33,11 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
 | 
			
		||||
      if (type === "pve") {
 | 
			
		||||
        scriptPath = `tools/pve/${slug}.sh`;
 | 
			
		||||
      } else if (type === "addon") {
 | 
			
		||||
      }
 | 
			
		||||
      else if (type === "addon") {
 | 
			
		||||
        scriptPath = `tools/addon/${slug}.sh`;
 | 
			
		||||
      } else {
 | 
			
		||||
      }
 | 
			
		||||
      else {
 | 
			
		||||
        scriptPath = `${type}/${slug}.sh`;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@@ -65,8 +72,8 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
            const updatedMethod = { ...method, [key]: value };
 | 
			
		||||
 | 
			
		||||
            if (key === "type") {
 | 
			
		||||
              updatedMethod.script =
 | 
			
		||||
                value === "alpine" ? `${prev.type}/alpine-${prev.slug}.sh` : `${prev.type}/${prev.slug}.sh`;
 | 
			
		||||
              updatedMethod.script
 | 
			
		||||
                = value === "alpine" ? `${prev.type}/alpine-${prev.slug}.sh` : `${prev.type}/${prev.slug}.sh`;
 | 
			
		||||
 | 
			
		||||
              // Set OS to Alpine and reset version if type is alpine
 | 
			
		||||
              if (value === "alpine") {
 | 
			
		||||
@@ -89,7 +96,8 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
        setIsValid(result.success);
 | 
			
		||||
        if (!result.success) {
 | 
			
		||||
          setZodErrors(result.error);
 | 
			
		||||
        } else {
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
          setZodErrors(null);
 | 
			
		||||
        }
 | 
			
		||||
        return updated;
 | 
			
		||||
@@ -100,7 +108,7 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
 | 
			
		||||
  const removeInstallMethod = useCallback(
 | 
			
		||||
    (index: number) => {
 | 
			
		||||
      setScript((prev) => ({
 | 
			
		||||
      setScript(prev => ({
 | 
			
		||||
        ...prev,
 | 
			
		||||
        install_methods: prev.install_methods.filter((_, i) => i !== index),
 | 
			
		||||
      }));
 | 
			
		||||
@@ -113,7 +121,7 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
      <h3 className="text-xl font-semibold">Install Methods</h3>
 | 
			
		||||
      {script.install_methods.map((method, index) => (
 | 
			
		||||
        <div key={index} className="space-y-2 border p-4 rounded">
 | 
			
		||||
          <Select value={method.type} onValueChange={(value) => updateInstallMethod(index, "type", value)}>
 | 
			
		||||
          <Select value={method.type} onValueChange={value => updateInstallMethod(index, "type", value)}>
 | 
			
		||||
            <SelectTrigger>
 | 
			
		||||
              <SelectValue placeholder="Type" />
 | 
			
		||||
            </SelectTrigger>
 | 
			
		||||
@@ -130,12 +138,11 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
              placeholder="CPU in Cores"
 | 
			
		||||
              type="number"
 | 
			
		||||
              value={method.resources.cpu || ""}
 | 
			
		||||
              onChange={(e) =>
 | 
			
		||||
              onChange={e =>
 | 
			
		||||
                updateInstallMethod(index, "resources", {
 | 
			
		||||
                  ...method.resources,
 | 
			
		||||
                  cpu: e.target.value ? Number(e.target.value) : null,
 | 
			
		||||
                })
 | 
			
		||||
              }
 | 
			
		||||
                })}
 | 
			
		||||
            />
 | 
			
		||||
            <Input
 | 
			
		||||
              ref={(el) => {
 | 
			
		||||
@@ -144,12 +151,11 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
              placeholder="RAM in MB"
 | 
			
		||||
              type="number"
 | 
			
		||||
              value={method.resources.ram || ""}
 | 
			
		||||
              onChange={(e) =>
 | 
			
		||||
              onChange={e =>
 | 
			
		||||
                updateInstallMethod(index, "resources", {
 | 
			
		||||
                  ...method.resources,
 | 
			
		||||
                  ram: e.target.value ? Number(e.target.value) : null,
 | 
			
		||||
                })
 | 
			
		||||
              }
 | 
			
		||||
                })}
 | 
			
		||||
            />
 | 
			
		||||
            <Input
 | 
			
		||||
              ref={(el) => {
 | 
			
		||||
@@ -158,31 +164,29 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
              placeholder="HDD in GB"
 | 
			
		||||
              type="number"
 | 
			
		||||
              value={method.resources.hdd || ""}
 | 
			
		||||
              onChange={(e) =>
 | 
			
		||||
              onChange={e =>
 | 
			
		||||
                updateInstallMethod(index, "resources", {
 | 
			
		||||
                  ...method.resources,
 | 
			
		||||
                  hdd: e.target.value ? Number(e.target.value) : null,
 | 
			
		||||
                })
 | 
			
		||||
              }
 | 
			
		||||
                })}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="flex gap-2">
 | 
			
		||||
            <Select
 | 
			
		||||
              value={method.resources.os || undefined}
 | 
			
		||||
              onValueChange={(value) =>
 | 
			
		||||
              onValueChange={value =>
 | 
			
		||||
                updateInstallMethod(index, "resources", {
 | 
			
		||||
                  ...method.resources,
 | 
			
		||||
                  os: value || null,
 | 
			
		||||
                  version: null, // Reset version when OS changes
 | 
			
		||||
                })
 | 
			
		||||
              }
 | 
			
		||||
                })}
 | 
			
		||||
              disabled={method.type === "alpine"}
 | 
			
		||||
            >
 | 
			
		||||
              <SelectTrigger>
 | 
			
		||||
                <SelectValue placeholder="OS" />
 | 
			
		||||
              </SelectTrigger>
 | 
			
		||||
              <SelectContent>
 | 
			
		||||
                {OperatingSystems.map((os) => (
 | 
			
		||||
                {OperatingSystems.map(os => (
 | 
			
		||||
                  <SelectItem key={os.name} value={os.name}>
 | 
			
		||||
                    {os.name}
 | 
			
		||||
                  </SelectItem>
 | 
			
		||||
@@ -191,19 +195,18 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
            </Select>
 | 
			
		||||
            <Select
 | 
			
		||||
              value={method.resources.version || undefined}
 | 
			
		||||
              onValueChange={(value) =>
 | 
			
		||||
              onValueChange={value =>
 | 
			
		||||
                updateInstallMethod(index, "resources", {
 | 
			
		||||
                  ...method.resources,
 | 
			
		||||
                  version: value || null,
 | 
			
		||||
                })
 | 
			
		||||
              }
 | 
			
		||||
                })}
 | 
			
		||||
              disabled={method.type === "alpine"}
 | 
			
		||||
            >
 | 
			
		||||
              <SelectTrigger>
 | 
			
		||||
                <SelectValue placeholder="Version" />
 | 
			
		||||
              </SelectTrigger>
 | 
			
		||||
              <SelectContent>
 | 
			
		||||
                {OperatingSystems.find((os) => os.name === method.resources.os)?.versions.map((version) => (
 | 
			
		||||
                {OperatingSystems.find(os => os.name === method.resources.os)?.versions.map(version => (
 | 
			
		||||
                  <SelectItem key={version.slug} value={version.name}>
 | 
			
		||||
                    {version.name}
 | 
			
		||||
                  </SelectItem>
 | 
			
		||||
@@ -212,12 +215,16 @@ function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallM
 | 
			
		||||
            </Select>
 | 
			
		||||
          </div>
 | 
			
		||||
          <Button variant="destructive" size="sm" type="button" onClick={() => removeInstallMethod(index)}>
 | 
			
		||||
            <Trash2 className="mr-2 h-4 w-4" /> Remove Install Method
 | 
			
		||||
            <Trash2 className="mr-2 h-4 w-4" />
 | 
			
		||||
            {" "}
 | 
			
		||||
            Remove Install Method
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
      ))}
 | 
			
		||||
      <Button type="button" size="sm" disabled={script.install_methods.length >= 2} onClick={addInstallMethod}>
 | 
			
		||||
        <PlusCircle className="mr-2 h-4 w-4" /> Add Install Method
 | 
			
		||||
        <PlusCircle className="mr-2 h-4 w-4" />
 | 
			
		||||
        {" "}
 | 
			
		||||
        Add Install Method
 | 
			
		||||
      </Button>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
							
								
								
									
										159
									
								
								frontend/src/app/json-editor/_components/note.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								frontend/src/app/json-editor/_components/note.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,159 @@
 | 
			
		||||
import type { z } from "zod";
 | 
			
		||||
 | 
			
		||||
import { PlusCircle, Trash2 } from "lucide-react";
 | 
			
		||||
import { memo, useCallback, useRef } from "react";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  Select,
 | 
			
		||||
  SelectContent,
 | 
			
		||||
  SelectItem,
 | 
			
		||||
  SelectTrigger,
 | 
			
		||||
  SelectValue,
 | 
			
		||||
} from "@/components/ui/select";
 | 
			
		||||
import { AlertColors } from "@/config/site-config";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
import type { Script } from "../_schemas/schemas";
 | 
			
		||||
 | 
			
		||||
import { ScriptSchema } from "../_schemas/schemas";
 | 
			
		||||
 | 
			
		||||
const NoteItem = memo(
 | 
			
		||||
  ({
 | 
			
		||||
    note,
 | 
			
		||||
    index,
 | 
			
		||||
    updateNote,
 | 
			
		||||
    removeNote,
 | 
			
		||||
  }: {
 | 
			
		||||
    note: Script["notes"][number];
 | 
			
		||||
    index: number;
 | 
			
		||||
    updateNote: (index: number, key: keyof Script["notes"][number], value: string) => void;
 | 
			
		||||
    removeNote: (index: number) => void;
 | 
			
		||||
  }) => {
 | 
			
		||||
    const inputRef = useRef<HTMLInputElement | null>(null);
 | 
			
		||||
 | 
			
		||||
    const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
 | 
			
		||||
      updateNote(index, "text", e.target.value);
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        inputRef.current?.focus();
 | 
			
		||||
      }, 0);
 | 
			
		||||
    }, [index, updateNote]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="space-y-2 border p-4 rounded">
 | 
			
		||||
        <Input
 | 
			
		||||
          placeholder="Note Text"
 | 
			
		||||
          value={note.text}
 | 
			
		||||
          onChange={handleTextChange}
 | 
			
		||||
          ref={inputRef}
 | 
			
		||||
        />
 | 
			
		||||
        <Select
 | 
			
		||||
          value={note.type}
 | 
			
		||||
          onValueChange={value => updateNote(index, "type", value)}
 | 
			
		||||
        >
 | 
			
		||||
          <SelectTrigger className="flex-1">
 | 
			
		||||
            <SelectValue placeholder="Type" />
 | 
			
		||||
          </SelectTrigger>
 | 
			
		||||
          <SelectContent>
 | 
			
		||||
            {Object.keys(AlertColors).map(type => (
 | 
			
		||||
              <SelectItem key={type} value={type}>
 | 
			
		||||
                <span className="flex items-center gap-2">
 | 
			
		||||
                  {type.charAt(0).toUpperCase() + type.slice(1)}
 | 
			
		||||
                  {" "}
 | 
			
		||||
                  <div
 | 
			
		||||
                    className={cn(
 | 
			
		||||
                      "size-4 rounded-full border",
 | 
			
		||||
                      AlertColors[type as keyof typeof AlertColors],
 | 
			
		||||
                    )}
 | 
			
		||||
                  />
 | 
			
		||||
                </span>
 | 
			
		||||
              </SelectItem>
 | 
			
		||||
            ))}
 | 
			
		||||
          </SelectContent>
 | 
			
		||||
        </Select>
 | 
			
		||||
        <Button
 | 
			
		||||
          size="sm"
 | 
			
		||||
          variant="destructive"
 | 
			
		||||
          type="button"
 | 
			
		||||
          onClick={() => removeNote(index)}
 | 
			
		||||
        >
 | 
			
		||||
          <Trash2 className="mr-2 h-4 w-4" />
 | 
			
		||||
          {" "}
 | 
			
		||||
          Remove Note
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
type NoteProps = {
 | 
			
		||||
  script: Script;
 | 
			
		||||
  setScript: (script: Script) => void;
 | 
			
		||||
  setIsValid: (isValid: boolean) => void;
 | 
			
		||||
  setZodErrors: (zodErrors: z.ZodError | null) => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function Note({
 | 
			
		||||
  script,
 | 
			
		||||
  setScript,
 | 
			
		||||
  setIsValid,
 | 
			
		||||
  setZodErrors,
 | 
			
		||||
}: NoteProps) {
 | 
			
		||||
  const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
 | 
			
		||||
 | 
			
		||||
  const addNote = useCallback(() => {
 | 
			
		||||
    setScript({
 | 
			
		||||
      ...script,
 | 
			
		||||
      notes: [...script.notes, { text: "", type: "" }],
 | 
			
		||||
    });
 | 
			
		||||
  }, [script, setScript]);
 | 
			
		||||
 | 
			
		||||
  const updateNote = useCallback((
 | 
			
		||||
    index: number,
 | 
			
		||||
    key: keyof Script["notes"][number],
 | 
			
		||||
    value: string,
 | 
			
		||||
  ) => {
 | 
			
		||||
    const updated: Script = {
 | 
			
		||||
      ...script,
 | 
			
		||||
      notes: script.notes.map((note, i) =>
 | 
			
		||||
        i === index ? { ...note, [key]: value } : note,
 | 
			
		||||
      ),
 | 
			
		||||
    };
 | 
			
		||||
    const result = ScriptSchema.safeParse(updated);
 | 
			
		||||
    setIsValid(result.success);
 | 
			
		||||
    setZodErrors(result.success ? null : result.error);
 | 
			
		||||
    setScript(updated);
 | 
			
		||||
    // Restore focus after state update
 | 
			
		||||
    if (key === "text") {
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        inputRefs.current[index]?.focus();
 | 
			
		||||
      }, 0);
 | 
			
		||||
    }
 | 
			
		||||
  }, [script, setScript, setIsValid, setZodErrors]);
 | 
			
		||||
 | 
			
		||||
  const removeNote = useCallback((index: number) => {
 | 
			
		||||
    setScript({
 | 
			
		||||
      ...script,
 | 
			
		||||
      notes: script.notes.filter((_, i) => i !== index),
 | 
			
		||||
    });
 | 
			
		||||
  }, [script, setScript]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <h3 className="text-xl font-semibold">Notes</h3>
 | 
			
		||||
      {script.notes.map((note, index) => (
 | 
			
		||||
        <NoteItem key={index} note={note} index={index} updateNote={updateNote} removeNote={removeNote} />
 | 
			
		||||
      ))}
 | 
			
		||||
      <Button type="button" size="sm" onClick={addNote}>
 | 
			
		||||
        <PlusCircle className="mr-2 h-4 w-4" />
 | 
			
		||||
        {" "}
 | 
			
		||||
        Add Note
 | 
			
		||||
      </Button>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
NoteItem.displayName = "NoteItem";
 | 
			
		||||
 | 
			
		||||
export default memo(Note);
 | 
			
		||||
@@ -2,7 +2,7 @@ import { z } from "zod";
 | 
			
		||||
 | 
			
		||||
export const InstallMethodSchema = z.object({
 | 
			
		||||
  type: z.enum(["default", "alpine"], {
 | 
			
		||||
    errorMap: () => ({ message: "Type must be either 'default' or 'alpine'" })
 | 
			
		||||
    errorMap: () => ({ message: "Type must be either 'default' or 'alpine'" }),
 | 
			
		||||
  }),
 | 
			
		||||
  script: z.string().min(1, "Script content cannot be empty"),
 | 
			
		||||
  resources: z.object({
 | 
			
		||||
@@ -25,7 +25,7 @@ export const ScriptSchema = z.object({
 | 
			
		||||
  categories: z.array(z.number()),
 | 
			
		||||
  date_created: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").min(1, "Date is required"),
 | 
			
		||||
  type: z.enum(["vm", "ct", "pve", "addon", "turnkey"], {
 | 
			
		||||
    errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'pve', 'addon' or 'turnkey'" })
 | 
			
		||||
    errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'pve', 'addon' or 'turnkey'" }),
 | 
			
		||||
  }),
 | 
			
		||||
  updateable: z.boolean(),
 | 
			
		||||
  privileged: z.boolean(),
 | 
			
		||||
 
 | 
			
		||||
@@ -1,26 +1,32 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Calendar } from "@/components/ui/calendar";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
 | 
			
		||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
 | 
			
		||||
import { Switch } from "@/components/ui/switch";
 | 
			
		||||
import { Textarea } from "@/components/ui/textarea";
 | 
			
		||||
import { fetchCategories } from "@/lib/data";
 | 
			
		||||
import { Category } from "@/lib/types";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { format } from "date-fns";
 | 
			
		||||
import type { z } from "zod";
 | 
			
		||||
 | 
			
		||||
import { CalendarIcon, Check, Clipboard, Download } from "lucide-react";
 | 
			
		||||
import { useCallback, useEffect, useMemo, useState } from "react";
 | 
			
		||||
import { format } from "date-fns";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
import Categories from "./_components/Categories";
 | 
			
		||||
import InstallMethod from "./_components/InstallMethod";
 | 
			
		||||
import Note from "./_components/Note";
 | 
			
		||||
import { ScriptSchema, type Script } from "./_schemas/schemas";
 | 
			
		||||
 | 
			
		||||
import type { Category } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
 | 
			
		||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
 | 
			
		||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
 | 
			
		||||
import { Calendar } from "@/components/ui/calendar";
 | 
			
		||||
import { Textarea } from "@/components/ui/textarea";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Switch } from "@/components/ui/switch";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
import { fetchCategories } from "@/lib/data";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
import type { Script } from "./_schemas/schemas";
 | 
			
		||||
 | 
			
		||||
import InstallMethod from "./_components/install-method";
 | 
			
		||||
import { ScriptSchema } from "./_schemas/schemas";
 | 
			
		||||
import Categories from "./_components/categories";
 | 
			
		||||
import Note from "./_components/note";
 | 
			
		||||
 | 
			
		||||
const initialScript: Script = {
 | 
			
		||||
  name: "",
 | 
			
		||||
@@ -54,7 +60,7 @@ export default function JSONGenerator() {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    fetchCategories()
 | 
			
		||||
      .then(setCategories)
 | 
			
		||||
      .catch((error) => console.error("Error fetching categories:", error));
 | 
			
		||||
      .catch(error => console.error("Error fetching categories:", error));
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const updateScript = useCallback((key: keyof Script, value: Script[keyof Script]) => {
 | 
			
		||||
@@ -67,11 +73,14 @@ export default function JSONGenerator() {
 | 
			
		||||
 | 
			
		||||
          if (updated.type === "pve") {
 | 
			
		||||
            scriptPath = `tools/pve/${updated.slug}.sh`;
 | 
			
		||||
          } else if (updated.type === "addon") {
 | 
			
		||||
          }
 | 
			
		||||
          else if (updated.type === "addon") {
 | 
			
		||||
            scriptPath = `tools/addon/${updated.slug}.sh`;
 | 
			
		||||
          } else if (method.type === "alpine") {
 | 
			
		||||
          }
 | 
			
		||||
          else if (method.type === "alpine") {
 | 
			
		||||
            scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
 | 
			
		||||
          } else {
 | 
			
		||||
          }
 | 
			
		||||
          else {
 | 
			
		||||
            scriptPath = `${updated.type}/${updated.slug}.sh`;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@@ -136,7 +145,10 @@ export default function JSONGenerator() {
 | 
			
		||||
          <div className="mt-2 space-y-1">
 | 
			
		||||
            {zodErrors.errors.map((error, index) => (
 | 
			
		||||
              <AlertDescription key={index} className="p-1 text-red-500">
 | 
			
		||||
                {error.path.join(".")} - {error.message}
 | 
			
		||||
                {error.path.join(".")}
 | 
			
		||||
                {" "}
 | 
			
		||||
                -
 | 
			
		||||
                {error.message}
 | 
			
		||||
              </AlertDescription>
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -154,25 +166,31 @@ export default function JSONGenerator() {
 | 
			
		||||
          <div className="grid grid-cols-2 gap-4">
 | 
			
		||||
            <div>
 | 
			
		||||
              <Label>
 | 
			
		||||
                Name <span className="text-red-500">*</span>
 | 
			
		||||
                Name
 | 
			
		||||
                {" "}
 | 
			
		||||
                <span className="text-red-500">*</span>
 | 
			
		||||
              </Label>
 | 
			
		||||
              <Input placeholder="Example" value={script.name} onChange={(e) => updateScript("name", e.target.value)} />
 | 
			
		||||
              <Input placeholder="Example" value={script.name} onChange={e => updateScript("name", e.target.value)} />
 | 
			
		||||
            </div>
 | 
			
		||||
            <div>
 | 
			
		||||
              <Label>
 | 
			
		||||
                Slug <span className="text-red-500">*</span>
 | 
			
		||||
                Slug
 | 
			
		||||
                {" "}
 | 
			
		||||
                <span className="text-red-500">*</span>
 | 
			
		||||
              </Label>
 | 
			
		||||
              <Input placeholder="example" value={script.slug} onChange={(e) => updateScript("slug", e.target.value)} />
 | 
			
		||||
              <Input placeholder="example" value={script.slug} onChange={e => updateScript("slug", e.target.value)} />
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <Label>
 | 
			
		||||
              Logo <span className="text-red-500">*</span>
 | 
			
		||||
              Logo
 | 
			
		||||
              {" "}
 | 
			
		||||
              <span className="text-red-500">*</span>
 | 
			
		||||
            </Label>
 | 
			
		||||
            <Input
 | 
			
		||||
              placeholder="Full logo URL"
 | 
			
		||||
              value={script.logo || ""}
 | 
			
		||||
              onChange={(e) => updateScript("logo", e.target.value || null)}
 | 
			
		||||
              onChange={e => updateScript("logo", e.target.value || null)}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>
 | 
			
		||||
@@ -180,17 +198,19 @@ export default function JSONGenerator() {
 | 
			
		||||
            <Input
 | 
			
		||||
              placeholder="Path to config file"
 | 
			
		||||
              value={script.config_path || ""}
 | 
			
		||||
              onChange={(e) => updateScript("config_path", e.target.value || null)}
 | 
			
		||||
              onChange={e => updateScript("config_path", e.target.value || null)}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <Label>
 | 
			
		||||
              Description <span className="text-red-500">*</span>
 | 
			
		||||
              Description
 | 
			
		||||
              {" "}
 | 
			
		||||
              <span className="text-red-500">*</span>
 | 
			
		||||
            </Label>
 | 
			
		||||
            <Textarea
 | 
			
		||||
              placeholder="Example"
 | 
			
		||||
              value={script.description}
 | 
			
		||||
              onChange={(e) => updateScript("description", e.target.value)}
 | 
			
		||||
              onChange={e => updateScript("description", e.target.value)}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
          <Categories script={script} setScript={setScript} categories={categories} />
 | 
			
		||||
@@ -200,7 +220,7 @@ export default function JSONGenerator() {
 | 
			
		||||
              <Popover>
 | 
			
		||||
                <PopoverTrigger asChild className="flex-1">
 | 
			
		||||
                  <Button
 | 
			
		||||
                    variant={"outline"}
 | 
			
		||||
                    variant="outline"
 | 
			
		||||
                    className={cn("pl-3 text-left font-normal w-full", !script.date_created && "text-muted-foreground")}
 | 
			
		||||
                  >
 | 
			
		||||
                    {formattedDate || <span>Pick a date</span>}
 | 
			
		||||
@@ -219,7 +239,7 @@ export default function JSONGenerator() {
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="flex flex-col gap-2 w-full">
 | 
			
		||||
              <Label>Type</Label>
 | 
			
		||||
              <Select value={script.type} onValueChange={(value) => updateScript("type", value)}>
 | 
			
		||||
              <Select value={script.type} onValueChange={value => updateScript("type", value)}>
 | 
			
		||||
                <SelectTrigger className="flex-1">
 | 
			
		||||
                  <SelectValue placeholder="Type" />
 | 
			
		||||
                </SelectTrigger>
 | 
			
		||||
@@ -234,11 +254,11 @@ export default function JSONGenerator() {
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="w-full flex gap-5">
 | 
			
		||||
            <div className="flex items-center space-x-2">
 | 
			
		||||
              <Switch checked={script.updateable} onCheckedChange={(checked) => updateScript("updateable", checked)} />
 | 
			
		||||
              <Switch checked={script.updateable} onCheckedChange={checked => updateScript("updateable", checked)} />
 | 
			
		||||
              <label>Updateable</label>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="flex items-center space-x-2">
 | 
			
		||||
              <Switch checked={script.privileged} onCheckedChange={(checked) => updateScript("privileged", checked)} />
 | 
			
		||||
              <Switch checked={script.privileged} onCheckedChange={checked => updateScript("privileged", checked)} />
 | 
			
		||||
              <label>Privileged</label>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -246,18 +266,18 @@ export default function JSONGenerator() {
 | 
			
		||||
            placeholder="Interface Port"
 | 
			
		||||
            type="number"
 | 
			
		||||
            value={script.interface_port || ""}
 | 
			
		||||
            onChange={(e) => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
 | 
			
		||||
            onChange={e => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
 | 
			
		||||
          />
 | 
			
		||||
          <div className="flex gap-2">
 | 
			
		||||
            <Input
 | 
			
		||||
              placeholder="Website URL"
 | 
			
		||||
              value={script.website || ""}
 | 
			
		||||
              onChange={(e) => updateScript("website", e.target.value || null)}
 | 
			
		||||
              onChange={e => updateScript("website", e.target.value || null)}
 | 
			
		||||
            />
 | 
			
		||||
            <Input
 | 
			
		||||
              placeholder="Documentation URL"
 | 
			
		||||
              value={script.documentation || ""}
 | 
			
		||||
              onChange={(e) => updateScript("documentation", e.target.value || null)}
 | 
			
		||||
              onChange={e => updateScript("documentation", e.target.value || null)}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
          <InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
 | 
			
		||||
@@ -265,22 +285,20 @@ export default function JSONGenerator() {
 | 
			
		||||
          <Input
 | 
			
		||||
            placeholder="Username"
 | 
			
		||||
            value={script.default_credentials.username || ""}
 | 
			
		||||
            onChange={(e) =>
 | 
			
		||||
            onChange={e =>
 | 
			
		||||
              updateScript("default_credentials", {
 | 
			
		||||
                ...script.default_credentials,
 | 
			
		||||
                username: e.target.value || null,
 | 
			
		||||
              })
 | 
			
		||||
            }
 | 
			
		||||
              })}
 | 
			
		||||
          />
 | 
			
		||||
          <Input
 | 
			
		||||
            placeholder="Password"
 | 
			
		||||
            value={script.default_credentials.password || ""}
 | 
			
		||||
            onChange={(e) =>
 | 
			
		||||
            onChange={e =>
 | 
			
		||||
              updateScript("default_credentials", {
 | 
			
		||||
                ...script.default_credentials,
 | 
			
		||||
                password: e.target.value || null,
 | 
			
		||||
              })
 | 
			
		||||
            }
 | 
			
		||||
              })}
 | 
			
		||||
          />
 | 
			
		||||
          <Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
 | 
			
		||||
        </form>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +1,20 @@
 | 
			
		||||
import Footer from "@/components/Footer";
 | 
			
		||||
import Navbar from "@/components/Navbar";
 | 
			
		||||
import QueryProvider from "@/components/query-provider";
 | 
			
		||||
import { ThemeProvider } from "@/components/theme-provider";
 | 
			
		||||
import { Toaster } from "@/components/ui/sonner";
 | 
			
		||||
import { analytics, basePath } from "@/config/siteConfig";
 | 
			
		||||
import "@/styles/globals.css";
 | 
			
		||||
import type { Metadata } from "next";
 | 
			
		||||
import { Inter } from "next/font/google";
 | 
			
		||||
 | 
			
		||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
 | 
			
		||||
import { Inter } from "next/font/google";
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
import { ThemeProvider } from "@/components/theme-provider";
 | 
			
		||||
import { analytics, basePath } from "@/config/site-config";
 | 
			
		||||
import "@/styles/globals.css";
 | 
			
		||||
import QueryProvider from "@/components/query-provider";
 | 
			
		||||
import { Toaster } from "@/components/ui/sonner";
 | 
			
		||||
import Footer from "@/components/footer";
 | 
			
		||||
import Navbar from "@/components/navbar";
 | 
			
		||||
 | 
			
		||||
const inter = Inter({ subsets: ["latin"] });
 | 
			
		||||
 | 
			
		||||
export const metadata : Metadata = {
 | 
			
		||||
export const metadata: Metadata = {
 | 
			
		||||
  title: "Proxmox VE Helper-Scripts",
 | 
			
		||||
  description:
 | 
			
		||||
    "The official website for the Proxmox VE Helper-Scripts (Community) Repository. Featuring over 300+ scripts to help you manage your Proxmox VE environment.",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,10 @@
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import type { MetadataRoute } from "next";
 | 
			
		||||
 | 
			
		||||
export const generateStaticParams = () => {
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
 | 
			
		||||
export function generateStaticParams() {
 | 
			
		||||
  return [];
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function manifest(): MetadataRoute.Manifest {
 | 
			
		||||
  return {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,10 @@
 | 
			
		||||
"use client";
 | 
			
		||||
import FAQ from "@/components/FAQ";
 | 
			
		||||
import AnimatedGradientText from "@/components/ui/animated-gradient-text";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { CardFooter } from "@/components/ui/card";
 | 
			
		||||
import { ArrowRightIcon, ExternalLink } from "lucide-react";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { FaGithub } from "react-icons/fa";
 | 
			
		||||
import { useTheme } from "next-themes";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
@@ -11,15 +13,14 @@ import {
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
  DialogTrigger,
 | 
			
		||||
} from "@/components/ui/dialog";
 | 
			
		||||
import Particles from "@/components/ui/particles";
 | 
			
		||||
import AnimatedGradientText from "@/components/ui/animated-gradient-text";
 | 
			
		||||
import { Separator } from "@/components/ui/separator";
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import { CardFooter } from "@/components/ui/card";
 | 
			
		||||
import Particles from "@/components/ui/particles";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
import FAQ from "@/components/faq";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { ArrowRightIcon, ExternalLink } from "lucide-react";
 | 
			
		||||
import { useTheme } from "next-themes";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { FaGithub } from "react-icons/fa";
 | 
			
		||||
 | 
			
		||||
function CustomArrowRightIcon() {
 | 
			
		||||
  return <ArrowRightIcon className="h-4 w-4" width={1} />;
 | 
			
		||||
@@ -50,7 +51,9 @@ export default function Page() {
 | 
			
		||||
                        `p-px ![mask-composite:subtract]`,
 | 
			
		||||
                      )}
 | 
			
		||||
                    />
 | 
			
		||||
                    ❤️ <Separator className="mx-2 h-4" orientation="vertical" />
 | 
			
		||||
                    ❤️
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    <Separator className="mx-2 h-4" orientation="vertical" />
 | 
			
		||||
                    <span
 | 
			
		||||
                      className={cn(
 | 
			
		||||
                        `animate-gradient bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
 | 
			
		||||
@@ -78,7 +81,9 @@ export default function Page() {
 | 
			
		||||
                      rel="noopener noreferrer"
 | 
			
		||||
                      className="flex items-center justify-center"
 | 
			
		||||
                    >
 | 
			
		||||
                      <FaGithub className="mr-2 h-4 w-4" /> Tteck's GitHub
 | 
			
		||||
                      <FaGithub className="mr-2 h-4 w-4" />
 | 
			
		||||
                      {" "}
 | 
			
		||||
                      Tteck's GitHub
 | 
			
		||||
                    </a>
 | 
			
		||||
                  </Button>
 | 
			
		||||
                  <Button className="w-full" asChild>
 | 
			
		||||
@@ -88,7 +93,9 @@ export default function Page() {
 | 
			
		||||
                      rel="noopener noreferrer"
 | 
			
		||||
                      className="flex items-center justify-center"
 | 
			
		||||
                    >
 | 
			
		||||
                      <ExternalLink className="mr-2 h-4 w-4" /> Proxmox Helper Scripts
 | 
			
		||||
                      <ExternalLink className="mr-2 h-4 w-4" />
 | 
			
		||||
                      {" "}
 | 
			
		||||
                      Proxmox Helper Scripts
 | 
			
		||||
                    </a>
 | 
			
		||||
                  </Button>
 | 
			
		||||
                </CardFooter>
 | 
			
		||||
@@ -104,7 +111,10 @@ export default function Page() {
 | 
			
		||||
                  We are a community-driven initiative that simplifies the setup of Proxmox Virtual Environment (VE).
 | 
			
		||||
                </p>
 | 
			
		||||
                <p>
 | 
			
		||||
                  With 300+ scripts to help you manage your <b>Proxmox VE environment</b>. Whether you're a seasoned
 | 
			
		||||
                  With 300+ scripts to help you manage your
 | 
			
		||||
                  {" "}
 | 
			
		||||
                  <b>Proxmox VE environment</b>
 | 
			
		||||
                  . Whether you're a seasoned
 | 
			
		||||
                  user or a newcomer, we've got you covered.
 | 
			
		||||
                </p>
 | 
			
		||||
              </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import type { MetadataRoute } from "next";
 | 
			
		||||
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
 | 
			
		||||
export const dynamic = "force-static";
 | 
			
		||||
 | 
			
		||||
export default function robots(): MetadataRoute.Robots {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +0,0 @@
 | 
			
		||||
import handleCopy from "@/components/handleCopy";
 | 
			
		||||
import { buttonVariants } from "@/components/ui/button";
 | 
			
		||||
import { Script } from "@/lib/types";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { ClipboardIcon } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
export default function InterFaces({ item }: { item: Script }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col gap-2 w-full">
 | 
			
		||||
      {item.interface_port !== null ? (
 | 
			
		||||
        <div className="flex items-center justify-end">
 | 
			
		||||
          <h2 className="mr-2 text-end text-lg font-semibold">Default Interface:</h2>
 | 
			
		||||
          <span className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex items-center gap-2")}>
 | 
			
		||||
            {item.interface_port}
 | 
			
		||||
            <ClipboardIcon onClick={() => handleCopy("default interface", String(item.interface_port))} className="size-4 cursor-pointer" />
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : null}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,51 +1,84 @@
 | 
			
		||||
import CodeCopyButton from "@/components/ui/code-copy-button";
 | 
			
		||||
import { Info } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
import type { Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
 | 
			
		||||
import { Alert, AlertDescription } from "@/components/ui/alert";
 | 
			
		||||
import { Info } from "lucide-react";
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import { Script } from "@/lib/types";
 | 
			
		||||
import { getDisplayValueFromType } from "../ScriptInfoBlocks";
 | 
			
		||||
import CodeCopyButton from "@/components/ui/code-copy-button";
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
 | 
			
		||||
const getInstallCommand = (scriptPath = "", isAlpine = false, useGitea = false) => {
 | 
			
		||||
import { getDisplayValueFromType } from "../script-info-blocks";
 | 
			
		||||
 | 
			
		||||
function getInstallCommand(scriptPath = "", isAlpine = false, useGitea = false) {
 | 
			
		||||
  const githubUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`;
 | 
			
		||||
  const giteaUrl = `https://git.community-scripts.org/community-scripts/${basePath}/raw/branch/main/${scriptPath}`;
 | 
			
		||||
  const url = useGitea ? giteaUrl : githubUrl;
 | 
			
		||||
  return `bash -c "$(curl -fsSL ${url})"`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
  return isAlpine ? `bash -c "$(curl -fsSL ${url})"` : `bash -c "$(curl -fsSL ${url})"`;
 | 
			
		||||
}
 | 
			
		||||
export default function InstallCommand({ item }: { item: Script }) {
 | 
			
		||||
  const alpineScript = item.install_methods.find((method) => method.type === "alpine");
 | 
			
		||||
  const defaultScript = item.install_methods.find((method) => method.type === "default");
 | 
			
		||||
  const alpineScript = item.install_methods.find(method => method.type === "alpine");
 | 
			
		||||
  const defaultScript = item.install_methods.find(method => method.type === "default");
 | 
			
		||||
 | 
			
		||||
  const renderInstructions = (isAlpine = false) => (
 | 
			
		||||
    <>
 | 
			
		||||
      <p className="text-sm mt-2">
 | 
			
		||||
        {isAlpine ? (
 | 
			
		||||
          <>
 | 
			
		||||
            As an alternative option, you can use Alpine Linux and the {item.name} package to create a {item.name}{" "}
 | 
			
		||||
            {getDisplayValueFromType(item.type)} container with faster creation time and minimal system resource usage.
 | 
			
		||||
            You are also obliged to adhere to updates provided by the package maintainer.
 | 
			
		||||
          </>
 | 
			
		||||
        ) : item.type === "pve" ? (
 | 
			
		||||
          <>
 | 
			
		||||
            To use the {item.name} script, run the command below **only** in the Proxmox VE Shell. This script is
 | 
			
		||||
            intended for managing or enhancing the host system directly.
 | 
			
		||||
          </>
 | 
			
		||||
        ) : item.type === "addon" ? (
 | 
			
		||||
          <>
 | 
			
		||||
            This script enhances an existing setup. You can use it inside a running LXC container or directly on the
 | 
			
		||||
            Proxmox VE host to extend functionality with {item.name}.
 | 
			
		||||
          </>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <>
 | 
			
		||||
            To create a new Proxmox VE {item.name} {getDisplayValueFromType(item.type)}, run the command below in the
 | 
			
		||||
            Proxmox VE Shell.
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
        {isAlpine
 | 
			
		||||
          ? (
 | 
			
		||||
              <>
 | 
			
		||||
                As an alternative option, you can use Alpine Linux and the
 | 
			
		||||
                {" "}
 | 
			
		||||
                {item.name}
 | 
			
		||||
                {" "}
 | 
			
		||||
                package to create a
 | 
			
		||||
                {" "}
 | 
			
		||||
                {item.name}
 | 
			
		||||
                {" "}
 | 
			
		||||
                {getDisplayValueFromType(item.type)}
 | 
			
		||||
                {" "}
 | 
			
		||||
                container with faster creation time and minimal system resource usage.
 | 
			
		||||
                You are also obliged to adhere to updates provided by the package maintainer.
 | 
			
		||||
              </>
 | 
			
		||||
            )
 | 
			
		||||
          : item.type === "pve"
 | 
			
		||||
            ? (
 | 
			
		||||
                <>
 | 
			
		||||
                  To use the
 | 
			
		||||
                  {" "}
 | 
			
		||||
                  {item.name}
 | 
			
		||||
                  {" "}
 | 
			
		||||
                  script, run the command below **only** in the Proxmox VE Shell. This script is
 | 
			
		||||
                  intended for managing or enhancing the host system directly.
 | 
			
		||||
                </>
 | 
			
		||||
              )
 | 
			
		||||
            : item.type === "addon"
 | 
			
		||||
              ? (
 | 
			
		||||
                  <>
 | 
			
		||||
                    This script enhances an existing setup. You can use it inside a running LXC container or directly on the
 | 
			
		||||
                    Proxmox VE host to extend functionality with
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    {item.name}
 | 
			
		||||
                    .
 | 
			
		||||
                  </>
 | 
			
		||||
                )
 | 
			
		||||
              : (
 | 
			
		||||
                  <>
 | 
			
		||||
                    To create a new Proxmox VE
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    {item.name}
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    {getDisplayValueFromType(item.type)}
 | 
			
		||||
                    , run the command below in the
 | 
			
		||||
                    Proxmox VE Shell.
 | 
			
		||||
                  </>
 | 
			
		||||
                )}
 | 
			
		||||
      </p>
 | 
			
		||||
      {isAlpine && (
 | 
			
		||||
        <p className="mt-2 text-sm">
 | 
			
		||||
          To create a new Proxmox VE Alpine-{item.name} {getDisplayValueFromType(item.type)}, run the command below in
 | 
			
		||||
          To create a new Proxmox VE Alpine-
 | 
			
		||||
          {item.name}
 | 
			
		||||
          {" "}
 | 
			
		||||
          {getDisplayValueFromType(item.type)}
 | 
			
		||||
          , run the command below in
 | 
			
		||||
          the Proxmox VE Shell.
 | 
			
		||||
        </p>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -56,7 +89,9 @@ export default function InstallCommand({ item }: { item: Script }) {
 | 
			
		||||
    <Alert className="mt-3 mb-3">
 | 
			
		||||
      <Info className="h-4 w-4" />
 | 
			
		||||
      <AlertDescription className="text-sm">
 | 
			
		||||
        <strong>When to use Gitea:</strong> GitHub may have issues including slow connections, delayed updates after bug
 | 
			
		||||
        <strong>When to use Gitea:</strong>
 | 
			
		||||
        {" "}
 | 
			
		||||
        GitHub may have issues including slow connections, delayed updates after bug
 | 
			
		||||
        fixes, no IPv6 support, API rate limits (60/hour). Use our Gitea mirror as a reliable alternative when
 | 
			
		||||
        experiencing these issues.
 | 
			
		||||
      </AlertDescription>
 | 
			
		||||
@@ -81,7 +116,8 @@ export default function InstallCommand({ item }: { item: Script }) {
 | 
			
		||||
          </TabsContent>
 | 
			
		||||
        </Tabs>
 | 
			
		||||
      );
 | 
			
		||||
    } else if (defaultScript?.script) {
 | 
			
		||||
    }
 | 
			
		||||
    else if (defaultScript?.script) {
 | 
			
		||||
      return (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderInstructions()}
 | 
			
		||||
@@ -109,4 +145,4 @@ export default function InstallCommand({ item }: { item: Script }) {
 | 
			
		||||
      </Tabs>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,43 +0,0 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import type { Category, Script } from "@/lib/types";
 | 
			
		||||
import ScriptAccordion from "./ScriptAccordion";
 | 
			
		||||
 | 
			
		||||
const Sidebar = ({
 | 
			
		||||
	items,
 | 
			
		||||
	selectedScript,
 | 
			
		||||
	setSelectedScript,
 | 
			
		||||
}: {
 | 
			
		||||
	items: Category[];
 | 
			
		||||
	selectedScript: string | null;
 | 
			
		||||
	setSelectedScript: (script: string | null) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
	const uniqueScripts = items.reduce((acc, category) => {
 | 
			
		||||
		for (const script of category.scripts) {
 | 
			
		||||
			if (!acc.some((s) => s.name === script.name)) {
 | 
			
		||||
				acc.push(script);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return acc;
 | 
			
		||||
	}, [] as Script[]);
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="flex min-w-[350px] flex-col sm:max-w-[350px]">
 | 
			
		||||
			<div className="flex items-end justify-between pb-4">
 | 
			
		||||
				<h1 className="text-xl font-bold">Categories</h1>
 | 
			
		||||
				<p className="text-xs italic text-muted-foreground">
 | 
			
		||||
					{uniqueScripts.length} Total scripts
 | 
			
		||||
				</p>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div className="rounded-lg">
 | 
			
		||||
				<ScriptAccordion
 | 
			
		||||
					items={items}
 | 
			
		||||
					selectedScript={selectedScript}
 | 
			
		||||
					setSelectedScript={setSelectedScript}
 | 
			
		||||
				/>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Sidebar;
 | 
			
		||||
@@ -1,17 +1,17 @@
 | 
			
		||||
import { CPUIcon, HDDIcon, RAMIcon } from "@/components/icons/resource-icons";
 | 
			
		||||
import { getDisplayValueFromRAM } from "@/lib/utils/resource-utils";
 | 
			
		||||
 | 
			
		||||
interface ResourceDisplayProps {
 | 
			
		||||
type ResourceDisplayProps = {
 | 
			
		||||
  title: string;
 | 
			
		||||
  cpu: number | null;
 | 
			
		||||
  ram: number | null;
 | 
			
		||||
  hdd: number | null;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface IconTextProps {
 | 
			
		||||
type IconTextProps = {
 | 
			
		||||
  icon: React.ReactNode;
 | 
			
		||||
  label: string;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function IconText({ icon, label }: IconTextProps) {
 | 
			
		||||
  return (
 | 
			
		||||
@@ -27,7 +27,8 @@ export function ResourceDisplay({ title, cpu, ram, hdd }: ResourceDisplayProps)
 | 
			
		||||
  const hasRAM = typeof ram === "number" && ram > 0;
 | 
			
		||||
  const hasHDD = typeof hdd === "number" && hdd > 0;
 | 
			
		||||
 | 
			
		||||
  if (!hasCPU && !hasRAM && !hasHDD) return null;
 | 
			
		||||
  if (!hasCPU && !hasRAM && !hasHDD)
 | 
			
		||||
    return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-wrap items-center gap-2">
 | 
			
		||||
@@ -1,18 +1,18 @@
 | 
			
		||||
import { useCallback, useEffect, useRef } from "react";
 | 
			
		||||
import { useCallback, useEffect, useRef, useState } from "react";
 | 
			
		||||
import Image from "next/image";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
import type { Category } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { formattedBadge } from "@/components/CommandMenu";
 | 
			
		||||
import {
 | 
			
		||||
  Accordion,
 | 
			
		||||
  AccordionContent,
 | 
			
		||||
  AccordionItem,
 | 
			
		||||
  AccordionTrigger,
 | 
			
		||||
} from "@/components/ui/accordion";
 | 
			
		||||
import { Category } from "@/lib/types";
 | 
			
		||||
import { formattedBadge } from "@/components/command-menu";
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import Image from "next/image";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
 | 
			
		||||
export default function ScriptAccordion({
 | 
			
		||||
  items,
 | 
			
		||||
@@ -41,8 +41,8 @@ export default function ScriptAccordion({
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (selectedScript) {
 | 
			
		||||
      const category = items.find((category) =>
 | 
			
		||||
        category.scripts.some((script) => script.slug === selectedScript),
 | 
			
		||||
      const category = items.find(category =>
 | 
			
		||||
        category.scripts.some(script => script.slug === selectedScript),
 | 
			
		||||
      );
 | 
			
		||||
      if (category) {
 | 
			
		||||
        setExpandedItem(category.name);
 | 
			
		||||
@@ -58,11 +58,11 @@ export default function ScriptAccordion({
 | 
			
		||||
      collapsible
 | 
			
		||||
      className="overflow-y-scroll max-h-[calc(100vh-225px)] overflow-x-hidden p-2"
 | 
			
		||||
    >
 | 
			
		||||
      {items.map((category) => (
 | 
			
		||||
      {items.map(category => (
 | 
			
		||||
        <AccordionItem
 | 
			
		||||
          key={category.id + ":category"}
 | 
			
		||||
          key={`${category.id}:category`}
 | 
			
		||||
          value={category.name}
 | 
			
		||||
          className={cn("sm:text-md flex flex-col border-none", {
 | 
			
		||||
          className={cn("sm:text-sm flex flex-col border-none", {
 | 
			
		||||
            "rounded-lg bg-accent/30": expandedItem === category.name,
 | 
			
		||||
          })}
 | 
			
		||||
        >
 | 
			
		||||
@@ -72,11 +72,15 @@ export default function ScriptAccordion({
 | 
			
		||||
            )}
 | 
			
		||||
          >
 | 
			
		||||
            <div className="mr-2 flex w-full items-center justify-between">
 | 
			
		||||
              <span className="pl-2 text-left">{category.name} </span>
 | 
			
		||||
              <span className="pl-2 text-left">
 | 
			
		||||
                {category.name}
 | 
			
		||||
                {" "}
 | 
			
		||||
              </span>
 | 
			
		||||
              <span className="rounded-full bg-gray-200 px-2 py-1 text-xs text-muted-foreground hover:no-underline dark:bg-blue-800/20">
 | 
			
		||||
                {category.scripts.length}
 | 
			
		||||
              </span>
 | 
			
		||||
            </div>{" "}
 | 
			
		||||
            </div>
 | 
			
		||||
            {" "}
 | 
			
		||||
          </AccordionTrigger>
 | 
			
		||||
          <AccordionContent
 | 
			
		||||
            data-state={expandedItem === category.name ? "open" : "closed"}
 | 
			
		||||
@@ -109,10 +113,9 @@ export default function ScriptAccordion({
 | 
			
		||||
                        height={16}
 | 
			
		||||
                        width={16}
 | 
			
		||||
                        unoptimized
 | 
			
		||||
                        onError={(e) =>
 | 
			
		||||
                          ((e.currentTarget as HTMLImageElement).src =
 | 
			
		||||
                            `/${basePath}/logo.png`)
 | 
			
		||||
                        }
 | 
			
		||||
                        onError={e =>
 | 
			
		||||
                          ((e.currentTarget as HTMLImageElement).src
 | 
			
		||||
                            = `/${basePath}/logo.png`)}
 | 
			
		||||
                        alt={script.name}
 | 
			
		||||
                        className="mr-1 w-4 h-4 rounded-full"
 | 
			
		||||
                      />
 | 
			
		||||
@@ -1,16 +1,18 @@
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import { basePath, mostPopularScripts } from "@/config/siteConfig";
 | 
			
		||||
import { extractDate } from "@/lib/time";
 | 
			
		||||
import { Category, Script } from "@/lib/types";
 | 
			
		||||
import { CalendarPlus } from "lucide-react";
 | 
			
		||||
import { useMemo, useState } from "react";
 | 
			
		||||
import Image from "next/image";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
import { useMemo, useState } from "react";
 | 
			
		||||
 | 
			
		||||
import type { Category, Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import { basePath, mostPopularScripts } from "@/config/site-config";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { extractDate } from "@/lib/time";
 | 
			
		||||
 | 
			
		||||
const ITEMS_PER_PAGE = 3;
 | 
			
		||||
 | 
			
		||||
export const getDisplayValueFromType = (type: string) => {
 | 
			
		||||
export function getDisplayValueFromType(type: string) {
 | 
			
		||||
  switch (type) {
 | 
			
		||||
    case "ct":
 | 
			
		||||
      return "LXC";
 | 
			
		||||
@@ -22,15 +24,16 @@ export const getDisplayValueFromType = (type: string) => {
 | 
			
		||||
    default:
 | 
			
		||||
      return "";
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LatestScripts({ items }: { items: Category[] }) {
 | 
			
		||||
  const [page, setPage] = useState(1);
 | 
			
		||||
 | 
			
		||||
  const latestScripts = useMemo(() => {
 | 
			
		||||
    if (!items) return [];
 | 
			
		||||
    if (!items)
 | 
			
		||||
      return [];
 | 
			
		||||
 | 
			
		||||
    const scripts = items.flatMap((category) => category.scripts || []);
 | 
			
		||||
    const scripts = items.flatMap(category => category.scripts || []);
 | 
			
		||||
 | 
			
		||||
    // Filter out duplicates by slug
 | 
			
		||||
    const uniqueScriptsMap = new Map<string, Script>();
 | 
			
		||||
@@ -46,11 +49,11 @@ export function LatestScripts({ items }: { items: Category[] }) {
 | 
			
		||||
  }, [items]);
 | 
			
		||||
 | 
			
		||||
  const goToNextPage = () => {
 | 
			
		||||
    setPage((prevPage) => prevPage + 1);
 | 
			
		||||
    setPage(prevPage => prevPage + 1);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const goToPreviousPage = () => {
 | 
			
		||||
    setPage((prevPage) => prevPage - 1);
 | 
			
		||||
    setPage(prevPage => prevPage - 1);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const startIndex = (page - 1) * ITEMS_PER_PAGE;
 | 
			
		||||
@@ -80,7 +83,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
      <div className="min-w flex w-full flex-row flex-wrap gap-4">
 | 
			
		||||
        {latestScripts.slice(startIndex, endIndex).map((script) => (
 | 
			
		||||
        {latestScripts.slice(startIndex, endIndex).map(script => (
 | 
			
		||||
          <Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
 | 
			
		||||
            <CardHeader>
 | 
			
		||||
              <CardTitle className="flex items-center gap-3">
 | 
			
		||||
@@ -91,13 +94,15 @@ export function LatestScripts({ items }: { items: Category[] }) {
 | 
			
		||||
                    height={64}
 | 
			
		||||
                    width={64}
 | 
			
		||||
                    alt=""
 | 
			
		||||
                    onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
 | 
			
		||||
                    onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
 | 
			
		||||
                    className="h-11 w-11 object-contain"
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="flex flex-col">
 | 
			
		||||
                  <p className="text-lg line-clamp-1">
 | 
			
		||||
                    {script.name} {getDisplayValueFromType(script.type)}
 | 
			
		||||
                    {script.name}
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    {getDisplayValueFromType(script.type)}
 | 
			
		||||
                  </p>
 | 
			
		||||
                  <p className="text-sm text-muted-foreground flex items-center gap-1">
 | 
			
		||||
                    <CalendarPlus className="h-4 w-4" />
 | 
			
		||||
@@ -130,7 +135,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
 | 
			
		||||
 | 
			
		||||
export function MostViewedScripts({ items }: { items: Category[] }) {
 | 
			
		||||
  const mostViewedScripts = items.reduce((acc: Script[], category) => {
 | 
			
		||||
    const foundScripts = category.scripts.filter((script) => mostPopularScripts.includes(script.slug));
 | 
			
		||||
    const foundScripts = category.scripts.filter(script => mostPopularScripts.includes(script.slug));
 | 
			
		||||
    return acc.concat(foundScripts);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
@@ -142,7 +147,7 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
      <div className="min-w flex w-full flex-row flex-wrap gap-4">
 | 
			
		||||
        {mostViewedScripts.map((script) => (
 | 
			
		||||
        {mostViewedScripts.map(script => (
 | 
			
		||||
          <Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
 | 
			
		||||
            <CardHeader>
 | 
			
		||||
              <CardTitle className="flex items-center gap-3">
 | 
			
		||||
@@ -153,13 +158,15 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
 | 
			
		||||
                    height={64}
 | 
			
		||||
                    width={64}
 | 
			
		||||
                    alt=""
 | 
			
		||||
                    onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
 | 
			
		||||
                    onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
 | 
			
		||||
                    className="h-11 w-11 object-contain"
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="flex flex-col">
 | 
			
		||||
                  <p className="line-clamp-1 text-lg">
 | 
			
		||||
                    {script.name} {getDisplayValueFromType(script.type)}
 | 
			
		||||
                    {script.name}
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    {getDisplayValueFromType(script.type)}
 | 
			
		||||
                  </p>
 | 
			
		||||
                  <p className="flex items-center gap-1 text-sm text-muted-foreground">
 | 
			
		||||
                    <CalendarPlus className="h-4 w-4" />
 | 
			
		||||
@@ -1,31 +1,32 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { extractDate } from "@/lib/time";
 | 
			
		||||
import { AppVersion, Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { X } from "lucide-react";
 | 
			
		||||
import { Suspense } from "react";
 | 
			
		||||
import Image from "next/image";
 | 
			
		||||
 | 
			
		||||
import { Separator } from "@/components/ui/separator";
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import { useVersions } from "@/hooks/useVersions";
 | 
			
		||||
import { cleanSlug } from "@/lib/utils/resource-utils";
 | 
			
		||||
import { Suspense } from "react";
 | 
			
		||||
import { ResourceDisplay } from "./ResourceDisplay";
 | 
			
		||||
import { getDisplayValueFromType } from "./ScriptInfoBlocks";
 | 
			
		||||
import Alerts from "./ScriptItems/Alerts";
 | 
			
		||||
import Buttons from "./ScriptItems/Buttons";
 | 
			
		||||
import ConfigFile from "./ScriptItems/ConfigFile";
 | 
			
		||||
import DefaultPassword from "./ScriptItems/DefaultPassword";
 | 
			
		||||
import Description from "./ScriptItems/Description";
 | 
			
		||||
import InstallCommand from "./ScriptItems/InstallCommand";
 | 
			
		||||
import InterFaces from "./ScriptItems/InterFaces";
 | 
			
		||||
import Tooltips from "./ScriptItems/Tooltips";
 | 
			
		||||
import type { AppVersion, Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
interface ScriptItemProps {
 | 
			
		||||
import { cleanSlug } from "@/lib/utils/resource-utils";
 | 
			
		||||
import { Separator } from "@/components/ui/separator";
 | 
			
		||||
import { useVersions } from "@/hooks/use-versions";
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
import { extractDate } from "@/lib/time";
 | 
			
		||||
 | 
			
		||||
import { getDisplayValueFromType } from "./script-info-blocks";
 | 
			
		||||
import DefaultPassword from "./script-items/default-password";
 | 
			
		||||
import InstallCommand from "./script-items/install-command";
 | 
			
		||||
import { ResourceDisplay } from "./resource-display";
 | 
			
		||||
import Description from "./script-items/description";
 | 
			
		||||
import ConfigFile from "./script-items/config-file";
 | 
			
		||||
import InterFaces from "./script-items/interfaces";
 | 
			
		||||
import Tooltips from "./script-items/tool-tips";
 | 
			
		||||
import Buttons from "./script-items/buttons";
 | 
			
		||||
import Alerts from "./script-items/alerts";
 | 
			
		||||
 | 
			
		||||
type ScriptItemProps = {
 | 
			
		||||
  item: Script;
 | 
			
		||||
  setSelectedScript: (script: string | null) => void;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function ScriptHeader({ item }: { item: Script }) {
 | 
			
		||||
  const defaultInstallMethod = item.install_methods?.[0];
 | 
			
		||||
@@ -40,7 +41,7 @@ function ScriptHeader({ item }: { item: Script }) {
 | 
			
		||||
            className="h-32 w-32 rounded-xl bg-gradient-to-br from-accent/40 to-accent/60 object-contain p-3 shadow-lg transition-transform hover:scale-105"
 | 
			
		||||
            src={item.logo || `/${basePath}/logo.png`}
 | 
			
		||||
            width={400}
 | 
			
		||||
            onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
 | 
			
		||||
            onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
 | 
			
		||||
            height={400}
 | 
			
		||||
            alt={item.name}
 | 
			
		||||
            unoptimized
 | 
			
		||||
@@ -58,10 +59,15 @@ function ScriptHeader({ item }: { item: Script }) {
 | 
			
		||||
                  </span>
 | 
			
		||||
                </h1>
 | 
			
		||||
                <div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
 | 
			
		||||
                  <span>Added {extractDate(item.date_created)}</span>
 | 
			
		||||
                  <span>
 | 
			
		||||
                    Added
 | 
			
		||||
                    {extractDate(item.date_created)}
 | 
			
		||||
                  </span>
 | 
			
		||||
                  <span>•</span>
 | 
			
		||||
                  <span className=" capitalize">
 | 
			
		||||
                    {os} {version}
 | 
			
		||||
                    {os}
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    {version}
 | 
			
		||||
                  </span>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
@@ -76,10 +82,10 @@ function ScriptHeader({ item }: { item: Script }) {
 | 
			
		||||
                  hdd={defaultInstallMethod.resources.hdd}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
              {item.install_methods.find((method) => method.type === "alpine")?.resources && (
 | 
			
		||||
              {item.install_methods.find(method => method.type === "alpine")?.resources && (
 | 
			
		||||
                <ResourceDisplay
 | 
			
		||||
                  title="Alpine"
 | 
			
		||||
                  {...item.install_methods.find((method) => method.type === "alpine")!.resources!}
 | 
			
		||||
                  {...item.install_methods.find(method => method.type === "alpine")!.resources!}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
@@ -108,7 +114,8 @@ function VersionInfo({ item }: { item: Script }) {
 | 
			
		||||
    return cleanName === cleanSlug(item.slug) || cleanName.includes(cleanSlug(item.slug));
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!matchedVersion) return null;
 | 
			
		||||
  if (!matchedVersion)
 | 
			
		||||
    return null;
 | 
			
		||||
 | 
			
		||||
  return <span className="font-medium text-sm">{matchedVersion.version}</span>;
 | 
			
		||||
}
 | 
			
		||||
@@ -132,7 +139,7 @@ export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className="rounded-xl border border-border bg-gradient-to-b from-card/30 to-background/50 backdrop-blur-sm shadow-sm">
 | 
			
		||||
        <div className="rounded-xl border border-border bg-accent/30 backdrop-blur-sm shadow-sm">
 | 
			
		||||
          <div className="p-6 space-y-6">
 | 
			
		||||
            <Suspense fallback={<div className="animate-pulse h-32 bg-accent/20 rounded-xl" />}>
 | 
			
		||||
              <ScriptHeader item={item} />
 | 
			
		||||
@@ -144,7 +151,9 @@ export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
 | 
			
		||||
            <div className="mt-4 rounded-lg border shadow-sm">
 | 
			
		||||
              <div className="flex gap-3 px-4 py-2 bg-accent/25">
 | 
			
		||||
                <h2 className="text-lg font-semibold">
 | 
			
		||||
                  How to {item.type === "pve" ? "use" : item.type === "addon" ? "apply" : "install"}
 | 
			
		||||
                  How to
 | 
			
		||||
                  {" "}
 | 
			
		||||
                  {item.type === "pve" ? "use" : item.type === "addon" ? "apply" : "install"}
 | 
			
		||||
                </h2>
 | 
			
		||||
                <Tooltips item={item} />
 | 
			
		||||
              </div>
 | 
			
		||||
@@ -1,19 +1,21 @@
 | 
			
		||||
import TextCopyBlock from "@/components/TextCopyBlock";
 | 
			
		||||
import { AlertColors } from "@/config/siteConfig";
 | 
			
		||||
import { Script } from "@/lib/types";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { AlertCircle, NotepadText } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
import type { Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import TextCopyBlock from "@/components/text-copy-block";
 | 
			
		||||
import { AlertColors } from "@/config/site-config";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
type NoteProps = {
 | 
			
		||||
  text: string;
 | 
			
		||||
  type: keyof typeof AlertColors;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function Alerts({ item }: { item: Script }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {item?.notes?.length > 0 &&
 | 
			
		||||
        item.notes.map((note: NoteProps, index: number) => (
 | 
			
		||||
      {item?.notes?.length > 0
 | 
			
		||||
        && item.notes.map((note: NoteProps, index: number) => (
 | 
			
		||||
          <div key={index} className="mt-4 flex flex-col shadow-sm gap-2">
 | 
			
		||||
            <p
 | 
			
		||||
              className={cn(
 | 
			
		||||
@@ -21,11 +23,13 @@ export default function Alerts({ item }: { item: Script }) {
 | 
			
		||||
                AlertColors[note.type],
 | 
			
		||||
              )}
 | 
			
		||||
            >
 | 
			
		||||
              {note.type == "info" ? (
 | 
			
		||||
                <NotepadText className="h-4 min-h-4 w-4 min-w-4" />
 | 
			
		||||
              ) : (
 | 
			
		||||
                <AlertCircle className="h-4 min-h-4 w-4 min-w-4" />
 | 
			
		||||
              )}
 | 
			
		||||
              {note.type === "info"
 | 
			
		||||
                ? (
 | 
			
		||||
                    <NotepadText className="h-4 min-h-4 w-4 min-w-4" />
 | 
			
		||||
                  )
 | 
			
		||||
                : (
 | 
			
		||||
                    <AlertCircle className="h-4 min-h-4 w-4 min-w-4" />
 | 
			
		||||
                  )}
 | 
			
		||||
              <span>{TextCopyBlock(note.text)}</span>
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -1,20 +1,22 @@
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { BookOpenText, Code, Globe, LinkIcon, RefreshCcw } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
import type { Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  DropdownMenu,
 | 
			
		||||
  DropdownMenuContent,
 | 
			
		||||
  DropdownMenuItem,
 | 
			
		||||
  DropdownMenuTrigger,
 | 
			
		||||
} from "@/components/ui/dropdown-menu";
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import { Script } from "@/lib/types";
 | 
			
		||||
import { BookOpenText, Code, Globe, LinkIcon, RefreshCcw } from "lucide-react";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
 | 
			
		||||
const generateInstallSourceUrl = (slug: string) => {
 | 
			
		||||
function generateInstallSourceUrl(slug: string) {
 | 
			
		||||
  const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
 | 
			
		||||
  return `${baseUrl}/install/${slug}-install.sh`;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const generateSourceUrl = (slug: string, type: string) => {
 | 
			
		||||
function generateSourceUrl(slug: string, type: string) {
 | 
			
		||||
  const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
 | 
			
		||||
 | 
			
		||||
  switch (type) {
 | 
			
		||||
@@ -29,18 +31,18 @@ const generateSourceUrl = (slug: string, type: string) => {
 | 
			
		||||
    default:
 | 
			
		||||
      return `${baseUrl}/ct/${slug}.sh`; // fallback for "ct"
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const generateUpdateUrl = (slug: string) => {
 | 
			
		||||
function generateUpdateUrl(slug: string) {
 | 
			
		||||
  const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
 | 
			
		||||
  return `${baseUrl}/ct/${slug}.sh`;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface LinkItem {
 | 
			
		||||
type LinkItem = {
 | 
			
		||||
  href: string;
 | 
			
		||||
  icon: React.ReactNode;
 | 
			
		||||
  text: string;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function Buttons({ item }: { item: Script }) {
 | 
			
		||||
  const isCtOrDefault = ["ct"].includes(item.type);
 | 
			
		||||
@@ -76,7 +78,8 @@ export default function Buttons({ item }: { item: Script }) {
 | 
			
		||||
    },
 | 
			
		||||
  ].filter(Boolean) as LinkItem[];
 | 
			
		||||
 | 
			
		||||
  if (links.length === 0) return null;
 | 
			
		||||
  if (links.length === 0)
 | 
			
		||||
    return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <DropdownMenu>
 | 
			
		||||
@@ -1,13 +1,15 @@
 | 
			
		||||
import handleCopy from "@/components/handleCopy";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import type { Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { Separator } from "@/components/ui/separator";
 | 
			
		||||
import { Script } from "@/lib/types";
 | 
			
		||||
import handleCopy from "@/components/handle-copy";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
 | 
			
		||||
export default function DefaultPassword({ item }: { item: Script }) {
 | 
			
		||||
  const { username, password } = item.default_credentials;
 | 
			
		||||
  const hasDefaultLogin = username || password;
 | 
			
		||||
 | 
			
		||||
  if (!hasDefaultLogin) return null;
 | 
			
		||||
  if (!hasDefaultLogin)
 | 
			
		||||
    return null;
 | 
			
		||||
 | 
			
		||||
  const copyCredential = (type: "username" | "password") => {
 | 
			
		||||
    handleCopy(type, item.default_credentials[type] ?? "");
 | 
			
		||||
@@ -21,18 +23,27 @@ export default function DefaultPassword({ item }: { item: Script }) {
 | 
			
		||||
      <Separator className="w-full" />
 | 
			
		||||
      <div className="flex flex-col gap-2 p-4">
 | 
			
		||||
        <p className="mb-2 text-sm">
 | 
			
		||||
          You can use the following credentials to login to the {item.name} {item.type}.
 | 
			
		||||
          You can use the following credentials to login to the
 | 
			
		||||
          {" "}
 | 
			
		||||
          {item.name}
 | 
			
		||||
          {" "}
 | 
			
		||||
          {item.type}
 | 
			
		||||
          .
 | 
			
		||||
        </p>
 | 
			
		||||
        {["username", "password"].map((type) => {
 | 
			
		||||
          const value = item.default_credentials[type as "username" | "password"];
 | 
			
		||||
          return value && value.trim() !== "" ? (
 | 
			
		||||
            <div key={type} className="text-sm">
 | 
			
		||||
              {type.charAt(0).toUpperCase() + type.slice(1)}:{" "}
 | 
			
		||||
              <Button variant="secondary" size="null" onClick={() => copyCredential(type as "username" | "password")}>
 | 
			
		||||
                {value}
 | 
			
		||||
              </Button>
 | 
			
		||||
            </div>
 | 
			
		||||
          ) : null;
 | 
			
		||||
          return value && value.trim() !== ""
 | 
			
		||||
            ? (
 | 
			
		||||
                <div key={type} className="text-sm">
 | 
			
		||||
                  {type.charAt(0).toUpperCase() + type.slice(1)}
 | 
			
		||||
                  :
 | 
			
		||||
                  {" "}
 | 
			
		||||
                  <Button variant="secondary" size="null" onClick={() => copyCredential(type as "username" | "password")}>
 | 
			
		||||
                    {value}
 | 
			
		||||
                  </Button>
 | 
			
		||||
                </div>
 | 
			
		||||
              )
 | 
			
		||||
            : null;
 | 
			
		||||
        })}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { Script } from "@/lib/types";
 | 
			
		||||
import type { Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
export default function DefaultSettings({ item }: { item: Script }) {
 | 
			
		||||
  const getDisplayValueFromRAM = (ram: number) => (ram >= 1024 ? `${Math.floor(ram / 1024)}GB` : `${ram}MB`);
 | 
			
		||||
@@ -8,15 +8,26 @@ export default function DefaultSettings({ item }: { item: Script }) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div>
 | 
			
		||||
        <h2 className="text-md font-semibold">{title}</h2>
 | 
			
		||||
        <p className="text-sm text-muted-foreground">CPU: {cpu}vCPU</p>
 | 
			
		||||
        <p className="text-sm text-muted-foreground">RAM: {getDisplayValueFromRAM(ram ?? 0)}</p>
 | 
			
		||||
        <p className="text-sm text-muted-foreground">HDD: {hdd}GB</p>
 | 
			
		||||
        <p className="text-sm text-muted-foreground">
 | 
			
		||||
          CPU:
 | 
			
		||||
          {cpu}
 | 
			
		||||
          vCPU
 | 
			
		||||
        </p>
 | 
			
		||||
        <p className="text-sm text-muted-foreground">
 | 
			
		||||
          RAM:
 | 
			
		||||
          {getDisplayValueFromRAM(ram ?? 0)}
 | 
			
		||||
        </p>
 | 
			
		||||
        <p className="text-sm text-muted-foreground">
 | 
			
		||||
          HDD:
 | 
			
		||||
          {hdd}
 | 
			
		||||
          GB
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const defaultSettings = item.install_methods.find((method) => method.type === "default");
 | 
			
		||||
  const defaultAlpineSettings = item.install_methods.find((method) => method.type === "alpine");
 | 
			
		||||
  const defaultSettings = item.install_methods.find(method => method.type === "default");
 | 
			
		||||
  const defaultAlpineSettings = item.install_methods.find(method => method.type === "alpine");
 | 
			
		||||
 | 
			
		||||
  const hasDefaultSettings = defaultSettings?.resources && Object.values(defaultSettings.resources).some(Boolean);
 | 
			
		||||
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import TextCopyBlock from "@/components/TextCopyBlock";
 | 
			
		||||
import { Script } from "@/lib/types";
 | 
			
		||||
import type { Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import TextCopyBlock from "@/components/text-copy-block";
 | 
			
		||||
 | 
			
		||||
export default function Description({ item }: { item: Script }) {
 | 
			
		||||
  return (
 | 
			
		||||
@@ -0,0 +1,149 @@
 | 
			
		||||
import { Info } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
import type { Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
 | 
			
		||||
import { Alert, AlertDescription } from "@/components/ui/alert";
 | 
			
		||||
import CodeCopyButton from "@/components/ui/code-copy-button";
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
 | 
			
		||||
import { getDisplayValueFromType } from "../script-info-blocks";
 | 
			
		||||
 | 
			
		||||
function getInstallCommand(scriptPath = "", isAlpine = false, useGitea = false) {
 | 
			
		||||
  const githubUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`;
 | 
			
		||||
  const giteaUrl = `https://git.community-scripts.org/community-scripts/${basePath}/raw/branch/main/${scriptPath}`;
 | 
			
		||||
  const url = useGitea ? giteaUrl : githubUrl;
 | 
			
		||||
  return isAlpine ? `bash -c "$(curl -fsSL ${url})"` : `bash -c "$(curl -fsSL ${url})"`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function InstallCommand({ item }: { item: Script }) {
 | 
			
		||||
  const alpineScript = item.install_methods.find(method => method.type === "alpine");
 | 
			
		||||
  const defaultScript = item.install_methods.find(method => method.type === "default");
 | 
			
		||||
 | 
			
		||||
  const renderInstructions = (isAlpine = false) => (
 | 
			
		||||
    <>
 | 
			
		||||
      <p className="text-sm mt-2">
 | 
			
		||||
        {isAlpine
 | 
			
		||||
          ? (
 | 
			
		||||
              <>
 | 
			
		||||
                As an alternative option, you can use Alpine Linux and the
 | 
			
		||||
                {" "}
 | 
			
		||||
                {item.name}
 | 
			
		||||
                {" "}
 | 
			
		||||
                package to create a
 | 
			
		||||
                {" "}
 | 
			
		||||
                {item.name}
 | 
			
		||||
                {" "}
 | 
			
		||||
                {getDisplayValueFromType(item.type)}
 | 
			
		||||
                {" "}
 | 
			
		||||
                container with faster creation time and minimal system resource usage.
 | 
			
		||||
                You are also obliged to adhere to updates provided by the package maintainer.
 | 
			
		||||
              </>
 | 
			
		||||
            )
 | 
			
		||||
          : item.type === "pve"
 | 
			
		||||
            ? (
 | 
			
		||||
                <>
 | 
			
		||||
                  To use the
 | 
			
		||||
                  {" "}
 | 
			
		||||
                  {item.name}
 | 
			
		||||
                  {" "}
 | 
			
		||||
                  script, run the command below **only** in the Proxmox VE Shell. This script is
 | 
			
		||||
                  intended for managing or enhancing the host system directly.
 | 
			
		||||
                </>
 | 
			
		||||
              )
 | 
			
		||||
            : item.type === "addon"
 | 
			
		||||
              ? (
 | 
			
		||||
                  <>
 | 
			
		||||
                    This script enhances an existing setup. You can use it inside a running LXC container or directly on the
 | 
			
		||||
                    Proxmox VE host to extend functionality with
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    {item.name}
 | 
			
		||||
                    .
 | 
			
		||||
                  </>
 | 
			
		||||
                )
 | 
			
		||||
              : (
 | 
			
		||||
                  <>
 | 
			
		||||
                    To create a new Proxmox VE
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    {item.name}
 | 
			
		||||
                    {" "}
 | 
			
		||||
                    {getDisplayValueFromType(item.type)}
 | 
			
		||||
                    , run the command below in the
 | 
			
		||||
                    Proxmox VE Shell.
 | 
			
		||||
                  </>
 | 
			
		||||
                )}
 | 
			
		||||
      </p>
 | 
			
		||||
      {isAlpine && (
 | 
			
		||||
        <p className="mt-2 text-sm">
 | 
			
		||||
          To create a new Proxmox VE Alpine-
 | 
			
		||||
          {item.name}
 | 
			
		||||
          {" "}
 | 
			
		||||
          {getDisplayValueFromType(item.type)}
 | 
			
		||||
          , run the command below in
 | 
			
		||||
          the Proxmox VE Shell.
 | 
			
		||||
        </p>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const renderGiteaInfo = () => (
 | 
			
		||||
    <Alert className="mt-3 mb-3">
 | 
			
		||||
      <Info className="h-4 w-4" />
 | 
			
		||||
      <AlertDescription className="text-sm">
 | 
			
		||||
        <strong>When to use Gitea:</strong>
 | 
			
		||||
        {" "}
 | 
			
		||||
        GitHub may have issues including slow connections, delayed updates after bug
 | 
			
		||||
        fixes, no IPv6 support, API rate limits (60/hour). Use our Gitea mirror as a reliable alternative when
 | 
			
		||||
        experiencing these issues.
 | 
			
		||||
      </AlertDescription>
 | 
			
		||||
    </Alert>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const renderScriptTabs = (useGitea = false) => {
 | 
			
		||||
    if (alpineScript) {
 | 
			
		||||
      return (
 | 
			
		||||
        <Tabs defaultValue="default" className="mt-2 w-full max-w-4xl">
 | 
			
		||||
          <TabsList>
 | 
			
		||||
            <TabsTrigger value="default">Default</TabsTrigger>
 | 
			
		||||
            <TabsTrigger value="alpine">Alpine Linux</TabsTrigger>
 | 
			
		||||
          </TabsList>
 | 
			
		||||
          <TabsContent value="default">
 | 
			
		||||
            {renderInstructions()}
 | 
			
		||||
            <CodeCopyButton>{getInstallCommand(defaultScript?.script, false, useGitea)}</CodeCopyButton>
 | 
			
		||||
          </TabsContent>
 | 
			
		||||
          <TabsContent value="alpine">
 | 
			
		||||
            {renderInstructions(true)}
 | 
			
		||||
            <CodeCopyButton>{getInstallCommand(alpineScript.script, true, useGitea)}</CodeCopyButton>
 | 
			
		||||
          </TabsContent>
 | 
			
		||||
        </Tabs>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    else if (defaultScript?.script) {
 | 
			
		||||
      return (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderInstructions()}
 | 
			
		||||
          <CodeCopyButton>{getInstallCommand(defaultScript.script, false, useGitea)}</CodeCopyButton>
 | 
			
		||||
        </>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="p-4">
 | 
			
		||||
      <Tabs defaultValue="github" className="w-full max-w-4xl">
 | 
			
		||||
        <TabsList>
 | 
			
		||||
          <TabsTrigger value="github">GitHub</TabsTrigger>
 | 
			
		||||
          <TabsTrigger value="gitea">Gitea</TabsTrigger>
 | 
			
		||||
        </TabsList>
 | 
			
		||||
        <TabsContent value="github">
 | 
			
		||||
          {renderScriptTabs(false)}
 | 
			
		||||
        </TabsContent>
 | 
			
		||||
        <TabsContent value="gitea">
 | 
			
		||||
          {renderGiteaInfo()}
 | 
			
		||||
          {renderScriptTabs(true)}
 | 
			
		||||
        </TabsContent>
 | 
			
		||||
      </Tabs>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
import { ClipboardIcon } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
import type { Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { buttonVariants } from "@/components/ui/button";
 | 
			
		||||
import handleCopy from "@/components/handle-copy";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
export default function InterFaces({ item }: { item: Script }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col gap-2 w-full">
 | 
			
		||||
      {item.interface_port !== null
 | 
			
		||||
        ? (
 | 
			
		||||
            <div className="flex items-center justify-end">
 | 
			
		||||
              <h2 className="mr-2 text-end text-lg font-semibold">Default Interface:</h2>
 | 
			
		||||
              <span className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex items-center gap-2")}>
 | 
			
		||||
                {item.interface_port}
 | 
			
		||||
                <ClipboardIcon onClick={() => handleCopy("default interface", String(item.interface_port))} className="size-4 cursor-pointer" />
 | 
			
		||||
              </span>
 | 
			
		||||
            </div>
 | 
			
		||||
          )
 | 
			
		||||
        : null}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,22 +1,27 @@
 | 
			
		||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
 | 
			
		||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 | 
			
		||||
import { Script } from "@/lib/types";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { CircleHelp } from "lucide-react";
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
interface TooltipProps {
 | 
			
		||||
import type { BadgeProps } from "@/components/ui/badge";
 | 
			
		||||
import type { Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 | 
			
		||||
import { Badge } from "@/components/ui/badge";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
type TooltipProps = {
 | 
			
		||||
  variant: BadgeProps["variant"];
 | 
			
		||||
  label: string;
 | 
			
		||||
  content?: string;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const TooltipBadge: React.FC<TooltipProps> = ({ variant, label, content }) => (
 | 
			
		||||
  <TooltipProvider>
 | 
			
		||||
    <Tooltip delayDuration={100}>
 | 
			
		||||
      <TooltipTrigger className={cn("flex items-center", !content && "cursor-default")}>
 | 
			
		||||
        <Badge variant={variant} className="flex items-center gap-1">
 | 
			
		||||
          {label} {content && <CircleHelp className="size-3" />}
 | 
			
		||||
          {label}
 | 
			
		||||
          {" "}
 | 
			
		||||
          {content && <CircleHelp className="size-3" />}
 | 
			
		||||
        </Badge>
 | 
			
		||||
      </TooltipTrigger>
 | 
			
		||||
      {content && (
 | 
			
		||||
							
								
								
									
										46
									
								
								frontend/src/app/scripts/_components/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/src/app/scripts/_components/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,46 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import type { Category, Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import ScriptAccordion from "./script-accordion";
 | 
			
		||||
 | 
			
		||||
function Sidebar({
 | 
			
		||||
  items,
 | 
			
		||||
  selectedScript,
 | 
			
		||||
  setSelectedScript,
 | 
			
		||||
}: {
 | 
			
		||||
  items: Category[];
 | 
			
		||||
  selectedScript: string | null;
 | 
			
		||||
  setSelectedScript: (script: string | null) => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const uniqueScripts = items.reduce((acc, category) => {
 | 
			
		||||
    for (const script of category.scripts) {
 | 
			
		||||
      if (!acc.some(s => s.name === script.name)) {
 | 
			
		||||
        acc.push(script);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return acc;
 | 
			
		||||
  }, [] as Script[]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex min-w-[350px] flex-col sm:max-w-[350px]">
 | 
			
		||||
      <div className="flex items-end justify-between pb-4">
 | 
			
		||||
        <h1 className="text-xl font-bold">Categories</h1>
 | 
			
		||||
        <p className="text-xs italic text-muted-foreground">
 | 
			
		||||
          {uniqueScripts.length}
 | 
			
		||||
          {" "}
 | 
			
		||||
          Total scripts
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="rounded-lg">
 | 
			
		||||
        <ScriptAccordion
 | 
			
		||||
          items={items}
 | 
			
		||||
          selectedScript={selectedScript}
 | 
			
		||||
          setSelectedScript={setSelectedScript}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Sidebar;
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
import { AppVersion } from "@/lib/types";
 | 
			
		||||
import type { AppVersion } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
interface VersionBadgeProps {
 | 
			
		||||
type VersionBadgeProps = {
 | 
			
		||||
  version: AppVersion;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function VersionBadge({ version }: VersionBadgeProps) {
 | 
			
		||||
  return (
 | 
			
		||||
@@ -1,18 +1,20 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
export const dynamic = "force-static";
 | 
			
		||||
 | 
			
		||||
import { ScriptItem } from "@/app/scripts/_components/ScriptItem";
 | 
			
		||||
import { fetchCategories } from "@/lib/data";
 | 
			
		||||
import { Category, Script } from "@/lib/types";
 | 
			
		||||
import { Suspense, useEffect, useState } from "react";
 | 
			
		||||
import { Loader2 } from "lucide-react";
 | 
			
		||||
import { useQueryState } from "nuqs";
 | 
			
		||||
import { Suspense, useEffect, useState } from "react";
 | 
			
		||||
 | 
			
		||||
import type { Category, Script } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { ScriptItem } from "@/app/scripts/_components/script-item";
 | 
			
		||||
import { fetchCategories } from "@/lib/data";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  LatestScripts,
 | 
			
		||||
  MostViewedScripts,
 | 
			
		||||
} from "./_components/ScriptInfoBlocks";
 | 
			
		||||
import Sidebar from "./_components/Sidebar";
 | 
			
		||||
} from "./_components/script-info-blocks";
 | 
			
		||||
import Sidebar from "./_components/sidebar";
 | 
			
		||||
 | 
			
		||||
export const dynamic = "force-static";
 | 
			
		||||
 | 
			
		||||
function ScriptContent() {
 | 
			
		||||
  const [selectedScript, setSelectedScript] = useQueryState("id");
 | 
			
		||||
@@ -22,9 +24,9 @@ function ScriptContent() {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (selectedScript && links.length > 0) {
 | 
			
		||||
      const script = links
 | 
			
		||||
        .map((category) => category.scripts)
 | 
			
		||||
        .map(category => category.scripts)
 | 
			
		||||
        .flat()
 | 
			
		||||
        .find((script) => script.slug === selectedScript);
 | 
			
		||||
        .find(script => script.slug === selectedScript);
 | 
			
		||||
      setItem(script);
 | 
			
		||||
    }
 | 
			
		||||
  }, [selectedScript, links]);
 | 
			
		||||
@@ -34,7 +36,7 @@ function ScriptContent() {
 | 
			
		||||
      .then((categories) => {
 | 
			
		||||
        setLinks(categories);
 | 
			
		||||
      })
 | 
			
		||||
      .catch((error) => console.error(error));
 | 
			
		||||
      .catch(error => console.error(error));
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@@ -48,14 +50,16 @@ function ScriptContent() {
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="mx-4 w-full sm:mx-0 sm:ml-4">
 | 
			
		||||
          {selectedScript && item ? (
 | 
			
		||||
            <ScriptItem item={item} setSelectedScript={setSelectedScript} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <div className="flex w-full flex-col gap-5">
 | 
			
		||||
              <LatestScripts items={links} />
 | 
			
		||||
              <MostViewedScripts items={links} />
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
          {selectedScript && item
 | 
			
		||||
            ? (
 | 
			
		||||
                <ScriptItem item={item} setSelectedScript={setSelectedScript} />
 | 
			
		||||
              )
 | 
			
		||||
            : (
 | 
			
		||||
                <div className="flex w-full flex-col gap-5">
 | 
			
		||||
                  <LatestScripts items={links} />
 | 
			
		||||
                  <MostViewedScripts items={links} />
 | 
			
		||||
                </div>
 | 
			
		||||
              )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -65,13 +69,13 @@ function ScriptContent() {
 | 
			
		||||
export default function Page() {
 | 
			
		||||
  return (
 | 
			
		||||
    <Suspense
 | 
			
		||||
      fallback={
 | 
			
		||||
      fallback={(
 | 
			
		||||
        <div className="flex h-screen w-full flex-col items-center justify-center gap-5 bg-background px-4 md:px-6">
 | 
			
		||||
          <div className="space-y-2 text-center">
 | 
			
		||||
            <Loader2 className="h-10 w-10 animate-spin" />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      }
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      <ScriptContent />
 | 
			
		||||
    </Suspense>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,12 @@
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import type { MetadataRoute } from "next";
 | 
			
		||||
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
 | 
			
		||||
export const dynamic = "force-static";
 | 
			
		||||
 | 
			
		||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
 | 
			
		||||
  let domain = "community-scripts.github.io";
 | 
			
		||||
  let protocol = "https";
 | 
			
		||||
  const domain = "community-scripts.github.io";
 | 
			
		||||
  const protocol = "https";
 | 
			
		||||
  return [
 | 
			
		||||
    {
 | 
			
		||||
      url: `${protocol}://${domain}/${basePath}`,
 | 
			
		||||
@@ -18,6 +19,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
 | 
			
		||||
    {
 | 
			
		||||
      url: `${protocol}://${domain}/${basePath}/json-editor`,
 | 
			
		||||
      lastModified: new Date(),
 | 
			
		||||
    }
 | 
			
		||||
    },
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
@@ -1,12 +1,11 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
} from "@/components/ui/dialog";
 | 
			
		||||
import { ArcElement, Chart as ChartJS, Tooltip as ChartTooltip, Legend } from "chart.js";
 | 
			
		||||
import ChartDataLabels from "chartjs-plugin-datalabels";
 | 
			
		||||
import { BarChart3, PieChart } from "lucide-react";
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { Pie } from "react-chartjs-2";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  Table,
 | 
			
		||||
  TableBody,
 | 
			
		||||
@@ -21,21 +20,23 @@ import {
 | 
			
		||||
  TooltipProvider,
 | 
			
		||||
  TooltipTrigger,
 | 
			
		||||
} from "@/components/ui/tooltip";
 | 
			
		||||
import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from "chart.js";
 | 
			
		||||
import ChartDataLabels from "chartjs-plugin-datalabels";
 | 
			
		||||
import { BarChart3, PieChart } from "lucide-react";
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { Pie, Bar } from "react-chartjs-2";
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
} from "@/components/ui/dialog";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
 | 
			
		||||
ChartJS.register(ArcElement, ChartTooltip, Legend, ChartDataLabels);
 | 
			
		||||
 | 
			
		||||
interface SummaryData {
 | 
			
		||||
type SummaryData = {
 | 
			
		||||
  nsapp_count: Record<string, number>;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface ApplicationChartProps {
 | 
			
		||||
type ApplicationChartProps = {
 | 
			
		||||
  data: SummaryData | null;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const ITEMS_PER_PAGE = 20;
 | 
			
		||||
const CHART_COLORS = [
 | 
			
		||||
@@ -61,14 +62,15 @@ export default function ApplicationChart({ data }: ApplicationChartProps) {
 | 
			
		||||
  const [chartStartIndex, setChartStartIndex] = useState(0);
 | 
			
		||||
  const [tableLimit, setTableLimit] = useState(ITEMS_PER_PAGE);
 | 
			
		||||
 | 
			
		||||
  if (!data) return null;
 | 
			
		||||
  if (!data)
 | 
			
		||||
    return null;
 | 
			
		||||
 | 
			
		||||
  const sortedApps = Object.entries(data.nsapp_count)
 | 
			
		||||
    .sort(([, a], [, b]) => b - a);
 | 
			
		||||
 | 
			
		||||
  const chartApps = sortedApps.slice(
 | 
			
		||||
    chartStartIndex,
 | 
			
		||||
    chartStartIndex + ITEMS_PER_PAGE
 | 
			
		||||
    chartStartIndex + ITEMS_PER_PAGE,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const chartData = {
 | 
			
		||||
@@ -141,14 +143,18 @@ export default function ApplicationChart({ data }: ApplicationChartProps) {
 | 
			
		||||
              onClick={() => setChartStartIndex(Math.max(0, chartStartIndex - ITEMS_PER_PAGE))}
 | 
			
		||||
              disabled={chartStartIndex === 0}
 | 
			
		||||
            >
 | 
			
		||||
              Previous {ITEMS_PER_PAGE}
 | 
			
		||||
              Previous
 | 
			
		||||
              {" "}
 | 
			
		||||
              {ITEMS_PER_PAGE}
 | 
			
		||||
            </Button>
 | 
			
		||||
            <Button
 | 
			
		||||
              variant="outline"
 | 
			
		||||
              onClick={() => setChartStartIndex(chartStartIndex + ITEMS_PER_PAGE)}
 | 
			
		||||
              disabled={chartStartIndex + ITEMS_PER_PAGE >= sortedApps.length}
 | 
			
		||||
            >
 | 
			
		||||
              Next {ITEMS_PER_PAGE}
 | 
			
		||||
              Next
 | 
			
		||||
              {" "}
 | 
			
		||||
              {ITEMS_PER_PAGE}
 | 
			
		||||
            </Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </DialogContent>
 | 
			
		||||
@@ -190,4 +196,4 @@ export default function ApplicationChart({ data }: ApplicationChartProps) {
 | 
			
		||||
      </Dialog>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -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 {
 | 
			
		||||
  CommandDialog,
 | 
			
		||||
  CommandEmpty,
 | 
			
		||||
@@ -6,22 +13,16 @@ import {
 | 
			
		||||
  CommandItem,
 | 
			
		||||
  CommandList,
 | 
			
		||||
} from "@/components/ui/command";
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import { basePath } from "@/config/site-config";
 | 
			
		||||
import { fetchCategories } from "@/lib/data";
 | 
			
		||||
import { Category, Script } from "@/lib/types";
 | 
			
		||||
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) {
 | 
			
		||||
    case "vm":
 | 
			
		||||
      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 null;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// random Script
 | 
			
		||||
function getRandomScript(categories: Category[]): Script | null {
 | 
			
		||||
  const allScripts = categories.flatMap((cat) => cat.scripts || []);
 | 
			
		||||
  if (allScripts.length === 0) return null;
 | 
			
		||||
  const allScripts = categories.flatMap(cat => cat.scripts || []);
 | 
			
		||||
  if (allScripts.length === 0)
 | 
			
		||||
    return null;
 | 
			
		||||
  const idx = Math.floor(Math.random() * allScripts.length);
 | 
			
		||||
  return allScripts[idx];
 | 
			
		||||
}
 | 
			
		||||
@@ -49,18 +51,6 @@ export default function CommandMenu() {
 | 
			
		||||
  const [isLoading, setIsLoading] = React.useState(false);
 | 
			
		||||
  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 = () => {
 | 
			
		||||
    setIsLoading(true);
 | 
			
		||||
    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 () => {
 | 
			
		||||
    if (links.length === 0) {
 | 
			
		||||
      setIsLoading(true);
 | 
			
		||||
@@ -84,10 +86,12 @@ export default function CommandMenu() {
 | 
			
		||||
        if (randomScript) {
 | 
			
		||||
          router.push(`/scripts?id=${randomScript.slug}`);
 | 
			
		||||
        }
 | 
			
		||||
      } finally {
 | 
			
		||||
      }
 | 
			
		||||
      finally {
 | 
			
		||||
        setIsLoading(false);
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
    }
 | 
			
		||||
    else {
 | 
			
		||||
      const randomScript = getRandomScript(links);
 | 
			
		||||
      if (randomScript) {
 | 
			
		||||
        router.push(`/scripts?id=${randomScript.slug}`);
 | 
			
		||||
@@ -110,7 +114,8 @@ export default function CommandMenu() {
 | 
			
		||||
        >
 | 
			
		||||
          <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">
 | 
			
		||||
            <span className="text-xs">⌘</span>K
 | 
			
		||||
            <span className="text-xs">⌘</span>
 | 
			
		||||
            K
 | 
			
		||||
          </kbd>
 | 
			
		||||
        </Button>
 | 
			
		||||
 | 
			
		||||
@@ -134,54 +139,34 @@ export default function CommandMenu() {
 | 
			
		||||
        <CommandInput placeholder="Search for a script..." />
 | 
			
		||||
        <CommandList>
 | 
			
		||||
          <CommandEmpty>{isLoading ? "Loading..." : "No scripts found."}</CommandEmpty>
 | 
			
		||||
          {(() => {
 | 
			
		||||
            // Track seen scripts globally to avoid duplicates across all categories
 | 
			
		||||
            const globalSeenScripts = new Set<string>();
 | 
			
		||||
            
 | 
			
		||||
            return links.map((category) => {
 | 
			
		||||
              const uniqueScripts = category.scripts.filter((script) => {
 | 
			
		||||
                if (globalSeenScripts.has(script.slug)) {
 | 
			
		||||
                  return false;
 | 
			
		||||
                }
 | 
			
		||||
                globalSeenScripts.add(script.slug);
 | 
			
		||||
                return true;
 | 
			
		||||
              });
 | 
			
		||||
 | 
			
		||||
              // Only render category if it has unique scripts
 | 
			
		||||
              if (uniqueScripts.length === 0) {
 | 
			
		||||
                return null;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              return (
 | 
			
		||||
                <CommandGroup key={`category:${category.name}`} heading={category.name}>
 | 
			
		||||
                  {uniqueScripts.map((script) => (
 | 
			
		||||
                    <CommandItem
 | 
			
		||||
                      key={`script:${script.slug}`}
 | 
			
		||||
                      value={`${script.slug}-${script.name}`}
 | 
			
		||||
                      onSelect={() => {
 | 
			
		||||
                        setOpen(false);
 | 
			
		||||
                        router.push(`/scripts?id=${script.slug}`);
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      <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>
 | 
			
		||||
              );
 | 
			
		||||
            });
 | 
			
		||||
          })()}
 | 
			
		||||
          {links.map(category => (
 | 
			
		||||
            <CommandGroup key={`category:${category.name}`} heading={category.name}>
 | 
			
		||||
              {category.scripts.map(script => (
 | 
			
		||||
                <CommandItem
 | 
			
		||||
                  key={`script:${script.slug}`}
 | 
			
		||||
                  value={`${script.slug}-${script.name}`}
 | 
			
		||||
                  onSelect={() => {
 | 
			
		||||
                    setOpen(false);
 | 
			
		||||
                    router.push(`/scripts?id=${script.slug}`);
 | 
			
		||||
                  }}
 | 
			
		||||
                >
 | 
			
		||||
                  <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>
 | 
			
		||||
      </CommandDialog>
 | 
			
		||||
    </>
 | 
			
		||||
@@ -1,7 +1,8 @@
 | 
			
		||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
 | 
			
		||||
import { Plus } from "lucide-react";
 | 
			
		||||
import { FAQ_Items } from "../config/faqConfig";
 | 
			
		||||
 | 
			
		||||
import { Accordion, AccordionContent, AccordionItem } from "./ui/accordion";
 | 
			
		||||
import { FAQ_Items } from "../config/faq-config";
 | 
			
		||||
 | 
			
		||||
export default function FAQ() {
 | 
			
		||||
  return (
 | 
			
		||||
@@ -1,16 +1,19 @@
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import { FileJson, Server } from "lucide-react";
 | 
			
		||||
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 { buttonVariants } from "./ui/button";
 | 
			
		||||
 | 
			
		||||
export default function Footer() {
 | 
			
		||||
  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="mx-6 w-full flex justify-between text-xs sm:text-sm text-muted-foreground">
 | 
			
		||||
        <div className="flex items-center">
 | 
			
		||||
          <p>
 | 
			
		||||
            Website built by the community. The source code is available on{" "}
 | 
			
		||||
            Website built by the community. The source code is available on
 | 
			
		||||
            {" "}
 | 
			
		||||
            <Link
 | 
			
		||||
              href={`https://github.com/community-scripts/${basePath}/tree/main/frontend`}
 | 
			
		||||
              target="_blank"
 | 
			
		||||
@@ -28,13 +31,17 @@ export default function Footer() {
 | 
			
		||||
            href="/json-editor"
 | 
			
		||||
            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
 | 
			
		||||
            href="/data"
 | 
			
		||||
            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>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -2,14 +2,15 @@
 | 
			
		||||
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
interface ModalProps {
 | 
			
		||||
type ModalProps = {
 | 
			
		||||
  isOpen: boolean;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
 | 
			
		||||
  if (!isOpen) return null;
 | 
			
		||||
  if (!isOpen)
 | 
			
		||||
    return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
 | 
			
		||||
							
								
								
									
										86
									
								
								frontend/src/components/navbar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								frontend/src/components/navbar.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import { ClipboardIcon } from "lucide-react";
 | 
			
		||||
import handleCopy from "./handleCopy";
 | 
			
		||||
 | 
			
		||||
import handleCopy from "./handle-copy";
 | 
			
		||||
 | 
			
		||||
export default function TextCopyBlock(description: string) {
 | 
			
		||||
  const pattern = /`([^`]*)`/g;
 | 
			
		||||
@@ -19,7 +20,8 @@ export default function TextCopyBlock(description: string) {
 | 
			
		||||
          />
 | 
			
		||||
        </span>
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
    }
 | 
			
		||||
    else {
 | 
			
		||||
      return part;
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
@@ -1,7 +1,8 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import type { ThemeProviderProps } from "next-themes";
 | 
			
		||||
 | 
			
		||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
 | 
			
		||||
import { type ThemeProviderProps } from "next-themes/dist/types";
 | 
			
		||||
 | 
			
		||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
 | 
			
		||||
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,9 @@
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import { cva, type VariantProps } from "class-variance-authority"
 | 
			
		||||
import 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(
 | 
			
		||||
  "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: {
 | 
			
		||||
      variant: "default",
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
)
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const Alert = React.forwardRef<
 | 
			
		||||
  HTMLDivElement,
 | 
			
		||||
@@ -29,8 +31,8 @@ const Alert = React.forwardRef<
 | 
			
		||||
    className={cn(alertVariants({ variant }), className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
Alert.displayName = "Alert"
 | 
			
		||||
));
 | 
			
		||||
Alert.displayName = "Alert";
 | 
			
		||||
 | 
			
		||||
const AlertTitle = React.forwardRef<
 | 
			
		||||
  HTMLParagraphElement,
 | 
			
		||||
@@ -41,8 +43,8 @@ const AlertTitle = React.forwardRef<
 | 
			
		||||
    className={cn("mb-1 font-medium leading-none tracking-tight", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
AlertTitle.displayName = "AlertTitle"
 | 
			
		||||
));
 | 
			
		||||
AlertTitle.displayName = "AlertTitle";
 | 
			
		||||
 | 
			
		||||
const AlertDescription = React.forwardRef<
 | 
			
		||||
  HTMLParagraphElement,
 | 
			
		||||
@@ -53,7 +55,7 @@ const AlertDescription = React.forwardRef<
 | 
			
		||||
    className={cn("text-sm [&_p]:leading-relaxed", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
AlertDescription.displayName = "AlertDescription"
 | 
			
		||||
));
 | 
			
		||||
AlertDescription.displayName = "AlertDescription";
 | 
			
		||||
 | 
			
		||||
export { Alert, AlertTitle, AlertDescription }
 | 
			
		||||
export { Alert, AlertDescription, AlertTitle };
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { ReactNode } from "react";
 | 
			
		||||
import type { ReactNode } from "react";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
@@ -17,7 +17,7 @@ export default function AnimatedGradientText({
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      <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}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 { cn } from "@/lib/utils";
 | 
			
		||||
@@ -26,9 +28,7 @@ const badgeVariants = cva(
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export interface BadgeProps
 | 
			
		||||
  extends React.HTMLAttributes<HTMLDivElement>,
 | 
			
		||||
    VariantProps<typeof badgeVariants> {}
 | 
			
		||||
export type BadgeProps = {} & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
 | 
			
		||||
 | 
			
		||||
function Badge({ className, variant, ...props }: BadgeProps) {
 | 
			
		||||
  return (
 | 
			
		||||
 
 | 
			
		||||
@@ -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 { cva, type VariantProps } from "class-variance-authority";
 | 
			
		||||
import { cva } from "class-variance-authority";
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
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",
 | 
			
		||||
  {
 | 
			
		||||
@@ -47,21 +50,19 @@ const buttonVariants = cva(
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
interface IconProps {
 | 
			
		||||
type IconProps = {
 | 
			
		||||
  Icon: React.ElementType;
 | 
			
		||||
  iconPlacement: "left" | "right";
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface IconRefProps {
 | 
			
		||||
type IconRefProps = {
 | 
			
		||||
  Icon?: never;
 | 
			
		||||
  iconPlacement?: undefined;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface ButtonProps
 | 
			
		||||
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
 | 
			
		||||
    VariantProps<typeof buttonVariants> {
 | 
			
		||||
export type ButtonProps = {
 | 
			
		||||
  asChild?: boolean;
 | 
			
		||||
}
 | 
			
		||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
 | 
			
		||||
 | 
			
		||||
export type ButtonIconProps = IconProps | IconRefProps;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,13 @@
 | 
			
		||||
"use client"
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import { ChevronLeft, ChevronRight } from "lucide-react"
 | 
			
		||||
import { DayPicker } from "react-day-picker"
 | 
			
		||||
import { ChevronLeft, ChevronRight } from "lucide-react";
 | 
			
		||||
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({
 | 
			
		||||
  className,
 | 
			
		||||
@@ -27,7 +27,7 @@ function Calendar({
 | 
			
		||||
        nav: "space-x-1 flex items-center",
 | 
			
		||||
        nav_button: cn(
 | 
			
		||||
          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_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",
 | 
			
		||||
        day: cn(
 | 
			
		||||
          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_selected:
 | 
			
		||||
@@ -54,13 +54,17 @@ function Calendar({
 | 
			
		||||
        ...classNames,
 | 
			
		||||
      }}
 | 
			
		||||
      components={{
 | 
			
		||||
        IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
 | 
			
		||||
        IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
 | 
			
		||||
        Chevron: ({ ...props }) => {
 | 
			
		||||
          if (props.orientation === "left") {
 | 
			
		||||
            return <ChevronLeft className="h-4 w-4" />;
 | 
			
		||||
          }
 | 
			
		||||
          return <ChevronRight className="h-4 w-4" />;
 | 
			
		||||
        },
 | 
			
		||||
      }}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
Calendar.displayName = "Calendar"
 | 
			
		||||
Calendar.displayName = "Calendar";
 | 
			
		||||
 | 
			
		||||
export { Calendar }
 | 
			
		||||
export { Calendar };
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,10 @@
 | 
			
		||||
"use client";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
import { Card } from "./card";
 | 
			
		||||
 | 
			
		||||
export default function CodeCopyButton({
 | 
			
		||||
@@ -26,7 +28,7 @@ export default function CodeCopyButton({
 | 
			
		||||
 | 
			
		||||
    setHasCopied(true);
 | 
			
		||||
 | 
			
		||||
    let warning = localStorage.getItem("warning");
 | 
			
		||||
    const warning = localStorage.getItem("warning");
 | 
			
		||||
 | 
			
		||||
    if (warning === null) {
 | 
			
		||||
      localStorage.setItem("warning", "1");
 | 
			
		||||
@@ -50,11 +52,13 @@ export default function CodeCopyButton({
 | 
			
		||||
          className={cn("bg-muted px-3 py-4")}
 | 
			
		||||
          title="Copy"
 | 
			
		||||
        >
 | 
			
		||||
          {hasCopied ? (
 | 
			
		||||
            <CheckIcon className="h-4 w-4" />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <ClipboardIcon className="h-4 w-4" />
 | 
			
		||||
          )}
 | 
			
		||||
          {hasCopied
 | 
			
		||||
            ? (
 | 
			
		||||
                <CheckIcon className="h-4 w-4" />
 | 
			
		||||
              )
 | 
			
		||||
            : (
 | 
			
		||||
                <ClipboardIcon className="h-4 w-4" />
 | 
			
		||||
              )}
 | 
			
		||||
        </button>
 | 
			
		||||
      </Card>
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,18 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { basePath } from "@/config/siteConfig";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { cva, type VariantProps } from "class-variance-authority";
 | 
			
		||||
import type { VariantProps } from "class-variance-authority";
 | 
			
		||||
 | 
			
		||||
import { cva } from "class-variance-authority";
 | 
			
		||||
import { Clipboard, Copy } from "lucide-react";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
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 { Button } from "./button";
 | 
			
		||||
 | 
			
		||||
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",
 | 
			
		||||
@@ -40,23 +44,24 @@ const buttonVariants = cva(
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const handleCopy = (type: string, value: string) => {
 | 
			
		||||
function handleCopy(type: string, value: string) {
 | 
			
		||||
  navigator.clipboard.writeText(value);
 | 
			
		||||
 | 
			
		||||
  let amountOfScriptsCopied = localStorage.getItem("amountOfScriptsCopied");
 | 
			
		||||
 | 
			
		||||
  if (amountOfScriptsCopied === null) {
 | 
			
		||||
    localStorage.setItem("amountOfScriptsCopied", "1");
 | 
			
		||||
  } else {
 | 
			
		||||
    amountOfScriptsCopied = (parseInt(amountOfScriptsCopied) + 1).toString();
 | 
			
		||||
  }
 | 
			
		||||
  else {
 | 
			
		||||
    amountOfScriptsCopied = (Number.parseInt(amountOfScriptsCopied) + 1).toString();
 | 
			
		||||
    localStorage.setItem("amountOfScriptsCopied", amountOfScriptsCopied);
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      parseInt(amountOfScriptsCopied) === 3 ||
 | 
			
		||||
      parseInt(amountOfScriptsCopied) === 10 ||
 | 
			
		||||
      parseInt(amountOfScriptsCopied) === 25 ||
 | 
			
		||||
      parseInt(amountOfScriptsCopied) === 50 ||
 | 
			
		||||
      parseInt(amountOfScriptsCopied) === 100
 | 
			
		||||
      Number.parseInt(amountOfScriptsCopied) === 3
 | 
			
		||||
      || Number.parseInt(amountOfScriptsCopied) === 10
 | 
			
		||||
      || Number.parseInt(amountOfScriptsCopied) === 25
 | 
			
		||||
      || Number.parseInt(amountOfScriptsCopied) === 50
 | 
			
		||||
      || Number.parseInt(amountOfScriptsCopied) === 100
 | 
			
		||||
    ) {
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        toast.info(
 | 
			
		||||
@@ -86,17 +91,20 @@ const handleCopy = (type: string, value: string) => {
 | 
			
		||||
  toast.success(
 | 
			
		||||
    <div className="flex items-center gap-2">
 | 
			
		||||
      <Clipboard className="h-4 w-4" />
 | 
			
		||||
      <span>Copied {type} to clipboard</span>
 | 
			
		||||
      <span>
 | 
			
		||||
        Copied
 | 
			
		||||
        {type}
 | 
			
		||||
        {" "}
 | 
			
		||||
        to clipboard
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>,
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface CodeBlockProps
 | 
			
		||||
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
 | 
			
		||||
    VariantProps<typeof buttonVariants> {
 | 
			
		||||
export type CodeBlockProps = {
 | 
			
		||||
  asChild?: boolean;
 | 
			
		||||
  code: string;
 | 
			
		||||
}
 | 
			
		||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
 | 
			
		||||
 | 
			
		||||
const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
 | 
			
		||||
  ({ className, variant, size, asChild = false, code }, ref) => {
 | 
			
		||||
@@ -121,7 +129,10 @@ const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          <p className="flex items-center gap-2">
 | 
			
		||||
            {code} <Separator orientation="vertical" />{" "}
 | 
			
		||||
            {code}
 | 
			
		||||
            {" "}
 | 
			
		||||
            <Separator orientation="vertical" />
 | 
			
		||||
            {" "}
 | 
			
		||||
            <Copy
 | 
			
		||||
              className="cursor-pointer"
 | 
			
		||||
              size={16}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
"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 { Search } from "lucide-react";
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
@@ -23,9 +24,9 @@ const Command = React.forwardRef<
 | 
			
		||||
));
 | 
			
		||||
Command.displayName = CommandPrimitive.displayName;
 | 
			
		||||
 | 
			
		||||
interface CommandDialogProps extends DialogProps {}
 | 
			
		||||
type CommandDialogProps = {} & DialogProps;
 | 
			
		||||
 | 
			
		||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
 | 
			
		||||
function CommandDialog({ children, ...props }: CommandDialogProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog {...props}>
 | 
			
		||||
      <DialogContent className="overflow-hidden p-0 shadow-lg">
 | 
			
		||||
@@ -35,7 +36,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const CommandInput = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof CommandPrimitive.Input>,
 | 
			
		||||
@@ -126,10 +127,10 @@ const CommandItem = React.forwardRef<
 | 
			
		||||
 | 
			
		||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
 | 
			
		||||
 | 
			
		||||
const CommandShortcut = ({
 | 
			
		||||
function CommandShortcut({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
 | 
			
		||||
}: React.HTMLAttributes<HTMLSpanElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <span
 | 
			
		||||
      className={cn(
 | 
			
		||||
@@ -139,7 +140,7 @@ const CommandShortcut = ({
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
CommandShortcut.displayName = "CommandShortcut";
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,9 @@
 | 
			
		||||
"use client";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
import { Card } from "./card";
 | 
			
		||||
 | 
			
		||||
export default function CodeCopyButton({
 | 
			
		||||
@@ -26,7 +27,6 @@ export default function CodeCopyButton({
 | 
			
		||||
 | 
			
		||||
    setHasCopied(true);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    // toast.success(`copied ${type} to clipboard`, {
 | 
			
		||||
    //   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")}
 | 
			
		||||
          onClick={() => handleCopy("install command", children)}
 | 
			
		||||
        >
 | 
			
		||||
          {hasCopied ? (
 | 
			
		||||
            <CheckIcon className="h-4 w-4" />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <ClipboardIcon className="h-4 w-4" />
 | 
			
		||||
          )}
 | 
			
		||||
          {hasCopied
 | 
			
		||||
            ? (
 | 
			
		||||
                <CheckIcon className="h-4 w-4" />
 | 
			
		||||
              )
 | 
			
		||||
            : (
 | 
			
		||||
                <ClipboardIcon className="h-4 w-4" />
 | 
			
		||||
              )}
 | 
			
		||||
          <span className="sr-only">Copy</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </Card>
 | 
			
		||||
 
 | 
			
		||||
@@ -53,32 +53,36 @@ const DialogContent = React.forwardRef<
 | 
			
		||||
));
 | 
			
		||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
 | 
			
		||||
 | 
			
		||||
const DialogHeader = ({
 | 
			
		||||
function DialogHeader({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.HTMLAttributes<HTMLDivElement>) => (
 | 
			
		||||
  <div
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "flex flex-col space-y-1.5 text-center sm:text-left",
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
);
 | 
			
		||||
}: React.HTMLAttributes<HTMLDivElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex flex-col space-y-1.5 text-center sm:text-left",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
DialogHeader.displayName = "DialogHeader";
 | 
			
		||||
 | 
			
		||||
const DialogFooter = ({
 | 
			
		||||
function DialogFooter({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.HTMLAttributes<HTMLDivElement>) => (
 | 
			
		||||
  <div
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
);
 | 
			
		||||
}: React.HTMLAttributes<HTMLDivElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
DialogFooter.displayName = "DialogFooter";
 | 
			
		||||
 | 
			
		||||
const DialogTitle = React.forwardRef<
 | 
			
		||||
 
 | 
			
		||||
@@ -37,8 +37,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
 | 
			
		||||
    <ChevronRight className="ml-auto h-4 w-4" />
 | 
			
		||||
  </DropdownMenuPrimitive.SubTrigger>
 | 
			
		||||
));
 | 
			
		||||
DropdownMenuSubTrigger.displayName =
 | 
			
		||||
  DropdownMenuPrimitive.SubTrigger.displayName;
 | 
			
		||||
DropdownMenuSubTrigger.displayName
 | 
			
		||||
  = DropdownMenuPrimitive.SubTrigger.displayName;
 | 
			
		||||
 | 
			
		||||
const DropdownMenuSubContent = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
 | 
			
		||||
@@ -53,8 +53,8 @@ const DropdownMenuSubContent = React.forwardRef<
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
));
 | 
			
		||||
DropdownMenuSubContent.displayName =
 | 
			
		||||
  DropdownMenuPrimitive.SubContent.displayName;
 | 
			
		||||
DropdownMenuSubContent.displayName
 | 
			
		||||
  = DropdownMenuPrimitive.SubContent.displayName;
 | 
			
		||||
 | 
			
		||||
const DropdownMenuContent = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
 | 
			
		||||
@@ -113,8 +113,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
 | 
			
		||||
    {children}
 | 
			
		||||
  </DropdownMenuPrimitive.CheckboxItem>
 | 
			
		||||
));
 | 
			
		||||
DropdownMenuCheckboxItem.displayName =
 | 
			
		||||
  DropdownMenuPrimitive.CheckboxItem.displayName;
 | 
			
		||||
DropdownMenuCheckboxItem.displayName
 | 
			
		||||
  = DropdownMenuPrimitive.CheckboxItem.displayName;
 | 
			
		||||
 | 
			
		||||
const DropdownMenuRadioItem = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
 | 
			
		||||
@@ -168,17 +168,17 @@ const DropdownMenuSeparator = React.forwardRef<
 | 
			
		||||
));
 | 
			
		||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
 | 
			
		||||
 | 
			
		||||
const DropdownMenuShortcut = ({
 | 
			
		||||
function DropdownMenuShortcut({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
 | 
			
		||||
}: React.HTMLAttributes<HTMLSpanElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <span
 | 
			
		||||
      className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,8 +2,7 @@ import * as React from "react";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
export interface InputProps
 | 
			
		||||
  extends React.InputHTMLAttributes<HTMLInputElement> {}
 | 
			
		||||
export type InputProps = {} & React.InputHTMLAttributes<HTMLInputElement>;
 | 
			
		||||
 | 
			
		||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
 | 
			
		||||
  ({ className, type, ...props }, ref) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,26 +1,28 @@
 | 
			
		||||
"use client"
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as LabelPrimitive from "@radix-ui/react-label"
 | 
			
		||||
import { cva, type VariantProps } from "class-variance-authority"
 | 
			
		||||
import 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(
 | 
			
		||||
  "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<
 | 
			
		||||
  React.ElementRef<typeof LabelPrimitive.Root>,
 | 
			
		||||
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
 | 
			
		||||
    VariantProps<typeof labelVariants>
 | 
			
		||||
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
 | 
			
		||||
  & VariantProps<typeof labelVariants>
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <LabelPrimitive.Root
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn(labelVariants(), className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
Label.displayName = LabelPrimitive.Root.displayName
 | 
			
		||||
));
 | 
			
		||||
Label.displayName = LabelPrimitive.Root.displayName;
 | 
			
		||||
 | 
			
		||||
export { Label }
 | 
			
		||||
export { Label };
 | 
			
		||||
 
 | 
			
		||||
@@ -53,7 +53,8 @@ const NavigationMenuTrigger = React.forwardRef<
 | 
			
		||||
    className={cn(navigationMenuTriggerStyle(), "group", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  >
 | 
			
		||||
    {children}{" "}
 | 
			
		||||
    {children}
 | 
			
		||||
    {" "}
 | 
			
		||||
    <ChevronDown
 | 
			
		||||
      className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
 | 
			
		||||
      aria-hidden="true"
 | 
			
		||||
@@ -94,8 +95,8 @@ const NavigationMenuViewport = React.forwardRef<
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
));
 | 
			
		||||
NavigationMenuViewport.displayName =
 | 
			
		||||
  NavigationMenuPrimitive.Viewport.displayName;
 | 
			
		||||
NavigationMenuViewport.displayName
 | 
			
		||||
  = NavigationMenuPrimitive.Viewport.displayName;
 | 
			
		||||
 | 
			
		||||
const NavigationMenuIndicator = React.forwardRef<
 | 
			
		||||
  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" />
 | 
			
		||||
  </NavigationMenuPrimitive.Indicator>
 | 
			
		||||
));
 | 
			
		||||
NavigationMenuIndicator.displayName =
 | 
			
		||||
  NavigationMenuPrimitive.Indicator.displayName;
 | 
			
		||||
NavigationMenuIndicator.displayName
 | 
			
		||||
  = NavigationMenuPrimitive.Indicator.displayName;
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  NavigationMenu,
 | 
			
		||||
 
 | 
			
		||||
@@ -30,10 +30,10 @@ export default function NumberTicker({
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    isInView &&
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        motionValue.set(direction === "down" ? 0 : value);
 | 
			
		||||
      }, delay * 1000);
 | 
			
		||||
    isInView
 | 
			
		||||
    && setTimeout(() => {
 | 
			
		||||
      motionValue.set(direction === "down" ? 0 : value);
 | 
			
		||||
    }, delay * 1000);
 | 
			
		||||
  }, [motionValue, isInView, delay, value, direction]);
 | 
			
		||||
 | 
			
		||||
  useEffect(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,13 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
 | 
			
		||||
interface MousePosition {
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
type MousePosition = {
 | 
			
		||||
  x: number;
 | 
			
		||||
  y: number;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function MousePosition(): MousePosition {
 | 
			
		||||
  const [mousePosition, setMousePosition] = useState<MousePosition>({
 | 
			
		||||
@@ -29,7 +30,7 @@ function MousePosition(): MousePosition {
 | 
			
		||||
  return mousePosition;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ParticlesProps {
 | 
			
		||||
type ParticlesProps = {
 | 
			
		||||
  className?: string;
 | 
			
		||||
  quantity?: number;
 | 
			
		||||
  staticity?: number;
 | 
			
		||||
@@ -39,18 +40,18 @@ interface ParticlesProps {
 | 
			
		||||
  color?: string;
 | 
			
		||||
  vx?: number;
 | 
			
		||||
  vy?: number;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
function hexToRgb(hex: string): number[] {
 | 
			
		||||
  hex = hex.replace("#", "");
 | 
			
		||||
 | 
			
		||||
  if (hex.length === 3) {
 | 
			
		||||
    hex = hex
 | 
			
		||||
      .split("")
 | 
			
		||||
      .map((char) => char + char)
 | 
			
		||||
      .map(char => char + char)
 | 
			
		||||
      .join("");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const hexInt = parseInt(hex, 16);
 | 
			
		||||
  const hexInt = Number.parseInt(hex, 16);
 | 
			
		||||
  const red = (hexInt >> 16) & 255;
 | 
			
		||||
  const green = (hexInt >> 8) & 255;
 | 
			
		||||
  const blue = hexInt & 255;
 | 
			
		||||
@@ -150,7 +151,7 @@ const Particles: React.FC<ParticlesProps> = ({
 | 
			
		||||
    const translateY = 0;
 | 
			
		||||
    const pSize = Math.floor(Math.random() * 2) + size;
 | 
			
		||||
    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 dy = (Math.random() - 0.5) * 0.1;
 | 
			
		||||
    const magnetism = 0.1 + Math.random() * 4;
 | 
			
		||||
@@ -213,8 +214,8 @@ const Particles: React.FC<ParticlesProps> = ({
 | 
			
		||||
    start2: number,
 | 
			
		||||
    end2: number,
 | 
			
		||||
  ): number => {
 | 
			
		||||
    const remapped =
 | 
			
		||||
      ((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
 | 
			
		||||
    const remapped
 | 
			
		||||
      = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
 | 
			
		||||
    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
 | 
			
		||||
      ];
 | 
			
		||||
      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),
 | 
			
		||||
      );
 | 
			
		||||
      if (remapClosestEdge > 1) {
 | 
			
		||||
@@ -237,26 +238,27 @@ const Particles: React.FC<ParticlesProps> = ({
 | 
			
		||||
        if (circle.alpha > circle.targetAlpha) {
 | 
			
		||||
          circle.alpha = circle.targetAlpha;
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
      }
 | 
			
		||||
      else {
 | 
			
		||||
        circle.alpha = circle.targetAlpha * remapClosestEdge;
 | 
			
		||||
      }
 | 
			
		||||
      circle.x += circle.dx + vx;
 | 
			
		||||
      circle.y += circle.dy + vy;
 | 
			
		||||
      circle.translateX +=
 | 
			
		||||
        (mouse.current.x / (staticity / circle.magnetism) - circle.translateX) /
 | 
			
		||||
        ease;
 | 
			
		||||
      circle.translateY +=
 | 
			
		||||
        (mouse.current.y / (staticity / circle.magnetism) - circle.translateY) /
 | 
			
		||||
        ease;
 | 
			
		||||
      circle.translateX
 | 
			
		||||
        += (mouse.current.x / (staticity / circle.magnetism) - circle.translateX)
 | 
			
		||||
          / ease;
 | 
			
		||||
      circle.translateY
 | 
			
		||||
        += (mouse.current.y / (staticity / circle.magnetism) - circle.translateY)
 | 
			
		||||
          / ease;
 | 
			
		||||
 | 
			
		||||
      drawCircle(circle, true);
 | 
			
		||||
 | 
			
		||||
      // circle gets out of the canvas
 | 
			
		||||
      if (
 | 
			
		||||
        circle.x < -circle.size ||
 | 
			
		||||
        circle.x > canvasSize.current.w + circle.size ||
 | 
			
		||||
        circle.y < -circle.size ||
 | 
			
		||||
        circle.y > canvasSize.current.h + circle.size
 | 
			
		||||
        circle.x < -circle.size
 | 
			
		||||
        || circle.x > canvasSize.current.w + circle.size
 | 
			
		||||
        || circle.y < -circle.size
 | 
			
		||||
        || circle.y > canvasSize.current.h + circle.size
 | 
			
		||||
      ) {
 | 
			
		||||
        // remove the circle from the array
 | 
			
		||||
        circles.current.splice(i, 1);
 | 
			
		||||
 
 | 
			
		||||
@@ -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<
 | 
			
		||||
  React.ElementRef<typeof PopoverPrimitive.Content>,
 | 
			
		||||
@@ -20,12 +20,12 @@ const PopoverContent = React.forwardRef<
 | 
			
		||||
      sideOffset={sideOffset}
 | 
			
		||||
      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",
 | 
			
		||||
        className
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  </PopoverPrimitive.Portal>
 | 
			
		||||
))
 | 
			
		||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
 | 
			
		||||
));
 | 
			
		||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
 | 
			
		||||
 | 
			
		||||
export { Popover, PopoverTrigger, PopoverContent }
 | 
			
		||||
export { Popover, PopoverContent, PopoverTrigger };
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,16 @@
 | 
			
		||||
"use client"
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as SelectPrimitive from "@radix-ui/react-select"
 | 
			
		||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
 | 
			
		||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
 | 
			
		||||
import * as SelectPrimitive from "@radix-ui/react-select";
 | 
			
		||||
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<
 | 
			
		||||
  React.ElementRef<typeof SelectPrimitive.Trigger>,
 | 
			
		||||
@@ -20,7 +20,7 @@ const SelectTrigger = React.forwardRef<
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    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",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  >
 | 
			
		||||
@@ -29,8 +29,8 @@ const SelectTrigger = React.forwardRef<
 | 
			
		||||
      <ChevronDown className="h-4 w-4 opacity-50" />
 | 
			
		||||
    </SelectPrimitive.Icon>
 | 
			
		||||
  </SelectPrimitive.Trigger>
 | 
			
		||||
))
 | 
			
		||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
 | 
			
		||||
));
 | 
			
		||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
 | 
			
		||||
 | 
			
		||||
const SelectScrollUpButton = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
 | 
			
		||||
@@ -40,14 +40,14 @@ const SelectScrollUpButton = React.forwardRef<
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "flex cursor-default items-center justify-center py-1",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  >
 | 
			
		||||
    <ChevronUp className="h-4 w-4" />
 | 
			
		||||
  </SelectPrimitive.ScrollUpButton>
 | 
			
		||||
))
 | 
			
		||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
 | 
			
		||||
));
 | 
			
		||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
 | 
			
		||||
 | 
			
		||||
const SelectScrollDownButton = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
 | 
			
		||||
@@ -57,15 +57,15 @@ const SelectScrollDownButton = React.forwardRef<
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "flex cursor-default items-center justify-center py-1",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  >
 | 
			
		||||
    <ChevronDown className="h-4 w-4" />
 | 
			
		||||
  </SelectPrimitive.ScrollDownButton>
 | 
			
		||||
))
 | 
			
		||||
SelectScrollDownButton.displayName =
 | 
			
		||||
  SelectPrimitive.ScrollDownButton.displayName
 | 
			
		||||
));
 | 
			
		||||
SelectScrollDownButton.displayName
 | 
			
		||||
  = SelectPrimitive.ScrollDownButton.displayName;
 | 
			
		||||
 | 
			
		||||
const SelectContent = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof SelectPrimitive.Content>,
 | 
			
		||||
@@ -76,9 +76,9 @@ const SelectContent = React.forwardRef<
 | 
			
		||||
      ref={ref}
 | 
			
		||||
      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",
 | 
			
		||||
        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",
 | 
			
		||||
        className
 | 
			
		||||
        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",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      position={position}
 | 
			
		||||
      {...props}
 | 
			
		||||
@@ -87,8 +87,8 @@ const SelectContent = React.forwardRef<
 | 
			
		||||
      <SelectPrimitive.Viewport
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "p-1",
 | 
			
		||||
          position === "popper" &&
 | 
			
		||||
            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
 | 
			
		||||
          position === "popper"
 | 
			
		||||
          && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
 | 
			
		||||
        )}
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
@@ -96,8 +96,8 @@ const SelectContent = React.forwardRef<
 | 
			
		||||
      <SelectScrollDownButton />
 | 
			
		||||
    </SelectPrimitive.Content>
 | 
			
		||||
  </SelectPrimitive.Portal>
 | 
			
		||||
))
 | 
			
		||||
SelectContent.displayName = SelectPrimitive.Content.displayName
 | 
			
		||||
));
 | 
			
		||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
 | 
			
		||||
 | 
			
		||||
const SelectLabel = React.forwardRef<
 | 
			
		||||
  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)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
 | 
			
		||||
));
 | 
			
		||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
 | 
			
		||||
 | 
			
		||||
const SelectItem = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof SelectPrimitive.Item>,
 | 
			
		||||
@@ -119,7 +119,7 @@ const SelectItem = React.forwardRef<
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    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",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  >
 | 
			
		||||
@@ -131,8 +131,8 @@ const SelectItem = React.forwardRef<
 | 
			
		||||
 | 
			
		||||
    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
 | 
			
		||||
  </SelectPrimitive.Item>
 | 
			
		||||
))
 | 
			
		||||
SelectItem.displayName = SelectPrimitive.Item.displayName
 | 
			
		||||
));
 | 
			
		||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
 | 
			
		||||
 | 
			
		||||
const SelectSeparator = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof SelectPrimitive.Separator>,
 | 
			
		||||
@@ -143,18 +143,18 @@ const SelectSeparator = React.forwardRef<
 | 
			
		||||
    className={cn("-mx-1 my-1 h-px bg-muted", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
 | 
			
		||||
));
 | 
			
		||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Select,
 | 
			
		||||
  SelectGroup,
 | 
			
		||||
  SelectValue,
 | 
			
		||||
  SelectTrigger,
 | 
			
		||||
  SelectContent,
 | 
			
		||||
  SelectLabel,
 | 
			
		||||
  SelectGroup,
 | 
			
		||||
  SelectItem,
 | 
			
		||||
  SelectSeparator,
 | 
			
		||||
  SelectScrollUpButton,
 | 
			
		||||
  SelectLabel,
 | 
			
		||||
  SelectScrollDownButton,
 | 
			
		||||
}
 | 
			
		||||
  SelectScrollUpButton,
 | 
			
		||||
  SelectSeparator,
 | 
			
		||||
  SelectTrigger,
 | 
			
		||||
  SelectValue,
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,9 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import type { VariantProps } from "class-variance-authority";
 | 
			
		||||
 | 
			
		||||
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 * as React from "react";
 | 
			
		||||
 | 
			
		||||
@@ -49,9 +51,7 @@ const sheetVariants = cva(
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
interface SheetContentProps
 | 
			
		||||
  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
 | 
			
		||||
    VariantProps<typeof sheetVariants> {}
 | 
			
		||||
type SheetContentProps = {} & React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & VariantProps<typeof sheetVariants>;
 | 
			
		||||
 | 
			
		||||
const SheetContent = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof SheetPrimitive.Content>,
 | 
			
		||||
@@ -74,32 +74,36 @@ const SheetContent = React.forwardRef<
 | 
			
		||||
));
 | 
			
		||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
 | 
			
		||||
 | 
			
		||||
const SheetHeader = ({
 | 
			
		||||
function SheetHeader({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.HTMLAttributes<HTMLDivElement>) => (
 | 
			
		||||
  <div
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "flex flex-col space-y-2 text-center sm:text-left",
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
);
 | 
			
		||||
}: React.HTMLAttributes<HTMLDivElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex flex-col space-y-2 text-center sm:text-left",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
SheetHeader.displayName = "SheetHeader";
 | 
			
		||||
 | 
			
		||||
const SheetFooter = ({
 | 
			
		||||
function SheetFooter({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.HTMLAttributes<HTMLDivElement>) => (
 | 
			
		||||
  <div
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
);
 | 
			
		||||
}: React.HTMLAttributes<HTMLDivElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
SheetFooter.displayName = "SheetFooter";
 | 
			
		||||
 | 
			
		||||
const SheetTitle = React.forwardRef<
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { useTheme } from "next-themes";
 | 
			
		||||
import { Toaster as Sonner } from "sonner";
 | 
			
		||||
import { useTheme } from "next-themes";
 | 
			
		||||
 | 
			
		||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
 | 
			
		||||
 | 
			
		||||
const Toaster = ({ ...props }: ToasterProps) => {
 | 
			
		||||
function Toaster({ ...props }: ToasterProps) {
 | 
			
		||||
  const { theme = "system" } = useTheme();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@@ -26,6 +26,6 @@ const Toaster = ({ ...props }: ToasterProps) => {
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Toaster };
 | 
			
		||||
 
 | 
			
		||||
@@ -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 { 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 { buttonVariants } from "./button";
 | 
			
		||||
 | 
			
		||||
export default function StarOnGithubButton() {
 | 
			
		||||
  const [stars, setStars] = useState(0);
 | 
			
		||||
@@ -23,7 +25,8 @@ export default function StarOnGithubButton() {
 | 
			
		||||
          const data = await res.json();
 | 
			
		||||
          setStars(data.stargazers_count || stars);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
      }
 | 
			
		||||
      catch (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" />
 | 
			
		||||
      <div className="flex items-center">
 | 
			
		||||
        <FaGithub className="size-4" />
 | 
			
		||||
        <span className="ml-1">Star on GitHub</span>{" "}
 | 
			
		||||
        <span className="ml-1">Star on GitHub</span>
 | 
			
		||||
        {" "}
 | 
			
		||||
      </div>
 | 
			
		||||
      <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" />
 | 
			
		||||
 
 | 
			
		||||
@@ -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<
 | 
			
		||||
  React.ElementRef<typeof SwitchPrimitives.Root>,
 | 
			
		||||
@@ -12,18 +12,18 @@ const Switch = React.forwardRef<
 | 
			
		||||
  <SwitchPrimitives.Root
 | 
			
		||||
    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",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  >
 | 
			
		||||
    <SwitchPrimitives.Thumb
 | 
			
		||||
      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>
 | 
			
		||||
))
 | 
			
		||||
Switch.displayName = SwitchPrimitives.Root.displayName
 | 
			
		||||
));
 | 
			
		||||
Switch.displayName = SwitchPrimitives.Root.displayName;
 | 
			
		||||
 | 
			
		||||
export { Switch }
 | 
			
		||||
export { Switch };
 | 
			
		||||
 
 | 
			
		||||
@@ -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<
 | 
			
		||||
  HTMLTableElement,
 | 
			
		||||
@@ -13,16 +13,16 @@ const Table = React.forwardRef<
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
))
 | 
			
		||||
Table.displayName = "Table"
 | 
			
		||||
));
 | 
			
		||||
Table.displayName = "Table";
 | 
			
		||||
 | 
			
		||||
const TableHeader = React.forwardRef<
 | 
			
		||||
  HTMLTableSectionElement,
 | 
			
		||||
  React.HTMLAttributes<HTMLTableSectionElement>
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
 | 
			
		||||
))
 | 
			
		||||
TableHeader.displayName = "TableHeader"
 | 
			
		||||
));
 | 
			
		||||
TableHeader.displayName = "TableHeader";
 | 
			
		||||
 | 
			
		||||
const TableBody = React.forwardRef<
 | 
			
		||||
  HTMLTableSectionElement,
 | 
			
		||||
@@ -33,8 +33,8 @@ const TableBody = React.forwardRef<
 | 
			
		||||
    className={cn("[&_tr:last-child]:border-0", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
TableBody.displayName = "TableBody"
 | 
			
		||||
));
 | 
			
		||||
TableBody.displayName = "TableBody";
 | 
			
		||||
 | 
			
		||||
const TableFooter = React.forwardRef<
 | 
			
		||||
  HTMLTableSectionElement,
 | 
			
		||||
@@ -44,12 +44,12 @@ const TableFooter = React.forwardRef<
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
TableFooter.displayName = "TableFooter"
 | 
			
		||||
));
 | 
			
		||||
TableFooter.displayName = "TableFooter";
 | 
			
		||||
 | 
			
		||||
const TableRow = React.forwardRef<
 | 
			
		||||
  HTMLTableRowElement,
 | 
			
		||||
@@ -59,12 +59,12 @@ const TableRow = React.forwardRef<
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
TableRow.displayName = "TableRow"
 | 
			
		||||
));
 | 
			
		||||
TableRow.displayName = "TableRow";
 | 
			
		||||
 | 
			
		||||
const TableHead = React.forwardRef<
 | 
			
		||||
  HTMLTableCellElement,
 | 
			
		||||
@@ -74,12 +74,12 @@ const TableHead = React.forwardRef<
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    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]",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
TableHead.displayName = "TableHead"
 | 
			
		||||
));
 | 
			
		||||
TableHead.displayName = "TableHead";
 | 
			
		||||
 | 
			
		||||
const TableCell = React.forwardRef<
 | 
			
		||||
  HTMLTableCellElement,
 | 
			
		||||
@@ -89,12 +89,12 @@ const TableCell = React.forwardRef<
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
TableCell.displayName = "TableCell"
 | 
			
		||||
));
 | 
			
		||||
TableCell.displayName = "TableCell";
 | 
			
		||||
 | 
			
		||||
const TableCaption = React.forwardRef<
 | 
			
		||||
  HTMLTableCaptionElement,
 | 
			
		||||
@@ -105,16 +105,16 @@ const TableCaption = React.forwardRef<
 | 
			
		||||
    className={cn("mt-4 text-sm text-muted-foreground", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
TableCaption.displayName = "TableCaption"
 | 
			
		||||
));
 | 
			
		||||
TableCaption.displayName = "TableCaption";
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Table,
 | 
			
		||||
  TableHeader,
 | 
			
		||||
  TableBody,
 | 
			
		||||
  TableCaption,
 | 
			
		||||
  TableCell,
 | 
			
		||||
  TableFooter,
 | 
			
		||||
  TableHead,
 | 
			
		||||
  TableHeader,
 | 
			
		||||
  TableRow,
 | 
			
		||||
  TableCell,
 | 
			
		||||
  TableCaption,
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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<
 | 
			
		||||
  HTMLTextAreaElement,
 | 
			
		||||
@@ -10,13 +10,13 @@ const Textarea = React.forwardRef<
 | 
			
		||||
    <textarea
 | 
			
		||||
      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",
 | 
			
		||||
        className
 | 
			
		||||
        className,
 | 
			
		||||
      )}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
})
 | 
			
		||||
Textarea.displayName = "Textarea"
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
Textarea.displayName = "Textarea";
 | 
			
		||||
 | 
			
		||||
export { Textarea }
 | 
			
		||||
export { Textarea };
 | 
			
		||||
 
 | 
			
		||||
@@ -2,21 +2,24 @@
 | 
			
		||||
 | 
			
		||||
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
 | 
			
		||||
import { useTheme } from "next-themes";
 | 
			
		||||
import { Button } from "./button";
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  Tooltip,
 | 
			
		||||
  TooltipContent,
 | 
			
		||||
  TooltipProvider,
 | 
			
		||||
  TooltipTrigger,
 | 
			
		||||
} from "./tooltip";
 | 
			
		||||
import { Button } from "./button";
 | 
			
		||||
 | 
			
		||||
export function ThemeToggle() {
 | 
			
		||||
  const { setTheme, theme: currentTheme } = useTheme();
 | 
			
		||||
 | 
			
		||||
  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));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -31,8 +34,7 @@ export function ThemeToggle() {
 | 
			
		||||
            className="px-2"
 | 
			
		||||
            aria-label="Toggle theme"
 | 
			
		||||
            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" />
 | 
			
		||||
            <MoonIcon className="hidden size-[1.2rem] text-neutral-800 dark:block dark:text-neutral-200" />
 | 
			
		||||
 
 | 
			
		||||
@@ -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<
 | 
			
		||||
  React.ElementRef<typeof TooltipPrimitive.Content>,
 | 
			
		||||
@@ -20,11 +20,11 @@ const TooltipContent = React.forwardRef<
 | 
			
		||||
    sideOffset={sideOffset}
 | 
			
		||||
    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]",
 | 
			
		||||
      className
 | 
			
		||||
      className,
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
 | 
			
		||||
));
 | 
			
		||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
 | 
			
		||||
 | 
			
		||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
 | 
			
		||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
 | 
			
		||||
 
 | 
			
		||||
@@ -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.",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: 'Why am I getting a "502 Bad Gateway" error?',
 | 
			
		||||
    title: "Why am I getting a \"502 Bad Gateway\" error?",
 | 
			
		||||
    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?",
 | 
			
		||||
							
								
								
									
										72
									
								
								frontend/src/config/site-config.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								frontend/src/config/site-config.tsx
									
									
									
									
									
										Normal 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" },
 | 
			
		||||
    ],
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
@@ -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" },
 | 
			
		||||
    ],
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
@@ -1,9 +1,11 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { fetchVersions } from "@/lib/data";
 | 
			
		||||
import { AppVersion } from "@/lib/types";
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
 | 
			
		||||
import type { AppVersion } from "@/lib/types";
 | 
			
		||||
 | 
			
		||||
import { fetchVersions } from "@/lib/data";
 | 
			
		||||
 | 
			
		||||
export function useVersions() {
 | 
			
		||||
  return useQuery<AppVersion[]>({
 | 
			
		||||
    queryKey: ["versions"],
 | 
			
		||||
@@ -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");
 | 
			
		||||
  if (!response.ok) {
 | 
			
		||||
    throw new Error(`Failed to fetch categories: ${response.statusText}`);
 | 
			
		||||
  }
 | 
			
		||||
  const categories: Category[] = await response.json();
 | 
			
		||||
  return categories;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const fetchVersions = async () => {
 | 
			
		||||
export async function fetchVersions() {
 | 
			
		||||
  const response = await fetch(`api/versions`);
 | 
			
		||||
  if (!response.ok) {
 | 
			
		||||
      throw new Error(`Failed to fetch versions: ${response.statusText}`);
 | 
			
		||||
    throw new Error(`Failed to fetch versions: ${response.statusText}`);
 | 
			
		||||
  }
 | 
			
		||||
  return response.json();
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import { AlertColors } from "@/config/siteConfig";
 | 
			
		||||
import type { AlertColors } from "@/config/site-config";
 | 
			
		||||
 | 
			
		||||
export type Script = {
 | 
			
		||||
  name: string;
 | 
			
		||||
@@ -48,18 +48,18 @@ export type Metadata = {
 | 
			
		||||
  categories: Category[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface Version {
 | 
			
		||||
export type Version = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  slug: string;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface OperatingSystem {
 | 
			
		||||
export type OperatingSystem = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  versions: Version[];
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface AppVersion {
 | 
			
		||||
export type AppVersion = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  version: string;
 | 
			
		||||
  date: Date;
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
import { clsx, type ClassValue } from "clsx";
 | 
			
		||||
import type { ClassValue } from "clsx";
 | 
			
		||||
 | 
			
		||||
import { twMerge } from "tailwind-merge";
 | 
			
		||||
import { clsx } from "clsx";
 | 
			
		||||
 | 
			
		||||
export function cn(...inputs: ClassValue[]) {
 | 
			
		||||
  return twMerge(clsx(inputs));
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@
 | 
			
		||||
@tailwind components;
 | 
			
		||||
@tailwind utilities;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
  :root {
 | 
			
		||||
    --background: 0 0% 100%;
 | 
			
		||||
@@ -92,4 +91,4 @@
 | 
			
		||||
.glass {
 | 
			
		||||
  backdrop-filter: blur(15px) saturate(100%);
 | 
			
		||||
  -webkit-backdrop-filter: blur(15px) saturate(100%);
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,11 @@
 | 
			
		||||
/* eslint-disable ts/no-require-imports */
 | 
			
		||||
//
 | 
			
		||||
import type { Config } from "tailwindcss";
 | 
			
		||||
 | 
			
		||||
const svgToDataUri = require("mini-svg-data-uri");
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
  default: flattenColorPalette,
 | 
			
		||||
} = require("tailwindcss/lib/util/flattenColorPalette");
 | 
			
		||||
const svgToDataUri = require("mini-svg-data-uri");
 | 
			
		||||
 | 
			
		||||
const config = {
 | 
			
		||||
  darkMode: ["class"],
 | 
			
		||||
@@ -73,11 +74,11 @@ const config = {
 | 
			
		||||
          from: { height: "var(--radix-accordion-content-height)" },
 | 
			
		||||
          to: { height: "0" },
 | 
			
		||||
        },
 | 
			
		||||
        shine: {
 | 
			
		||||
        "shine": {
 | 
			
		||||
          from: { backgroundPosition: "200% 0" },
 | 
			
		||||
          to: { backgroundPosition: "-200% 0" },
 | 
			
		||||
        },
 | 
			
		||||
        gradient: {
 | 
			
		||||
        "gradient": {
 | 
			
		||||
          to: {
 | 
			
		||||
            backgroundPosition: "var(--bg-size) 0",
 | 
			
		||||
          },
 | 
			
		||||
@@ -89,11 +90,11 @@ const config = {
 | 
			
		||||
          "50%": {
 | 
			
		||||
            "background-position": "100% 100%",
 | 
			
		||||
          },
 | 
			
		||||
          to: {
 | 
			
		||||
          "to": {
 | 
			
		||||
            "background-position": "0% 0%",
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        moveHorizontal: {
 | 
			
		||||
        "moveHorizontal": {
 | 
			
		||||
          "0%": {
 | 
			
		||||
            transform: "translateX(-50%) translateY(-10%)",
 | 
			
		||||
          },
 | 
			
		||||
@@ -104,7 +105,7 @@ const config = {
 | 
			
		||||
            transform: "translateX(-50%) translateY(-10%)",
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        moveInCircle: {
 | 
			
		||||
        "moveInCircle": {
 | 
			
		||||
          "0%": {
 | 
			
		||||
            transform: "rotate(0deg)",
 | 
			
		||||
          },
 | 
			
		||||
@@ -115,7 +116,7 @@ const config = {
 | 
			
		||||
            transform: "rotate(360deg)",
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        moveVertical: {
 | 
			
		||||
        "moveVertical": {
 | 
			
		||||
          "0%": {
 | 
			
		||||
            transform: "translateY(-50%)",
 | 
			
		||||
          },
 | 
			
		||||
@@ -130,8 +131,8 @@ const config = {
 | 
			
		||||
      animation: {
 | 
			
		||||
        "accordion-down": "accordion-down 0.2s ease-out",
 | 
			
		||||
        "accordion-up": "accordion-up 0.2s ease-out",
 | 
			
		||||
        shine: "shine 8s ease-in-out infinite",
 | 
			
		||||
        gradient: "gradient 8s linear infinite",
 | 
			
		||||
        "shine": "shine 8s ease-in-out infinite",
 | 
			
		||||
        "gradient": "gradient 8s linear infinite",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
@@ -168,8 +169,8 @@ const config = {
 | 
			
		||||
} satisfies Config;
 | 
			
		||||
 | 
			
		||||
function addVariablesForColors({ addBase, theme }: any) {
 | 
			
		||||
  let allColors = flattenColorPalette(theme("colors"));
 | 
			
		||||
  let newVars = Object.fromEntries(
 | 
			
		||||
  const allColors = flattenColorPalette(theme("colors"));
 | 
			
		||||
  const newVars = Object.fromEntries(
 | 
			
		||||
    Object.entries(allColors).map(([key, val]) => [`--${key}`, val]),
 | 
			
		||||
  );
 | 
			
		||||
  addBase({
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										30
									
								
								frontend/tsconfig.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										30
									
								
								frontend/tsconfig.json
									
									
									
										generated
									
									
									
								
							@@ -1,32 +1,32 @@
 | 
			
		||||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "incremental": true,
 | 
			
		||||
    "target": "ES2017",
 | 
			
		||||
    "jsx": "preserve",
 | 
			
		||||
    "lib": [
 | 
			
		||||
      "dom",
 | 
			
		||||
      "dom.iterable",
 | 
			
		||||
      "esnext"
 | 
			
		||||
    ],
 | 
			
		||||
    "allowJs": true,
 | 
			
		||||
    "skipLibCheck": true,
 | 
			
		||||
    "strict": true,
 | 
			
		||||
    "noEmit": true,
 | 
			
		||||
    "esModuleInterop": true,
 | 
			
		||||
    "module": "esnext",
 | 
			
		||||
    "moduleResolution": "bundler",
 | 
			
		||||
    "resolveJsonModule": true,
 | 
			
		||||
    "isolatedModules": true,
 | 
			
		||||
    "jsx": "preserve",
 | 
			
		||||
    "incremental": true,
 | 
			
		||||
    "plugins": [
 | 
			
		||||
      {
 | 
			
		||||
        "name": "next"
 | 
			
		||||
      }
 | 
			
		||||
    ],
 | 
			
		||||
    "paths": {
 | 
			
		||||
      "@/*": [
 | 
			
		||||
        "./src/*"
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
    "target": "ES2017"
 | 
			
		||||
    "resolveJsonModule": true,
 | 
			
		||||
    "allowJs": true,
 | 
			
		||||
    "strict": true,
 | 
			
		||||
    "noEmit": true,
 | 
			
		||||
    "esModuleInterop": true,
 | 
			
		||||
    "isolatedModules": true,
 | 
			
		||||
    "skipLibCheck": true,
 | 
			
		||||
    "plugins": [
 | 
			
		||||
      {
 | 
			
		||||
        "name": "next"
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "include": [
 | 
			
		||||
    "next-env.d.ts",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
import { defineConfig } from 'vitest/config'
 | 
			
		||||
import react from '@vitejs/plugin-react'
 | 
			
		||||
import tsconfigPaths from 'vite-tsconfig-paths'
 | 
			
		||||
 
 | 
			
		||||
import tsconfigPaths from "vite-tsconfig-paths";
 | 
			
		||||
import { defineConfig } from "vitest/config";
 | 
			
		||||
import react from "@vitejs/plugin-react";
 | 
			
		||||
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  plugins: [tsconfigPaths(), react()],
 | 
			
		||||
  test: {
 | 
			
		||||
    environment: "jsdom",
 | 
			
		||||
    setupFiles: ["src/__tests__/setupTests.ts"]
 | 
			
		||||
    setupFiles: ["src/__tests__/setupTests.ts"],
 | 
			
		||||
  },
 | 
			
		||||
})
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user