Compare commits

..

17 Commits

Author SHA1 Message Date
CanbiZ (MickLesk)
6b33afca1e refactor: remove frontend, move JSONs to json/ top-level
- Archive frontend to community-scripts/ProxmoxVE-Frontend-Archive
- Move frontend/public/json/ -> json/
- Update workflow paths in 5 actions:
  - update-versions-github.yml
  - update-json-date.yml
  - push-json-to-pocketbase.yml
  - delete-pocketbase-entry-on-removal.yml
  - autolabeler.yml
- Remove frontend-cicd.yml (no longer needed)
- Clean up .gitignore (remove frontend-specific entries)
2026-03-12 13:52:25 +01:00
community-scripts-pr-app[bot]
12bdbcce5c chore: update github-versions.json (#12811)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 12:11:11 +00:00
community-scripts-pr-app[bot]
51418f0d99 Update CHANGELOG.md (#12809)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 11:21:56 +00:00
CanbiZ (MickLesk)
3601388abe core: add mode=generated for unattended frontend installs (#12807) 2026-03-12 12:21:28 +01:00
community-scripts-pr-app[bot]
4189137f55 Update CHANGELOG.md (#12803)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 08:17:48 +00:00
community-scripts-pr-app[bot]
043401876b Update CHANGELOG.md (#12802)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 08:17:40 +00:00
community-scripts-pr-app[bot]
543b93ced0 Update CHANGELOG.md (#12801)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 08:17:22 +00:00
CanbiZ (MickLesk)
dd3b381813 core: validate storage availability when loading defaults (#12794) 2026-03-12 09:17:18 +01:00
CanbiZ (MickLesk)
667efeab5e SparkyFitness: install pnpm dependencies from workspace root (#12792) 2026-03-12 09:16:59 +01:00
community-scripts-pr-app[bot]
4103efd10b Update CHANGELOG.md (#12800)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 08:11:40 +00:00
CanbiZ (MickLesk)
00be37a151 n8n: add build-essential to update dependencies (#12795) 2026-03-12 09:11:14 +01:00
community-scripts-pr-app[bot]
c9f7453222 Update CHANGELOG.md (#12799)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 08:09:41 +00:00
semtex1987
7f95652e80 Frigate openvino labelmap patch (#12751)
Co-authored-by: CanbiZ (MickLesk) <47820557+MickLesk@users.noreply.github.com>
2026-03-12 09:09:12 +01:00
community-scripts-pr-app[bot]
15f6591d4c Update CHANGELOG.md (#12798)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 08:08:20 +00:00
community-scripts-pr-app[bot]
a530da5760 Update CHANGELOG.md (#12797)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 08:07:56 +00:00
CanbiZ (MickLesk)
cc95ef2987 tools.func: support older NVIDIA driver versions with 2 segments (xxx.xxx) (#12796) 2026-03-12 09:07:49 +01:00
CanbiZ (MickLesk)
38c9421493 tools.func: correct PATH escaping in ROCm profile script (#12793) 2026-03-12 09:07:33 +01:00
610 changed files with 68 additions and 11590 deletions

2
.github/workflows/autolabeler.yml generated vendored
View File

@@ -93,7 +93,7 @@ jobs:
const websiteRegex = new RegExp(`- \\[(x|X)\\]\\s*${escapedWebsite}`, "i");
if (websiteRegex.test(prBody)) {
const hasJson = prFiles.some((f) => f.filename.startsWith("frontend/public/json/"));
const hasJson = prFiles.some((f) => f.filename.startsWith("json/"));
const hasUpdateScript = labelsToAdd.has("update script");
const hasContentLabel = ["bugfix", "feature", "refactor"].some((l) => labelsToAdd.has(l));

View File

@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- "frontend/public/json/**"
- "json/**"
- "vm/**"
- "tools/**"
- "turnkey/**"
@@ -29,7 +29,7 @@ jobs:
slugs=""
# Deleted JSON files: get slug from previous commit
deleted_json=$(git diff --name-only --diff-filter=D "$BEFORE" "$AFTER" -- frontend/public/json/ | grep '\.json$' || true)
deleted_json=$(git diff --name-only --diff-filter=D "$BEFORE" "$AFTER" -- json/ | grep '\.json$' || true)
for f in $deleted_json; do
[[ -z "$f" ]] && continue
s=$(git show "$BEFORE:$f" 2>/dev/null | jq -r '.slug // empty' 2>/dev/null || true)

147
.github/workflows/frontend-cicd.yml generated vendored
View File

@@ -1,147 +0,0 @@
# Based on https://github.com/actions/starter-workflows/blob/main/pages/nextjs.yml
name: Frontend CI/CD
on:
push:
branches: ["main"]
paths:
- frontend/**
pull_request:
branches: ["main"]
types: [opened, synchronize, reopened, edited]
paths:
- frontend/**
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: false
jobs:
test-json-files:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Test JSON files
run: |
python3 << 'EOF'
import json
import glob
import os
import sys
def test_json_files():
# Change to the correct directory
json_dir = "public/json"
if not os.path.exists(json_dir):
print(f"❌ Directory not found: {json_dir}")
return False
# Find all JSON files
pattern = os.path.join(json_dir, "*.json")
json_files = glob.glob(pattern)
if not json_files:
print(f"⚠️ No JSON files found in {json_dir}")
return True
print(f"Testing {len(json_files)} JSON files for valid syntax...")
invalid_files = []
for file_path in json_files:
try:
with open(file_path, 'r', encoding='utf-8') as f:
json.load(f)
print(f"✅ Valid JSON: {file_path}")
except json.JSONDecodeError as e:
print(f"❌ Invalid JSON syntax in: {file_path}")
print(f" Error: {e}")
invalid_files.append(file_path)
except Exception as e:
print(f"⚠️ Error reading: {file_path}")
print(f" Error: {e}")
invalid_files.append(file_path)
print("\n=== JSON Validation Summary ===")
print(f"Total files tested: {len(json_files)}")
print(f"Valid files: {len(json_files) - len(invalid_files)}")
print(f"Invalid files: {len(invalid_files)}")
if invalid_files:
print("\n❌ Found invalid JSON file(s):")
for file_path in invalid_files:
print(f" - {file_path}")
return False
else:
print("\n✅ All JSON files have valid syntax!")
return True
if __name__ == "__main__":
success = test_json_files()
sys.exit(0 if success else 1)
EOF
build:
if: github.repository == 'community-scripts/ProxmoxVE'
needs: test-json-files
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Configure Next.js for pages
uses: actions/configure-pages@v5
with:
static_site_generator: next
- name: Build with Next.js
run: bun run build
- name: Upload artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
with:
path: frontend/out
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.repository == 'community-scripts/ProxmoxVE'
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- "frontend/public/json/**"
- "json/**"
jobs:
push-json:
@@ -19,7 +19,7 @@ jobs:
- name: Get changed JSON files with slug
id: changed
run: |
changed=$(git diff --name-only "${{ github.event.before }}" "${{ github.event.after }}" -- frontend/public/json/ | grep '\.json$' || true)
changed=$(git diff --name-only "${{ github.event.before }}" "${{ github.event.after }}" -- json/ | grep '\.json$' || true)
with_slug=""
for f in $changed; do
[[ -f "$f" ]] || continue
@@ -96,7 +96,7 @@ jobs:
const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records';
let categoryIdToName = {};
try {
const metadata = JSON.parse(fs.readFileSync('frontend/public/json/metadata.json', 'utf8'));
const metadata = JSON.parse(fs.readFileSync('json/metadata.json', 'utf8'));
(metadata.categories || []).forEach(function(cat) { categoryIdToName[cat.id] = cat.name; });
} catch (e) { console.warn('Could not load metadata.json:', e.message); }
let typeValueToId = {};

View File

@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- "frontend/public/json/**.json"
- "json/**.json"
workflow_dispatch:
jobs:
@@ -57,7 +57,7 @@ jobs:
- name: Get Newly Added JSON Files
id: new_json_files
run: |
git diff --name-only --diff-filter=A ${{ env.prev_commit }} HEAD | grep '^frontend/public/json/.*\.json$' > new_files.txt || true
git diff --name-only --diff-filter=A ${{ env.prev_commit }} HEAD | grep '^json/.*\.json$' > new_files.txt || true
echo "New files detected:"
cat new_files.txt || echo "No new files."

View File

@@ -11,7 +11,7 @@ permissions:
pull-requests: write
env:
VERSIONS_FILE: frontend/public/json/github-versions.json
VERSIONS_FILE: json/github-versions.json
BRANCH_NAME: automated/update-github-versions
AUTOMATED_PR_LABEL: "automated pr"
@@ -74,7 +74,7 @@ jobs:
echo ""
echo "=== Scanning JSON files for slugs ==="
for json_file in frontend/public/json/*.json; do
for json_file in json/*.json; do
[[ ! -f "$json_file" ]] && continue
# Skip non-app JSON files

7
.gitignore vendored
View File

@@ -24,13 +24,6 @@ venv/
env/
*.env
# Node.js dependencies (frontend folder was excluded, but keeping this rule for reference)
frontend/node_modules/
frontend/.svelte-kit/
frontend/.turbo/
frontend/.vite/
frontend/build/
# API and Backend specific exclusions
api/.env
api/__pycache__/

View File

@@ -422,6 +422,29 @@ Exercise vigilance regarding copycat or coat-tailing sites that seek to exploit
## 2026-03-12
### 🚀 Updated Scripts
- #### 🐞 Bug Fixes
- SparkyFitness: install pnpm dependencies from workspace root [@MickLesk](https://github.com/MickLesk) ([#12792](https://github.com/community-scripts/ProxmoxVE/pull/12792))
- n8n: add build-essential to update dependencies [@MickLesk](https://github.com/MickLesk) ([#12795](https://github.com/community-scripts/ProxmoxVE/pull/12795))
- Frigate openvino labelmap patch [@semtex1987](https://github.com/semtex1987) ([#12751](https://github.com/community-scripts/ProxmoxVE/pull/12751))
### 💾 Core
- #### 🐞 Bug Fixes
- tools.func: correct PATH escaping in ROCm profile script [@MickLesk](https://github.com/MickLesk) ([#12793](https://github.com/community-scripts/ProxmoxVE/pull/12793))
- #### ✨ New Features
- core: add mode=generated for unattended frontend installs [@MickLesk](https://github.com/MickLesk) ([#12807](https://github.com/community-scripts/ProxmoxVE/pull/12807))
- core: validate storage availability when loading defaults [@MickLesk](https://github.com/MickLesk) ([#12794](https://github.com/community-scripts/ProxmoxVE/pull/12794))
- #### 🔧 Refactor
- tools.func: support older NVIDIA driver versions with 2 segments (xxx.xxx) [@MickLesk](https://github.com/MickLesk) ([#12796](https://github.com/community-scripts/ProxmoxVE/pull/12796))
## 2026-03-11
### 🚀 Updated Scripts

View File

@@ -28,7 +28,7 @@ function update_script() {
exit
fi
ensure_dependencies graphicsmagick
ensure_dependencies build-essential python3-setuptools graphicsmagick
NODE_VERSION="24" setup_nodejs
msg_info "Updating n8n"

39
frontend/.gitignore vendored
View File

@@ -1,39 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# wrangler
.worker-next
.wrangler
# testing
/coverage
# next.js
/.next/
out
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# # local env files
# .env*.local
# .env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

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

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

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024-Present Bram Suurd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

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

2031
frontend/bun.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "@/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
},
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json"
}
}

View File

@@ -1,41 +0,0 @@
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"],
},
],
},
},
);

View File

@@ -1,29 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.resolve.alias.canvas = false;
return config;
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
},
env: {
BASE_PATH: "ProxmoxVE",
},
eslint: {
ignoreDuringBuilds: true,
},
output: "export",
basePath: `/ProxmoxVE`,
};
export default nextConfig;

87
frontend/package.json generated
View File

@@ -1,87 +0,0 @@
{
"name": "proxmox-helper-scripts-website",
"type": "module",
"version": "1.0.0",
"private": true,
"author": {
"name": "Bram Suurd",
"url": "https://github.com/community-scripts"
},
"license": "MIT",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint . --fix",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@types/react-syntax-highlighter": "^15.5.13",
"chart.js": "^4.5.1",
"chartjs-plugin-datalabels": "^2.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.26",
"fuse.js": "^7.1.0",
"lucide-react": "^0.561.0",
"mini-svg-data-uri": "^1.4.4",
"motion": "^12.23.26",
"next": "15.5.8",
"next-themes": "^0.4.6",
"nuqs": "^2.8.5",
"react": "19.2.3",
"react-chartjs-2": "^5.3.1",
"react-code-blocks": "^0.1.6",
"react-datepicker": "^9.0.0",
"react-day-picker": "^9.12.0",
"react-dom": "19.2.3",
"react-icons": "^5.5.0",
"react-syntax-highlighter": "^16.1.0",
"react-use-measure": "^2.1.7",
"recharts": "3.6.0",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.2.1"
},
"devDependencies": {
"@antfu/eslint-config": "^6.7.1",
"@eslint-react/eslint-plugin": "^2.3.13",
"@next/eslint-plugin-next": "^15.5.8",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@types/node": "^25.0.2",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-config-next": "15.5.8",
"eslint-plugin-format": "^1.1.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.25",
"jsdom": "^27.3.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-animated": "^1.1.2",
"typescript": "^5.9.3"
}
}

View File

@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,63 +0,0 @@
import { NextResponse } from "next/server";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Metadata, Script } from "@/lib/types";
export const dynamic = "force-static";
const jsonDir = "public/json";
const metadataFileName = "metadata.json";
const versionFileName = "version.json";
const encoding = "utf-8";
async function getMetadata() {
const filePath = path.resolve(jsonDir, metadataFileName);
const fileContent = await fs.readFile(filePath, encoding);
const metadata: Metadata = JSON.parse(fileContent);
return metadata;
}
async function getScripts() {
const filePaths = (await fs.readdir(jsonDir))
.filter(fileName =>
fileName.endsWith(".json")
&& fileName !== metadataFileName
&& fileName !== versionFileName,
)
.map(fileName => path.resolve(jsonDir, fileName));
const scripts = await Promise.all(
filePaths.map(async (filePath) => {
const fileContent = await fs.readFile(filePath, encoding);
const script: Script = JSON.parse(fileContent);
return script;
}),
);
return scripts;
}
export async function GET() {
try {
const metadata = await getMetadata();
const scripts = await getScripts();
const categories = metadata.categories
.map((category) => {
category.scripts = scripts.filter(script =>
script.categories?.includes(category.id),
);
return category;
})
.sort((a, b) => a.sort_order - b.sort_order);
return NextResponse.json(categories);
}
catch (error) {
console.error(error as Error);
return NextResponse.json(
{ error: "Failed to fetch categories" },
{ status: 500 },
);
}
}

View File

@@ -1,36 +0,0 @@
import { NextResponse } from "next/server";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { GitHubVersionsResponse } from "@/lib/types";
export const dynamic = "force-static";
const jsonDir = "public/json";
const versionsFileName = "github-versions.json";
const encoding = "utf-8";
async function getVersions(): Promise<GitHubVersionsResponse> {
const filePath = path.resolve(jsonDir, versionsFileName);
const fileContent = await fs.readFile(filePath, encoding);
const data: GitHubVersionsResponse = JSON.parse(fileContent);
return data;
}
export async function GET() {
try {
const versions = await getVersions();
return NextResponse.json(versions);
}
catch (error) {
console.error(error);
const err = error as globalThis.Error;
return NextResponse.json({
generated: "",
versions: [],
error: err.message || "An unexpected error occurred",
}, {
status: 500,
});
}
}

View File

@@ -1,48 +0,0 @@
// import Error from "next/error";
import { NextResponse } from "next/server";
import { promises as fs } from "node:fs";
import path from "node:path";
export const dynamic = "force-static";
const jsonDir = "public/json";
const versionsFileName = "versions.json";
const encoding = "utf-8";
interface LegacyVersion {
name: string;
version: string;
date: string;
}
async function getVersions() {
const filePath = path.resolve(jsonDir, versionsFileName);
const fileContent = await fs.readFile(filePath, encoding);
const versions: LegacyVersion[] = JSON.parse(fileContent);
const modifiedVersions = versions.map((version) => {
let newName = version.name;
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) {
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",
}, {
status: 500,
});
}
}

View File

@@ -1,509 +0,0 @@
"use client";
import {
ArrowUpDown,
Box,
CheckCircle2,
ChevronLeft,
ChevronRight,
List,
Loader2,
Trophy,
XCircle,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, Cell, LabelList, XAxis } from "recharts";
import type { ChartConfig } from "@/components/ui/chart";
import { formattedBadge } from "@/components/command-menu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
type DataModel = {
id: number;
ct_type: number;
disk_size: number;
core_count: number;
ram_size: number;
os_type: string;
os_version: string;
disableip6: string;
nsapp: string;
created_at: string;
method: string;
pve_version: string;
status: string;
error: string;
type: string;
[key: string]: any;
};
type SummaryData = {
total_entries: number;
status_count: Record<string, number>;
nsapp_count: Record<string, number>;
};
// Chart colors optimized for both light and dark modes
// Medium-toned colors that are visible and not too flashy in both themes
const CHART_COLORS = [
"#5B8DEF", // blue - medium tone
"#4ECDC4", // teal - medium tone
"#FF8C42", // orange - medium tone
"#A78BFA", // purple - medium tone
"#F472B6", // pink - medium tone
"#38BDF8", // cyan - medium tone
"#4ADE80", // green - medium tone
"#FBBF24", // yellow - medium tone
"#818CF8", // indigo - medium tone
"#FB7185", // rose - medium tone
"#2DD4BF", // turquoise - medium tone
"#C084FC", // violet - medium tone
"#60A5FA", // sky blue - medium tone
"#84CC16", // lime - medium tone
"#F59E0B", // amber - medium tone
"#A855F7", // purple - medium tone
"#10B981", // emerald - medium tone
"#EAB308", // gold - medium tone
"#3B82F6", // royal blue - medium tone
"#EF4444", // red - medium tone
];
const chartConfigApps = {
count: {
label: "Installations",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
export default function DataPage() {
const [data, setData] = useState<DataModel[]>([]);
const [summary, setSummary] = useState<SummaryData | null>(null);
const [summaryLoading, setSummaryLoading] = useState<boolean>(true);
const [dataLoading, setDataLoading] = useState<boolean>(true);
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 nf = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
// Fetch summary only once on mount
useEffect(() => {
const fetchSummary = async () => {
try {
const summaryRes = await fetch("https://api.htl-braunau.at/data/summary");
if (!summaryRes.ok) {
throw new Error(`Failed to fetch summary: ${summaryRes.statusText}`);
}
const summaryData: SummaryData = await summaryRes.json();
setSummary(summaryData);
} catch (err) {
setError((err as Error).message);
} finally {
setSummaryLoading(false);
}
};
fetchSummary();
}, []);
useEffect(() => {
const fetchData = async () => {
setDataLoading(true);
try {
const dataRes = await fetch(`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage}`);
if (!dataRes.ok) {
throw new Error(`Failed to fetch data: ${dataRes.statusText}`);
}
const pageData: DataModel[] = await dataRes.json();
setData(pageData);
} catch (err) {
setError((err as Error).message);
} finally {
setDataLoading(false);
}
};
fetchData();
}, [currentPage, itemsPerPage]);
const sortedData = useMemo(() => {
if (!sortConfig) return data;
return [...data].sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === "ascending" ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === "ascending" ? 1 : -1;
}
return 0;
});
}, [data, sortConfig]);
const requestSort = (key: string) => {
let direction: "ascending" | "descending" = "ascending";
if (sortConfig && sortConfig.key === key && sortConfig.direction === "ascending") {
direction = "descending";
}
setSortConfig({ key, direction });
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
};
const getTypeBadge = (type: string) => {
if (type === "lxc") return formattedBadge("ct");
if (type === "vm") return formattedBadge("vm");
return null;
};
// Stats calculations
const successCount = summary?.status_count.done ?? 0;
const failureCount = summary?.status_count.failed ?? 0;
const totalCount = summary?.total_entries ?? 0;
const successRate = totalCount > 0 ? (successCount / totalCount) * 100 : 0;
const allApps = useMemo(() => {
if (!summary?.nsapp_count) return [];
return Object.entries(summary.nsapp_count).sort(([, a], [, b]) => b - a);
}, [summary]);
const topApps = useMemo(() => {
return allApps.slice(0, 15);
}, [allApps]);
const mostPopularApp = topApps[0];
// Chart Data
const appsChartData = topApps.map(([name, count], index) => ({
app: name,
count,
fill: CHART_COLORS[index % CHART_COLORS.length],
}));
if (error) {
return (
<div className="p-6 text-center text-red-500">
<p>
Error loading data:
{error}
</p>
</div>
);
}
return (
<div className="mb-3">
<div className="mt-20 flex sm:px-4 xl:px-0">
<div className="mx-4 w-full sm:mx-0 space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
<p className="text-muted-foreground">Overview of container installations and system statistics.</p>
</div>
{/* Widgets */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Created</CardTitle>
<Box className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{nf.format(totalCount)}</div>
<p className="text-xs text-muted-foreground">Total LXC/VM entries found</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{successRate.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground">{nf.format(successCount)} successful installations</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Failures</CardTitle>
<XCircle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{nf.format(failureCount)}</div>
<p className="text-xs text-muted-foreground">Installations encountered errors</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Most Popular</CardTitle>
<Trophy className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="truncate text-2xl font-bold">{mostPopularApp ? mostPopularApp[0] : "N/A"}</div>
<p className="text-xs text-muted-foreground">
{mostPopularApp ? nf.format(mostPopularApp[1]) : 0} installations
</p>
</CardContent>
</Card>
</div>
{/* Graphs */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="space-y-1.5">
<CardTitle>Top Applications</CardTitle>
<CardDescription>The most frequently installed applications.</CardDescription>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="ml-auto">
<List className="mr-2 h-4 w-4" />
View All
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] sm:max-w-md">
<DialogHeader>
<DialogTitle>Application Statistics</DialogTitle>
<DialogDescription>Installation counts for all {allApps.length} applications.</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[60vh] w-full rounded-md border p-4">
<div className="space-y-4">
{allApps.map(([name, count], index) => (
<div key={name} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="w-8 font-mono text-muted-foreground">{index + 1}.</span>
<span className="font-medium">{name}</span>
</div>
<span className="font-mono">{nf.format(count)}</span>
</div>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent className="pl-2">
<div className="h-[300px] w-full">
{summaryLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<ChartContainer config={chartConfigApps} className="h-full w-full">
<BarChart
accessibilityLayer
data={appsChartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="app"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => (value.length > 8 ? `${value.slice(0, 8)}...` : value)}
/>
<ChartTooltip cursor={false} content={<ChartTooltipContent nameKey="app" />} />
<Bar dataKey="count" radius={8}>
{appsChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
<LabelList position="top" offset={12} className="fill-foreground" fontSize={12} />
</Bar>
</BarChart>
</ChartContainer>
)}
</div>
</CardContent>
</Card>
{/* Data Table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Installation Log</CardTitle>
<CardDescription>Detailed records of all container creation attempts.</CardDescription>
</div>
<div className="flex items-center gap-2">
<Select value={String(itemsPerPage)} onValueChange={(val) => setItemsPerPage(Number(val))}>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder="Limit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px] cursor-pointer" onClick={() => requestSort("status")}>
Status
{sortConfig?.key === "status" && <ArrowUpDown className="ml-2 inline h-4 w-4" />}
</TableHead>
<TableHead className="cursor-pointer" onClick={() => requestSort("type")}>
Type
{sortConfig?.key === "type" && <ArrowUpDown className="ml-2 inline h-4 w-4" />}
</TableHead>
<TableHead className="cursor-pointer" onClick={() => requestSort("nsapp")}>
Application
{sortConfig?.key === "nsapp" && <ArrowUpDown className="ml-2 inline h-4 w-4" />}
</TableHead>
<TableHead className="hidden cursor-pointer md:table-cell" onClick={() => requestSort("os_type")}>
OS
{sortConfig?.key === "os_type" && <ArrowUpDown className="ml-2 inline h-4 w-4" />}
</TableHead>
<TableHead
className="hidden cursor-pointer md:table-cell"
onClick={() => requestSort("disk_size")}
>
Disk Size
{sortConfig?.key === "disk_size" && <ArrowUpDown className="ml-2 inline h-4 w-4" />}
</TableHead>
<TableHead
className="hidden cursor-pointer lg:table-cell"
onClick={() => requestSort("core_count")}
>
Core Count
{sortConfig?.key === "core_count" && <ArrowUpDown className="ml-2 inline h-4 w-4" />}
</TableHead>
<TableHead
className="hidden cursor-pointer lg:table-cell"
onClick={() => requestSort("ram_size")}
>
RAM Size
{sortConfig?.key === "ram_size" && <ArrowUpDown className="ml-2 inline h-4 w-4" />}
</TableHead>
<TableHead className="cursor-pointer text-right" onClick={() => requestSort("created_at")}>
Created At
{sortConfig?.key === "created_at" && <ArrowUpDown className="ml-2 inline h-4 w-4" />}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dataLoading ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading data...
</div>
</TableCell>
</TableRow>
) : sortedData.length > 0 ? (
sortedData.map((item, idx) => (
<TableRow key={`${item.id}-${idx}`}>
<TableCell>
{item.status === "done" ? (
<Badge className="text-green-500/75 border-green-500/75">Success</Badge>
) : item.status === "failed" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="text-red-500/75 border-red-500/75 cursor-help">Failed</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="font-semibold">Error:</p>
<p className="text-sm">{item.error || "Unknown error"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : item.status === "installing" ? (
<Badge className="text-blue-500/75 border-blue-500/75">Installing</Badge>
) : (
<Badge variant="outline">{item.status}</Badge>
)}
</TableCell>
<TableCell>
{getTypeBadge(item.type) || <Badge variant="outline">{item.type}</Badge>}
</TableCell>
<TableCell className="font-medium">{item.nsapp}</TableCell>
<TableCell className="hidden md:table-cell">
{item.os_type} {item.os_version}
</TableCell>
<TableCell className="hidden md:table-cell">
{item.disk_size}
GB
</TableCell>
<TableCell className="hidden lg:table-cell">{item.core_count}</TableCell>
<TableCell className="hidden lg:table-cell">
{item.ram_size}
MB
</TableCell>
<TableCell className="text-right">{formatDate(item.created_at)}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center">
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1 || dataLoading}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Previous
</Button>
<div className="text-sm text-muted-foreground">Page {currentPage}</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={dataLoading || sortedData.length < itemsPerPage}
>
Next
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,125 +0,0 @@
import type { z } from "zod";
import { memo } from "react";
import type { Category } from "@/lib/types";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { Script } from "../_schemas/schemas";
type CategoryProps = {
script: Script;
setScript: (script: Script) => void;
setIsValid: (isValid: boolean) => void;
setZodErrors: (zodErrors: z.ZodError | null) => void;
categories: Category[];
};
const CategoryTag = memo(({
category,
onRemove,
}: {
category: Category;
onRemove: () => void;
}) => (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{category.name}
<button
type="button"
className="ml-1 inline-flex text-blue-400 hover:text-blue-600"
onClick={onRemove}
>
<span className="sr-only">Remove</span>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
));
CategoryTag.displayName = "CategoryTag";
function Categories({
script,
setScript,
categories,
}: Omit<CategoryProps, "setIsValid" | "setZodErrors">) {
const addCategory = (categoryId: number) => {
setScript({
...script,
categories: [...new Set([...script.categories, categoryId])],
});
};
const removeCategory = (categoryId: number) => {
setScript({
...script,
categories: script.categories.filter((id: number) => id !== categoryId),
});
};
const categoryMap = new Map(categories.map(c => [c.id, c]));
return (
<div>
<Label>
Category
{" "}
<span className="text-red-500">*</span>
</Label>
<Select onValueChange={value => addCategory(Number(value))}>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div
className={cn(
"flex flex-wrap gap-2",
script.categories.length !== 0 && "mt-2",
)}
>
{script.categories.map((categoryId) => {
const category = categoryMap.get(categoryId);
return category
? (
<CategoryTag
key={categoryId}
category={category}
onRemove={() => removeCategory(categoryId)}
/>
)
: null;
})}
</div>
</div>
);
}
export default memo(Categories);

View File

@@ -1,233 +0,0 @@
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 { 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;
setScript: (value: Script | ((prevState: Script) => Script)) => void;
setIsValid: (isValid: boolean) => void;
setZodErrors: (zodErrors: z.ZodError | null) => void;
};
function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallMethodProps) {
const cpuRefs = useRef<(HTMLInputElement | null)[]>([]);
const ramRefs = useRef<(HTMLInputElement | null)[]>([]);
const hddRefs = useRef<(HTMLInputElement | null)[]>([]);
const addInstallMethod = useCallback(() => {
setScript((prev) => {
const { type, slug } = prev;
const newMethodType = "default";
let scriptPath = "";
if (type === "pve") {
scriptPath = `tools/pve/${slug}.sh`;
}
else if (type === "addon") {
scriptPath = `tools/addon/${slug}.sh`;
}
else {
scriptPath = `${type}/${slug}.sh`;
}
const method = InstallMethodSchema.parse({
type: newMethodType,
script: scriptPath,
resources: {
cpu: null,
ram: null,
hdd: null,
os: null,
version: null,
},
});
return {
...prev,
install_methods: [...prev.install_methods, method],
};
});
}, [setScript]);
const updateInstallMethod = useCallback(
(
index: number,
key: keyof Script["install_methods"][number],
value: Script["install_methods"][number][keyof Script["install_methods"][number]],
) => {
setScript((prev) => {
const updatedMethods = prev.install_methods.map((method, i) => {
if (i === index) {
const updatedMethod = { ...method, [key]: value };
if (key === "type") {
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") {
updatedMethod.resources.os = "Alpine";
updatedMethod.resources.version = null;
}
}
return updatedMethod;
}
return method;
});
const updated = {
...prev,
install_methods: updatedMethods,
};
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
if (!result.success) {
setZodErrors(result.error);
}
else {
setZodErrors(null);
}
return updated;
});
},
[setScript, setIsValid, setZodErrors],
);
const removeInstallMethod = useCallback(
(index: number) => {
setScript(prev => ({
...prev,
install_methods: prev.install_methods.filter((_, i) => i !== index),
}));
},
[setScript],
);
return (
<>
<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)}>
<SelectTrigger>
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="alpine">Alpine</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Input
ref={(el) => {
cpuRefs.current[index] = el;
}}
placeholder="CPU in Cores"
type="number"
value={method.resources.cpu || ""}
onChange={e =>
updateInstallMethod(index, "resources", {
...method.resources,
cpu: e.target.value ? Number(e.target.value) : null,
})}
/>
<Input
ref={(el) => {
ramRefs.current[index] = el;
}}
placeholder="RAM in MB"
type="number"
value={method.resources.ram || ""}
onChange={e =>
updateInstallMethod(index, "resources", {
...method.resources,
ram: e.target.value ? Number(e.target.value) : null,
})}
/>
<Input
ref={(el) => {
hddRefs.current[index] = el;
}}
placeholder="HDD in GB"
type="number"
value={method.resources.hdd || ""}
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 =>
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 => (
<SelectItem key={os.name} value={os.name}>
{os.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={method.resources.version || undefined}
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 => (
<SelectItem key={version.slug} value={version.name}>
{version.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="destructive" size="sm" type="button" onClick={() => removeInstallMethod(index)}>
<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
</Button>
</>
);
}
export default memo(InstallMethod);

View File

@@ -1,159 +0,0 @@
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: "info" }],
});
}, [script, setScript]);
const updateNote = useCallback((
index: number,
key: keyof Script["notes"][number],
value: string,
) => {
const updated: Script = {
...script,
notes: script.notes.map((note, i) =>
i === index ? { ...note, [key]: value } : note,
),
};
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
setZodErrors(result.success ? null : result.error);
setScript(updated);
// Restore focus after state update
if (key === "text") {
setTimeout(() => {
inputRefs.current[index]?.focus();
}, 0);
}
}, [script, setScript, setIsValid, setZodErrors]);
const removeNote = useCallback((index: number) => {
setScript({
...script,
notes: script.notes.filter((_, i) => i !== index),
});
}, [script, setScript]);
return (
<>
<h3 className="text-xl font-semibold">Notes</h3>
{script.notes.map((note, index) => (
<NoteItem key={index} note={note} index={index} updateNote={updateNote} removeNote={removeNote} />
))}
<Button type="button" size="sm" onClick={addNote}>
<PlusCircle className="mr-2 h-4 w-4" />
{" "}
Add Note
</Button>
</>
);
}
NoteItem.displayName = "NoteItem";
export default memo(Note);

View File

@@ -1,59 +0,0 @@
import { z } from "zod";
import { AlertColors } from "@/config/site-config";
export const InstallMethodSchema = z.object({
type: z.enum(["default", "alpine"], {
message: "Type must be either 'default' or 'alpine'",
}),
script: z.string().min(1, "Script content cannot be empty"),
resources: z.object({
cpu: z.number().nullable(),
ram: z.number().nullable(),
hdd: z.number().nullable(),
os: z.string().nullable(),
version: z.string().nullable(),
}),
});
const NoteSchema = z.object({
text: z.string().min(1, "Note text cannot be empty"),
type: z.enum(Object.keys(AlertColors) as [keyof typeof AlertColors, ...(keyof typeof AlertColors)[]], {
message: `Type must be one of: ${Object.keys(AlertColors).join(", ")}`,
}),
});
export const ScriptSchema = z.object({
name: z.string().min(1, "Name is required"),
slug: z.string().min(1, "Slug is required"),
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"], {
message: "Type must be either 'vm', 'ct', 'pve', 'addon' or 'turnkey'",
}),
updateable: z.boolean(),
privileged: z.boolean(),
interface_port: z.number().nullable(),
documentation: z.string().nullable(),
website: z.url().nullable(),
logo: z.url().nullable(),
config_path: z.string(),
description: z.string().min(1, "Description is required"),
disable: z.boolean().optional(),
disable_description: z.string().optional(),
install_methods: z.array(InstallMethodSchema).min(1, "At least one install method is required"),
default_credentials: z.object({
username: z.string().nullable(),
password: z.string().nullable(),
}),
notes: z.array(NoteSchema).optional().default([]),
}).refine((data) => {
if (data.disable === true && !data.disable_description) {
return false;
}
return true;
}, {
message: "disable_description is required when disable is true",
path: ["disable_description"],
});
export type Script = z.infer<typeof ScriptSchema>;

View File

@@ -1,590 +0,0 @@
"use client";
import type { z } from "zod";
import { githubGist, nord } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { CalendarIcon, Check, Clipboard, Download } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import SyntaxHighlighter from "react-syntax-highlighter";
import { useTheme } from "next-themes";
import { format } from "date-fns";
import { toast } from "sonner";
import Image from "next/image";
import type { Category } from "@/lib/types";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 { basePath } from "@/config/site-config";
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 { ScriptItem } from "../scripts/_components/script-item";
import InstallMethod from "./_components/install-method";
import { ScriptSchema } from "./_schemas/schemas";
import Categories from "./_components/categories";
import Note from "./_components/note";
function search(scripts: Script[], query: string): Script[] {
const queryLower = query.toLowerCase().trim();
const searchWords = queryLower.split(/\s+/).filter(Boolean);
return scripts
.map((script) => {
const nameLower = script.name.toLowerCase();
const descriptionLower = (script.description || "").toLowerCase();
let score = 0;
for (const word of searchWords) {
if (nameLower.includes(word)) {
score += 10;
}
if (descriptionLower.includes(word)) {
score += 5;
}
}
return { script, score };
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 20)
.map(({ script }) => script);
}
const initialScript: Script = {
name: "",
slug: "",
categories: [],
date_created: format(new Date(), "yyyy-MM-dd"),
type: "ct",
updateable: false,
privileged: false,
interface_port: null,
documentation: null,
config_path: "",
website: null,
logo: null,
description: "",
disable: undefined,
disable_description: undefined,
install_methods: [],
default_credentials: {
username: null,
password: null,
},
notes: [],
};
export default function JSONGenerator() {
const { theme } = useTheme();
const [script, setScript] = useState<Script>(initialScript);
const [isCopied, setIsCopied] = useState(false);
const [isValid, setIsValid] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [currentTab, setCurrentTab] = useState<"json" | "preview">("json");
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [searchQuery, setSearchQuery] = useState<string>("");
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [zodErrors, setZodErrors] = useState<z.ZodError | null>(null);
const selectedCategoryObj = useMemo(
() => categories.find(cat => cat.id.toString() === selectedCategory),
[categories, selectedCategory],
);
const allScripts = useMemo(
() => categories.flatMap(cat => cat.scripts || []),
[categories],
);
const scripts = useMemo(() => {
const query = searchQuery.trim();
if (query) {
return search(allScripts, query);
}
if (selectedCategoryObj) {
return selectedCategoryObj.scripts || [];
}
return [];
}, [allScripts, selectedCategoryObj, searchQuery]);
useEffect(() => {
fetchCategories()
.then(setCategories)
.catch(error => console.error("Error fetching categories:", error));
}, []);
useEffect(() => {
if (!isValid && currentTab === "preview") {
setCurrentTab("json");
toast.error("Switched to JSON tab due to invalid configuration.");
}
}, [isValid, currentTab]);
const updateScript = useCallback((key: keyof Script, value: Script[keyof Script]) => {
setScript((prev) => {
const updated = { ...prev, [key]: value };
if (updated.slug && updated.type) {
updated.install_methods = updated.install_methods.map((method) => {
let scriptPath = "";
if (updated.type === "pve") {
scriptPath = `tools/pve/${updated.slug}.sh`;
}
else if (updated.type === "addon") {
scriptPath = `tools/addon/${updated.slug}.sh`;
}
else if (method.type === "alpine") {
scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
}
else {
scriptPath = `${updated.type}/${updated.slug}.sh`;
}
return {
...method,
script: scriptPath,
};
});
}
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
setZodErrors(result.success ? null : result.error);
return updated;
});
}, []);
const handleCopy = useCallback(() => {
if (!isValid)
toast.warning("JSON schema is invalid. Copying anyway.");
navigator.clipboard.writeText(JSON.stringify(script, null, 2));
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
if (isValid)
toast.success("Copied metadata to clipboard");
}, [script]);
const importScript = (script: Script) => {
try {
const result = ScriptSchema.safeParse(script);
if (!result.success) {
setIsValid(false);
setZodErrors(result.error);
toast.error("Imported JSON is invalid according to the schema.");
return;
}
setScript(result.data);
setIsValid(true);
setZodErrors(null);
toast.success("Imported JSON successfully");
}
catch (error) {
toast.error("Failed to read or parse the JSON file.");
}
};
const handleFileImport = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file)
return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const content = event.target?.result as string;
const parsed = JSON.parse(content);
importScript(parsed);
toast.success("Imported JSON successfully");
}
catch (error) {
toast.error("Failed to read the JSON file.");
}
};
reader.readAsText(file);
};
input.click();
}, [setScript]);
const handleDownload = useCallback(() => {
if (isValid === false) {
toast.error("Cannot download invalid JSON");
return;
}
const jsonString = JSON.stringify(script, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${script.slug || "script"}.json`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
}, [script]);
const handleDateSelect = useCallback(
(date: Date | undefined) => {
updateScript("date_created", format(date || new Date(), "yyyy-MM-dd"));
},
[updateScript],
);
const formattedDate = useMemo(
() => (script.date_created ? format(script.date_created, "PPP") : undefined),
[script.date_created],
);
const validationAlert = useMemo(
() => (
<Alert className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}>
<AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle>
<AlertDescription>
{isValid
? "The current JSON is valid according to the schema."
: "The current JSON does not match the required schema."}
</AlertDescription>
{zodErrors && (
<div className="mt-2 space-y-1">
{zodErrors.issues.map((error, index) => (
<AlertDescription key={index} className="p-1 text-red-500">
{error.path.join(".")}
{" "}
-
{error.message}
</AlertDescription>
))}
</div>
)}
</Alert>
),
[isValid, zodErrors],
);
return (
<div className="flex h-screen mt-20">
<div className="w-1/2 p-4 overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">JSON Generator</h2>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>Import</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={handleFileImport}>Import local JSON file</DropdownMenuItem>
<Dialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={e => e.preventDefault()}>
Import existing script
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-md w-full">
<DialogHeader>
<DialogTitle>Import existing script</DialogTitle>
<DialogDescription>
Select one of the puplished scripts to import its metadata.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<div className="grid flex-1 gap-2">
<Select
value={selectedCategory}
onValueChange={setSelectedCategory}
>
<SelectTrigger>
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Search for a script..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
{!selectedCategory && !searchQuery
? (
<p className="text-muted-foreground text-sm text-center">
Select a category or search for a script
</p>
)
: scripts.length === 0
? (
<p className="text-muted-foreground text-sm text-center">
No scripts found
</p>
)
: (
<div className="grid grid-cols-3 auto-rows-min h-64 overflow-y-auto gap-4">
{scripts.map(script => (
<div
key={script.slug}
className="p-2 border rounded cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={() => {
importScript(script);
setIsImportDialogOpen(false);
}}
>
<Image
src={script.logo || `/${basePath}/logo.png`}
alt={script.name}
className="w-full h-12 object-contain mb-2"
width={16}
height={16}
unoptimized
/>
<p className="text-sm text-center">{script.name}</p>
</div>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<form className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>
Name
{" "}
<span className="text-red-500">*</span>
</Label>
<Input placeholder="Example" value={script.name} onChange={e => updateScript("name", e.target.value)} />
</div>
<div>
<Label>
Slug
{" "}
<span className="text-red-500">*</span>
</Label>
<Input placeholder="example" value={script.slug} onChange={e => updateScript("slug", e.target.value)} />
</div>
</div>
<div>
<Label>
Logo
</Label>
<Input
placeholder="Full logo URL"
value={script.logo || ""}
onChange={e => updateScript("logo", e.target.value || null)}
/>
</div>
<div>
<Label>Config Path</Label>
<Input
placeholder="Path to config file"
value={script.config_path || ""}
onChange={e => updateScript("config_path", e.target.value || "")}
/>
</div>
<div>
<Label>
Description
{" "}
<span className="text-red-500">*</span>
</Label>
<Textarea
placeholder="Example"
value={script.description}
onChange={e => updateScript("description", e.target.value)}
/>
</div>
<Categories script={script} setScript={setScript} categories={categories} />
<div className="flex gap-2">
<div className="flex flex-col gap-2 w-full">
<Label>
Date Created
{" "}
<span className="text-red-500">*</span>
</Label>
<Popover>
<PopoverTrigger asChild className="flex-1">
<Button
variant="outline"
className={cn("pl-3 text-left font-normal w-full", !script.date_created && "text-muted-foreground")}
>
{formattedDate || <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={new Date(script.date_created)}
onSelect={handleDateSelect}
autoFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-col gap-2 w-full">
<Label>Type</Label>
<Select value={script.type} onValueChange={value => updateScript("type", value)}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ct">LXC Container</SelectItem>
<SelectItem value="vm">Virtual Machine</SelectItem>
<SelectItem value="pve">PVE-Tool</SelectItem>
<SelectItem value="addon">Add-On</SelectItem>
</SelectContent>
</Select>
</div>
</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)} />
<label>Updateable</label>
</div>
<div className="flex items-center space-x-2">
<Switch checked={script.privileged} onCheckedChange={checked => updateScript("privileged", checked)} />
<label>Privileged</label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={script.disable || false}
onCheckedChange={checked => updateScript("disable", checked)}
/>
<label>Disabled</label>
</div>
</div>
{script.disable && (
<div>
<Label>
Disable Description
{" "}
<span className="text-red-500">*</span>
</Label>
<Textarea
placeholder="Explain why this script is disabled..."
value={script.disable_description || ""}
onChange={e => updateScript("disable_description", e.target.value)}
/>
</div>
)}
<Input
placeholder="Interface Port"
type="number"
value={script.interface_port || ""}
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)}
/>
<Input
placeholder="Documentation URL"
value={script.documentation || ""}
onChange={e => updateScript("documentation", e.target.value || null)}
/>
</div>
<InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
<h3 className="text-xl font-semibold">Default Credentials</h3>
<Input
placeholder="Username"
value={script.default_credentials.username || ""}
onChange={e =>
updateScript("default_credentials", {
...script.default_credentials,
username: e.target.value || null,
})}
/>
<Input
placeholder="Password"
value={script.default_credentials.password || ""}
onChange={e =>
updateScript("default_credentials", {
...script.default_credentials,
password: e.target.value || null,
})}
/>
<Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
</form>
</div>
<div className="w-1/2 p-4 bg-background overflow-y-auto">
<Tabs
defaultValue="json"
className="w-full"
onValueChange={value => setCurrentTab(value as "json" | "preview")}
value={currentTab}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="json">JSON</TabsTrigger>
<TabsTrigger disabled={!isValid} value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="json" className="h-full w-full">
{validationAlert}
<div className="relative">
<div className="absolute right-2 top-2 flex gap-1">
<Button size="icon" variant="outline" onClick={handleCopy}>
{isCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</Button>
<Button size="icon" variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
</Button>
</div>
<SyntaxHighlighter
language="json"
style={theme === "light" ? githubGist : nord}
className="mt-4 p-4 bg-secondary rounded shadow overflow-x-scroll"
>
{JSON.stringify(script, null, 2)}
</SyntaxHighlighter>
</div>
</TabsContent>
<TabsContent value="preview" className="h-full w-full">
<ScriptItem item={script} />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -1,132 +0,0 @@
import type { Metadata } from "next";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Inter } from "next/font/google";
import Script from "next/script";
import React from "react";
import { CopycatWarningToast } from "@/components/copycat-warning-toast";
import { ThemeProvider } from "@/components/theme-provider";
import { analytics, basePath } from "@/config/site-config";
import QueryProvider from "@/components/query-provider";
import { Toaster } from "@/components/ui/sonner";
import Footer from "@/components/footer";
import Navbar from "@/components/navbar";
import "@/styles/globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Proxmox VE Helper-Scripts",
description:
"The official website for the Proxmox VE Helper-Scripts (Community) repository. Featuring over 400+ scripts to help you manage your Proxmox Virtual Environment.",
applicationName: "Proxmox VE Helper-Scripts",
generator: "Next.js",
referrer: "origin-when-cross-origin",
keywords: [
"Proxmox VE",
"Helper-Scripts",
"tteck",
"helper",
"scripts",
"proxmox",
"VE",
"virtualization",
"containers",
"LXC",
"VM",
],
authors: [
{ name: "Bram Suurd", url: "https://github.com/BramSuurdje" },
{ name: "Community Scripts", url: "https://github.com/Community-Scripts" },
],
creator: "Bram Suurd",
publisher: "Community Scripts",
metadataBase: new URL(`https://community-scripts.github.io/${basePath}/`),
alternates: {
canonical: `https://community-scripts.github.io/${basePath}/`,
},
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 5,
},
formatDetection: {
email: false,
address: false,
telephone: false,
},
openGraph: {
title: "Proxmox VE Helper-Scripts",
description:
"The official website for the Proxmox VE Helper-Scripts (Community) repository. Featuring over 400+ scripts to help you manage your Proxmox Virtual Environment.",
url: `https://community-scripts.github.io/${basePath}/`,
siteName: "Proxmox VE Helper-Scripts",
images: [
{
url: `https://community-scripts.github.io/${basePath}/defaultimg.png`,
width: 1200,
height: 630,
alt: "Proxmox VE Helper-Scripts",
},
],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Proxmox VE Helper-Scripts",
creator: "@BramSuurdje",
description:
"The official website for the Proxmox VE Helper-Scripts (Community) repository. Featuring over 400+ scripts to help you manage your Proxmox Virtual Environment.",
images: [`https://community-scripts.github.io/${basePath}/defaultimg.png`],
},
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Proxmox VE Helper-Scripts",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="canonical" href={metadata.metadataBase?.href} />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="preconnect" href="https://api.github.com" />
</head>
<body className={inter.className}>
<Script
src={`https://${analytics.url}/api/script.js`}
data-site-id={analytics.token}
strategy="afterInteractive"
/>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
<div className="flex w-full flex-col justify-center">
<NuqsAdapter>
<QueryProvider>
<Navbar />
<div className="flex min-h-screen flex-col justify-center">
<div className="flex w-full justify-center">
<div className="w-full max-w-[1440px] ">
{children}
<Toaster richColors />
<CopycatWarningToast />
</div>
</div>
<Footer />
</div>
</QueryProvider>
</NuqsAdapter>
</div>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -1,29 +0,0 @@
import type { MetadataRoute } from "next";
import { basePath } from "@/config/site-config";
export function generateStaticParams() {
return [];
}
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Proxmox VE Helper-Scripts",
short_name: "Proxmox VE Helper-Scripts",
description:
"A redesigned front-end for the Proxmox VE Helper-Scripts repository. Featuring over 300+ scripts to help you manage your Proxmox Virtual Environment.",
theme_color: "#030712",
background_color: "#030712",
display: "standalone",
orientation: "portrait",
scope: `${basePath}`,
start_url: `${basePath}`,
icons: [
{
src: "logo.png",
sizes: "512x512",
type: "image/png",
},
],
};
}

View File

@@ -1,31 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { basePath } from "@/config/site-config";
export default function NotFoundPage() {
return (
<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">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
404
</h1>
<p className="text-muted-foreground md:text-xl">
Oops, the page you are looking for could not be found.
</p>
</div>
<Button
onClick={() => {
if (window.history.length > 1) {
window.history.back();
}
else {
window.location.href = `/${basePath}`;
}
}}
variant="secondary"
>
Go Back
</Button>
</div>
);
}

View File

@@ -1,153 +0,0 @@
"use client";
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,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import AnimatedGradientText from "@/components/ui/animated-gradient-text";
import { Separator } from "@/components/ui/separator";
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";
function CustomArrowRightIcon() {
return <ArrowRightIcon className="h-4 w-4" width={1} />;
}
export default function Page() {
const { theme } = useTheme();
const [color, setColor] = useState("#000000");
useEffect(() => {
setColor(theme === "dark" ? "#ffffff" : "#000000");
}, [theme]);
return (
<>
<div className="w-full mt-16">
<Particles className="absolute inset-0 -z-40" quantity={100} ease={80} color={color} refresh />
<div className="container mx-auto">
<div className="flex h-[80vh] flex-col items-center justify-center gap-4 py-20 lg:py-40">
<Dialog>
<DialogTrigger>
<div>
<AnimatedGradientText>
<div
className={cn(
`absolute inset-0 block size-full animate-gradient bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:var(--bg-size)_100%] [border-radius:inherit] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]`,
`p-px ![mask-composite:subtract]`,
)}
/>
{" "}
<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`,
`inline`,
)}
>
Scripts by tteck
</span>
</AnimatedGradientText>
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Thank You!</DialogTitle>
<DialogDescription>
A big thank you to tteck and the many contributors who have made this project possible. Your hard
work is truly appreciated by the entire Proxmox community!
</DialogDescription>
</DialogHeader>
<CardFooter className="flex flex-col gap-2">
<Button className="w-full" variant="outline" asChild>
<a
href="https://github.com/tteck"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<FaGithub className="mr-2 h-4 w-4" />
{" "}
Tteck&apos;s GitHub
</a>
</Button>
<Button className="w-full" asChild>
<a
href={`https://github.com/community-scripts/${basePath}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<ExternalLink className="mr-2 h-4 w-4" />
{" "}
Proxmox Helper Scripts
</a>
</Button>
</CardFooter>
</DialogContent>
</Dialog>
<div className="flex flex-col gap-4">
<h1 className="max-w-2xl text-center text-3xl font-semibold tracking-tighter md:text-7xl">
Make managing your Homelab a breeze
</h1>
<div className="max-w-2xl gap-2 flex flex-col text-center sm:text-lg text-sm leading-relaxed tracking-tight text-muted-foreground md:text-xl">
<p>
We are a community-driven initiative that simplifies the setup of Proxmox Virtual Environment (VE).
</p>
<p>
With 400+ scripts to help you manage your
{" "}
<b>Proxmox VE</b>
, whether you&#39;re a seasoned user or a
newcomer, we&#39;ve got you covered.
</p>
</div>
</div>
<div className="flex flex-row gap-3">
<Link href="/scripts">
<Button
size="lg"
variant="expandIcon"
Icon={CustomArrowRightIcon}
iconPlacement="right"
className="hover:"
>
View Scripts
</Button>
</Link>
</div>
</div>
{/* FAQ Section */}
<div className="py-20" id="faq">
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold tracking-tighter md:text-5xl mb-4">Frequently Asked Questions</h2>
<p className="text-muted-foreground text-lg">
Find answers to common questions about our Proxmox VE scripts
</p>
</div>
<FAQ />
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,15 +0,0 @@
import type { MetadataRoute } from "next";
import { basePath } from "@/config/site-config";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `https://community-scripts.github.io/${basePath}/sitemap.xml`,
};
}

View File

@@ -1,43 +0,0 @@
import { CPUIcon, HDDIcon, RAMIcon } from "@/components/icons/resource-icons";
import { getDisplayValueFromRAM } from "@/lib/utils/resource-utils";
type ResourceDisplayProps = {
title: string;
cpu: number | null;
ram: number | null;
hdd: number | null;
};
type IconTextProps = {
icon: React.ReactNode;
label: string;
};
function IconText({ icon, label }: IconTextProps) {
return (
<span className="inline-flex items-center gap-1.5 rounded-md bg-accent/20 px-2 py-1 text-sm">
{icon}
<span className="text-foreground/90">{label}</span>
</span>
);
}
export function ResourceDisplay({ title, cpu, ram, hdd }: ResourceDisplayProps) {
const hasCPU = typeof cpu === "number" && cpu > 0;
const hasRAM = typeof ram === "number" && ram > 0;
const hasHDD = typeof hdd === "number" && hdd > 0;
if (!hasCPU && !hasRAM && !hasHDD)
return null;
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">{title}</span>
<div className="flex flex-wrap gap-2">
{hasCPU && <IconText icon={<CPUIcon />} label={`${cpu} vCPU`} />}
{hasRAM && <IconText icon={<RAMIcon />} label={getDisplayValueFromRAM(ram!)} />}
{hasHDD && <IconText icon={<HDDIcon />} label={`${hdd} GB`} />}
</div>
</div>
);
}

View File

@@ -1,159 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import * as Icons from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import type { Category } from "@/lib/types";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { formattedBadge } from "@/components/command-menu";
import { basePath } from "@/config/site-config";
import { cn } from "@/lib/utils";
function getCategoryIcon(iconName: string) {
// Convert kebab-case to PascalCase for Lucide icon names
const pascalCaseName = iconName
.split("-")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
const IconComponent = (Icons as any)[pascalCaseName];
return IconComponent ? <IconComponent className="size-4 text-[#0083c3] mr-2" /> : null;
}
export default function ScriptAccordion({
items,
selectedScript,
setSelectedScript,
selectedCategory,
setSelectedCategory,
onItemSelect,
}: {
items: Category[];
selectedScript: string | null;
setSelectedScript: (script: string | null) => void;
selectedCategory: string | null;
setSelectedCategory: (category: string | null) => void;
onItemSelect?: () => void;
}) {
const [expandedItem, setExpandedItem] = useState<string | undefined>(undefined);
const linkRefs = useRef<{ [key: string]: HTMLAnchorElement | null }>({});
const handleAccordionChange = (value: string | undefined) => {
setExpandedItem(value);
};
const handleSelected = useCallback(
(slug: string) => {
setSelectedScript(slug);
},
[setSelectedScript],
);
useEffect(() => {
if (selectedScript) {
let category;
// If we have a selected category, try to find the script in that specific category
if (selectedCategory) {
category = items.find(
cat => cat.name === selectedCategory && cat.scripts.some(script => script.slug === selectedScript),
);
}
// Fallback: if no category is selected or script not found in selected category,
// use the first category containing the script (backward compatibility)
if (!category) {
category = items.find(category => category.scripts.some(script => script.slug === selectedScript));
}
if (category) {
setExpandedItem(category.name);
handleSelected(selectedScript);
}
}
}, [selectedScript, selectedCategory, items, handleSelected]);
return (
<Accordion
type="single"
value={expandedItem}
onValueChange={handleAccordionChange}
collapsible
className="overflow-y-scroll sm:max-h-[calc(100vh-209px)] overflow-x-hidden p-1"
>
{items.map(category => (
<AccordionItem
key={`${category.id}:category`}
value={category.name}
className={cn("sm:text-sm flex flex-col border-none", {
"rounded-lg bg-accent/30": expandedItem === category.name,
})}
>
<AccordionTrigger
className={cn(
"duration-250 rounded-lg transition ease-in-out hover:-translate-y-1 hover:scale-105 hover:bg-accent",
)}
>
<div className="mr-2 flex w-full items-center justify-between">
<div className="flex items-center pl-2 text-left">
{getCategoryIcon(category.icon)}
<span>
{category.name}
{" "}
</span>
</div>
<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>
{" "}
</AccordionTrigger>
<AccordionContent data-state={expandedItem === category.name ? "open" : "closed"} className="pt-0">
{category.scripts
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((script, index) => (
<div key={index}>
<Link
href={{
pathname: "/scripts",
query: { id: script.slug, category: category.name },
}}
prefetch={false}
className={`flex cursor-pointer items-center justify-between gap-1 px-1 py-1 text-muted-foreground hover:rounded-lg hover:bg-accent/60 hover:dark:bg-accent/20 ${selectedScript === script.slug
? "rounded-lg bg-accent font-semibold dark:bg-accent/30 dark:text-white"
: ""
} ${script.disable ? "opacity-60" : ""}`}
onClick={() => {
handleSelected(script.slug);
setSelectedCategory(category.name);
onItemSelect?.();
}}
ref={(el) => {
linkRefs.current[script.slug] = el;
}}
>
<div className="flex items-center">
<Image
src={script.logo || `/${basePath}/logo.png`}
height={16}
width={16}
unoptimized
onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
alt={script.name}
className="mr-1 w-4 h-4 rounded-full"
/>
<span className="flex items-center gap-2">
{script.name}
</span>
</div>
{formattedBadge(script.type)}
</Link>
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
}

View File

@@ -1,211 +0,0 @@
import { CalendarPlus } from "lucide-react";
import { useEffect, useMemo } from "react";
import Image from "next/image";
import Link from "next/link";
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 function getDisplayValueFromType(type: string) {
switch (type) {
case "ct":
return "LXC";
case "vm":
return "VM";
case "pve":
return "PVE";
case "addon":
return "ADDON";
default:
return "";
}
}
export function LatestScripts({
items,
page,
onPageChange,
}: {
items: Category[];
page: number;
onPageChange: (page: number) => void;
}) {
const latestScripts = useMemo(() => {
if (!items) return [];
const scripts = items.flatMap((category) => category.scripts || []);
// Filter out duplicates by slug
const uniqueScriptsMap = new Map<string, Script>();
scripts.forEach((script) => {
if (!uniqueScriptsMap.has(script.slug)) {
uniqueScriptsMap.set(script.slug, script);
}
});
return Array.from(uniqueScriptsMap.values()).sort(
(a, b) => new Date(b.date_created).getTime() - new Date(a.date_created).getTime(),
);
}, [items]);
const totalPages = Math.max(1, Math.ceil(latestScripts.length / ITEMS_PER_PAGE));
useEffect(() => {
if (page > totalPages) {
onPageChange(totalPages);
}
}, [page, totalPages, onPageChange]);
const goToNextPage = () => {
onPageChange(Math.min(totalPages, page + 1));
};
const goToPreviousPage = () => {
onPageChange(Math.max(1, page - 1));
};
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = page * ITEMS_PER_PAGE;
if (!items) {
return null;
}
return (
<div className="">
{latestScripts.length > 0 && (
<div className="flex w-full items-center justify-between">
<h2 className="text-lg font-semibold">Newest Scripts</h2>
<div className="flex items-center justify-end gap-1">
{page > 1 && (
<div className="cursor-pointer select-none p-2 text-sm font-semibold" onClick={goToPreviousPage}>
Previous
</div>
)}
{endIndex < latestScripts.length && (
<div onClick={goToNextPage} className="cursor-pointer select-none p-2 text-sm font-semibold">
{page === 1 ? "More.." : "Next"}
</div>
)}
</div>
</div>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{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">
<div className="flex h-16 w-16 min-w-16 items-center justify-center rounded-lg bg-accent p-1">
<Image
src={script.logo || `/${basePath}/logo.png`}
unoptimized
height={64}
width={64}
alt=""
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)}
</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarPlus className="h-4 w-4" />
{extractDate(script.date_created)}
</p>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="line-clamp-3 text-card-foreground">{script.description}</CardDescription>
</CardContent>
<CardFooter className="">
<Button asChild variant="outline">
<Link
href={{
pathname: "/scripts",
query: { id: script.slug },
}}
>
View Script
</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
);
}
export function MostViewedScripts({ items }: { items: Category[] }) {
const mostViewedScripts = items.reduce((acc: Script[], category) => {
const foundScripts = category.scripts.filter((script) => mostPopularScripts.includes(script.slug));
return acc.concat(foundScripts);
}, []);
return (
<div className="">
{mostViewedScripts.length > 0 && (
<>
<h2 className="text-lg font-semibold mb-1">Most Viewed Scripts</h2>
</>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{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">
<div className="flex size-16 min-w-16 items-center justify-center rounded-lg bg-accent p-1">
<Image
unoptimized
src={script.logo || `/${basePath}/logo.png`}
height={64}
width={64}
alt=""
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)}
</p>
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarPlus className="h-4 w-4" />
{extractDate(script.date_created)}
</p>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="line-clamp-3 text-card-foreground break-words">
{script.description}
</CardDescription>
</CardContent>
<CardFooter className="">
<Button asChild variant="outline">
<Link
href={{
pathname: "/scripts",
query: { id: script.slug },
}}
prefetch={false}
>
View Script
</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
);
}

View File

@@ -1,191 +0,0 @@
"use client";
import { X, HelpCircle } from "lucide-react";
import { Suspense } from "react";
import Image from "next/image";
import type { AppVersion } from "@/lib/types";
import type { Script } from "@/app/json-editor/_schemas/schemas";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useVersions } from "@/hooks/use-versions";
import { basePath } from "@/config/site-config";
import { extractDate } from "@/lib/time";
import DisableDescription from "./script-items/disable-description";
import { formattedBadge } from "@/components/command-menu";
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;
};
function ScriptHeader({ item }: { item: Script }) {
const defaultInstallMethod = item.install_methods?.[0];
const os = defaultInstallMethod?.resources?.os || (item.type === "addon" ? "Existing LXC or Proxmox Node" : "Proxmox Node");
const version = defaultInstallMethod?.resources?.version || "";
return (
<div className="flex flex-col lg:flex-row gap-6 w-full">
<div className="flex flex-col md:flex-row gap-6 flex-grow">
<div className="flex-shrink-0">
<Image
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`)}
height={400}
alt={item.name}
unoptimized
/>
</div>
<div className="flex flex-col justify-between flex-grow space-y-4">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
{item.name}
<VersionInfo item={item} />
{formattedBadge(item.type)}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>
Added
{" "}
{extractDate(item.date_created)}
</span>
<span></span>
<span className=" capitalize">
{os}
{" "}
{version}
</span>
</div>
</div>
{/* <VersionInfo item={item} /> */}
</div>
<div className="flex flex-col gap-2 text-sm text-muted-foreground">
{defaultInstallMethod?.resources && (
<ResourceDisplay
title="Default"
cpu={defaultInstallMethod.resources.cpu}
ram={defaultInstallMethod.resources.ram}
hdd={defaultInstallMethod.resources.hdd}
/>
)}
{item.install_methods.find(method => method.type === "alpine")?.resources && (
<ResourceDisplay
title="Alpine"
{...item.install_methods.find(method => method.type === "alpine")!.resources!}
/>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 justify-between">
<InterFaces item={item} />
<div className="flex justify-end">
<Buttons item={item} />
</div>
</div>
</div>
);
}
function VersionInfo({ item }: { item: Script }) {
const { data: versions = [], isLoading } = useVersions();
if (isLoading || versions.length === 0) {
return null;
}
const matchedVersion = versions.find((v: AppVersion) => v.slug === item.slug);
if (!matchedVersion)
return null;
return (
<span className="font-medium text-sm flex items-center gap-1">
{matchedVersion.version}
{matchedVersion.pinned && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>This version is pinned. We test each update for breaking changes before releasing new versions.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
);
}
export function ScriptItem({ item }: ScriptItemProps) {
return (
<div className="w-full mx-auto">
<div className="flex w-full flex-col">
<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} />
</Suspense>
{item.disable && item.disable_description && (
<DisableDescription item={item} />
)}
{!item.disable && (
<>
<Description item={item} />
<Alerts item={item} />
<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"}
</h2>
<Tooltips item={item} />
</div>
<Separator />
<div className="">
<InstallCommand item={item} />
</div>
{item.config_path && (
<>
<Separator />
<div className="flex gap-3 px-4 py-2 bg-accent/25">
<h2 className="text-lg font-semibold">Location of config file</h2>
</div>
<Separator />
<div className="">
<ConfigFile configPath={item.config_path} />
</div>
</>
)}
</div>
<DefaultPassword item={item} />
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,39 +0,0 @@
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) => (
<div key={index} className="mt-4 flex flex-col shadow-sm gap-2">
<p
className={cn(
"inline-flex items-center gap-2 rounded-lg border p-2 pl-4 text-sm",
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" />
)}
<span>{TextCopyBlock(note.text)}</span>
</p>
</div>
))}
</>
);
}

View File

@@ -1,104 +0,0 @@
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 { Button } from "@/components/ui/button";
import { basePath } from "@/config/site-config";
function generateInstallSourceUrl(slug: string) {
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
return `${baseUrl}/install/${slug}-install.sh`;
}
function generateSourceUrl(slug: string, type: string) {
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
switch (type) {
case "vm":
return `${baseUrl}/vm/${slug}.sh`;
case "pve":
return `${baseUrl}/tools/pve/${slug}.sh`;
case "addon":
return `${baseUrl}/tools/addon/${slug}.sh`;
case "turnkey":
return `${baseUrl}/turnkey/${slug}.sh`;
default:
return `${baseUrl}/ct/${slug}.sh`; // fallback for "ct"
}
}
function generateUpdateUrl(slug: string) {
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
return `${baseUrl}/ct/${slug}.sh`;
}
type LinkItem = {
href: string;
icon: React.ReactNode;
text: string;
};
export default function Buttons({ item }: { item: Script }) {
const isCtOrDefault = ["ct"].includes(item.type);
const installSourceUrl = isCtOrDefault ? generateInstallSourceUrl(item.slug) : null;
const updateSourceUrl = isCtOrDefault ? generateUpdateUrl(item.slug) : null;
const sourceUrl = !isCtOrDefault ? generateSourceUrl(item.slug, item.type) : null;
const links = [
item.website && {
href: item.website,
icon: <Globe className="h-4 w-4" />,
text: "Website",
},
item.documentation && {
href: item.documentation,
icon: <BookOpenText className="h-4 w-4" />,
text: "Documentation",
},
installSourceUrl && {
href: installSourceUrl,
icon: <Code className="h-4 w-4" />,
text: "Install Source",
},
updateSourceUrl && item.updateable && {
href: updateSourceUrl,
icon: <RefreshCcw className="h-4 w-4" />,
text: "Update Source",
},
sourceUrl && {
href: sourceUrl,
icon: <Code className="h-4 w-4" />,
text: "Source Code",
},
].filter(Boolean) as LinkItem[];
if (links.length === 0)
return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<LinkIcon className="size-4" />
Links
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{links.map((link, index) => (
<DropdownMenuItem key={index} asChild>
<a href={link.href} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2">
<span className="text-muted-foreground size-4">{link.icon}</span>
<span>{link.text}</span>
</a>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,9 +0,0 @@
import ConfigCopyButton from "@/components/ui/config-copy-button";
export default function ConfigFile({ configPath }: { configPath: string }) {
return (
<div className="px-4 pb-4">
<ConfigCopyButton>{configPath}</ConfigCopyButton>
</div>
);
}

View File

@@ -1,51 +0,0 @@
import type { Script } from "@/lib/types";
import { Separator } from "@/components/ui/separator";
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;
const copyCredential = (type: "username" | "password") => {
handleCopy(type, item.default_credentials[type] ?? "");
};
return (
<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">Default Login Credentials</h2>
</div>
<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}
.
</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;
})}
</div>
</div>
);
}

View File

@@ -1,40 +0,0 @@
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`);
const ResourceDisplay = ({ settings, title }: { settings: (typeof item.install_methods)[0]; title: string }) => {
const { cpu, ram, hdd } = settings.resources;
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>
</div>
);
};
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);
return (
<div className="space-y-4 flex-col flex">
{hasDefaultSettings && <ResourceDisplay settings={defaultSettings} title="Default settings" />}
{defaultAlpineSettings && <ResourceDisplay settings={defaultAlpineSettings} title="Default Alpine settings" />}
</div>
);
}

View File

@@ -1,14 +0,0 @@
import type { Script } from "@/lib/types";
import TextCopyBlock from "@/components/text-copy-block";
export default function Description({ item }: { item: Script }) {
return (
<div className="p-2">
<h2 className="mb-2 max-w-prose text-lg font-semibold">Description</h2>
<p className="text-sm text-muted-foreground">
{TextCopyBlock(item.description)}
</p>
</div>
);
}

View File

@@ -1,26 +0,0 @@
import { AlertCircle } from "lucide-react";
import type { Script } from "@/lib/types";
import TextParseLinks from "@/components/text-parse-links";
import { AlertColors } from "@/config/site-config";
import { cn } from "@/lib/utils";
export default function DisableDescription({ item }: { item: Script }) {
return (
<div className="mt-4 flex flex-col shadow-sm gap-2">
<div
className={cn(
"flex items-start gap-3 rounded-lg border p-4 text-sm",
AlertColors.warning,
)}
>
<AlertCircle className="h-5 min-h-5 w-5 min-w-5 mt-0.5" />
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-base">Script Disabled</h3>
<p>{TextParseLinks(item.disable_description!)}</p>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,59 +0,0 @@
import { CircleHelp } from "lucide-react";
import React from "react";
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" />}
</Badge>
</TooltipTrigger>
{content && (
<TooltipContent side="bottom" className="text-sm max-w-64">
{content}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
);
export default function Tooltips({ item }: { item: Script }) {
return (
<div className="flex items-center gap-2">
{item.privileged && item.type !== "addon" && (
<TooltipBadge variant="warning" label="Privileged" content="This script will be run in a privileged LXC" />
)}
{item.updateable && item.type !== "pve" && item.type !== "addon" && (
<TooltipBadge
variant="success"
label="Updateable"
content={`To Update ${item.name}, run the command below (or type update) in the LXC Console.`}
/>
)}
{item.updateable && item.type === "addon" && (
<TooltipBadge
variant="success"
label="Updateable"
content={`Run update_${item.slug} to update or use the bash command inside the LXC.`}
/>
)}
{!item.updateable && item.type !== "pve" && item.type !== "addon" && <TooltipBadge variant="failure" label="Not Updateable" />}
</div>
);
}

View File

@@ -1,61 +0,0 @@
"use client";
import type { Category, Script } from "@/lib/types";
import { cn } from "@/lib/utils";
import ScriptAccordion from "./script-accordion";
type SidebarProps = {
items: Category[];
selectedScript: string | null;
setSelectedScript: (script: string | null) => void;
selectedCategory: string | null;
setSelectedCategory: (category: string | null) => void;
onItemSelect?: () => void;
className?: string;
};
function Sidebar({
items,
selectedScript,
setSelectedScript,
selectedCategory,
setSelectedCategory,
onItemSelect,
className,
}: SidebarProps) {
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={cn("flex w-full flex-col sm:min-w-[350px] sm:max-w-[350px]", className)}>
<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}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
onItemSelect={onItemSelect}
/>
</div>
</div>
);
}
export default Sidebar;

View File

@@ -1,13 +0,0 @@
import type { AppVersion } from "@/lib/types";
type VersionBadgeProps = {
version: AppVersion;
};
export function VersionBadge({ version }: VersionBadgeProps) {
return (
<div className="flex items-center">
<span className="font-medium text-sm">{version.version}</span>
</div>
);
}

View File

@@ -1,105 +0,0 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Loader2, X } from "lucide-react";
import { useQueryState } from "nuqs";
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/script-info-blocks";
import Sidebar from "./_components/sidebar";
export const dynamic = "force-static";
function ScriptContent() {
const [selectedScript, setSelectedScript] = useQueryState("id");
const [selectedCategory, setSelectedCategory] = useQueryState("category");
const [links, setLinks] = useState<Category[]>([]);
const [item, setItem] = useState<Script>();
const [latestPage, setLatestPage] = useState(1);
const closeScript = () => {
window.history.pushState({}, document.title, window.location.pathname);
setSelectedScript(null);
};
useEffect(() => {
if (selectedScript && links.length > 0) {
const script = links
.map(category => category.scripts)
.flat()
.find(script => script.slug === selectedScript);
setItem(script);
if (script) {
document.title = `${script.name} | Proxmox VE Helper-Scripts`;
}
} else {
document.title = "Proxmox VE Helper-Scripts";
}
}, [selectedScript, links]);
useEffect(() => {
fetchCategories()
.then((categories) => {
setLinks(categories);
})
.catch(error => console.error(error));
}, []);
return (
<div className="mb-3">
<div className="mt-20 flex gap-4 sm:px-4 xl:px-0">
<div className="hidden sm:flex">
<Sidebar
items={links}
selectedScript={selectedScript}
setSelectedScript={setSelectedScript}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
/>
</div>
<div className="px-4 w-full sm:max-w-[calc(100%-350px-16px)]">
{selectedScript && item
? (
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight text-foreground/90">Selected Script</h2>
<button
onClick={closeScript}
className="rounded-full p-2 text-muted-foreground hover:bg-card/50 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<ScriptItem item={item} />
</div>
)
: (
<div className="flex w-full flex-col gap-5">
<LatestScripts items={links} page={latestPage} onPageChange={setLatestPage} />
<MostViewedScripts items={links} />
</div>
)}
</div>
</div>
</div>
);
}
export default function Page() {
return (
<Suspense
fallback={(
<div className="flex h-screen w-full flex-col items-center justify-center gap-5 bg-background px-4 md:px-6">
<div className="space-y-2 text-center">
<Loader2 className="h-10 w-10 animate-spin" />
</div>
</div>
)}
>
<ScriptContent />
</Suspense>
);
}

View File

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

View File

@@ -1,61 +0,0 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import * as React from "react";
import type { ButtonProps as ButtonPrimitiveProps } from "@/components/animate-ui/primitives/buttons/button";
import {
Button as ButtonPrimitive,
} from "@/components/animate-ui/primitives/buttons/button";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
accent: "bg-accent text-accent-foreground shadow-xs hover:bg-accent/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8 rounded-md",
"icon-lg": "size-10 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = ButtonPrimitiveProps & VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<ButtonPrimitive
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, type ButtonProps, buttonVariants };

View File

@@ -1,109 +0,0 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { StarIcon } from "lucide-react";
import Link from "next/link";
import type { ButtonProps as ButtonPrimitiveProps } from "@/components/animate-ui/primitives/buttons/button";
import type { GithubStarsProps } from "@/components/animate-ui/primitives/animate/github-stars";
import {
GithubStars,
GithubStarsIcon,
GithubStarsLogo,
GithubStarsNumber,
GithubStarsParticles,
} from "@/components/animate-ui/primitives/animate/github-stars";
import { Button as ButtonPrimitive } from "@/components/animate-ui/primitives/buttons/button";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
accent: "bg-accent text-accent-foreground shadow-xs hover:bg-accent/90",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const buttonStarVariants = cva("", {
variants: {
variant: {
default: "fill-neutral-700 stroke-neutral-700 dark:fill-neutral-300 dark:stroke-neutral-300",
accent: "fill-neutral-300 stroke-neutral-300 dark:fill-neutral-700 dark:stroke-neutral-700",
outline: "fill-neutral-300 stroke-neutral-300 dark:fill-neutral-700 dark:stroke-neutral-700",
ghost: "fill-neutral-300 stroke-neutral-300 dark:fill-neutral-700 dark:stroke-neutral-700",
},
},
defaultVariants: {
variant: "default",
},
});
type GitHubStarsButtonProps = Omit<ButtonPrimitiveProps & GithubStarsProps, "asChild" | "children">
& VariantProps<typeof buttonVariants>;
function GitHubStarsButton({
className,
username,
repo,
value,
delay,
inView,
inViewMargin,
inViewOnce,
variant,
size,
...props
}: GitHubStarsButtonProps) {
return (
<Link
target="_blank"
rel="noopener noreferrer"
data-umami-event="github-stars"
href={`https://github.com/${username}/${repo}`}
>
<GithubStars
asChild
username={username}
repo={repo}
value={value}
delay={delay}
inView={inView}
inViewMargin={inViewMargin}
inViewOnce={inViewOnce}
>
<ButtonPrimitive className={cn(buttonVariants({ variant, size, className }))} {...props}>
<GithubStarsLogo />
<GithubStarsNumber />
<GithubStarsParticles className="text-yellow-500">
<GithubStarsIcon
icon={StarIcon}
data-variant={variant}
className={cn(buttonStarVariants({ variant }))}
activeClassName="text-yellow-500"
size={18}
/>
</GithubStarsParticles>
</ButtonPrimitive>
</GithubStars>
</Link>
);
}
export { GitHubStarsButton, type GitHubStarsButtonProps };

View File

@@ -1,206 +0,0 @@
"use client";
import type { HTMLMotionProps } from "motion/react";
import { motion } from "motion/react";
import * as React from "react";
import type { SlidingNumberProps } from "@/components/animate-ui/primitives/texts/sliding-number";
import type { ParticlesEffectProps } from "@/components/animate-ui/primitives/effects/particles";
import type { WithAsChild } from "@/components/animate-ui/primitives/animate/slot";
import type { UseIsInViewOptions } from "@/hooks/use-is-in-view";
import { Particles, ParticlesEffect } from "@/components/animate-ui/primitives/effects/particles";
import { SlidingNumber } from "@/components/animate-ui/primitives/texts/sliding-number";
import { Slot } from "@/components/animate-ui/primitives/animate/slot";
import { getStrictContext } from "@/lib/get-strict-context";
import { useIsInView } from "@/hooks/use-is-in-view";
import { cn } from "@/lib/utils";
type GithubStarsContextType = {
stars: number;
setStars: (stars: number) => void;
currentStars: number;
setCurrentStars: (stars: number) => void;
isCompleted: boolean;
isLoading: boolean;
};
const [GithubStarsProvider, useGithubStars] = getStrictContext<GithubStarsContextType>("GithubStarsContext");
type GithubStarsProps = WithAsChild<
{
children: React.ReactNode;
username?: string;
repo?: string;
value?: number;
delay?: number;
} & UseIsInViewOptions
& HTMLMotionProps<"div">
>;
function GithubStars({
ref,
children,
username,
repo,
value,
delay = 0,
inView = false,
inViewMargin = "0px",
inViewOnce = true,
asChild = false,
...props
}: GithubStarsProps) {
const { ref: localRef, isInView } = useIsInView(ref as React.Ref<HTMLDivElement>, {
inView,
inViewOnce,
inViewMargin,
});
const [stars, setStars] = React.useState(value ?? 0);
const [currentStars, setCurrentStars] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const isCompleted = React.useMemo(() => currentStars === stars, [currentStars, stars]);
const Component = asChild ? Slot : motion.div;
React.useEffect(() => {
if (value !== undefined && username && repo)
return;
if (!isInView) {
setStars(0);
setIsLoading(true);
return;
}
const timeout = setTimeout(() => {
fetch(`https://api.github.com/repos/${username}/${repo}`)
.then(response => response.json())
.then((data) => {
if (data && typeof data.stargazers_count === "number") {
setStars(data.stargazers_count);
}
})
.catch(console.error)
.finally(() => setIsLoading(false));
}, delay);
return () => clearTimeout(timeout);
}, [username, repo, value, isInView, delay]);
return (
<GithubStarsProvider
value={{
stars,
currentStars,
isCompleted,
isLoading,
setStars,
setCurrentStars,
}}
>
{!isLoading && (
<Component ref={localRef} {...props}>
{children}
</Component>
)}
</GithubStarsProvider>
);
}
type GithubStarsNumberProps = Omit<SlidingNumberProps, "number" | "fromNumber">;
function GithubStarsNumber({ padStart = true, ...props }: GithubStarsNumberProps) {
const { stars, setCurrentStars } = useGithubStars();
return (
<SlidingNumber number={stars} fromNumber={0} onNumberChange={setCurrentStars} padStart={padStart} {...props} />
);
}
type GithubStarsIconProps<T extends React.ElementType> = {
icon: React.ReactElement<T>;
color?: string;
activeClassName?: string;
} & React.ComponentProps<T>;
function GithubStarsIcon<T extends React.ElementType>({
icon: Icon,
color = "currentColor",
activeClassName,
className,
...props
}: GithubStarsIconProps<T>) {
const { stars, currentStars, isCompleted } = useGithubStars();
const fillPercentage = (currentStars / stars) * 100;
return (
<div style={{ position: "relative" }}>
<Icon aria-hidden="true" className={cn(className)} {...props} />
<Icon
aria-hidden="true"
style={{
position: "absolute",
top: 0,
left: 0,
fill: color,
stroke: color,
clipPath: `inset(${100 - (isCompleted ? fillPercentage : fillPercentage - 10)}% 0 0 0)`,
}}
className={cn(className, activeClassName)}
{...props}
/>
</div>
);
}
type GithubStarsParticlesProps = ParticlesEffectProps & {
children: React.ReactElement;
size?: number;
};
function GithubStarsParticles({ children, size = 4, style, ...props }: GithubStarsParticlesProps) {
const { isCompleted } = useGithubStars();
return (
<Particles animate={isCompleted}>
{children}
<ParticlesEffect
style={{
backgroundColor: "currentcolor",
borderRadius: "50%",
width: size,
height: size,
...style,
}}
{...props}
/>
</Particles>
);
}
type GithubStarsLogoProps = React.SVGProps<SVGSVGElement>;
function GithubStarsLogo(props: GithubStarsLogoProps) {
return (
<svg role="img" viewBox="0 0 24 24" fill="currentColor" aria-label="GitHub" {...props}>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
</svg>
);
}
export {
GithubStars,
type GithubStarsContextType,
GithubStarsIcon,
type GithubStarsIconProps,
GithubStarsLogo,
type GithubStarsLogoProps,
GithubStarsNumber,
type GithubStarsNumberProps,
GithubStarsParticles,
type GithubStarsParticlesProps,
type GithubStarsProps,
useGithubStars,
};

View File

@@ -1,101 +0,0 @@
"use client";
import type { HTMLMotionProps } from "motion/react";
import { isMotionComponent, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
HTMLMotionProps<keyof HTMLElementTagNameMap>,
"ref"
> & { ref?: React.Ref<T> };
type WithAsChild<Base extends object>
= | (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
children?: any;
} & DOMMotionProps<T>;
function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref)
return;
if (typeof ref === "function") {
ref(node);
}
else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(
childProps: AnyProps,
slotProps: DOMMotionProps<T>,
): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(
childProps.className as string,
slotProps.className as string,
);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion
= typeof children.type === "object"
&& children.type !== null
&& isMotionComponent(children.type);
const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
);
if (!React.isValidElement(children))
return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return (
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
export {
type AnyProps,
type DOMMotionProps,
Slot,
type SlotProps,
type WithAsChild,
};

View File

@@ -1,36 +0,0 @@
"use client";
import type { HTMLMotionProps } from "motion/react";
import { motion } from "motion/react";
import * as React from "react";
import type { WithAsChild } from "@/components/animate-ui/primitives/animate/slot";
import { Slot } from "@/components/animate-ui/primitives/animate/slot";
type ButtonProps = WithAsChild<
HTMLMotionProps<"button"> & {
hoverScale?: number;
tapScale?: number;
}
>;
function Button({
hoverScale = 1.05,
tapScale = 0.95,
asChild = false,
...props
}: ButtonProps) {
const Component = asChild ? Slot : motion.button;
return (
<Component
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
{...props}
/>
);
}
export { Button, type ButtonProps };

View File

@@ -1,160 +0,0 @@
"use client";
import type { HTMLMotionProps } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import type { WithAsChild } from "@/components/animate-ui/primitives/animate/slot";
import type { UseIsInViewOptions } from "@/hooks/use-is-in-view";
import { Slot } from "@/components/animate-ui/primitives/animate/slot";
import { getStrictContext } from "@/lib/get-strict-context";
import {
useIsInView,
} from "@/hooks/use-is-in-view";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
type ParticlesContextType = {
animate: boolean;
isInView: boolean;
};
const [ParticlesProvider, useParticles]
= getStrictContext<ParticlesContextType>("ParticlesContext");
type ParticlesProps = WithAsChild<
Omit<HTMLMotionProps<"div">, "children"> & {
animate?: boolean;
children: React.ReactNode;
} & UseIsInViewOptions
>;
function Particles({
ref,
animate = true,
asChild = false,
inView = false,
inViewMargin = "0px",
inViewOnce = true,
children,
style,
...props
}: ParticlesProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLDivElement>,
{ inView, inViewOnce, inViewMargin },
);
const Component = asChild ? Slot : motion.div;
return (
<ParticlesProvider value={{ animate, isInView }}>
<Component
ref={localRef}
style={{ position: "relative", ...style }}
{...props}
>
{children}
</Component>
</ParticlesProvider>
);
}
type ParticlesEffectProps = Omit<HTMLMotionProps<"div">, "children"> & {
side?: Side;
align?: Align;
count?: number;
radius?: number;
spread?: number;
duration?: number;
holdDelay?: number;
sideOffset?: number;
alignOffset?: number;
delay?: number;
};
function ParticlesEffect({
side = "top",
align = "center",
count = 6,
radius = 30,
spread = 360,
duration = 0.8,
holdDelay = 0.05,
sideOffset = 0,
alignOffset = 0,
delay = 0,
transition,
style,
...props
}: ParticlesEffectProps) {
const { animate, isInView } = useParticles();
const isVertical = side === "top" || side === "bottom";
const alignPct = align === "start" ? "0%" : align === "end" ? "100%" : "50%";
const top = isVertical
? side === "top"
? `calc(0% - ${sideOffset}px)`
: `calc(100% + ${sideOffset}px)`
: `calc(${alignPct} + ${alignOffset}px)`;
const left = isVertical
? `calc(${alignPct} + ${alignOffset}px)`
: side === "left"
? `calc(0% - ${sideOffset}px)`
: `calc(100% + ${sideOffset}px)`;
const containerStyle: React.CSSProperties = {
position: "absolute",
top,
left,
transform: "translate(-50%, -50%)",
};
const angleStep = (spread * (Math.PI / 180)) / Math.max(1, count - 1);
return (
<AnimatePresence>
{animate
&& isInView
&& Array.from({ length: count }).map((_, i) => {
const angle = i * angleStep;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
return (
<motion.div
key={i}
style={{ ...containerStyle, ...style }}
initial={{ scale: 0, opacity: 0 }}
animate={{
x: `${x}px`,
y: `${y}px`,
scale: [0, 1, 0],
opacity: [0, 1, 0],
}}
transition={{
duration,
delay: delay + i * holdDelay,
ease: "easeOut",
...transition,
}}
{...props}
/>
);
})}
</AnimatePresence>
);
}
export {
Particles,
ParticlesEffect,
type ParticlesEffectProps,
type ParticlesProps,
};

View File

@@ -1,338 +0,0 @@
"use client";
import type { MotionValue, SpringOptions } from "motion/react";
import {
motion,
useMotionValue,
useSpring,
useTransform,
} from "motion/react";
import useMeasure from "react-use-measure";
import * as React from "react";
import type { UseIsInViewOptions } from "@/hooks/use-is-in-view";
import {
useIsInView,
} from "@/hooks/use-is-in-view";
type SlidingNumberRollerProps = {
prevValue: number;
value: number;
place: number;
transition: SpringOptions;
delay?: number;
};
function SlidingNumberRoller({
prevValue,
value,
place,
transition,
delay = 0,
}: SlidingNumberRollerProps) {
const startNumber = Math.floor(prevValue / place) % 10;
const targetNumber = Math.floor(value / place) % 10;
const animatedValue = useSpring(startNumber, transition);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
animatedValue.set(targetNumber);
}, delay);
return () => clearTimeout(timeoutId);
}, [targetNumber, animatedValue, delay]);
const [measureRef, { height }] = useMeasure();
return (
<span
ref={measureRef}
data-slot="sliding-number-roller"
style={{
position: "relative",
display: "inline-block",
width: "1ch",
overflowX: "visible",
overflowY: "clip",
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
}}
>
<span style={{ visibility: "hidden" }}>0</span>
{Array.from({ length: 10 }, (_, i) => (
<SlidingNumberDisplay
key={i}
motionValue={animatedValue}
number={i}
height={height}
transition={transition}
/>
))}
</span>
);
}
type SlidingNumberDisplayProps = {
motionValue: MotionValue<number>;
number: number;
height: number;
transition: SpringOptions;
};
function SlidingNumberDisplay({
motionValue,
number,
height,
transition,
}: SlidingNumberDisplayProps) {
const y = useTransform(motionValue, (latest) => {
if (!height)
return 0;
const currentNumber = latest % 10;
const offset = (10 + number - currentNumber) % 10;
let translateY = offset * height;
if (offset > 5)
translateY -= 10 * height;
return translateY;
});
if (!height) {
return (
<span style={{ visibility: "hidden", position: "absolute" }}>
{number}
</span>
);
}
return (
<motion.span
data-slot="sliding-number-display"
style={{
y,
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
transition={{ ...transition, type: "spring" }}
>
{number}
</motion.span>
);
}
type SlidingNumberProps = Omit<React.ComponentProps<"span">, "children"> & {
number: number;
fromNumber?: number;
onNumberChange?: (number: number) => void;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
thousandSeparator?: string;
transition?: SpringOptions;
delay?: number;
} & UseIsInViewOptions;
function SlidingNumber({
ref,
number,
fromNumber,
onNumberChange,
inView = false,
inViewMargin = "0px",
inViewOnce = true,
padStart = false,
decimalSeparator = ".",
decimalPlaces = 0,
thousandSeparator,
transition = { stiffness: 200, damping: 20, mass: 0.4 },
delay = 0,
...props
}: SlidingNumberProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const prevNumberRef = React.useRef<number>(0);
const hasAnimated = fromNumber !== undefined;
const motionVal = useMotionValue(fromNumber ?? 0);
const springVal = useSpring(motionVal, { stiffness: 90, damping: 50 });
React.useEffect(() => {
if (!hasAnimated)
return;
const timeoutId = setTimeout(() => {
if (isInView)
motionVal.set(number);
}, delay);
return () => clearTimeout(timeoutId);
}, [hasAnimated, isInView, number, motionVal, delay]);
const [effectiveNumber, setEffectiveNumber] = React.useState(0);
React.useEffect(() => {
if (hasAnimated) {
const inferredDecimals
= typeof decimalPlaces === "number" && decimalPlaces >= 0
? decimalPlaces
: (() => {
const s = String(number);
const idx = s.indexOf(".");
return idx >= 0 ? s.length - idx - 1 : 0;
})();
const factor = 10 ** inferredDecimals;
const unsubscribe = springVal.on("change", (latest: number) => {
const newValue
= inferredDecimals > 0
? Math.round(latest * factor) / factor
: Math.round(latest);
if (effectiveNumber !== newValue) {
setEffectiveNumber(newValue);
onNumberChange?.(newValue);
}
});
return () => unsubscribe();
}
else {
setEffectiveNumber(!isInView ? 0 : Math.abs(Number(number)));
}
}, [
hasAnimated,
springVal,
isInView,
number,
decimalPlaces,
onNumberChange,
effectiveNumber,
]);
const formatNumber = React.useCallback(
(num: number) =>
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
[decimalPlaces],
);
const numberStr = formatNumber(effectiveNumber);
const [newIntStrRaw, newDecStrRaw = ""] = numberStr.split(".");
const finalIntLength = padStart
? Math.max(
Math.floor(Math.abs(number)).toString().length,
newIntStrRaw.length,
)
: newIntStrRaw.length;
const newIntStr = padStart
? newIntStrRaw.padStart(finalIntLength, "0")
: newIntStrRaw;
const prevFormatted = formatNumber(prevNumberRef.current);
const [prevIntStrRaw = "", prevDecStrRaw = ""] = prevFormatted.split(".");
const prevIntStr = padStart
? prevIntStrRaw.padStart(finalIntLength, "0")
: prevIntStrRaw;
const adjustedPrevInt = React.useMemo(() => {
return prevIntStr.length > finalIntLength
? prevIntStr.slice(-finalIntLength)
: prevIntStr.padStart(finalIntLength, "0");
}, [prevIntStr, finalIntLength]);
const adjustedPrevDec = React.useMemo(() => {
if (!newDecStrRaw)
return "";
return prevDecStrRaw.length > newDecStrRaw.length
? prevDecStrRaw.slice(0, newDecStrRaw.length)
: prevDecStrRaw.padEnd(newDecStrRaw.length, "0");
}, [prevDecStrRaw, newDecStrRaw]);
React.useEffect(() => {
if (isInView)
prevNumberRef.current = effectiveNumber;
}, [effectiveNumber, isInView]);
const intPlaces = React.useMemo(
() =>
Array.from({ length: finalIntLength }, (_, i) =>
10 ** (finalIntLength - i - 1)),
[finalIntLength],
);
const decPlaces = React.useMemo(
() =>
newDecStrRaw
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
10 ** (newDecStrRaw.length - i - 1))
: [],
[newDecStrRaw],
);
const newDecValue = newDecStrRaw ? Number.parseInt(newDecStrRaw, 10) : 0;
const prevDecValue = adjustedPrevDec ? Number.parseInt(adjustedPrevDec, 10) : 0;
return (
<span
ref={localRef}
data-slot="sliding-number"
style={{
display: "inline-flex",
alignItems: "center",
}}
{...props}
>
{isInView && Number(number) < 0 && (
<span style={{ marginRight: "0.25rem" }}>-</span>
)}
{intPlaces.map((place, idx) => {
const digitsToRight = intPlaces.length - idx - 1;
const isSeparatorPosition
= typeof thousandSeparator !== "undefined"
&& digitsToRight > 0
&& digitsToRight % 3 === 0;
return (
<React.Fragment key={`int-${place}`}>
<SlidingNumberRoller
prevValue={Number.parseInt(adjustedPrevInt, 10)}
value={Number.parseInt(newIntStr ?? "0", 10)}
place={place}
transition={transition}
/>
{isSeparatorPosition && <span>{thousandSeparator}</span>}
</React.Fragment>
);
})}
{newDecStrRaw && (
<>
<span>{decimalSeparator}</span>
{decPlaces.map(place => (
<SlidingNumberRoller
key={`dec-${place}`}
prevValue={prevDecValue}
value={newDecValue}
place={place}
transition={transition}
delay={delay}
/>
))}
</>
)}
</span>
);
}
export { SlidingNumber, type SlidingNumberProps };

View File

@@ -1,282 +0,0 @@
import { ArrowRightIcon, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import type { Category, Script } from "@/lib/types";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { basePath } from "@/config/site-config";
import { fetchCategories } from "@/lib/data";
import { cn } from "@/lib/utils";
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>;
case "ct":
return <Badge className="text-yellow-500/75 border-yellow-500/75">LXC</Badge>;
case "pve":
return <Badge className="text-orange-500/75 border-orange-500/75">PVE</Badge>;
case "addon":
return <Badge className="text-green-500/75 border-green-500/75">ADDON</Badge>;
}
return null;
}
function getRandomScript(categories: Category[], previouslySelected: Set<string> = new Set()): Script | null {
const allScripts = categories.flatMap(cat => cat.scripts || []);
if (allScripts.length === 0)
return null;
const availableScripts = allScripts.filter(script => !previouslySelected.has(script.slug));
if (availableScripts.length === 0) {
return allScripts[Math.floor(Math.random() * allScripts.length)];
}
const idx = Math.floor(Math.random() * availableScripts.length);
return availableScripts[idx];
}
function CommandMenu() {
const [open, setOpen] = React.useState(false);
const [links, setLinks] = React.useState<Category[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [selectedScripts, setSelectedScripts] = React.useState<Set<string>>(new Set());
const router = useRouter();
const fetchSortedCategories = () => {
setIsLoading(true);
fetchCategories()
.then((categories) => {
setLinks(categories);
setIsLoading(false);
})
.catch((error) => {
setIsLoading(false);
console.error(error);
});
};
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
fetchSortedCategories();
setOpen(open => !open);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
const handleOpenRandomScript = async () => {
if (links.length === 0) {
setIsLoading(true);
try {
const categories = await fetchCategories();
setLinks(categories);
const randomScript = getRandomScript(categories, selectedScripts);
if (randomScript) {
setSelectedScripts(prev => new Set([...prev, randomScript.slug]));
router.push(`/scripts?id=${randomScript.slug}`);
}
}
finally {
setIsLoading(false);
}
}
else {
const randomScript = getRandomScript(links, selectedScripts);
if (randomScript) {
setSelectedScripts(prev => new Set([...prev, randomScript.slug]));
router.push(`/scripts?id=${randomScript.slug}`);
}
}
};
const getUniqueScriptsMap = React.useCallback(() => {
const scriptMap = new Map<string, { script: Script; categoryName: string }>();
for (const category of links) {
for (const script of category.scripts) {
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, { script, categoryName: category.name });
}
}
}
return scriptMap;
}, [links]);
const getUniqueScriptsByCategory = React.useCallback(() => {
const scriptMap = getUniqueScriptsMap();
const categoryOrder = links.map(cat => cat.name);
const grouped: Record<string, Script[]> = {};
for (const name of categoryOrder) {
grouped[name] = [];
}
for (const { script, categoryName } of scriptMap.values()) {
if (grouped[categoryName]) {
grouped[categoryName].push(script);
}
else {
grouped[categoryName] = [script];
}
}
Object.keys(grouped).forEach((cat) => {
if (grouped[cat].length === 0)
delete grouped[cat];
});
return grouped;
}, [getUniqueScriptsMap, links]);
const uniqueScriptsByCategory = getUniqueScriptsByCategory();
return (
<>
<div className="flex gap-2">
<Button
variant="outline"
className={cn(
"relative h-9 w-full justify-start rounded-[0.5rem] bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-64",
)}
onClick={() => {
fetchSortedCategories();
setOpen(true);
}}
>
<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
</kbd>
</Button>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleOpenRandomScript}
disabled={isLoading}
className="hidden lg:flex"
aria-label="Open Random Script"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleOpenRandomScript();
}
}}
>
<Sparkles className="size-4" />
<span className="sr-only">Open Random Script</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Random Script</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<CommandDialog
open={open}
onOpenChange={setOpen}
filter={(value: string, search: string) => {
const searchLower = search.toLowerCase().trim();
if (!searchLower)
return 1;
const valueLower = value.toLowerCase();
const searchWords = searchLower.split(/\s+/).filter(Boolean);
// All search words must appear somewhere in the value (name + description)
const allWordsMatch = searchWords.every((word: string) => valueLower.includes(word));
return allWordsMatch ? 1 : 0;
}}
>
<DialogTitle className="sr-only">Search scripts</DialogTitle>
<CommandInput placeholder="Search for a script..." />
<CommandList>
<CommandEmpty>
{isLoading
? (
"Searching..."
)
: (
<div className="flex flex-col items-center justify-center py-6 text-center">
<p className="text-sm text-muted-foreground">No scripts match your search.</p>
<div className="mt-4">
<p className="text-xs text-muted-foreground mb-2">Want to add a new script?</p>
<Button variant="outline" size="sm" asChild>
<Link
href={`https://github.com/community-scripts/${basePath}/tree/main/docs/contribution/GUIDE.md`}
target="_blank"
rel="noopener noreferrer"
>
Documentation
{" "}
<ArrowRightIcon className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
)}
</CommandEmpty>
{Object.entries(uniqueScriptsByCategory).map(([categoryName, scripts]) => (
<CommandGroup key={`category:${categoryName}`} heading={categoryName}>
{scripts.map(script => (
<CommandItem
key={`script:${script.slug}`}
value={`${script.name} ${script.type} ${script.description || ""}`}
onSelect={() => {
setOpen(false);
router.push(`/scripts?id=${script.slug}`);
}}
tabIndex={0}
aria-label={`Open script ${script.name}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
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>
</>
);
}
export default CommandMenu;

View File

@@ -1,24 +0,0 @@
"use client";
import { useEffect } from "react";
import { toast } from "sonner";
const STORAGE_KEY = "copycat-warning-dismissed";
export function CopycatWarningToast() {
useEffect(() => {
if (typeof window === "undefined")
return;
if (localStorage.getItem(STORAGE_KEY) === "true")
return;
toast.warning("Beware of copycat sites. Always verify the URL is correct before trusting or running scripts.", {
position: "top-center",
duration: Number.POSITIVE_INFINITY,
closeButton: true,
onDismiss: () => localStorage.setItem(STORAGE_KEY, "true"),
});
}, []);
return null;
}

View File

@@ -1,30 +0,0 @@
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { Plus } from "lucide-react";
import { Accordion, AccordionContent, AccordionItem } from "./ui/accordion";
import { FAQ_Items } from "../config/faq-config";
export default function FAQ() {
return (
<div className="space-y-4">
<Accordion type="single" collapsible className="w-full">
{FAQ_Items.map((item, index) => (
<AccordionItem value={index.toString()} key={index} className="py-2">
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger className="flex flex-1 items-center gap-3 py-2 text-left text-[15px] font-semibold leading-6 transition-all [&>svg>path:last-child]:origin-center [&>svg>path:last-child]:transition-all [&>svg>path:last-child]:duration-200 [&>svg]:-order-1 [&[data-state=open]>svg>path:last-child]:rotate-90 [&[data-state=open]>svg>path:last-child]:opacity-0 [&[data-state=open]>svg]:rotate-180">
{item.title}
<Plus
size={16}
strokeWidth={2}
className="shrink-0 opacity-60 transition-transform duration-200"
aria-hidden="true"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
<AccordionContent className="pb-2 ps-7 text-muted-foreground">{item.content}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
}

View File

@@ -1,50 +0,0 @@
import { FileJson, Server } from "lucide-react";
import Link from "next/link";
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
{" "}
<Link
href={`https://github.com/community-scripts/${basePath}/tree/main/frontend`}
target="_blank"
rel="noreferrer"
className="font-semibold underline-offset-2 duration-300 hover:underline"
data-umami-event="View Website Source Code on Github"
>
GitHub
</Link>
.
</p>
</div>
<div className="sm:flex hidden">
<Link
href="/json-editor"
className={cn(buttonVariants({ variant: "link" }), "text-muted-foreground flex items-center gap-2")}
>
<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
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +0,0 @@
import { ClipboardCheck } from "lucide-react";
import { toast } from "sonner";
export default function handleCopy(type: string, value: string) {
navigator.clipboard.writeText(value);
toast.success(`copied ${type} to clipboard`, {
icon: <ClipboardCheck className="h-4 w-4" />,
});
}

View File

@@ -1,48 +0,0 @@
export function CPUIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="size-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<rect x="9" y="9" width="6" height="6" />
<path d="M3 9h2m14 0h2M3 15h2m14 0h2M9 3v2m6-2v2M9 19v2m6-2v2" />
</svg>
);
}
export function RAMIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="size-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<rect x="4" y="6" width="16" height="12" rx="2" ry="2" />
<path d="M8 6v12M16 6v12" />
</svg>
);
}
export function HDDIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="size-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="M4 4h16v16H4z" />
<circle cx="8" cy="16" r="1" />
<circle cx="16" cy="16" r="1" />
</svg>
);
}

View File

@@ -1,30 +0,0 @@
"use client";
import React from "react";
type ModalProps = {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
};
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
if (!isOpen)
return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-lg w-11/12 max-w-4xl relative max-h-[90vh] overflow-y-auto">
<button
onClick={onClose}
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded"
>
</button>
{children}
</div>
</div>
);
};
export default Modal;

View File

@@ -1,80 +0,0 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { navbarLinks } from "@/config/site-config";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { GitHubStarsButton } from "./animate-ui/components/buttons/github-stars";
import { Button } from "./animate-ui/components/buttons/button";
import MobileSidebar from "./navigation/mobile-sidebar";
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="cursor-pointer w-full justify-center sm:justify-start flex-row-reverse hidden sm:flex items-center gap-2 font-semibold sm:flex-row"
>
<Image height={18} unoptimized width={18} alt="logo" src="/ProxmoxVE/logo.png" className="" />
<span className="">Proxmox VE Helper-Scripts</span>
</Link>
<div className="flex items-center justify-between sm:justify-end gap-2 w-full">
<div className="flex sm:hidden">
<Suspense>
<MobileSidebar />
</Suspense>
</div>
<div className="flex sm:gap-2">
<CommandMenu />
<GitHubStarsButton username="community-scripts" repo="ProxmoxVE" className="hidden md:flex" />
{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>
</div>
</>
);
}
export default Navbar;

View File

@@ -1,133 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { useQueryState } from "nuqs";
import { Menu } from "lucide-react";
import type { Category, Script } from "@/lib/types";
import { ScriptItem } from "@/app/scripts/_components/script-item";
import Sidebar from "@/app/scripts/_components/sidebar";
import { fetchCategories } from "@/lib/data";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "../ui/sheet";
import { Button } from "../ui/button";
function MobileSidebar() {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [lastViewedScript, setLastViewedScript] = useState<Script | undefined>(undefined);
const pathname = usePathname();
// Always call the hooks (React hooks can't be conditional)
const [selectedScript, setSelectedScript] = useQueryState("id");
const [selectedCategory, setSelectedCategory] = useQueryState("category");
// For non-scripts pages, we'll manage state locally
const [tempSelectedScript, setTempSelectedScript] = useState<string | null>(null);
const [tempSelectedCategory, setTempSelectedCategory] = useState<string | null>(null);
const isOnScriptsPage = pathname === "/scripts";
const currentSelectedScript = isOnScriptsPage ? selectedScript : tempSelectedScript;
const currentSelectedCategory = isOnScriptsPage ? selectedCategory : tempSelectedCategory;
const loadCategories = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetchCategories();
setCategories(response);
}
catch (error) {
console.error(error);
}
finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadCategories();
}, [loadCategories]);
useEffect(() => {
if (!currentSelectedScript || categories.length === 0) {
return;
}
const scriptMatch = categories
.flatMap(category => category.scripts)
.find(script => script.slug === currentSelectedScript);
setLastViewedScript(scriptMatch);
}, [currentSelectedScript, categories]);
const handleOpenChange = (openState: boolean) => {
setIsOpen(openState);
};
const handleItemSelect = () => {
setIsOpen(false);
};
const hasLinks = categories.length > 0;
return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Open navigation menu"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
setIsOpen(true);
}
}}
>
<Menu className="size-5" aria-hidden="true" />
</Button>
</SheetTrigger>
<SheetHeader className="border-b border-border px-6 pb-4 pt-2 sr-only">
<SheetTitle className="sr-only">Categories</SheetTitle>
</SheetHeader>
<SheetContent side="left" className="flex w-full max-w-xs flex-col gap-4 overflow-hidden px-0 pb-6">
<div className="flex h-full flex-col gap-4 overflow-y-auto">
{isLoading && !hasLinks
? (
<div className="flex w-full flex-col items-center justify-center gap-2 px-6 py-4 text-sm text-muted-foreground">
Loading categories...
</div>
)
: (
<div className="flex flex-col gap-4 px-4">
<Sidebar
items={categories}
selectedScript={currentSelectedScript}
setSelectedScript={isOnScriptsPage ? setSelectedScript : setTempSelectedScript}
selectedCategory={currentSelectedCategory}
setSelectedCategory={isOnScriptsPage ? setSelectedCategory : setTempSelectedCategory}
onItemSelect={handleItemSelect}
/>
</div>
)}
{currentSelectedScript && lastViewedScript
? (
<div className="flex flex-col gap-3 px-4">
<p className="text-sm font-medium">Last Viewed</p>
<ScriptItem
item={lastViewedScript}
/>
</div>
)
: null}
</div>
</SheetContent>
</Sheet>
);
}
export default MobileSidebar;

View File

@@ -1,9 +0,0 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
const queryClient = new QueryClient();
export default function QueryProvider({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -1,30 +0,0 @@
import { ClipboardIcon } from "lucide-react";
import handleCopy from "./handle-copy";
export default function TextCopyBlock(description: string) {
const pattern = /`([^`]*)`/g;
const parts = description.split(pattern);
const formattedDescription = parts.map((part: string, index: number) => {
if (index % 2 === 1) {
return (
<span
key={index}
className="bg-secondary py-1 px-2 rounded-lg inline-flex items-center gap-2"
>
{part}
<ClipboardIcon
className="size-3 cursor-pointer"
onClick={() => handleCopy("command", part)}
/>
</span>
);
}
else {
return part;
}
});
return formattedDescription;
}

View File

@@ -1,52 +0,0 @@
import { ClipboardIcon, ExternalLink } from "lucide-react";
import { Fragment } from "react";
import handleCopy from "./handle-copy";
const URL_PATTERN = /(https?:\/\/[^\s,]+)/;
const CODE_PATTERN = /`([^`]*)`/;
export default function TextParseLinks(text: string) {
const codeParts = text.split(CODE_PATTERN);
return codeParts.map((part: string, codeIndex: number) => {
if (codeIndex % 2 === 1) {
return (
<span
key={`code-${codeIndex}`}
className="bg-secondary py-1 px-2 rounded-lg inline-flex items-center gap-2"
>
{part}
<ClipboardIcon
className="size-3 cursor-pointer"
onClick={() => handleCopy("command", part)}
/>
</span>
);
}
const urlParts = part.split(URL_PATTERN);
return (
<Fragment key={`text-${codeIndex}`}>
{urlParts.map((urlPart: string, urlIndex: number) => {
if (urlIndex % 2 === 1) {
return (
<a
key={`url-${codeIndex}-${urlIndex}`}
href={urlPart}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline font-medium transition-colors"
>
{urlPart}
<ExternalLink className="size-3" />
</a>
);
}
return <Fragment key={`plain-${codeIndex}-${urlIndex}`}>{urlPart}</Fragment>;
})}
</Fragment>
);
});
}

View File

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

View File

@@ -1,57 +0,0 @@
"use client";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-1 pr-2 font-medium transition-all [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden py-1 text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -1,61 +0,0 @@
import type { VariantProps } from "class-variance-authority";
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",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertDescription, AlertTitle };

View File

@@ -1,26 +0,0 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export default function AnimatedGradientText({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"group relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-2xl bg-white/40 px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#8fdfff1f] backdrop-blur-sm transition-shadow duration-500 ease-out [--bg-size:300%] hover:shadow-[inset_0_-5px_10px_#8fdfff3f] dark:bg-black/40",
className,
)}
>
<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)]"
/>
{children}
</div>
);
}

View File

@@ -1,39 +0,0 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-1.5 py-0.1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent text-primary-foreground border-primary-foreground",
secondary:
"border-transparent text-secondary-foreground border-secondary-foreground",
destructive:
"border-transparent text-destructive-foreground border-destructive-foreground",
outline: "text-foreground",
success: "text-green-500 border-green-500",
warning: "text-yellow-500 border-yellow-500",
failure: "text-red-500 border-red-500",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type BadgeProps = {} & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -1,109 +0,0 @@
import type { VariantProps } from "class-variance-authority";
import { Slot, Slottable } from "@radix-ui/react-slot";
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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
expandIcon:
"group relative text-primary-foreground bg-primary hover:bg-primary/90",
ringHover:
"bg-primary text-primary-foreground transition-all duration-300 hover:bg-primary/90 hover:ring-2 hover:ring-primary/90 hover:ring-offset-2",
shine:
"text-primary-foreground animate-shine bg-gradient-to-r from-primary via-primary/75 to-primary bg-[length:400%_100%] ",
gooeyRight:
"text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 before:absolute before:inset-0 before:-z-10 before:translate-x-[150%] before:translate-y-[150%] before:scale-[2.5] before:rounded-[100%] before:bg-gradient-to-r from-zinc-400 before:transition-transform before:duration-1000 hover:before:translate-x-[0%] hover:before:translate-y-[0%] ",
gooeyLeft:
"text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 after:absolute after:inset-0 after:-z-10 after:translate-x-[-150%] after:translate-y-[150%] after:scale-[2.5] after:rounded-[100%] after:bg-gradient-to-l from-zinc-400 after:transition-transform after:duration-1000 hover:after:translate-x-[0%] hover:after:translate-y-[0%] ",
linkHover1:
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
linkHover2:
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9 ",
null: "py-1 px-3 rouded-xs",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type IconProps = {
Icon: React.ElementType;
iconPlacement: "left" | "right";
};
type IconRefProps = {
Icon?: never;
iconPlacement?: undefined;
};
export type ButtonProps = {
asChild?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
export type ButtonIconProps = IconProps | IconRefProps;
const Button = React.forwardRef<
HTMLButtonElement,
ButtonProps & ButtonIconProps
>(
(
{
className,
variant,
size,
asChild = false,
Icon,
iconPlacement,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
>
{Icon && iconPlacement === "left" && (
<div className="group-hover:translate-x-100 w-0 translate-x-[0%] pr-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:pr-2 group-hover:opacity-100">
<Icon />
</div>
)}
<Slottable>{props.children}</Slottable>
{Icon && iconPlacement === "right" && (
<div className="w-0 translate-x-[100%] pl-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:translate-x-0 group-hover:pl-2 group-hover:opacity-100">
<Icon />
</div>
)}
</Comp>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -1,70 +0,0 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import * as React from "react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
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",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
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",
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ ...props }) => {
if (props.orientation === "left") {
return <ChevronLeft className="h-4 w-4" />;
}
return <ChevronRight className="h-4 w-4" />;
},
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -1,89 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-4", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn(
"min-h-[40px] text-sm text-muted-foreground sm:min-h-[60px]",
className,
)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("mt-auto items-center p-4 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@@ -1,334 +0,0 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
type TooltipPayloadItem = {
value?: string | number;
name?: string;
dataKey?: string | number;
payload?: Record<string, unknown>;
color?: string;
fill?: string;
type?: string;
};
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
Omit<React.ComponentProps<typeof RechartsPrimitive.Tooltip>, "content"> &
React.ComponentProps<"div"> & {
active?: boolean;
payload?: TooltipPayloadItem[];
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
label?: string;
labelFormatter?: (value: any, payload: TooltipPayloadItem[]) => React.ReactNode;
labelClassName?: string;
formatter?: (value: any, name: string, item: TooltipPayloadItem, index: number, payload: any) => React.ReactNode;
color?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload?.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
type LegendPayloadItem = {
value?: string;
type?: string;
dataKey?: string | number;
color?: string;
};
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
payload?: LegendPayloadItem[];
verticalAlign?: "top" | "bottom";
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

@@ -1,66 +0,0 @@
"use client";
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({
children,
}: {
children: React.ReactNode;
}) {
const [hasCopied, setHasCopied] = useState(false);
const isMobile = window.innerWidth <= 640;
useEffect(() => {
if (hasCopied) {
setTimeout(() => {
setHasCopied(false);
}, 2000);
}
}, [hasCopied]);
const handleCopy = (type: string, value: any) => {
navigator.clipboard.writeText(value);
setHasCopied(true);
const warning = localStorage.getItem("warning");
if (warning === null) {
localStorage.setItem("warning", "1");
setTimeout(() => {
toast.error(
"Be careful when copying scripts from the internet. Always remember to check the source!",
{ duration: 8000 },
);
}, 500);
}
};
return (
<div className="mt-4 flex">
<Card className="flex items-center overflow-x-auto bg-primary-foreground pl-4 [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground/20">
<div className="overflow-x-auto whitespace-pre-wrap text-nowrap break-all pr-4 text-sm [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground/20">
{!isMobile && children ? children : "Copy install command"}
</div>
<button
onClick={() => handleCopy("install command", children)}
className={cn("bg-muted px-3 py-4")}
title="Copy"
>
{hasCopied
? (
<CheckIcon className="h-4 w-4" />
)
: (
<ClipboardIcon className="h-4 w-4" />
)}
</button>
</Card>
</div>
);
}

View File

@@ -1,149 +0,0 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { Clipboard, Copy } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:border-primary hover:text-accent-foreground",
secondary:
"bg-secondary border-secondary text-secondary-foreground hover:bg-secondary/80 hover:border-primary",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
null: "py-1 px-3 rouded-xs",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function handleCopy(type: string, value: string) {
navigator.clipboard.writeText(value);
let amountOfScriptsCopied = localStorage.getItem("amountOfScriptsCopied");
if (amountOfScriptsCopied === null) {
localStorage.setItem("amountOfScriptsCopied", "1");
}
else {
amountOfScriptsCopied = (Number.parseInt(amountOfScriptsCopied) + 1).toString();
localStorage.setItem("amountOfScriptsCopied", amountOfScriptsCopied);
if (
Number.parseInt(amountOfScriptsCopied) === 3
|| Number.parseInt(amountOfScriptsCopied) === 10
|| Number.parseInt(amountOfScriptsCopied) === 25
|| Number.parseInt(amountOfScriptsCopied) === 50
|| Number.parseInt(amountOfScriptsCopied) === 100
) {
setTimeout(() => {
toast.info(
<div className="flex flex-col gap-3">
<p className="lg">
If you find these scripts useful, please consider starring the
repository on GitHub. It helps a lot!
</p>
<div>
<Button className="text-white">
<Link
href={`https://github.com/community-scripts/${basePath}`}
data-umami-event="Star on Github"
target="_blank"
>
Star on GitHub 💫
</Link>
</Button>
</div>
</div>,
{ duration: 8000 },
);
}, 500);
}
}
toast.success(
<div className="flex items-center gap-2">
<Clipboard className="h-4 w-4" />
<span>
Copied
{type}
{" "}
to clipboard
</span>
</div>,
);
}
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) => {
const copyToClipboard = () => {
navigator.clipboard.writeText(code);
};
return (
<div
style={{
position: "relative",
marginBottom: "1rem",
display: "flex",
gap: "8px",
}}
ref={ref}
>
<pre
className={cn(
buttonVariants({ variant, size, className }),
" flex flex-row p-4",
)}
>
<p className="flex items-center gap-2">
{code}
{" "}
<Separator orientation="vertical" />
{" "}
<Copy
className="cursor-pointer"
size={16}
onClick={() => handleCopy("install command", code)}
/>
</p>
</pre>
</div>
);
},
);
CodeBlock.displayName = "CodeBlock";
export { buttonVariants, CodeBlock };

View File

@@ -1,161 +0,0 @@
"use client";
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import * as React from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
type CommandDialogProps = {
filter?: (value: string, search: string, keywords?: string[]) => number;
} & DialogProps;
function CommandDialog({ children, filter, ...props }: CommandDialogProps) {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command
filter={filter}
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
{children}
</Command>
</DialogContent>
</Dialog>
);
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
function CommandShortcut({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
}
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
};

View File

@@ -1,57 +0,0 @@
"use client";
import { CheckIcon, ClipboardIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { Card } from "./card";
export default function CodeCopyButton({
children,
}: {
children: React.ReactNode;
}) {
const [hasCopied, setHasCopied] = useState(false);
const isMobile = window.innerWidth <= 640;
useEffect(() => {
if (hasCopied) {
setTimeout(() => {
setHasCopied(false);
}, 2000);
}
}, [hasCopied]);
const handleCopy = (type: string, value: any) => {
navigator.clipboard.writeText(value);
setHasCopied(true);
// toast.success(`copied ${type} to clipboard`, {
// icon: <ClipboardCheck className="h-4 w-4" />,
// });
};
return (
<div className="mt-4 flex">
<Card className="flex items-center overflow-x-auto bg-primary-foreground pl-4">
<div className="overflow-x-auto whitespace-pre-wrap text-nowrap break-all pr-4 text-sm">
{!isMobile && children ? children : "Copy Config File Path"}
</div>
<div
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" />
)}
<span className="sr-only">Copy</span>
</div>
</Card>
</div>
);
}

View File

@@ -1,126 +0,0 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-51%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({
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";
function DialogFooter({
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<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -1,200 +0,0 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName
= DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName
= DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"glass z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover/50 p-1 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",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName
= DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
function DropdownMenuShortcut({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@@ -1,24 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = {} & React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -1,28 +0,0 @@
"use client";
import type { VariantProps } from "class-variance-authority";
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",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
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;
export { Label };

View File

@@ -1,129 +0,0 @@
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className,
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}
{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className,
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName
= NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className,
)}
{...props}
>
<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;
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuViewport,
};

View File

@@ -1,61 +0,0 @@
"use client";
import { useInView, useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
export default function NumberTicker({
value,
direction = "up",
delay = 0,
className,
decimalPlaces = 0,
}: {
value: number;
direction?: "up" | "down";
className?: string;
delay?: number; // delay in s
decimalPlaces?: number;
}) {
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? value : 0);
const springValue = useSpring(motionValue, {
damping: 60,
stiffness: 100,
});
const isInView = useInView(ref as React.RefObject<Element>, {
once: true,
margin: "0px",
});
useEffect(() => {
isInView
&& setTimeout(() => {
motionValue.set(direction === "down" ? 0 : value);
}, delay * 1000);
}, [motionValue, isInView, delay, value, direction]);
useEffect(
() =>
springValue.on("change", (latest) => {
if (ref.current) {
ref.current.textContent = Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(Number(latest.toFixed(decimalPlaces)));
}
}),
[springValue, decimalPlaces],
);
return (
<span
className={cn(
"inline-block tabular-nums text-black dark:text-white tracking-wider",
className,
)}
ref={ref}
/>
);
}

View File

@@ -1,285 +0,0 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
type MousePosition = {
x: number;
y: number;
};
function MousePosition(): MousePosition {
const [mousePosition, setMousePosition] = useState<MousePosition>({
x: 0,
y: 0,
});
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
setMousePosition({ x: event.clientX, y: event.clientY });
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);
return mousePosition;
}
type ParticlesProps = {
className?: string;
quantity?: number;
staticity?: number;
ease?: number;
size?: number;
refresh?: boolean;
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)
.join("");
}
const hexInt = Number.parseInt(hex, 16);
const red = (hexInt >> 16) & 255;
const green = (hexInt >> 8) & 255;
const blue = hexInt & 255;
return [red, green, blue];
}
const Particles: React.FC<ParticlesProps> = ({
className = "",
quantity = 100,
staticity = 50,
ease = 50,
size = 0.4,
refresh = false,
color = "#ffffff",
vx = 0,
vy = 0,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const context = useRef<CanvasRenderingContext2D | null>(null);
const circles = useRef<Circle[]>([]);
const mousePosition = MousePosition();
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
useEffect(() => {
if (canvasRef.current) {
context.current = canvasRef.current.getContext("2d");
}
initCanvas();
animate();
window.addEventListener("resize", initCanvas);
return () => {
window.removeEventListener("resize", initCanvas);
};
}, [color]);
useEffect(() => {
onMouseMove();
}, [mousePosition.x, mousePosition.y]);
useEffect(() => {
initCanvas();
}, [refresh]);
const initCanvas = () => {
resizeCanvas();
drawParticles();
};
const onMouseMove = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const { w, h } = canvasSize.current;
const x = mousePosition.x - rect.left - w / 2;
const y = mousePosition.y - rect.top - h / 2;
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2;
if (inside) {
mouse.current.x = x;
mouse.current.y = y;
}
}
};
type Circle = {
x: number;
y: number;
translateX: number;
translateY: number;
size: number;
alpha: number;
targetAlpha: number;
dx: number;
dy: number;
magnetism: number;
};
const resizeCanvas = () => {
if (canvasContainerRef.current && canvasRef.current && context.current) {
circles.current.length = 0;
canvasSize.current.w = canvasContainerRef.current.offsetWidth;
canvasSize.current.h = canvasContainerRef.current.offsetHeight;
canvasRef.current.width = canvasSize.current.w * dpr;
canvasRef.current.height = canvasSize.current.h * dpr;
canvasRef.current.style.width = `${canvasSize.current.w}px`;
canvasRef.current.style.height = `${canvasSize.current.h}px`;
context.current.scale(dpr, dpr);
}
};
const circleParams = (): Circle => {
const x = Math.floor(Math.random() * canvasSize.current.w);
const y = Math.floor(Math.random() * canvasSize.current.h);
const translateX = 0;
const translateY = 0;
const pSize = Math.floor(Math.random() * 2) + size;
const alpha = 0;
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;
return {
x,
y,
translateX,
translateY,
size: pSize,
alpha,
targetAlpha,
dx,
dy,
magnetism,
};
};
const rgb = hexToRgb(color);
const drawCircle = (circle: Circle, update = false) => {
if (context.current) {
const { x, y, translateX, translateY, size, alpha } = circle;
context.current.translate(translateX, translateY);
context.current.beginPath();
context.current.arc(x, y, size, 0, 2 * Math.PI);
context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`;
context.current.fill();
context.current.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!update) {
circles.current.push(circle);
}
}
};
const clearContext = () => {
if (context.current) {
context.current.clearRect(
0,
0,
canvasSize.current.w,
canvasSize.current.h,
);
}
};
const drawParticles = () => {
clearContext();
const particleCount = quantity;
for (let i = 0; i < particleCount; i++) {
const circle = circleParams();
drawCircle(circle);
}
};
const remapValue = (
value: number,
start1: number,
end1: number,
start2: number,
end2: number,
): number => {
const remapped
= ((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
return remapped > 0 ? remapped : 0;
};
const animate = () => {
clearContext();
circles.current.forEach((circle: Circle, i: number) => {
// Handle the alpha value
const edge = [
circle.x + circle.translateX - circle.size, // distance from left edge
canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
circle.y + circle.translateY - circle.size, // distance from top edge
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
];
const closestEdge = edge.reduce((a, b) => Math.min(a, b));
const remapClosestEdge = Number.parseFloat(
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2),
);
if (remapClosestEdge > 1) {
circle.alpha += 0.02;
if (circle.alpha > circle.targetAlpha) {
circle.alpha = circle.targetAlpha;
}
}
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;
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
) {
// remove the circle from the array
circles.current.splice(i, 1);
// create a new circle
const newCircle = circleParams();
drawCircle(newCircle);
// update the circle position
}
});
window.requestAnimationFrame(animate);
};
return (
<div
className={cn("pointer-events-none", className)}
ref={canvasContainerRef}
aria-hidden="true"
>
<canvas ref={canvasRef} className="size-full" />
</div>
);
};
export default Particles;

View File

@@ -1,31 +0,0 @@
"use client";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
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,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverContent, PopoverTrigger };

View File

@@ -1,48 +0,0 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -1,160 +0,0 @@
"use client";
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";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
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,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName
= SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
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={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper"
&& "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
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,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -1,31 +0,0 @@
"use client";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -1,144 +0,0 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
type SheetContentProps = {} & React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & VariantProps<typeof sheetVariants>;
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
function SheetHeader({
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";
function SheetFooter({
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<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

@@ -1,31 +0,0 @@
"use client";
import { Toaster as Sonner } from "sonner";
import { useTheme } from "next-themes";
type ToasterProps = React.ComponentProps<typeof Sonner>;
function Toaster({ ...props }: ToasterProps) {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
}
export { Toaster };

View File

@@ -1,61 +0,0 @@
import { FaGithub, FaStar } from "react-icons/fa";
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);
useEffect(() => {
const fetchStars = async () => {
try {
const res = await fetch(
`https://api.github.com/repos/community-scripts/${basePath}`,
{
next: { revalidate: 60 * 60 * 24 },
},
);
if (res.ok) {
const data = await res.json();
setStars(data.stargazers_count || stars);
}
}
catch (error) {
console.error("Error fetching stars:", error);
}
};
fetchStars();
}, [stars]);
return (
<Link
className={cn(
buttonVariants(),
"hidden h-9 min-w-[240px] gap-2 overflow-hidden whitespace-pre sm:flex lg:flex",
"group relative justify-center gap-2 rounded-md transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-2",
)}
target="_blank"
href={`https://github.com/community-scripts/${basePath}`}
>
<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>
{" "}
</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" />
<NumberTicker
value={stars}
className="font-display font-medium text-white dark:text-black"
/>
</div>
</Link>
);
}

Some files were not shown because too many files have changed in this diff Show More