Compare commits

..

2 Commits

Author SHA1 Message Date
18927115a0 Update docmost.json 2025-06-25 13:27:12 +02:00
5dd267feba Update docmost.sh 2025-06-25 13:26:51 +02:00
143 changed files with 4540 additions and 9235 deletions

View File

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

48
.github/workflows/push-to-gitea.yaml generated vendored
View File

@ -1,48 +0,0 @@
name: Sync to Gitea
on:
push:
branches:
- main
jobs:
sync:
if: github.repository == 'community-scripts/ProxmoxVE'
runs-on: ubuntu-latest
steps:
- name: Checkout source repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Change all links to git.community-scripts.org
run: |
echo "Searching for files containing raw.githubusercontent.com URLs..."
# Find all files containing GitHub raw URLs, excluding certain directories
files_with_github_urls=$(grep -r "https://raw.githubusercontent.com/community-scripts/ProxmoxVE" . --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.github/workflows --files-with-matches || true)
if [ -n "$files_with_github_urls" ]; then
echo "$files_with_github_urls" | while read file; do
if [ -f "$file" ]; then
sed -i 's|https://raw\.githubusercontent\.com/community-scripts/ProxmoxVE/|https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/|g' "$file"
fi
done
else
echo "No files found containing GitHub raw URLs"
fi
- name: Push to Gitea
run: |
git config --global user.name "Push From Github"
git config --global user.email "actions@github.com"
git remote add gitea https://$GITEA_USER:$GITEA_TOKEN@git.community-scripts.org/community-scripts/ProxmoxVE.git
git add .
git commit -m "Sync to Gitea"
git push gitea --all --force
env:
GITEA_USER: ${{ secrets.GITEA_USERNAME }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@ -14,107 +14,10 @@ Exercise vigilance regarding copycat or coat-tailing sites that seek to exploit
All LXC instances created using this repository come pre-installed with Midnight Commander, which is a command-line tool (`mc`) that offers a user-friendly file and directory management interface for the terminal environment.
## 2025-06-30
### 🚀 Updated Scripts
- #### 🐞 Bug Fixes
- Immich: make changes to automatically enable QuickSync [@vhsdream](https://github.com/vhsdream) ([#5560](https://github.com/community-scripts/ProxmoxVE/pull/5560))
- Apache Guacamole: Install auth-jdbc component that matches release version [@tremor021](https://github.com/tremor021) ([#5563](https://github.com/community-scripts/ProxmoxVE/pull/5563))
- #### ✨ New Features
- [core]: add ipv6 configuration support [@MickLesk](https://github.com/MickLesk) ([#5575](https://github.com/community-scripts/ProxmoxVE/pull/5575))
## 2025-06-29
### 🚀 Updated Scripts
- #### 🐞 Bug Fixes
- Linkwarden: Add backing up of data folder to the update function [@tremor021](https://github.com/tremor021) ([#5548](https://github.com/community-scripts/ProxmoxVE/pull/5548))
- #### ✨ New Features
- Add cron-job api-key env variable to homarr script [@Meierschlumpf](https://github.com/Meierschlumpf) ([#5204](https://github.com/community-scripts/ProxmoxVE/pull/5204))
### 🧰 Maintenance
- #### 📝 Documentation
- update readme with valid discord link. other one expired [@BramSuurdje](https://github.com/BramSuurdje) ([#5567](https://github.com/community-scripts/ProxmoxVE/pull/5567))
### 🌐 Website
- Update script-item.tsx [@ape364](https://github.com/ape364) ([#5549](https://github.com/community-scripts/ProxmoxVE/pull/5549))
- #### 🐞 Bug Fixes
- fix bug in tooltip that would always render 'updateable' [@BramSuurdje](https://github.com/BramSuurdje) ([#5552](https://github.com/community-scripts/ProxmoxVE/pull/5552))
## 2025-06-28
### 🚀 Updated Scripts
- #### 🐞 Bug Fixes
- Ollama: Clean up old Ollama files before running update [@tremor021](https://github.com/tremor021) ([#5534](https://github.com/community-scripts/ProxmoxVE/pull/5534))
- ONLYOFFICE: Update install script to manually create RabbitMQ user [@tremor021](https://github.com/tremor021) ([#5535](https://github.com/community-scripts/ProxmoxVE/pull/5535))
### 🌐 Website
- #### 📝 Script Information
- Booklore: Correct documentation and website [@pieman3000](https://github.com/pieman3000) ([#5528](https://github.com/community-scripts/ProxmoxVE/pull/5528))
## 2025-06-27
### 🆕 New Scripts
- BookLore ([#5524](https://github.com/community-scripts/ProxmoxVE/pull/5524))
### 🚀 Updated Scripts
- #### 🐞 Bug Fixes
- wizarr: remove unneeded tmp file [@MickLesk](https://github.com/MickLesk) ([#5517](https://github.com/community-scripts/ProxmoxVE/pull/5517))
### 🧰 Maintenance
- #### 🐞 Bug Fixes
- Remove npm legacy errors, created single source of truth for ESlint. updated analytics url. updated script background [@BramSuurdje](https://github.com/BramSuurdje) ([#5498](https://github.com/community-scripts/ProxmoxVE/pull/5498))
- #### 📂 Github
- New workflow to push to gitea and change links to gitea [@michelroegl-brunner](https://github.com/michelroegl-brunner) ([#5510](https://github.com/community-scripts/ProxmoxVE/pull/5510))
### 🌐 Website
- #### 📝 Script Information
- Wireguard, Update Link to Documentation. [@michelroegl-brunner](https://github.com/michelroegl-brunner) ([#5514](https://github.com/community-scripts/ProxmoxVE/pull/5514))
## 2025-06-26
### 🆕 New Scripts
- ConvertX ([#5484](https://github.com/community-scripts/ProxmoxVE/pull/5484))
### 🚀 Updated Scripts
- [tools] Update setup_nodejs function [@tremor021](https://github.com/tremor021) ([#5488](https://github.com/community-scripts/ProxmoxVE/pull/5488))
- [tools] Fix setup_mongodb function [@tremor021](https://github.com/tremor021) ([#5486](https://github.com/community-scripts/ProxmoxVE/pull/5486))
## 2025-06-25
### 🚀 Updated Scripts
- #### 🐞 Bug Fixes
- Docmost: Increase resources [@tremor021](https://github.com/tremor021) ([#5458](https://github.com/community-scripts/ProxmoxVE/pull/5458))
- #### ✨ New Features
- tools.func: new helper for imagemagick [@MickLesk](https://github.com/MickLesk) ([#5452](https://github.com/community-scripts/ProxmoxVE/pull/5452))
@ -124,21 +27,6 @@ All LXC instances created using this repository come pre-installed with Midnight
- Refactor: Tailscale [@MickLesk](https://github.com/MickLesk) ([#5454](https://github.com/community-scripts/ProxmoxVE/pull/5454))
### 🌐 Website
- #### 🐞 Bug Fixes
- Update Tooltips component to conditionally display updateable status based on item type [@BramSuurdje](https://github.com/BramSuurdje) ([#5461](https://github.com/community-scripts/ProxmoxVE/pull/5461))
- Refactor CommandMenu to prevent duplicate scripts across categories [@BramSuurdje](https://github.com/BramSuurdje) ([#5463](https://github.com/community-scripts/ProxmoxVE/pull/5463))
- #### ✨ New Features
- Enhance InstallCommand component to support Gitea as an alternative source for installation scripts. [@BramSuurdje](https://github.com/BramSuurdje) ([#5464](https://github.com/community-scripts/ProxmoxVE/pull/5464))
- #### 📝 Script Information
- Website: mark VM's and "OS"-LXC's as updatable [@MickLesk](https://github.com/MickLesk) ([#5453](https://github.com/community-scripts/ProxmoxVE/pull/5453))
## 2025-06-24
### 🆕 New Scripts

View File

@ -13,7 +13,7 @@
<a href="https://helper-scripts.com">
<img src="https://img.shields.io/badge/Website-4c9b3f?style=for-the-badge&logo=github&logoColor=white" alt="Website" />
</a>
<a href="https://discord.gg/3AnUqsXnmK">
<a href="https://discord.gg/jsYVk5JBxq">
<img src="https://img.shields.io/badge/Discord-7289da?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" />
</a>
<a href="https://ko-fi.com/community_scripts">
@ -82,7 +82,7 @@ We appreciate any contributions to the project—whether it's bug reports, featu
Join our community for support:
- **Discord**: Join our [Proxmox Helper Scripts Discord server](https://discord.gg/3AnUqsXnmK) for real-time support.
- **Discord**: Join our [Proxmox Helper Scripts Discord server](https://discord.gg/jsYVk5JBxq) for real-time support.
- **GitHub Discussions**: [Ask questions or report issues](https://github.com/community-scripts/ProxmoxVE/discussions).
## 🤝 Report a Bug or Feature Request

View File

@ -1,45 +0,0 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func)
# Copyright (c) 2021-2025 community-scripts ORG
# Author: MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://syncthing.net/
APP="Alpine-Syncthing"
var_tags="${var_tags:-alpine;networking}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-256}"
var_disk="${var_disk:-1}"
var_os="${var_os:-alpine}"
var_version="${var_version:-3.22}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
msg_info "Updating Alpine Packages"
$STD apk -U upgrade
msg_ok "Updated Alpine Packages"
msg_info "Updating Syncthing"
$STD apk upgrade syncthing
msg_ok "Updated Syncthing"
msg_info "Restarting Syncthing"
$STD rc-service syncthing restart
msg_ok "Restarted Syncthing"
exit 1
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:8384${CL}"

View File

@ -1,79 +0,0 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func)
# Copyright (c) 2021-2025 community-scripts ORG
# Author: MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://github.com/adityachandelgit/BookLore
APP="BookLore"
var_tags="${var_tags:-books;library}"
var_cpu="${var_cpu:-3}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-7}"
var_os="${var_os:-debian}"
var_version="${var_version:-12}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/booklore ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
RELEASE=$(curl -fsSL https://api.github.com/repos/adityachandelgit/BookLore/releases/latest | yq '.tag_name' | sed 's/^v//')
if [[ "${RELEASE}" != "$(cat ~/.booklore 2>/dev/null)" ]] || [[ ! -f ~/.booklore ]]; then
msg_info "Stopping $APP"
systemctl stop booklore
msg_ok "Stopped $APP"
fetch_and_deploy_gh_release "booklore" "adityachandelgit/BookLore"
msg_info "Building Frontend"
cd /opt/booklore/booklore-ui
$STD npm install --force
$STD npm run build --configuration=production
msg_ok "Built Frontend"
msg_info "Building Backend"
cd /opt/booklore/booklore-api
APP_VERSION=$(curl -fsSL https://api.github.com/repos/adityachandelgit/BookLore/releases/latest | yq '.tag_name' | sed 's/^v//')
yq eval ".app.version = \"${APP_VERSION}\"" -i src/main/resources/application.yaml
$STD ./gradlew clean build --no-daemon
mkdir -p /opt/booklore/dist
JAR_PATH=$(find /opt/booklore/booklore-api/build/libs -maxdepth 1 -type f -name "booklore-api-*.jar" ! -name "*plain*" | head -n1)
if [[ -z "$JAR_PATH" ]]; then
msg_error "Backend JAR not found"
exit 1
fi
cp "$JAR_PATH" /opt/booklore/dist/app.jar
msg_ok "Built Backend"
msg_info "Starting $APP"
systemctl start booklore
systemctl reload nginx
msg_ok "Started $APP"
msg_ok "Update Successful"
else
msg_ok "No update required. ${APP} is already at v${RELEASE}"
fi
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:6060${CL}"

View File

@ -1,70 +0,0 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func)
# Copyright (c) 2021-2025 community-scripts ORG
# Author: Omar Minaya | MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://github.com/C4illin/ConvertX
APP="ConvertX"
var_tags="${var_tags:-converter}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-4096}"
var_disk="${var_disk:-20}"
var_os="${var_os:-debian}"
var_version="${var_version:-12}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /var ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
RELEASE=$(curl -fsSL https://api.github.com/repos/C4illin/ConvertX/releases/latest | grep "tag_name" | awk '{print substr($2, 2, length($2)-3) }')
if [[ "${RELEASE}" != "$(cat ~/.convertx 2>/dev/null)" ]] || [[ ! -f ~/.convertx ]]; then
msg_info "Stopping $APP"
systemctl stop convertx
msg_ok "Stopped $APP"
msg_info "Move data-Folder"
if [[ -d /opt/convertx/data ]]; then
mv /opt/convertx/data /opt/data
fi
msg_ok "Moved data-Folder"
fetch_and_deploy_gh_release "ConvertX" "C4illin/ConvertX" "tarball" "latest" "/opt/convertx"
msg_info "Updating $APP to v${RELEASE}"
if [[ -d /opt/data ]]; then
mv /opt/data /opt/convertx/data
fi
cd /opt/convertx
$STD bun install
msg_ok "Updated $APP to v${RELEASE}"
msg_info "Starting $APP"
systemctl start convertx
msg_ok "Started $APP"
msg_ok "Update Successful"
else
msg_ok "No update required. ${APP} is already at v${RELEASE}"
fi
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:3000${CL}"

View File

@ -1,6 +0,0 @@
____ __ __
/ __ )____ ____ / /__/ / ____ ________
/ __ / __ \/ __ \/ //_/ / / __ \/ ___/ _ \
/ /_/ / /_/ / /_/ / ,< / /___/ /_/ / / / __/
/_____/\____/\____/_/|_/_____/\____/_/ \___/

View File

@ -1,6 +0,0 @@
______ __ _ __
/ ____/___ ____ _ _____ _____/ /| |/ /
/ / / __ \/ __ \ | / / _ \/ ___/ __/ /
/ /___/ /_/ / / / / |/ / __/ / / /_/ |
\____/\____/_/ /_/|___/\___/_/ \__/_/|_|

View File

@ -48,7 +48,6 @@ source /opt/homarr/.env
set +a
export DB_DIALECT='sqlite'
export AUTH_SECRET=$(openssl rand -base64 32)
export CRON_JOB_API_KEY=$(openssl rand -base64 32)
node /opt/homarr_db/migrations/$DB_DIALECT/migrate.cjs /opt/homarr_db/migrations/$DB_DIALECT
for dir in $(find /opt/homarr_db/migrations/migrations -mindepth 1 -maxdepth 1 -type d); do
dirname=$(basename "$dir")
@ -115,7 +114,6 @@ source /opt/homarr/.env
set +a
export DB_DIALECT='sqlite'
export AUTH_SECRET=$(openssl rand -base64 32)
export CRON_JOB_API_KEY=$(openssl rand -base64 32)
node /opt/homarr_db/migrations/$DB_DIALECT/migrate.cjs /opt/homarr_db/migrations/$DB_DIALECT
for dir in $(find /opt/homarr_db/migrations/migrations -mindepth 1 -maxdepth 1 -type d); do
dirname=$(basename "$dir")

View File

@ -70,7 +70,6 @@ function update_script() {
systemctl stop jellyseerr
rm -rf dist .next node_modules
export CYPRESS_INSTALL_BINARY=0
cd /opt/jellyseerr
$STD pnpm install --frozen-lockfile
export NODE_OPTIONS="--max-old-space-size=3072"
$STD pnpm build

View File

@ -37,7 +37,6 @@ function update_script() {
msg_info "Updating ${APP} to ${RELEASE}"
mv /opt/linkwarden/.env /opt/.env
[ -d /opt/linkwarden/data ] && mv /opt/linkwarden/data /opt/data.bak
rm -rf /opt/linkwarden
fetch_and_deploy_gh_release "linkwarden" "linkwarden/linkwarden"
cd /opt/linkwarden
@ -48,7 +47,6 @@ function update_script() {
$STD yarn prisma:generate
$STD yarn web:build
$STD yarn prisma:deploy
[ -d /opt/data.bak ] && mv /opt/data.bak /opt/linkwarden/data
msg_ok "Updated ${APP} to ${RELEASE}"
msg_info "Starting ${APP}"

View File

@ -38,8 +38,6 @@ function update_script() {
TMP_TAR=$(mktemp --suffix=.tgz)
curl -fL# -o "${TMP_TAR}" "https://github.com/ollama/ollama/releases/download/${RELEASE}/ollama-linux-amd64.tgz"
msg_info "Updating Ollama to ${RELEASE}"
rm -rf /usr/local/lib/ollama
rm -rf /usr/local/bin/ollama
tar -xzf "${TMP_TAR}" -C /usr/local/lib/ollama
ln -sf /usr/local/lib/ollama/bin/ollama /usr/local/bin/ollama
echo "${RELEASE}" >/opt/Ollama_version.txt

View File

@ -30,8 +30,6 @@ function update_script() {
if [ -x "/usr/bin/ollama" ]; then
msg_info "Updating Ollama"
rm -rf /usr/lib/ollama
rm -rf /usr/bin/ollama
OLLAMA_VERSION=$(ollama -v | awk '{print $NF}')
RELEASE=$(curl -s https://api.github.com/repos/ollama/ollama/releases/latest | grep "tag_name" | awk '{print substr($2, 3, length($2)-4)}')
if [ "$OLLAMA_VERSION" != "$RELEASE" ]; then

View File

@ -60,6 +60,7 @@ function update_script() {
msg_info "Cleaning Up"
rm -rf "$BACKUP_FILE"
rm /tmp/"$RELEASE".zip
msg_ok "Cleanup Completed"
msg_ok "Update Successful"
else

5
frontend/.eslintrc.json generated Normal file
View File

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

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,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/2wvnMDgdnU)**
- **[GitHub Discussions](https://github.com/community-scripts/ProxmoxVE/discussions)**
## 🔗 Links
- **🌐 Live Website**: [https://community-scripts.github.io/ProxmoxVE/](https://community-scripts.github.io/ProxmoxVE/)
- **💬 Discord Server**: [https://discord.gg/2wvnMDgdnU](https://discord.gg/2wvnMDgdnU)
- **📝 Change Log**: [https://github.com/community-scripts/ProxmoxVE/blob/main/CHANGELOG.md](https://github.com/community-scripts/ProxmoxVE/blob/main/CHANGELOG.md)
---
**Made with ❤️ by the Community-Scripts team and contributors**

View File

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

File diff suppressed because it is too large Load Diff

30
frontend/package.json generated
View File

@ -1,18 +1,22 @@
{
"name": "proxmox-helper-scripts-website",
"type": "module",
"version": "1.0.0",
"license": "MIT",
"private": true,
"author": {
"name": "Bram Suurd",
"url": "https://github.com/community-scripts"
},
"license": "MIT",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint . --fix",
"lint": "next lint",
"test": "vitest",
"deploy": "next build && touch out/.nojekyll && git add out/ && git commit -m \"Deploy\" && git subtree push --prefix out origin gh-pages",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@ -41,7 +45,7 @@
"lucide-react": "^0.453.0",
"mini-svg-data-uri": "^1.4.4",
"next": "15.2.4",
"next-themes": "^0.4.4",
"next-themes": "^0.3.0",
"nuqs": "^2.4.1",
"pocketbase": "^0.21.5",
"prettier-plugin-organize-imports": "^4.1.0",
@ -49,7 +53,7 @@
"react-chartjs-2": "^5.3.0",
"react-code-blocks": "^0.1.6",
"react-datepicker": "^7.6.0",
"react-day-picker": "^9.4.3",
"react-day-picker": "8.10.1",
"react-dom": "19.0.0",
"react-icons": "^5.5.0",
"react-simple-typewriter": "^5.0.1",
@ -60,10 +64,9 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@antfu/eslint-config": "^4.16.1",
"@eslint-react/eslint-plugin": "^1.52.2",
"@next/eslint-plugin-next": "^15.3.4",
"@tanstack/eslint-plugin-query": "^5.68.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.13.16",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
@ -72,9 +75,6 @@
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.23.0",
"eslint-config-next": "15.0.2",
"eslint-plugin-format": "^1.0.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
@ -83,13 +83,11 @@
"tailwindcss-animate": "^1.0.7",
"tailwindcss-animated": "^1.1.2",
"typescript": "^5.8.2",
"vite-tsconfig-paths": "^5.1.4"
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.1"
},
"overrides": {
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"date-fns": "^4.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
}
}

View File

@ -6,7 +6,7 @@
],
"date_created": "2024-05-02",
"type": "ct",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -6,7 +6,7 @@
],
"date_created": "2025-01-27",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -1,35 +0,0 @@
{
"name": "BookLore",
"slug": "booklore",
"categories": [
13
],
"date_created": "2025-06-27",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 6060,
"documentation": "https://github.com/adityachandelgit/BookLore",
"website": "https://github.com/adityachandelgit/BookLore",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/booklore.webp",
"config_path": "/opt/booklore_storage/.env",
"description": "BookLore is a self-hosted digital library for managing and reading books, offering a beautiful interface and support for metadata management. Built with a modern tech stack, it provides support for importing, organizing, and reading EPUBs and PDFs, while also managing cover images and book metadata.",
"install_methods": [
{
"type": "default",
"script": "ct/booklore.sh",
"resources": {
"cpu": 3,
"ram": 2048,
"hdd": 7,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

View File

@ -1,40 +0,0 @@
{
"name": "ConvertX",
"slug": "convertx",
"categories": [
9
],
"date_created": "2025-06-26",
"type": "ct",
"updateable": true,
"privileged": false,
"config_path": "/opt/convertx/.env",
"interface_port": 3000,
"documentation": "https://github.com/C4illin/ConvertX",
"website": "https://github.com/C4illin/ConvertX",
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/svg/convertx.svg",
"description": "ConvertX is a self-hosted online file converter supporting over 1000 formats, including images, audio, video, documents, and more, powered by FFmpeg, GraphicsMagick, and other libraries.",
"install_methods": [
{
"type": "default",
"script": "ct/convertx.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 20,
"os": "Debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Complete setup via the web interface at http://<container-ip>:3000. Create and secure the admin account immediately.",
"type": "info"
}
]
}

View File

@ -6,7 +6,7 @@
],
"date_created": "2024-05-02",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -1,35 +1,35 @@
{
"name": "Debian",
"slug": "debian",
"categories": [
2
],
"date_created": "2024-05-02",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": null,
"documentation": null,
"website": "https://www.debian.org/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/debian.webp",
"config_path": "",
"description": "Debian Linux is a distribution that emphasizes free software. It supports many hardware platforms.",
"install_methods": [
{
"type": "default",
"script": "ct/debian.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 2,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
"name": "Debian",
"slug": "debian",
"categories": [
2
],
"date_created": "2024-05-02",
"type": "ct",
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,
"website": "https://www.debian.org/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/debian.webp",
"config_path": "",
"description": "Debian Linux is a distribution that emphasizes free software. It supports many hardware platforms.",
"install_methods": [
{
"type": "default",
"script": "ct/debian.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 2,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

View File

@ -7,7 +7,7 @@
],
"date_created": "2025-01-20",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -1,44 +1,44 @@
{
"name": "Home Assistant OS",
"slug": "haos-vm",
"categories": [
16
],
"date_created": "2024-04-29",
"type": "vm",
"updateable": true,
"privileged": false,
"interface_port": 8123,
"documentation": "https://www.home-assistant.io/docs/",
"website": "https://www.home-assistant.io/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/home-assistant.webp",
"config_path": "",
"description": "This script automates the process of creating a Virtual Machine (VM) using the official KVM (qcow2) disk image provided by the Home Assistant Team. It involves finding, downloading, and extracting the image, defining user-defined settings, importing and attaching the disk, setting the boot order, and starting the VM. It supports various storage types, and does not involve any hidden installations. After the script completes, click on the VM, then on the Summary tab to find the VM IP.",
"install_methods": [
{
"type": "default",
"script": "vm/haos-vm.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 32,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "The disk must have a minimum size of 32GB and its size cannot be changed during the creation of the VM.",
"type": "warning"
"name": "Home Assistant OS",
"slug": "haos-vm",
"categories": [
16
],
"date_created": "2024-04-29",
"type": "vm",
"updateable": false,
"privileged": false,
"interface_port": 8123,
"documentation": "https://www.home-assistant.io/docs/",
"website": "https://www.home-assistant.io/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/home-assistant.webp",
"config_path": "",
"description": "This script automates the process of creating a Virtual Machine (VM) using the official KVM (qcow2) disk image provided by the Home Assistant Team. It involves finding, downloading, and extracting the image, defining user-defined settings, importing and attaching the disk, setting the boot order, and starting the VM. It supports various storage types, and does not involve any hidden installations. After the script completes, click on the VM, then on the Summary tab to find the VM IP.",
"install_methods": [
{
"type": "default",
"script": "vm/haos-vm.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 32,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
{
"text": "After the script completes, click on the VM, then on the Summary or Console tab to find the VM IP.",
"type": "info"
}
]
"notes": [
{
"text": "The disk must have a minimum size of 32GB and its size cannot be changed during the creation of the VM.",
"type": "warning"
},
{
"text": "After the script completes, click on the VM, then on the Summary or Console tab to find the VM IP.",
"type": "info"
}
]
}

View File

@ -7,7 +7,7 @@
],
"date_created": "2024-05-02",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -6,7 +6,7 @@
],
"date_created": "2023-11-14",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": 80,
"documentation": null,

View File

@ -7,7 +7,7 @@
],
"date_created": "2024-05-02",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -7,7 +7,7 @@
],
"date_created": "2025-02-11",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": 443,
"documentation": "https://docs.opnsense.org/",

View File

@ -6,7 +6,7 @@
],
"date_created": "2024-05-02",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": 80,
"documentation": null,

View File

@ -1,40 +1,40 @@
{
"name": "PiMox HAOS",
"slug": "pimox-haos-vm",
"categories": [
16
],
"date_created": "2024-04-29",
"type": "vm",
"updateable": true,
"privileged": false,
"interface_port": 8123,
"documentation": null,
"website": "https://github.com/jiangcuo/Proxmox-Port",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/home-assistant.webp",
"config_path": "",
"description": "The script automates the manual process of finding, downloading and extracting the aarch64 (qcow2) disk image provided by the Home Assistant Team, creating a VM with user defined settings, importing and attaching the disk, setting the boot order and starting the VM.",
"install_methods": [
{
"type": "default",
"script": "vm/pimox-haos-vm.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 32,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "After the script completes, click on the VM, then on the Summary or Console tab to find the VM IP.",
"type": "info"
}
]
"name": "PiMox HAOS",
"slug": "pimox-haos-vm",
"categories": [
16
],
"date_created": "2024-04-29",
"type": "vm",
"updateable": false,
"privileged": false,
"interface_port": 8123,
"documentation": null,
"website": "https://github.com/jiangcuo/Proxmox-Port",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/home-assistant.webp",
"config_path": "",
"description": "The script automates the manual process of finding, downloading and extracting the aarch64 (qcow2) disk image provided by the Home Assistant Team, creating a VM with user defined settings, importing and attaching the disk, setting the boot order and starting the VM.",
"install_methods": [
{
"type": "default",
"script": "vm/pimox-haos-vm.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 32,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "After the script completes, click on the VM, then on the Summary or Console tab to find the VM IP.",
"type": "info"
}
]
}

View File

@ -6,7 +6,7 @@
],
"date_created": "2024-05-02",
"type": "ct",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": 5432,
"documentation": null,

View File

@ -12,7 +12,7 @@
"documentation": null,
"website": "https://syncthing.net/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/syncthing.webp",
"config_path": "/root/.local/state/syncthing/config.xml - Alpine: /var/lib/syncthing/.local/state/syncthing/config.xml",
"config_path": "/root/.local/state/syncthing/config.xml",
"description": "Syncthing is an open-source file syncing tool that allows users to keep their files in sync across multiple devices by using peer-to-peer synchronization. It doesn't rely on any central server, so all data transfers are directly between devices.",
"install_methods": [
{
@ -25,19 +25,8 @@
"os": "debian",
"version": "12"
}
},
{
"type": "alpine",
"script": "ct/alpine-syncthing.sh",
"resources": {
"cpu": 1,
"ram": 256,
"hdd": 1,
"os": "alpine",
"version": "3.22"
}
}
],
}
],
"default_credentials": {
"username": null,
"password": null

View File

@ -1,35 +1,35 @@
{
"name": "Ubuntu",
"slug": "ubuntu",
"categories": [
2
],
"date_created": "2024-05-02",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": null,
"documentation": null,
"website": "https://ubuntu.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/ubuntu.webp",
"config_path": "",
"description": "Ubuntu is a distribution based on Debian, designed to have regular releases and a consistent user experience.",
"install_methods": [
{
"type": "default",
"script": "ct/ubuntu.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 2,
"os": "ubuntu",
"version": "24.04"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
"name": "Ubuntu",
"slug": "ubuntu",
"categories": [
2
],
"date_created": "2024-05-02",
"type": "ct",
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,
"website": "https://ubuntu.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/ubuntu.webp",
"config_path": "",
"description": "Ubuntu is a distribution based on Debian, designed to have regular releases and a consistent user experience.",
"install_methods": [
{
"type": "default",
"script": "ct/ubuntu.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 2,
"os": "ubuntu",
"version": "24.04"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

View File

@ -6,7 +6,7 @@
],
"date_created": "2024-05-02",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -6,7 +6,7 @@
],
"date_created": "2024-05-02",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -6,7 +6,7 @@
],
"date_created": "2025-01-24",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -6,7 +6,7 @@
],
"date_created": "2025-06-19",
"type": "vm",
"updateable": true,
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,

View File

@ -1,269 +1,19 @@
[
{
"name": "rcourtman/Pulse",
"version": "v3.33.1",
"date": "2025-06-30T11:29:32Z"
},
{
"name": "Graylog2/graylog2-server",
"version": "6.3.0",
"date": "2025-06-30T11:26:45Z"
},
{
"name": "grokability/snipe-it",
"version": "v8.1.17",
"date": "2025-06-30T11:26:27Z"
},
{
"name": "prometheus/prometheus",
"version": "v2.53.5",
"date": "2025-06-30T11:01:12Z"
},
{
"name": "documenso/documenso",
"version": "v1.12.0-rc.8",
"date": "2025-06-30T09:47:37Z"
},
{
"name": "PrivateBin/PrivateBin",
"version": "1.7.8",
"date": "2025-06-30T09:00:54Z"
},
{
"name": "fuma-nama/fumadocs",
"version": "fumadocs-mdx@11.6.10",
"date": "2025-06-30T07:07:36Z"
},
{
"name": "mattermost/mattermost",
"version": "preview-v0.1",
"date": "2025-06-27T14:35:47Z"
},
{
"name": "Jackett/Jackett",
"version": "v0.22.2097",
"date": "2025-06-30T05:53:30Z"
},
{
"name": "typesense/typesense",
"version": "v29.0",
"date": "2025-06-30T03:52:33Z"
},
{
"name": "firefly-iii/firefly-iii",
"version": "v6.2.19",
"date": "2025-06-28T06:53:45Z"
},
{
"name": "keycloak/keycloak",
"version": "26.2.5",
"date": "2025-05-28T06:49:43Z"
},
{
"name": "sysadminsmedia/homebox",
"version": "v0.20.0",
"date": "2025-06-29T18:50:03Z"
},
{
"name": "dgtlmoon/changedetection.io",
"version": "0.50.5",
"date": "2025-06-29T08:54:47Z"
},
{
"name": "emqx/emqx",
"version": "e5.9.1-rc.1",
"date": "2025-06-29T07:27:21Z"
},
{
"name": "theonedev/onedev",
"version": "v11.11.2",
"date": "2025-06-29T01:40:39Z"
},
{
"name": "home-assistant/core",
"version": "2025.6.3",
"date": "2025-06-24T13:00:12Z"
},
{
"name": "linkwarden/linkwarden",
"version": "v2.11.2",
"date": "2025-06-28T17:33:38Z"
},
{
"name": "msgbyte/tianji",
"version": "v1.22.5",
"date": "2025-06-28T16:06:19Z"
},
{
"name": "Luligu/matterbridge",
"version": "3.1.0",
"date": "2025-06-28T09:02:38Z"
},
{
"name": "esphome/esphome",
"version": "2025.6.2",
"date": "2025-06-28T03:47:16Z"
},
{
"name": "plexguide/Huntarr.io",
"version": "8.1.11",
"date": "2025-06-28T03:42:46Z"
},
{
"name": "tobychui/zoraxy",
"version": "v3.2.4",
"date": "2025-06-28T02:47:31Z"
},
{
"name": "pocket-id/pocket-id",
"version": "v1.5.0",
"date": "2025-06-27T22:04:32Z"
},
{
"name": "homarr-labs/homarr",
"version": "v1.26.0",
"date": "2025-06-27T19:15:24Z"
},
{
"name": "ollama/ollama",
"version": "v0.9.4-rc1",
"date": "2025-06-27T18:45:33Z"
},
{
"name": "goauthentik/authentik",
"version": "version/2025.6.3",
"date": "2025-06-27T14:01:06Z"
},
{
"name": "rclone/rclone",
"version": "v1.70.2",
"date": "2025-06-27T13:21:17Z"
},
{
"name": "sabnzbd/sabnzbd",
"version": "4.5.1",
"date": "2025-04-11T09:57:47Z"
},
{
"name": "FlowiseAI/Flowise",
"version": "flowise@3.0.3",
"date": "2025-06-27T09:53:57Z"
},
{
"name": "nzbgetcom/nzbget",
"version": "v25.1",
"date": "2025-06-27T09:14:14Z"
},
{
"name": "cockpit-project/cockpit",
"version": "341.1",
"date": "2025-06-27T08:50:16Z"
},
{
"name": "zabbix/zabbix",
"version": "7.2.10",
"date": "2025-06-27T06:40:00Z"
},
{
"name": "MediaBrowser/Emby.Releases",
"version": "4.9.1.2",
"date": "2025-06-26T22:08:00Z"
},
{
"name": "home-assistant/operating-system",
"version": "15.2",
"date": "2025-04-14T15:37:12Z"
},
{
"name": "netbox-community/netbox",
"version": "v4.3.3",
"date": "2025-06-26T18:42:56Z"
},
{
"name": "apache/tika",
"version": "3.2.1-rc2",
"date": "2025-06-26T17:10:25Z"
},
{
"name": "tailscale/tailscale",
"version": "v1.84.3",
"date": "2025-06-26T16:31:57Z"
},
{
"name": "traefik/traefik",
"version": "v3.5.0-rc1",
"date": "2025-06-26T15:08:43Z"
},
{
"name": "meilisearch/meilisearch",
"version": "prototype-no-simd-x86-arroy-0",
"date": "2025-06-26T14:54:18Z"
},
{
"name": "AdguardTeam/AdGuardHome",
"version": "v0.107.63",
"date": "2025-06-26T14:34:19Z"
},
{
"name": "node-red/node-red",
"version": "4.1.0-beta.2",
"date": "2025-06-26T14:23:26Z"
},
{
"name": "Dolibarr/dolibarr",
"version": "18.0.7",
"date": "2025-06-26T09:16:33Z"
},
{
"name": "mongodb/mongo",
"version": "r8.1.2-rc1",
"date": "2025-06-25T22:42:04Z"
},
{
"name": "gristlabs/grist-core",
"version": "v1.6.1",
"date": "2025-06-25T21:19:25Z"
},
{
"name": "coder/code-server",
"version": "v4.101.2",
"date": "2025-06-25T21:18:52Z"
},
{
"name": "influxdata/influxdb",
"version": "v3.2.0",
"date": "2025-06-25T17:31:48Z"
},
{
"name": "wavelog/wavelog",
"version": "2.0.5",
"date": "2025-06-25T14:53:31Z"
},
{
"name": "jenkinsci/jenkins",
"version": "jenkins-2.504.3",
"date": "2025-06-25T14:43:01Z"
},
{
"name": "bunkerity/bunkerweb",
"version": "testing",
"date": "2025-06-16T18:10:42Z"
},
{
"name": "n8n-io/n8n",
"version": "n8n@1.100.0",
"date": "2025-06-23T12:48:35Z"
},
{
"name": "moghtech/komodo",
"version": "v1.18.4",
"date": "2025-06-25T00:06:56Z"
},
{
"name": "duplicati/duplicati",
"version": "v2.1.0.120-2.1.0.120_canary_2025-06-24",
"date": "2025-06-24T22:39:50Z"
},
{
"name": "jenkinsci/jenkins",
"version": "jenkins-2.516",
"date": "2025-06-24T21:06:15Z"
},
{
"name": "ollama/ollama",
"version": "v0.9.3-rc1",
"date": "2025-06-24T20:26:55Z"
},
{
"name": "evcc-io/evcc",
"version": "0.204.5",
@ -289,26 +39,61 @@
"version": "v2.37.3",
"date": "2025-06-24T14:05:33Z"
},
{
"name": "influxdata/influxdb",
"version": "v3.2.0",
"date": "2025-06-24T13:20:53Z"
},
{
"name": "Checkmk/checkmk",
"version": "v2.4.0p5",
"date": "2025-06-24T13:06:53Z"
},
{
"name": "home-assistant/core",
"version": "2025.6.3",
"date": "2025-06-24T13:00:12Z"
},
{
"name": "rcourtman/Pulse",
"version": "v3.31.2",
"date": "2025-06-24T09:45:34Z"
},
{
"name": "fallenbagel/jellyseerr",
"version": "preview-fix-proxy-axios",
"date": "2025-06-24T08:50:22Z"
},
{
"name": "Jackett/Jackett",
"version": "v0.22.2052",
"date": "2025-06-24T05:59:30Z"
},
{
"name": "wazuh/wazuh",
"version": "coverity-w26-4.13.0",
"date": "2025-06-24T02:02:34Z"
},
{
"name": "meilisearch/meilisearch",
"version": "prototype-incremental-vector-store-1",
"date": "2025-06-23T21:37:47Z"
},
{
"name": "minio/minio",
"version": "RELEASE.2025-06-13T11-33-47Z",
"date": "2025-06-23T20:58:42Z"
},
{
"name": "keycloak/keycloak",
"version": "26.2.5",
"date": "2025-05-28T06:49:43Z"
},
{
"name": "esphome/esphome",
"version": "2025.6.1",
"date": "2025-06-23T19:28:09Z"
},
{
"name": "runtipi/runtipi",
"version": "v4.2.1",
@ -319,11 +104,36 @@
"version": "pmm-6401-v1.120.0",
"date": "2025-06-23T15:12:12Z"
},
{
"name": "n8n-io/n8n",
"version": "n8n@1.98.2",
"date": "2025-06-18T18:20:16Z"
},
{
"name": "Graylog2/graylog2-server",
"version": "6.3.0-rc.2",
"date": "2025-06-23T11:31:38Z"
},
{
"name": "mattermost/mattermost",
"version": "v9.11.17",
"date": "2025-06-18T08:12:05Z"
},
{
"name": "firefly-iii/firefly-iii",
"version": "v6.2.18",
"date": "2025-06-20T04:45:37Z"
},
{
"name": "gotson/komga",
"version": "1.22.0",
"date": "2025-06-23T03:11:37Z"
},
{
"name": "plexguide/Huntarr.io",
"version": "8.1.8",
"date": "2025-06-23T00:21:30Z"
},
{
"name": "OliveTin/OliveTin",
"version": "2025.6.22",
@ -334,11 +144,26 @@
"version": "release-5.1.1",
"date": "2025-06-22T21:41:17Z"
},
{
"name": "pocket-id/pocket-id",
"version": "v1.4.1",
"date": "2025-06-22T19:38:08Z"
},
{
"name": "msgbyte/tianji",
"version": "v1.22.3",
"date": "2025-06-22T18:29:00Z"
},
{
"name": "clusterzx/paperless-ai",
"version": "v3.0.7",
"date": "2025-06-22T17:49:29Z"
},
{
"name": "fuma-nama/fumadocs",
"version": "create-fumadocs-app@15.5.4",
"date": "2025-06-22T13:12:24Z"
},
{
"name": "TandoorRecipes/recipes",
"version": "1.5.35",
@ -359,11 +184,31 @@
"version": "v2.0.114",
"date": "2025-06-21T11:20:21Z"
},
{
"name": "Luligu/matterbridge",
"version": "3.0.7",
"date": "2025-06-21T09:24:21Z"
},
{
"name": "theonedev/onedev",
"version": "v11.11.1",
"date": "2025-06-21T09:23:39Z"
},
{
"name": "pocketbase/pocketbase",
"version": "v0.28.4",
"date": "2025-06-21T08:29:04Z"
},
{
"name": "dgtlmoon/changedetection.io",
"version": "0.50.4",
"date": "2025-06-21T07:47:02Z"
},
{
"name": "coder/code-server",
"version": "v4.101.1",
"date": "2025-06-21T02:47:08Z"
},
{
"name": "go-gitea/gitea",
"version": "v1.24.2",
@ -374,11 +219,41 @@
"version": "v1.135.3",
"date": "2025-06-20T20:19:20Z"
},
{
"name": "apache/tika",
"version": "3.2.1-rc1",
"date": "2025-06-20T19:41:10Z"
},
{
"name": "homarr-labs/homarr",
"version": "v1.25.0",
"date": "2025-06-20T19:15:43Z"
},
{
"name": "mongodb/mongo",
"version": "r8.1.2-rc0",
"date": "2025-06-20T17:35:38Z"
},
{
"name": "nzbgetcom/nzbget",
"version": "v25.0",
"date": "2025-05-12T09:12:04Z"
},
{
"name": "Sonarr/Sonarr",
"version": "v4.0.15.2941",
"date": "2025-06-20T17:20:54Z"
},
{
"name": "bunkerity/bunkerweb",
"version": "testing",
"date": "2025-06-16T18:10:42Z"
},
{
"name": "zabbix/zabbix",
"version": "7.2.9",
"date": "2025-06-20T10:58:45Z"
},
{
"name": "syncthing/syncthing",
"version": "2.0.0-rc.19",
@ -394,6 +269,11 @@
"version": "v2.17.1",
"date": "2025-06-19T19:35:01Z"
},
{
"name": "rclone/rclone",
"version": "v1.70.1",
"date": "2025-06-19T13:19:02Z"
},
{
"name": "icereed/paperless-gpt",
"version": "v0.21.0",
@ -484,6 +364,11 @@
"version": "2025.6.1",
"date": "2025-06-17T12:45:39Z"
},
{
"name": "sabnzbd/sabnzbd",
"version": "4.5.1",
"date": "2025-04-11T09:57:47Z"
},
{
"name": "crowdsecurity/crowdsec",
"version": "v1.6.9",
@ -514,11 +399,36 @@
"version": "2.36.1",
"date": "2025-06-16T19:20:54Z"
},
{
"name": "goauthentik/authentik",
"version": "version/2025.6.2",
"date": "2025-06-16T17:54:39Z"
},
{
"name": "emqx/emqx",
"version": "e5.9.1-alpha.1",
"date": "2025-06-16T15:34:01Z"
},
{
"name": "open-webui/open-webui",
"version": "v0.6.15",
"date": "2025-06-16T14:34:42Z"
},
{
"name": "grokability/snipe-it",
"version": "v8.1.16",
"date": "2025-06-16T13:49:37Z"
},
{
"name": "home-assistant/operating-system",
"version": "15.2",
"date": "2025-04-14T15:37:12Z"
},
{
"name": "moghtech/komodo",
"version": "v1.18.3",
"date": "2025-06-16T07:03:46Z"
},
{
"name": "jellyfin/jellyfin",
"version": "v10.10.7",
@ -539,6 +449,11 @@
"version": "cli/v0.25.0",
"date": "2025-06-15T17:48:29Z"
},
{
"name": "tobychui/zoraxy",
"version": "v3.1.9",
"date": "2025-03-01T02:24:33Z"
},
{
"name": "Prowlarr/Prowlarr",
"version": "v1.37.0.5076",
@ -584,6 +499,11 @@
"version": "v3.3.25",
"date": "2025-06-14T02:52:44Z"
},
{
"name": "FlowiseAI/Flowise",
"version": "flowise@3.0.2",
"date": "2025-06-12T22:48:11Z"
},
{
"name": "leiweibau/Pi.Alert",
"version": "v2025-06-12",
@ -594,6 +514,16 @@
"version": "v3.3.0",
"date": "2025-06-12T06:54:48Z"
},
{
"name": "documenso/documenso",
"version": "v1.12.0-rc.4",
"date": "2025-06-12T00:27:41Z"
},
{
"name": "MediaBrowser/Emby.Releases",
"version": "4.8.11.0",
"date": "2025-03-10T06:39:11Z"
},
{
"name": "autobrr/autobrr",
"version": "v1.63.1",
@ -609,6 +539,16 @@
"version": "v0.15.0-rc2",
"date": "2025-06-11T04:29:22Z"
},
{
"name": "node-red/node-red",
"version": "4.1.0-beta.1",
"date": "2025-06-10T15:47:59Z"
},
{
"name": "AdguardTeam/AdGuardHome",
"version": "v0.107.62",
"date": "2025-05-27T12:10:19Z"
},
{
"name": "OctoPrint/OctoPrint",
"version": "1.11.2",
@ -619,6 +559,11 @@
"version": "v0.8.4",
"date": "2025-06-10T07:57:14Z"
},
{
"name": "tailscale/tailscale",
"version": "v1.84.2",
"date": "2025-06-09T23:43:27Z"
},
{
"name": "Brandawg93/PeaNUT",
"version": "v5.8.0",
@ -674,6 +619,11 @@
"version": "10.1.42",
"date": "2025-06-05T22:39:40Z"
},
{
"name": "netbox-community/netbox",
"version": "v4.3.2",
"date": "2025-06-05T19:57:01Z"
},
{
"name": "benjaminjonard/koillection",
"version": "1.6.14",
@ -699,6 +649,11 @@
"version": "v4.1.1",
"date": "2025-06-04T19:10:05Z"
},
{
"name": "cockpit-project/cockpit",
"version": "340",
"date": "2025-06-04T16:41:44Z"
},
{
"name": "intri-in/manage-my-damn-life-nextjs",
"version": "v0.7.1",
@ -739,6 +694,11 @@
"version": "v5.18.1",
"date": "2025-05-31T23:06:08Z"
},
{
"name": "prometheus/prometheus",
"version": "v3.4.1",
"date": "2025-05-31T13:45:40Z"
},
{
"name": "blakeblackshear/frigate",
"version": "v0.14.1",
@ -754,6 +714,11 @@
"version": "0.26.3",
"date": "2025-05-29T21:18:15Z"
},
{
"name": "gristlabs/grist-core",
"version": "v1.6.0",
"date": "2025-05-29T19:11:21Z"
},
{
"name": "navidrome/navidrome",
"version": "v0.56.1",
@ -784,6 +749,11 @@
"version": "1.2.34",
"date": "2025-05-27T18:18:00Z"
},
{
"name": "traefik/traefik",
"version": "v3.4.1",
"date": "2025-05-27T12:53:58Z"
},
{
"name": "dani-garcia/vaultwarden",
"version": "1.34.1",
@ -854,6 +824,16 @@
"version": "v1.1.1",
"date": "2025-05-17T10:10:36Z"
},
{
"name": "wavelog/wavelog",
"version": "2.0.4",
"date": "2025-05-16T15:09:53Z"
},
{
"name": "Dolibarr/dolibarr",
"version": "18.0.7",
"date": "2025-05-15T08:24:30Z"
},
{
"name": "Ombi-app/Ombi",
"version": "v4.47.1",
@ -904,11 +884,21 @@
"version": "2025-05-07-r1",
"date": "2025-05-07T12:18:42Z"
},
{
"name": "sysadminsmedia/homebox",
"version": "v0.19.0",
"date": "2025-05-06T18:05:42Z"
},
{
"name": "garethgeorge/backrest",
"version": "v1.8.1",
"date": "2025-05-06T04:27:00Z"
},
{
"name": "linkwarden/linkwarden",
"version": "v2.10.2",
"date": "2025-05-06T03:12:53Z"
},
{
"name": "postgres/postgres",
"version": "REL_13_21",
@ -1189,6 +1179,11 @@
"version": "v2.7.1",
"date": "2025-02-22T01:14:41Z"
},
{
"name": "typesense/typesense",
"version": "v28.0",
"date": "2025-02-18T15:49:57Z"
},
{
"name": "recyclarr/recyclarr",
"version": "v7.4.1",
@ -1214,6 +1209,11 @@
"version": "v2.12.3",
"date": "2025-02-06T11:07:07Z"
},
{
"name": "PrivateBin/PrivateBin",
"version": "1.7.6",
"date": "2025-02-01T09:50:52Z"
},
{
"name": "AmruthPillai/Reactive-Resume",
"version": "v4.4.4",

View File

@ -44,7 +44,7 @@
},
"notes": [
{
"text": "Wireguard and WGDashboard are not the same. More info: `https://docs.wgdashboard.dev/what-is-wireguard-what-is-wgdashboard.html`",
"text": "Wireguard and WGDashboard are not the same. More info: `https://donaldzou.github.io/WGDashboard-Documentation/what-is-wireguard-what-is-wgdashboard.html`",
"type": "info"
}
]

View File

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

View File

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

View File

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

View File

@ -1,8 +1,7 @@
import { Metadata, Script } from "@/lib/types";
import { promises as fs } from "fs";
import { NextResponse } from "next/server";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Metadata, Script } from "@/lib/types";
import path from "path";
export const dynamic = "force-static";
@ -11,21 +10,21 @@ const metadataFileName = "metadata.json";
const versionFileName = "version.json";
const encoding = "utf-8";
async function getMetadata() {
const getMetadata = async () => {
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 getScripts = async () => {
const filePaths = (await fs.readdir(jsonDir))
.filter(fileName =>
fileName.endsWith(".json")
&& fileName !== metadataFileName
&& fileName !== versionFileName,
.filter((fileName) =>
fileName.endsWith(".json") &&
fileName !== metadataFileName &&
fileName !== versionFileName
)
.map(fileName => path.resolve(jsonDir, fileName));
.map((fileName) => path.resolve(jsonDir, fileName));
const scripts = await Promise.all(
filePaths.map(async (filePath) => {
@ -35,7 +34,7 @@ async function getScripts() {
}),
);
return scripts;
}
};
export async function GET() {
try {
@ -44,7 +43,7 @@ export async function GET() {
const categories = metadata.categories
.map((category) => {
category.scripts = scripts.filter(script =>
category.scripts = scripts.filter((script) =>
script.categories?.includes(category.id),
);
return category;
@ -52,8 +51,7 @@ export async function GET() {
.sort((a, b) => a.sort_order - b.sort_order);
return NextResponse.json(categories);
}
catch (error) {
} catch (error) {
console.error(error as Error);
return NextResponse.json(
{ error: "Failed to fetch categories" },

View File

@ -1,9 +1,9 @@
import { AppVersion } from "@/lib/types";
import { error } from "console";
import { promises as fs } from "fs";
// import Error from "next/error";
import { NextResponse } from "next/server";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { AppVersion } from "@/lib/types";
import path from "path";
export const dynamic = "force-static";
@ -11,32 +11,33 @@ const jsonDir = "public/json";
const versionsFileName = "versions.json";
const encoding = "utf-8";
async function getVersions() {
const getVersions = async () => {
const filePath = path.resolve(jsonDir, versionsFileName);
const fileContent = await fs.readFile(filePath, encoding);
const versions: AppVersion[] = JSON.parse(fileContent);
const modifiedVersions = versions.map((version) => {
const modifiedVersions = versions.map(version => {
let newName = version.name;
newName = newName.toLowerCase().replace(/[^a-z0-9/]/g, "");
newName = newName.toLowerCase().replace(/[^a-z0-9/]/g, '');
return { ...version, name: newName, date: new Date(version.date) };
});
return modifiedVersions;
}
};
export async function GET() {
try {
const versions = await getVersions();
return NextResponse.json(versions);
}
catch (error) {
} catch (error) {
console.error(error);
const err = error as globalThis.Error;
return NextResponse.json({
name: err.name,
message: err.message || "An unexpected error occurred",
version: "No version found - Error",
version: "No version found - Error"
}, {
status: 500,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { z } from "zod";
export const InstallMethodSchema = z.object({
type: z.enum(["default", "alpine"], {
errorMap: () => ({ message: "Type must be either 'default' or 'alpine'" }),
errorMap: () => ({ message: "Type must be either 'default' or 'alpine'" })
}),
script: z.string().min(1, "Script content cannot be empty"),
resources: z.object({
@ -25,7 +25,7 @@ export const ScriptSchema = z.object({
categories: z.array(z.number()),
date_created: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").min(1, "Date is required"),
type: z.enum(["vm", "ct", "pve", "addon", "turnkey"], {
errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'pve', 'addon' or 'turnkey'" }),
errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'pve', 'addon' or 'turnkey'" })
}),
updateable: z.boolean(),
privileged: z.boolean(),

View File

@ -1,32 +1,26 @@
"use client";
import type { z } from "zod";
import { CalendarIcon, Check, Clipboard, Download } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { format } from "date-fns";
import { toast } from "sonner";
import type { Category } from "@/lib/types";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Calendar } from "@/components/ui/calendar";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { fetchCategories } from "@/lib/data";
import { Category } from "@/lib/types";
import { cn } from "@/lib/utils";
import type { Script } from "./_schemas/schemas";
import InstallMethod from "./_components/install-method";
import { ScriptSchema } from "./_schemas/schemas";
import Categories from "./_components/categories";
import Note from "./_components/note";
import { format } from "date-fns";
import { CalendarIcon, Check, Clipboard, Download } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import Categories from "./_components/Categories";
import InstallMethod from "./_components/InstallMethod";
import Note from "./_components/Note";
import { ScriptSchema, type Script } from "./_schemas/schemas";
const initialScript: Script = {
name: "",
@ -60,7 +54,7 @@ export default function JSONGenerator() {
useEffect(() => {
fetchCategories()
.then(setCategories)
.catch(error => console.error("Error fetching categories:", error));
.catch((error) => console.error("Error fetching categories:", error));
}, []);
const updateScript = useCallback((key: keyof Script, value: Script[keyof Script]) => {
@ -73,14 +67,11 @@ export default function JSONGenerator() {
if (updated.type === "pve") {
scriptPath = `tools/pve/${updated.slug}.sh`;
}
else if (updated.type === "addon") {
} else if (updated.type === "addon") {
scriptPath = `tools/addon/${updated.slug}.sh`;
}
else if (method.type === "alpine") {
} else if (method.type === "alpine") {
scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
}
else {
} else {
scriptPath = `${updated.type}/${updated.slug}.sh`;
}
@ -145,10 +136,7 @@ export default function JSONGenerator() {
<div className="mt-2 space-y-1">
{zodErrors.errors.map((error, index) => (
<AlertDescription key={index} className="p-1 text-red-500">
{error.path.join(".")}
{" "}
-
{error.message}
{error.path.join(".")} - {error.message}
</AlertDescription>
))}
</div>
@ -166,31 +154,25 @@ export default function JSONGenerator() {
<div className="grid grid-cols-2 gap-4">
<div>
<Label>
Name
{" "}
<span className="text-red-500">*</span>
Name <span className="text-red-500">*</span>
</Label>
<Input placeholder="Example" value={script.name} onChange={e => updateScript("name", e.target.value)} />
<Input placeholder="Example" value={script.name} onChange={(e) => updateScript("name", e.target.value)} />
</div>
<div>
<Label>
Slug
{" "}
<span className="text-red-500">*</span>
Slug <span className="text-red-500">*</span>
</Label>
<Input placeholder="example" value={script.slug} onChange={e => updateScript("slug", e.target.value)} />
<Input placeholder="example" value={script.slug} onChange={(e) => updateScript("slug", e.target.value)} />
</div>
</div>
<div>
<Label>
Logo
{" "}
<span className="text-red-500">*</span>
Logo <span className="text-red-500">*</span>
</Label>
<Input
placeholder="Full logo URL"
value={script.logo || ""}
onChange={e => updateScript("logo", e.target.value || null)}
onChange={(e) => updateScript("logo", e.target.value || null)}
/>
</div>
<div>
@ -198,19 +180,17 @@ export default function JSONGenerator() {
<Input
placeholder="Path to config file"
value={script.config_path || ""}
onChange={e => updateScript("config_path", e.target.value || null)}
onChange={(e) => updateScript("config_path", e.target.value || null)}
/>
</div>
<div>
<Label>
Description
{" "}
<span className="text-red-500">*</span>
Description <span className="text-red-500">*</span>
</Label>
<Textarea
placeholder="Example"
value={script.description}
onChange={e => updateScript("description", e.target.value)}
onChange={(e) => updateScript("description", e.target.value)}
/>
</div>
<Categories script={script} setScript={setScript} categories={categories} />
@ -220,7 +200,7 @@ export default function JSONGenerator() {
<Popover>
<PopoverTrigger asChild className="flex-1">
<Button
variant="outline"
variant={"outline"}
className={cn("pl-3 text-left font-normal w-full", !script.date_created && "text-muted-foreground")}
>
{formattedDate || <span>Pick a date</span>}
@ -239,7 +219,7 @@ export default function JSONGenerator() {
</div>
<div className="flex flex-col gap-2 w-full">
<Label>Type</Label>
<Select value={script.type} onValueChange={value => updateScript("type", value)}>
<Select value={script.type} onValueChange={(value) => updateScript("type", value)}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Type" />
</SelectTrigger>
@ -254,11 +234,11 @@ export default function JSONGenerator() {
</div>
<div className="w-full flex gap-5">
<div className="flex items-center space-x-2">
<Switch checked={script.updateable} onCheckedChange={checked => updateScript("updateable", checked)} />
<Switch checked={script.updateable} onCheckedChange={(checked) => updateScript("updateable", checked)} />
<label>Updateable</label>
</div>
<div className="flex items-center space-x-2">
<Switch checked={script.privileged} onCheckedChange={checked => updateScript("privileged", checked)} />
<Switch checked={script.privileged} onCheckedChange={(checked) => updateScript("privileged", checked)} />
<label>Privileged</label>
</div>
</div>
@ -266,18 +246,18 @@ export default function JSONGenerator() {
placeholder="Interface Port"
type="number"
value={script.interface_port || ""}
onChange={e => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
onChange={(e) => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
/>
<div className="flex gap-2">
<Input
placeholder="Website URL"
value={script.website || ""}
onChange={e => updateScript("website", e.target.value || null)}
onChange={(e) => updateScript("website", e.target.value || null)}
/>
<Input
placeholder="Documentation URL"
value={script.documentation || ""}
onChange={e => updateScript("documentation", e.target.value || null)}
onChange={(e) => updateScript("documentation", e.target.value || null)}
/>
</div>
<InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
@ -285,20 +265,22 @@ export default function JSONGenerator() {
<Input
placeholder="Username"
value={script.default_credentials.username || ""}
onChange={e =>
onChange={(e) =>
updateScript("default_credentials", {
...script.default_credentials,
username: e.target.value || null,
})}
})
}
/>
<Input
placeholder="Password"
value={script.default_credentials.password || ""}
onChange={e =>
onChange={(e) =>
updateScript("default_credentials", {
...script.default_credentials,
password: e.target.value || null,
})}
})
}
/>
<Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
</form>

View File

@ -1,20 +1,18 @@
import type { Metadata } from "next";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Inter } from "next/font/google";
import React from "react";
import { ThemeProvider } from "@/components/theme-provider";
import { analytics, basePath } from "@/config/site-config";
import "@/styles/globals.css";
import Footer from "@/components/Footer";
import Navbar from "@/components/Navbar";
import QueryProvider from "@/components/query-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import Footer from "@/components/footer";
import Navbar from "@/components/navbar";
import { analytics, basePath } from "@/config/siteConfig";
import "@/styles/globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import React from "react";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
export const metadata : Metadata = {
title: "Proxmox VE Helper-Scripts",
description:
"The official website for the Proxmox VE Helper-Scripts (Community) Repository. Featuring over 300+ scripts to help you manage your Proxmox VE environment.",

View File

@ -1,10 +1,9 @@
import { basePath } from "@/config/siteConfig";
import type { MetadataRoute } from "next";
import { basePath } from "@/config/site-config";
export function generateStaticParams() {
export const generateStaticParams = () => {
return [];
}
};
export default function manifest(): MetadataRoute.Manifest {
return {

View File

@ -1,10 +1,8 @@
"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 FAQ from "@/components/FAQ";
import AnimatedGradientText from "@/components/ui/animated-gradient-text";
import { Button } from "@/components/ui/button";
import { CardFooter } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@ -13,14 +11,15 @@ import {
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 { Separator } from "@/components/ui/separator";
import { basePath } from "@/config/siteConfig";
import { cn } from "@/lib/utils";
import { ArrowRightIcon, ExternalLink } from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa";
function CustomArrowRightIcon() {
return <ArrowRightIcon className="h-4 w-4" width={1} />;
@ -51,9 +50,7 @@ export default function Page() {
`p-px ![mask-composite:subtract]`,
)}
/>
{" "}
<Separator className="mx-2 h-4" orientation="vertical" />
<Separator className="mx-2 h-4" orientation="vertical" />
<span
className={cn(
`animate-gradient bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
@ -81,9 +78,7 @@ export default function Page() {
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<FaGithub className="mr-2 h-4 w-4" />
{" "}
Tteck&apos;s GitHub
<FaGithub className="mr-2 h-4 w-4" /> Tteck&apos;s GitHub
</a>
</Button>
<Button className="w-full" asChild>
@ -93,9 +88,7 @@ export default function Page() {
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<ExternalLink className="mr-2 h-4 w-4" />
{" "}
Proxmox Helper Scripts
<ExternalLink className="mr-2 h-4 w-4" /> Proxmox Helper Scripts
</a>
</Button>
</CardFooter>
@ -111,10 +104,7 @@ export default function Page() {
We are a community-driven initiative that simplifies the setup of Proxmox Virtual Environment (VE).
</p>
<p>
With 300+ scripts to help you manage your
{" "}
<b>Proxmox VE environment</b>
. Whether you&#39;re a seasoned
With 300+ scripts to help you manage your <b>Proxmox VE environment</b>. Whether you&#39;re a seasoned
user or a newcomer, we&#39;ve got you covered.
</p>
</div>

View File

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

View File

@ -1,17 +1,17 @@
import { CPUIcon, HDDIcon, RAMIcon } from "@/components/icons/resource-icons";
import { getDisplayValueFromRAM } from "@/lib/utils/resource-utils";
type ResourceDisplayProps = {
interface ResourceDisplayProps {
title: string;
cpu: number | null;
ram: number | null;
hdd: number | null;
};
}
type IconTextProps = {
interface IconTextProps {
icon: React.ReactNode;
label: string;
};
}
function IconText({ icon, label }: IconTextProps) {
return (
@ -27,8 +27,7 @@ export function ResourceDisplay({ title, cpu, ram, hdd }: ResourceDisplayProps)
const hasRAM = typeof ram === "number" && ram > 0;
const hasHDD = typeof hdd === "number" && hdd > 0;
if (!hasCPU && !hasRAM && !hasHDD)
return null;
if (!hasCPU && !hasRAM && !hasHDD) return null;
return (
<div className="flex flex-wrap items-center gap-2">

View File

@ -1,18 +1,18 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import type { Category } from "@/lib/types";
import { useCallback, useEffect, useRef } from "react";
import { formattedBadge } from "@/components/CommandMenu";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { formattedBadge } from "@/components/command-menu";
import { basePath } from "@/config/site-config";
import { Category } from "@/lib/types";
import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { basePath } from "@/config/siteConfig";
export default function ScriptAccordion({
items,
@ -41,8 +41,8 @@ export default function ScriptAccordion({
useEffect(() => {
if (selectedScript) {
const category = items.find(category =>
category.scripts.some(script => script.slug === selectedScript),
const category = items.find((category) =>
category.scripts.some((script) => script.slug === selectedScript),
);
if (category) {
setExpandedItem(category.name);
@ -58,11 +58,11 @@ export default function ScriptAccordion({
collapsible
className="overflow-y-scroll max-h-[calc(100vh-225px)] overflow-x-hidden p-2"
>
{items.map(category => (
{items.map((category) => (
<AccordionItem
key={`${category.id}:category`}
key={category.id + ":category"}
value={category.name}
className={cn("sm:text-sm flex flex-col border-none", {
className={cn("sm:text-md flex flex-col border-none", {
"rounded-lg bg-accent/30": expandedItem === category.name,
})}
>
@ -72,15 +72,11 @@ export default function ScriptAccordion({
)}
>
<div className="mr-2 flex w-full items-center justify-between">
<span className="pl-2 text-left">
{category.name}
{" "}
</span>
<span className="pl-2 text-left">{category.name} </span>
<span className="rounded-full bg-gray-200 px-2 py-1 text-xs text-muted-foreground hover:no-underline dark:bg-blue-800/20">
{category.scripts.length}
</span>
</div>
{" "}
</div>{" "}
</AccordionTrigger>
<AccordionContent
data-state={expandedItem === category.name ? "open" : "closed"}
@ -113,9 +109,10 @@ export default function ScriptAccordion({
height={16}
width={16}
unoptimized
onError={e =>
((e.currentTarget as HTMLImageElement).src
= `/${basePath}/logo.png`)}
onError={(e) =>
((e.currentTarget as HTMLImageElement).src =
`/${basePath}/logo.png`)
}
alt={script.name}
className="mr-1 w-4 h-4 rounded-full"
/>

View File

@ -1,18 +1,16 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { basePath, mostPopularScripts } from "@/config/siteConfig";
import { extractDate } from "@/lib/time";
import { Category, Script } from "@/lib/types";
import { CalendarPlus } from "lucide-react";
import { useMemo, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import 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";
import { useMemo, useState } from "react";
const ITEMS_PER_PAGE = 3;
export function getDisplayValueFromType(type: string) {
export const getDisplayValueFromType = (type: string) => {
switch (type) {
case "ct":
return "LXC";
@ -24,16 +22,15 @@ export function getDisplayValueFromType(type: string) {
default:
return "";
}
}
};
export function LatestScripts({ items }: { items: Category[] }) {
const [page, setPage] = useState(1);
const latestScripts = useMemo(() => {
if (!items)
return [];
if (!items) return [];
const scripts = items.flatMap(category => category.scripts || []);
const scripts = items.flatMap((category) => category.scripts || []);
// Filter out duplicates by slug
const uniqueScriptsMap = new Map<string, Script>();
@ -49,11 +46,11 @@ export function LatestScripts({ items }: { items: Category[] }) {
}, [items]);
const goToNextPage = () => {
setPage(prevPage => prevPage + 1);
setPage((prevPage) => prevPage + 1);
};
const goToPreviousPage = () => {
setPage(prevPage => prevPage - 1);
setPage((prevPage) => prevPage - 1);
};
const startIndex = (page - 1) * ITEMS_PER_PAGE;
@ -83,7 +80,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
</div>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{latestScripts.slice(startIndex, endIndex).map(script => (
{latestScripts.slice(startIndex, endIndex).map((script) => (
<Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
<CardHeader>
<CardTitle className="flex items-center gap-3">
@ -94,15 +91,13 @@ export function LatestScripts({ items }: { items: Category[] }) {
height={64}
width={64}
alt=""
onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
className="h-11 w-11 object-contain"
/>
</div>
<div className="flex flex-col">
<p className="text-lg line-clamp-1">
{script.name}
{" "}
{getDisplayValueFromType(script.type)}
{script.name} {getDisplayValueFromType(script.type)}
</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarPlus className="h-4 w-4" />
@ -135,7 +130,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
export function MostViewedScripts({ items }: { items: Category[] }) {
const mostViewedScripts = items.reduce((acc: Script[], category) => {
const foundScripts = category.scripts.filter(script => mostPopularScripts.includes(script.slug));
const foundScripts = category.scripts.filter((script) => mostPopularScripts.includes(script.slug));
return acc.concat(foundScripts);
}, []);
@ -147,7 +142,7 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
</>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{mostViewedScripts.map(script => (
{mostViewedScripts.map((script) => (
<Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
<CardHeader>
<CardTitle className="flex items-center gap-3">
@ -158,15 +153,13 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
height={64}
width={64}
alt=""
onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
className="h-11 w-11 object-contain"
/>
</div>
<div className="flex flex-col">
<p className="line-clamp-1 text-lg">
{script.name}
{" "}
{getDisplayValueFromType(script.type)}
{script.name} {getDisplayValueFromType(script.type)}
</p>
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarPlus className="h-4 w-4" />

View File

@ -1,32 +1,31 @@
"use client";
import { extractDate } from "@/lib/time";
import { AppVersion, Script } from "@/lib/types";
import { X } from "lucide-react";
import { Suspense } from "react";
import Image from "next/image";
import type { AppVersion, Script } from "@/lib/types";
import { cleanSlug } from "@/lib/utils/resource-utils";
import { Separator } from "@/components/ui/separator";
import { useVersions } from "@/hooks/use-versions";
import { basePath } from "@/config/site-config";
import { extractDate } from "@/lib/time";
import { basePath } from "@/config/siteConfig";
import { useVersions } from "@/hooks/useVersions";
import { cleanSlug } from "@/lib/utils/resource-utils";
import { Suspense } from "react";
import { ResourceDisplay } from "./ResourceDisplay";
import { getDisplayValueFromType } from "./ScriptInfoBlocks";
import Alerts from "./ScriptItems/Alerts";
import Buttons from "./ScriptItems/Buttons";
import ConfigFile from "./ScriptItems/ConfigFile";
import DefaultPassword from "./ScriptItems/DefaultPassword";
import Description from "./ScriptItems/Description";
import InstallCommand from "./ScriptItems/InstallCommand";
import InterFaces from "./ScriptItems/InterFaces";
import Tooltips from "./ScriptItems/Tooltips";
import { 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 = {
interface ScriptItemProps {
item: Script;
setSelectedScript: (script: string | null) => void;
};
}
function ScriptHeader({ item }: { item: Script }) {
const defaultInstallMethod = item.install_methods?.[0];
@ -41,7 +40,7 @@ function ScriptHeader({ item }: { item: Script }) {
className="h-32 w-32 rounded-xl bg-gradient-to-br from-accent/40 to-accent/60 object-contain p-3 shadow-lg transition-transform hover:scale-105"
src={item.logo || `/${basePath}/logo.png`}
width={400}
onError={e => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
height={400}
alt={item.name}
unoptimized
@ -59,16 +58,10 @@ function ScriptHeader({ item }: { item: Script }) {
</span>
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>
Added
{" "}
{extractDate(item.date_created)}
</span>
<span>Added {extractDate(item.date_created)}</span>
<span></span>
<span className=" capitalize">
{os}
{" "}
{version}
{os} {version}
</span>
</div>
</div>
@ -83,10 +76,10 @@ function ScriptHeader({ item }: { item: Script }) {
hdd={defaultInstallMethod.resources.hdd}
/>
)}
{item.install_methods.find(method => method.type === "alpine")?.resources && (
{item.install_methods.find((method) => method.type === "alpine")?.resources && (
<ResourceDisplay
title="Alpine"
{...item.install_methods.find(method => method.type === "alpine")!.resources!}
{...item.install_methods.find((method) => method.type === "alpine")!.resources!}
/>
)}
</div>
@ -115,8 +108,7 @@ function VersionInfo({ item }: { item: Script }) {
return cleanName === cleanSlug(item.slug) || cleanName.includes(cleanSlug(item.slug));
});
if (!matchedVersion)
return null;
if (!matchedVersion) return null;
return <span className="font-medium text-sm">{matchedVersion.version}</span>;
}
@ -140,7 +132,7 @@ export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
</button>
</div>
<div className="rounded-xl border border-border bg-accent/30 backdrop-blur-sm shadow-sm">
<div className="rounded-xl border border-border bg-gradient-to-b from-card/30 to-background/50 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} />
@ -152,9 +144,7 @@ export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
<div className="mt-4 rounded-lg border shadow-sm">
<div className="flex gap-3 px-4 py-2 bg-accent/25">
<h2 className="text-lg font-semibold">
How to
{" "}
{item.type === "pve" ? "use" : item.type === "addon" ? "apply" : "install"}
How to {item.type === "pve" ? "use" : item.type === "addon" ? "apply" : "install"}
</h2>
<Tooltips item={item} />
</div>

View File

@ -1,21 +1,19 @@
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 TextCopyBlock from "@/components/TextCopyBlock";
import { AlertColors } from "@/config/siteConfig";
import { Script } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AlertCircle, NotepadText } from "lucide-react";
type NoteProps = {
text: string;
type: keyof typeof AlertColors;
};
}
export default function Alerts({ item }: { item: Script }) {
return (
<>
{item?.notes?.length > 0
&& item.notes.map((note: NoteProps, index: number) => (
{item?.notes?.length > 0 &&
item.notes.map((note: NoteProps, index: number) => (
<div key={index} className="mt-4 flex flex-col shadow-sm gap-2">
<p
className={cn(
@ -23,13 +21,11 @@ export default function Alerts({ item }: { item: Script }) {
AlertColors[note.type],
)}
>
{note.type === "info"
? (
<NotepadText className="h-4 min-h-4 w-4 min-w-4" />
)
: (
<AlertCircle className="h-4 min-h-4 w-4 min-w-4" />
)}
{note.type == "info" ? (
<NotepadText className="h-4 min-h-4 w-4 min-w-4" />
) : (
<AlertCircle className="h-4 min-h-4 w-4 min-w-4" />
)}
<span>{TextCopyBlock(note.text)}</span>
</p>
</div>

View File

@ -1,22 +1,20 @@
import { BookOpenText, Code, Globe, LinkIcon, RefreshCcw } from "lucide-react";
import type { Script } from "@/lib/types";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { basePath } from "@/config/site-config";
import { basePath } from "@/config/siteConfig";
import { Script } from "@/lib/types";
import { BookOpenText, Code, Globe, LinkIcon, RefreshCcw } from "lucide-react";
function generateInstallSourceUrl(slug: string) {
const 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 generateSourceUrl = (slug: string, type: string) => {
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
switch (type) {
@ -31,18 +29,18 @@ function generateSourceUrl(slug: string, type: string) {
default:
return `${baseUrl}/ct/${slug}.sh`; // fallback for "ct"
}
}
};
function generateUpdateUrl(slug: string) {
const generateUpdateUrl = (slug: string) => {
const baseUrl = `https://raw.githubusercontent.com/community-scripts/${basePath}/main`;
return `${baseUrl}/ct/${slug}.sh`;
}
};
type LinkItem = {
interface LinkItem {
href: string;
icon: React.ReactNode;
text: string;
};
}
export default function Buttons({ item }: { item: Script }) {
const isCtOrDefault = ["ct"].includes(item.type);
@ -78,8 +76,7 @@ export default function Buttons({ item }: { item: Script }) {
},
].filter(Boolean) as LinkItem[];
if (links.length === 0)
return null;
if (links.length === 0) return null;
return (
<DropdownMenu>

View File

@ -1,15 +1,13 @@
import type { Script } from "@/lib/types";
import { Separator } from "@/components/ui/separator";
import handleCopy from "@/components/handle-copy";
import handleCopy from "@/components/handleCopy";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Script } from "@/lib/types";
export default function DefaultPassword({ item }: { item: Script }) {
const { username, password } = item.default_credentials;
const hasDefaultLogin = username || password;
if (!hasDefaultLogin)
return null;
if (!hasDefaultLogin) return null;
const copyCredential = (type: "username" | "password") => {
handleCopy(type, item.default_credentials[type] ?? "");
@ -23,27 +21,18 @@ export default function DefaultPassword({ item }: { item: Script }) {
<Separator className="w-full" />
<div className="flex flex-col gap-2 p-4">
<p className="mb-2 text-sm">
You can use the following credentials to login to the
{" "}
{item.name}
{" "}
{item.type}
.
You can use the following credentials to login to the {item.name} {item.type}.
</p>
{["username", "password"].map((type) => {
const value = item.default_credentials[type as "username" | "password"];
return value && value.trim() !== ""
? (
<div key={type} className="text-sm">
{type.charAt(0).toUpperCase() + type.slice(1)}
:
{" "}
<Button variant="secondary" size="null" onClick={() => copyCredential(type as "username" | "password")}>
{value}
</Button>
</div>
)
: null;
return value && value.trim() !== "" ? (
<div key={type} className="text-sm">
{type.charAt(0).toUpperCase() + type.slice(1)}:{" "}
<Button variant="secondary" size="null" onClick={() => copyCredential(type as "username" | "password")}>
{value}
</Button>
</div>
) : null;
})}
</div>
</div>

View File

@ -1,4 +1,4 @@
import type { Script } from "@/lib/types";
import { Script } from "@/lib/types";
export default function DefaultSettings({ item }: { item: Script }) {
const getDisplayValueFromRAM = (ram: number) => (ram >= 1024 ? `${Math.floor(ram / 1024)}GB` : `${ram}MB`);
@ -8,26 +8,15 @@ export default function DefaultSettings({ item }: { item: Script }) {
return (
<div>
<h2 className="text-md font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">
CPU:
{cpu}
vCPU
</p>
<p className="text-sm text-muted-foreground">
RAM:
{getDisplayValueFromRAM(ram ?? 0)}
</p>
<p className="text-sm text-muted-foreground">
HDD:
{hdd}
GB
</p>
<p className="text-sm text-muted-foreground">CPU: {cpu}vCPU</p>
<p className="text-sm text-muted-foreground">RAM: {getDisplayValueFromRAM(ram ?? 0)}</p>
<p className="text-sm text-muted-foreground">HDD: {hdd}GB</p>
</div>
);
};
const defaultSettings = item.install_methods.find(method => method.type === "default");
const defaultAlpineSettings = item.install_methods.find(method => method.type === "alpine");
const defaultSettings = item.install_methods.find((method) => method.type === "default");
const defaultAlpineSettings = item.install_methods.find((method) => method.type === "alpine");
const hasDefaultSettings = defaultSettings?.resources && Object.values(defaultSettings.resources).some(Boolean);

View File

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

View File

@ -0,0 +1,77 @@
import CodeCopyButton from "@/components/ui/code-copy-button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { basePath } from "@/config/siteConfig";
import { Script } from "@/lib/types";
import { getDisplayValueFromType } from "../ScriptInfoBlocks";
const getInstallCommand = (scriptPath = "", isAlpine = false) => {
const url = `https://raw.githubusercontent.com/community-scripts/${basePath}/main/${scriptPath}`;
return isAlpine ? `bash -c "$(curl -fsSL ${url})"` : `bash -c "$(curl -fsSL ${url})"`;
};
export default function InstallCommand({ item }: { item: Script }) {
const alpineScript = item.install_methods.find((method) => method.type === "alpine");
const defaultScript = item.install_methods.find((method) => method.type === "default");
const renderInstructions = (isAlpine = false) => (
<>
<p className="text-sm mt-2">
{isAlpine ? (
<>
As an alternative option, you can use Alpine Linux and the {item.name} package to create a {item.name}{" "}
{getDisplayValueFromType(item.type)} container with faster creation time and minimal system resource usage.
You are also obliged to adhere to updates provided by the package maintainer.
</>
) : item.type === "pve" ? (
<>
To use the {item.name} script, run the command below **only** in the Proxmox VE Shell. This script is
intended for managing or enhancing the host system directly.
</>
) : item.type === "addon" ? (
<>
This script enhances an existing setup. You can use it inside a running LXC container or directly on the
Proxmox VE host to extend functionality with {item.name}.
</>
) : (
<>
To create a new Proxmox VE {item.name} {getDisplayValueFromType(item.type)}, run the command below in the
Proxmox VE Shell.
</>
)}
</p>
{isAlpine && (
<p className="mt-2 text-sm">
To create a new Proxmox VE Alpine-{item.name} {getDisplayValueFromType(item.type)}, run the command below in
the Proxmox VE Shell.
</p>
)}
</>
);
return (
<div className="p-4">
{alpineScript ? (
<Tabs defaultValue="default" className="mt-2 w-full max-w-4xl">
<TabsList>
<TabsTrigger value="default">Default</TabsTrigger>
<TabsTrigger value="alpine">Alpine Linux</TabsTrigger>
</TabsList>
<TabsContent value="default">
{renderInstructions()}
<CodeCopyButton>{getInstallCommand(defaultScript?.script)}</CodeCopyButton>
</TabsContent>
<TabsContent value="alpine">
{renderInstructions(true)}
<CodeCopyButton>{getInstallCommand(alpineScript.script, true)}</CodeCopyButton>
</TabsContent>
</Tabs>
) : defaultScript?.script ? (
<>
{renderInstructions()}
<CodeCopyButton>{getInstallCommand(defaultScript.script)}</CodeCopyButton>
</>
) : null}
</div>
);
}

View File

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

View File

@ -1,27 +1,22 @@
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Script } from "@/lib/types";
import { cn } from "@/lib/utils";
import { CircleHelp } from "lucide-react";
import React from "react";
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 = {
interface TooltipProps {
variant: BadgeProps["variant"];
label: string;
content?: string;
};
}
const TooltipBadge: React.FC<TooltipProps> = ({ variant, label, content }) => (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger className={cn("flex items-center", !content && "cursor-default")}>
<Badge variant={variant} className="flex items-center gap-1">
{label}
{" "}
{content && <CircleHelp className="size-3" />}
{label} {content && <CircleHelp className="size-3" />}
</Badge>
</TooltipTrigger>
{content && (
@ -39,14 +34,14 @@ export default function Tooltips({ item }: { item: Script }) {
{item.privileged && (
<TooltipBadge variant="warning" label="Privileged" content="This script will be run in a privileged LXC" />
)}
{item.updateable && item.type !== "pve" && (
{item.updateable && (
<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 !== "pve" && <TooltipBadge variant="failure" label="Not Updateable" />}
{!item.updateable && <TooltipBadge variant="failure" label="Not Updateable" />}
</div>
);
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,7 @@
import type { VariantProps } from "class-variance-authority";
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
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",
@ -18,8 +16,8 @@ const alertVariants = cva(
defaultVariants: {
variant: "default",
},
},
);
}
)
const Alert = React.forwardRef<
HTMLDivElement,
@ -31,8 +29,8 @@ const Alert = React.forwardRef<
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
@ -43,8 +41,8 @@ const AlertTitle = React.forwardRef<
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
@ -55,7 +53,7 @@ const AlertDescription = React.forwardRef<
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertDescription, AlertTitle };
export { Alert, AlertTitle, AlertDescription }

View File

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

View File

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

View File

@ -1,10 +1,7 @@
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";
import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
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",
@ -50,19 +47,21 @@ const buttonVariants = cva(
},
);
type IconProps = {
interface IconProps {
Icon: React.ElementType;
iconPlacement: "left" | "right";
};
}
type IconRefProps = {
interface IconRefProps {
Icon?: never;
iconPlacement?: undefined;
};
}
export type ButtonProps = {
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
}
export type ButtonIconProps = IconProps | IconRefProps;

View File

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

View File

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

View File

@ -1,18 +1,14 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { basePath } from "@/config/siteConfig";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { Clipboard, Copy } from "lucide-react";
import Link from "next/link";
import * as React from "react";
import { toast } from "sonner";
import Link from "next/link";
import { basePath } from "@/config/site-config";
import { cn } from "@/lib/utils";
import { Separator } from "./separator";
import { Button } from "./button";
import { Separator } from "./separator";
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",
@ -44,24 +40,23 @@ const buttonVariants = cva(
},
);
function handleCopy(type: string, value: string) {
const 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();
} else {
amountOfScriptsCopied = (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
parseInt(amountOfScriptsCopied) === 3 ||
parseInt(amountOfScriptsCopied) === 10 ||
parseInt(amountOfScriptsCopied) === 25 ||
parseInt(amountOfScriptsCopied) === 50 ||
parseInt(amountOfScriptsCopied) === 100
) {
setTimeout(() => {
toast.info(
@ -91,20 +86,17 @@ function handleCopy(type: string, value: string) {
toast.success(
<div className="flex items-center gap-2">
<Clipboard className="h-4 w-4" />
<span>
Copied
{type}
{" "}
to clipboard
</span>
<span>Copied {type} to clipboard</span>
</div>,
);
}
};
export type CodeBlockProps = {
export interface CodeBlockProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
code: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
}
const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
({ className, variant, size, asChild = false, code }, ref) => {
@ -129,10 +121,7 @@ const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
)}
>
<p className="flex items-center gap-2">
{code}
{" "}
<Separator orientation="vertical" />
{" "}
{code} <Separator orientation="vertical" />{" "}
<Copy
className="cursor-pointer"
size={16}

View File

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

View File

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

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