This commit is contained in:
Omar
2025-05-18 22:09:30 -04:00
273 changed files with 19680 additions and 7812 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -20,70 +20,71 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@vercel/analytics": "^1.2.2",
"chart.js": "^4.4.1",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.71.1",
"chart.js": "^4.4.8",
"chartjs-plugin-datalabels": "^2.2.0",
"class-variance-authority": "^0.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^11.11.11",
"fuse.js": "^7.0.0",
"framer-motion": "^11.18.2",
"fuse.js": "^7.1.0",
"lucide-react": "^0.453.0",
"mini-svg-data-uri": "^1.4.4",
"next": "15.2.3",
"next": "15.2.4",
"next-themes": "^0.3.0",
"nuqs": "^2.1.1",
"pocketbase": "^0.21.4",
"nuqs": "^2.4.1",
"pocketbase": "^0.21.5",
"prettier-plugin-organize-imports": "^4.1.0",
"react": "19.0.0-rc-02c0e824-20241028",
"react": "19.0.0",
"react-chartjs-2": "^5.3.0",
"react-code-blocks": "^0.1.6",
"react-datepicker": "^7.6.0",
"react-day-picker": "8.10.1",
"react-dom": "19.0.0-rc-02c0e824-20241028",
"react-icons": "^5.1.0",
"react-dom": "19.0.0",
"react-icons": "^5.5.0",
"react-simple-typewriter": "^5.0.1",
"sharp": "^0.33.5",
"simple-icons": "^13.5.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.3.0",
"zod": "^3.23.8"
"simple-icons": "^13.21.0",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.68.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.0.1",
"@types/node": "^22",
"@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",
"@typescript-eslint/eslint-plugin": "^8.8.1",
"@typescript-eslint/parser": "^8.8.1",
"@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.29.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.13.0",
"eslint": "^9.23.0",
"eslint-config-next": "15.0.2",
"jsdom": "^25.0.1",
"postcss": "^8",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-animated": "^1.1.2",
"typescript": "^5",
"vite-tsconfig-paths": "^5.1.3",
"vitest": "^3.0.8"
"typescript": "^5.8.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.1"
},
"overrides": {
"@types/react": "npm:types-react@19.0.0-rc.1",

View File

@@ -0,0 +1,49 @@
{
"name": "Proxmox VE LXC IP-Tag",
"slug": "add-lxc-iptag",
"categories": [
1
],
"date_created": "2025-04-02",
"type": "addon",
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,
"website": null,
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/svg/proxmox.svg",
"config_path": "",
"description": "This script automatically adds IP address as tags to LXC containers using a Systemd service. The service also updates the tags if a LXC IP address is changed.",
"install_methods": [
{
"type": "default",
"script": "tools/addon/add-iptag.sh",
"resources": {
"cpu": null,
"ram": null,
"hdd": null,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Execute within the Proxmox shell",
"type": "info"
},
{
"text": "Configuration: `nano /opt/iptag/iptag.conf`. iptag.service must be restarted after change.",
"type": "info"
},
{
"text": "The Proxmox Node must contain ipcalc and net-tools. `apt-get install -y ipcalc net-tools`",
"type": "warning"
}
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "AllStarLink",
"slug": "allstarlink-vm",
"categories": [
24
],
"date_created": "2025-05-05",
"type": "vm",
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": "https://allstarlink.github.io/",
"website": "https://www.allstarlink.org/",
"logo": "https://raw.githubusercontent.com/AllStarLink/ASL3-Manual/blob/main/docs/assets/allstar-logo-small.png",
"config_path": "",
"description": "AllStarLink is a network of Amateur Radio repeaters, remote base stations and hot spots accessible to each other via Voice over Internet Protocol.",
"install_methods": [
{
"type": "default",
"script": "vm/allstarlink-vm.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Options to Install Allmon3",
"type": "info"
}
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "Baby Buddy",
"slug": "babybuddy",
"categories": [
23
],
"date_created": "2025-05-16",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 80,
"documentation": "https://docs.baby-buddy.net/",
"website": "https://github.com/babybuddy/babybuddy",
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/svg/baby-buddy.svg",
"config_path": "/opt/babybuddy/babybuddy/production.py",
"description": "Baby Buddy is an open-source web application designed to assist caregivers in tracking various aspects of a baby's daily routine, including sleep, feedings, diaper changes, tummy time, and more. By recording this data, caregivers can better understand and anticipate their baby's needs, reducing guesswork in daily care. The application offers a user-friendly dashboard for data entry and visualization, supports multiple users, and provides features like timers and reminders. Additionally, Baby Buddy can be integrated with platforms like Home Assistant and Grafana for enhanced functionality.",
"install_methods": [
{
"type": "default",
"script": "ct/babybuddy.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 5,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": "admin",
"password": "admin"
},
"notes": [
{
"text": "for private SSL setup visit: `https://github.com/babybuddy/babybuddy/blob/master/docs/setup/ssl.md`",
"type": "info"
}
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "Backrest",
"slug": "backrest",
"categories": [
7
],
"date_created": "2025-05-11",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 9898,
"documentation": "https://garethgeorge.github.io/backrest/introduction/getting-started",
"website": "https://garethgeorge.github.io/backrest",
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/svg/backrest-light.svg",
"config_path": "/opt/backrest/config/config.json",
"description": "Backrest is a web-accessible backup solution built on top of restic and providing a WebUI which wraps the restic CLI and makes it easy to create repos, browse snapshots, and restore files. Additionally, Backrest can run in the background and take an opinionated approach to scheduling snapshots and orchestrating repo health operations.",
"install_methods": [
{
"type": "default",
"script": "ct/backrest.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 8,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"type": "info",
"text": "`cat ~/.ssh/id_ed25519.pub` to view ssh public key. This key is used to authenticate with sftp targets. You can add this key on the sftp server."
}
]
}

View File

@@ -1,43 +0,0 @@
{
"name": "Calibre-Web-Automated",
"slug": "calibre-web-automated",
"categories": [
11
],
"date_created": "2025-03-02",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 8083,
"documentation": null,
"website": "https://github.com/crocodilestick/Calibre-Web-Automated",
"logo": "https://sasquatters.com/media/2017/04/Calibre-web-banner-768x512.jpg",
"description": "Calibre-Web but automated and with Calibre features! Fully automate and simplify your eBook set up!",
"install_methods": [
{
"type": "default",
"script": "ct/calibre-web-automated.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 4,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": "admin",
"password": "admin123"
},
"notes": [
{
"text": "This LXC is not interchangeable with the Calibre-Web LXC",
"type": "warning"
},
{
"text": "Options enabled by default: Kobo sync; Goodreads author info; metadata extaction from epub, fb2, pdf; cover extraction from cbr, cbz, cbt files",
"type": "info"
}
]
}

View File

@@ -0,0 +1,44 @@
{
"name": "Cloudflare-DDNS",
"slug": "cloudflare-ddns",
"categories": [
4
],
"date_created": "2025-04-23",
"type": "ct",
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": "https://github.com/favonia/cloudflare-ddns/blob/main/README.markdown",
"config_path": "/etc/systemd/system/cloudflare-ddns.service",
"website": "https://github.com/favonia/cloudflare-ddns",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/cloudflare.svg",
"description": "A feature-rich and robust Cloudflare DDNS updater with a small footprint. The program will detect your machines public IP addresses and update DNS records using the Cloudflare API",
"install_methods": [
{
"type": "default",
"script": "ct/cloudflare-ddns.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 2,
"os": "Debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "To setup the updater you must have the Cloudflare Token and the domains, please read the Github documentation at \"Step 1: Updating the Compose File\" (only the expandable section)",
"type": "warning"
},
{
"text": "To update the configuration edit `/etc/systemd/system/cloudflare-ddns.service`. After edit please restard with `sudo systemctl restart cloudflare-ddns.service`",
"type": "info"
}
]
}

View File

@@ -1,39 +0,0 @@
{
"name": "FileFlows",
"slug": "fileflows",
"categories": [
13
],
"date_created": "2025-03-07",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 19200,
"documentation": "https://fileflows.com/docs",
"website": "https://fileflows.com/",
"logo": "https://raw.githubusercontent.com/revenz/FileFlows/refs/heads/develop/icon.png",
"description": "FileFlows is a powerful, open-source tool for automating media file processing workflows, including encoding, decoding, and media management. It offers an intuitive GUI and extensive plugin support, making it ideal for tasks like video transcoding, organizing, and managing large media libraries.",
"install_methods": [
{
"type": "default",
"script": "ct/fileflows.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 8,
"os": "Debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "With Privileged/Unprivileged Hardware Acceleration Support",
"type": "info"
}
]
}

View File

@@ -12,15 +12,16 @@
"documentation": "https://sangomakb.atlassian.net/wiki/spaces/FP/overview?homepageId=8454359",
"website": "https://www.freepbx.org/",
"logo": "https://avatars.githubusercontent.com/u/696423?s=200&v=4",
"config_path": "",
"description": "FreePBX is a web-based open-source graphical user interface that manages Asterisk, a voice over IP and telephony server.",
"install_methods": [
{
"type": "default",
"script": "ct/freepbx.sh",
"resources": {
"cpu": 1,
"ram": 1024,
"hdd": 20,
"cpu": 2,
"ram": 2048,
"hdd": 10,
"os": "debian",
"version": "12"
}
@@ -30,5 +31,11 @@
"username": null,
"password": null
},
"notes": []
"notes": [
{
"text": "This script uses the official FreePBX install script. Check it here: https://github.com/FreePBX/sng_freepbx_debian_install",
"type": "info"
}
]
}

View File

@@ -0,0 +1,35 @@
{
"name": "Homarr",
"slug": "homarr",
"categories": [
10
],
"date_created": "2025-05-08",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 7575,
"documentation": null,
"website": "https://homarr.dev/",
"logo": "https://raw.githubusercontent.com/loganmarchione/homelab-svg-assets/main/assets/homarr.svg",
"config_path": "/opt/homarr/.env",
"description": "Homarr is a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
"install_methods": [
{
"type": "default",
"script": "ct/homarr.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 8,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

View File

@@ -0,0 +1,41 @@
{
"name": "Immich",
"slug": "immich",
"categories": [
13
],
"date_created": "2025-03-30",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 2283,
"documentation": "https://immich.app/docs/overview/introduction",
"website": "https://immich.app",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/png/immich.png",
"config_path": "/opt/immich/.env",
"description": "High performance self-hosted photo and video management solution.",
"install_methods": [
{
"type": "default",
"script": "ct/immich.sh",
"resources": {
"cpu": 4,
"ram": 4096,
"hdd": 12,
"os": "Debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "During installation, HW-accelerated machine-learning is an available option. This also allows for HW-accelerated transcoding, but it must be enabled in Video Transcoding Settings",
"type": "info"
}
]
}

View File

@@ -0,0 +1,35 @@
{
"name": "jitsi-meet",
"slug": "jitsi-meet",
"categories": [
18
],
"date_created": "2025-05-06",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 443,
"documentation": "https://jitsi.github.io/handbook/docs/intro",
"website": "https://jitsi.org/jitsi-meet/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/jitsi-meet.webp",
"config_path": "/etc/jitsi/meet/[your-hostname]-config.js",
"description": "Go ahead, video chat with the whole team. In fact, invite everyone you know. Jitsi Meet is a fully encrypted, 100% open source video conferencing solution that you can use all day, every day, for free — with no account needed.",
"install_methods": [
{
"type": "default",
"script": "ct/jitsi-meet.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 20,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

View File

@@ -0,0 +1,35 @@
{
"name": "JumpServer",
"slug": "jumpserver",
"categories": [
6
],
"date_created": "2025-05-05",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 80,
"documentation": "https://www.jumpserver.com/docs",
"website": "https://www.jumpserver.com/",
"logo": "https://raw.githubusercontent.com/jumpserver/jumpserver/refs/heads/dev/apps/static/img/logo.png",
"config_path": "",
"description": "JumpServer is an open-source Privileged Access Management (PAM) tool that provides DevOps and IT teams with on-demand and secure access to SSH, RDP, Kubernetes, Database and RemoteApp endpoints through a web browser.",
"install_methods": [
{
"type": "default",
"script": "ct/jumpserver.sh",
"resources": {
"cpu": 2,
"ram": 8192,
"hdd": 60,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": "admin",
"password": "ChangeMe"
},
"notes": []
}

View File

@@ -12,6 +12,7 @@
"documentation": "https://docs.librenms.org/",
"website": "https://librenms.org/",
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/svg/librenms.svg",
"config_path": "/opt/librenms/config.php and /opt/librenms/.env",
"description": "LibreNMS is an open-source, community-driven network monitoring system that provides automatic discovery, alerting, and performance tracking for network devices. It supports a wide range of hardware and integrates with various notification and logging platforms.",
"install_methods": [
{
@@ -27,8 +28,9 @@
}
],
"default_credentials": {
"username": null,
"password": null
"username": "admin",
"password": "admin"
},
"notes": []
}
}

View File

@@ -0,0 +1,35 @@
{
"name": "Librespeed",
"slug": "librespeed",
"categories": [
4
],
"date_created": "2025-04-26",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 80,
"documentation": "https://github.com/librespeed/speedtest/blob/master/doc.md",
"config_path": "",
"website": "https://librespeed.org",
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/svg/librespeed.svg",
"description": "No Flash, No Java, No Websocket, No Bullshit. This is a very lightweight speed test implemented in Javascript, using XMLHttpRequest and Web Workers.",
"install_methods": [
{
"type": "default",
"script": "ct/librespeed.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 4,
"os": "Debian",
"version": "12"
}
}
],
"default_credentials": {
"username": "root",
"password": null
},
"notes": []
}

View File

@@ -12,6 +12,7 @@
"documentation": "https://manyfold.app/sysadmin/",
"website": "https://manyfold.app/",
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/webp/manyfold.webp",
"config_path": "",
"description": "Manyfold is an open source, self-hosted web application for managing a collection of 3d models, particularly focused on 3d printing.",
"install_methods": [
{
@@ -32,3 +33,4 @@
},
"notes": []
}

View File

@@ -1,39 +0,0 @@
{
"name": "Meilisearch",
"slug": "meilisearch",
"categories": [
8
],
"date_created": "2025-03-21",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 7700,
"documentation": "https://www.meilisearch.com/docs",
"website": "https://www.meilisearch.com/",
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/svg/meilisearch.svg",
"description": "Meilisearch is a fast, open-source search engine designed for instant, full-text search with typo tolerance. It provides an API that allows developers to integrate powerful search features into applications. Meilisearch-UI is an optional web-based interface that provides a simple way to interact with Meilisearch, visualize indexed data, and test queries without needing to use the API directly.",
"install_methods": [
{
"type": "default",
"script": "ct/meilisearch.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 5,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Meilisearch-UI can optionally be installed for a web-based search interface",
"type": "info"
}
]
}

View File

@@ -28,3 +28,4 @@
{ "name": "Miscellaneous", "id": 0, "sort_order": 99.0, "description": "General scripts and tools that don't fit into other categories." }
]
}

View File

@@ -1,34 +0,0 @@
{
"name": "OpenProject",
"slug": "openproject",
"categories": [
25
],
"date_created": "2025-03-21",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 80,
"documentation": "https://www.openproject.org",
"website": "https://www.openproject.org",
"logo": "https://raw.githubusercontent.com/opf/openproject/dev/docker/prod/logo.png",
"description": "OpenProject is a web-based project management software. Use OpenProject to manage your projects, tasks and goals. Collaborate via work packages and link them to your pull requests on Github. Read more about the OpenProject GitHub integration.",
"install_methods": [
{
"type": "default",
"script": "ct/openproject.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 8,
"os": "Debian",
"version": "12"
}
}
],
"default_credentials": {
"username": "admin",
"password": "admin"
},
"notes": []
}

View File

@@ -1,43 +0,0 @@
{
"name": "openziti-tunnel",
"slug": "openziti-tunnel",
"categories": [
4
],
"date_created": "2025-03-20",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": null,
"documentation": "https://openziti.io/docs/reference/tunnelers/docker/",
"website": "https://www.openziti.io/",
"logo": "https://raw.githubusercontent.com/openziti/ziti-doc/main/docusaurus/static/img/ziti-logo-dark.svg",
"description": "OpenZiti is an open-source, zero trust networking platform that enables secure connectivity between applications, services, and devices. It provides secure, encrypted connections between clients and services, and can be used to create secure, zero trust networks.",
"install_methods": [
{
"type": "default",
"script": "ct/openziti-tunnel.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 2,
"os": "Ubuntu",
"version": "24.04"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "The Openziti tunnel is installed in host mode; please see documentation for more information",
"type": "info"
},
{
"text": "Openziti tunnel prompts for identity enrollment token during installation",
"type": "info"
}
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "PhpMyAdmin",
"slug": "phpmyadmin",
"categories": [
8
],
"date_created": "2025-04-29",
"type": "addon",
"updateable": true,
"privileged": false,
"interface_port": null,
"documentation": "https://www.phpmyadmin.net/docs/",
"config_path": "Debian/Ubuntu: /var/www/html/phpMyAdmin | Alpine: /usr/share/phpmyadmin",
"website": "https://www.phpmyadmin.net/",
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/webp/phpmyadmin-light.webp",
"description": "phpMyAdmin is a free software tool written in PHP, intended to handle the administration of MySQL over the Web. phpMyAdmin supports a wide range of operations on MySQL and MariaDB. Frequently used operations (managing databases, tables, columns, relations, indexes, users, permissions, etc) can be performed via the user interface, while you still have the ability to directly execute any SQL statement.",
"install_methods": [
{
"type": "default",
"script": "tools/addon/phpmyadmin.sh",
"resources": {
"cpu": null,
"ram": null,
"hdd": null,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Execute within an existing LXC Console",
"type": "warning"
}
]
}

View File

@@ -0,0 +1,35 @@
{
"name": "Polaris",
"slug": "polaris",
"categories": [
13
],
"date_created": "2025-03-25",
"type": "ct",
"updateable": false,
"privileged": false,
"interface_port": 80,
"documentation": "https://github.com/agersant/polaris/blob/master/docs/SETUP.md",
"website": "https://github.com/agersant/polaris",
"logo": "https://raw.githubusercontent.com/agersant/polaris/refs/heads/master/res/branding/logo/sticker_print.svg",
"config_path": "",
"description": "Polaris is a self-hosted music streaming server that allows you to enjoy your personal music collection from any computer or mobile device. It is a free and open-source application with no premium version. Polaris is highly performant and responsive, supporting large music libraries with over 100,000 tracks. It features an intuitive user interface and supports various audio formats, including FLAC, MP3, MP4, OGG, and WAV. There is an Android client available through the Google Play Store, F-Droid, or GitHub Releases.",
"install_methods": [
{
"type": "default",
"script": "ct/polaris.sh",
"resources": {
"cpu": 3,
"ram": 2048,
"hdd": 7,
"os": "Debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

View File

@@ -1,34 +0,0 @@
{
"name": "qBittorrent",
"slug": "qbittorrent",
"categories": [
11
],
"date_created": "2024-05-02",
"type": "ct",
"updateable": false,
"privileged": false,
"interface_port": 8090,
"documentation": null,
"website": "https://www.qbittorrent.org/",
"logo": "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/svg/qbittorrent.svg",
"description": "qBittorrent offers a user-friendly interface that allows users to search for and download torrent files easily. It also supports magnet links, which allow users to start downloading files without the need for a torrent file.",
"install_methods": [
{
"type": "default",
"script": "ct/qbittorrent.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": "admin",
"password": "changeme"
},
"notes": []
}

View File

@@ -0,0 +1,44 @@
{
"name": "Rclone",
"slug": "rclone",
"categories": [
11
],
"date_created": "2025-05-06",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3000,
"documentation": "https://rclone.org/docs/",
"website": "https://rclone.org/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/rclone.svg",
"config_path": "~/.config/rclone/rclone.conf",
"description": "Rclone is a command-line program to manage files on cloud storage. It is a feature-rich alternative to cloud vendors' web storage interfaces",
"install_methods": [
{
"type": "default",
"script": "ct/rclone.sh",
"resources": {
"cpu": 1,
"ram": 256,
"hdd": 1,
"os": "debian",
"version": "12"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"type": "info",
"text": "`cat ~/rclone.creds` to view login credentials"
},
{
"type": "info",
"text": "`htpasswd -b -B /opt/login.pwd newuser newuserpassword` to add more users."
}
]
}

View File

@@ -1,43 +0,0 @@
{
"name": "slskd",
"slug": "slskd",
"categories": [
11
],
"date_created": "2025-03-11",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 5030,
"documentation": "https://github.com/slskd/slskd/tree/master/docs",
"website": "https://github.com/slskd/slskd",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/png/slskd.png",
"description": "A modern client-server application for the Soulseek file sharing network. ",
"install_methods": [
{
"type": "default",
"script": "ct/slskd.sh",
"resources": {
"cpu": 1,
"ram": 512,
"hdd": 4,
"os": "Debian",
"version": "12"
}
}
],
"default_credentials": {
"username": "slskd",
"password": "slskd"
},
"notes": [
{
"text": "See /opt/slskd/config/sksld.yml to add your Soulseek credentials",
"type": "info"
},
{
"text": "This LXC includes Soularr; it needs to be configured (/opt/soularr/config.ini) before it will work",
"type": "info"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,6 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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";
@@ -20,21 +14,29 @@ type InstallMethodProps = {
setZodErrors: (zodErrors: z.ZodError | null) => void;
};
function InstallMethod({
script,
setScript,
setIsValid,
setZodErrors,
}: InstallMethodProps) {
function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallMethodProps) {
const cpuRefs = useRef<(HTMLInputElement | null)[]>([]);
const ramRefs = useRef<(HTMLInputElement | null)[]>([]);
const hddRefs = useRef<(HTMLInputElement | null)[]>([]);
const addInstallMethod = useCallback(() => {
setScript((prev) => {
const { type, slug } = prev;
const newMethodType = "default";
let scriptPath = "";
if (type === "pve") {
scriptPath = `tools/pve/${slug}.sh`;
} else if (type === "addon") {
scriptPath = `tools/addon/${slug}.sh`;
} else {
scriptPath = `${type}/${slug}.sh`;
}
const method = InstallMethodSchema.parse({
type: "default",
script: `${prev.type}/${prev.slug}.sh`,
type: newMethodType,
script: scriptPath,
resources: {
cpu: null,
ram: null,
@@ -43,6 +45,7 @@ function InstallMethod({
version: null,
},
});
return {
...prev,
install_methods: [...prev.install_methods, method],
@@ -63,9 +66,7 @@ function InstallMethod({
if (key === "type") {
updatedMethod.script =
value === "alpine"
? `${prev.type}/alpine-${prev.slug}.sh`
: `${prev.type}/${prev.slug}.sh`;
value === "alpine" ? `${prev.type}/alpine-${prev.slug}.sh` : `${prev.type}/${prev.slug}.sh`;
// Set OS to Alpine and reset version if type is alpine
if (value === "alpine") {
@@ -112,10 +113,7 @@ function InstallMethod({
<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>
@@ -205,9 +203,7 @@ function InstallMethod({
<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,22 +211,12 @@ function InstallMethod({
</SelectContent>
</Select>
</div>
<Button
variant="destructive"
size="sm"
type="button"
onClick={() => removeInstallMethod(index)}
>
<Button variant="destructive" size="sm" type="button" onClick={() => removeInstallMethod(index)}>
<Trash2 className="mr-2 h-4 w-4" /> Remove Install Method
</Button>
</div>
))}
<Button
type="button"
size="sm"
disabled={script.install_methods.length >= 2}
onClick={addInstallMethod}
>
<Button type="button" size="sm" disabled={script.install_methods.length >= 2} onClick={addInstallMethod}>
<PlusCircle className="mr-2 h-4 w-4" /> Add Install Method
</Button>
</>

View File

@@ -147,4 +147,4 @@ const NoteItem = memo(
NoteItem.displayName = 'NoteItem';
export default memo(Note);
export default memo(Note);

View File

@@ -24,8 +24,8 @@ export const ScriptSchema = z.object({
slug: z.string().min(1, "Slug is required"),
categories: z.array(z.number()),
date_created: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").min(1, "Date is required"),
type: z.enum(["vm", "ct", "misc", "turnkey"], {
errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'misc' or 'turnkey'" })
type: z.enum(["vm", "ct", "pve", "addon", "turnkey"], {
errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'pve', 'addon' or 'turnkey'" })
}),
updateable: z.boolean(),
privileged: z.boolean(),
@@ -34,6 +34,7 @@ export const ScriptSchema = z.object({
website: z.string().url().nullable(),
logo: z.string().url().nullable(),
description: z.string().min(1, "Description is required"),
config_path: z.string(),
install_methods: z.array(InstallMethodSchema).min(1, "At least one install method is required"),
default_credentials: z.object({
username: z.string().nullable(),

View File

@@ -5,18 +5,8 @@ import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { 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";
@@ -42,6 +32,7 @@ const initialScript: Script = {
privileged: false,
interface_port: null,
documentation: null,
config_path: "",
website: null,
logo: null,
description: "",
@@ -66,29 +57,37 @@ export default function JSONGenerator() {
.catch((error) => console.error("Error fetching categories:", error));
}, []);
const updateScript = useCallback(
(key: keyof Script, value: Script[keyof Script]) => {
setScript((prev) => {
const updated = { ...prev, [key]: value };
const updateScript = useCallback((key: keyof Script, value: Script[keyof Script]) => {
setScript((prev) => {
const updated = { ...prev, [key]: value };
if (key === "type" || key === "slug") {
updated.install_methods = updated.install_methods.map((method) => ({
if (updated.slug && updated.type) {
updated.install_methods = updated.install_methods.map((method) => {
let scriptPath = "";
if (updated.type === "pve") {
scriptPath = `tools/pve/${updated.slug}.sh`;
} else if (updated.type === "addon") {
scriptPath = `tools/addon/${updated.slug}.sh`;
} else if (method.type === "alpine") {
scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
} else {
scriptPath = `${updated.type}/${updated.slug}.sh`;
}
return {
...method,
script:
method.type === "alpine"
? `${updated.type}/alpine-${updated.slug}.sh`
: `${updated.type}/${updated.slug}.sh`,
}));
}
script: scriptPath,
};
});
}
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
setZodErrors(result.success ? null : result.error);
return updated;
});
},
[],
);
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
setZodErrors(result.success ? null : result.error);
return updated;
});
}, []);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(JSON.stringify(script, null, 2));
@@ -101,13 +100,13 @@ export default function JSONGenerator() {
const jsonString = JSON.stringify(script, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${script.slug || "script"}.json`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
}, [script]);
@@ -120,16 +119,13 @@ export default function JSONGenerator() {
);
const formattedDate = useMemo(
() =>
script.date_created ? format(script.date_created, "PPP") : undefined,
() => (script.date_created ? format(script.date_created, "PPP") : undefined),
[script.date_created],
);
const validationAlert = useMemo(
() => (
<Alert
className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}
>
<Alert className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}>
<AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle>
<AlertDescription>
{isValid
@@ -160,21 +156,13 @@ export default function JSONGenerator() {
<Label>
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>
</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>
@@ -197,11 +185,15 @@ export default function JSONGenerator() {
onChange={(e) => updateScript("description", e.target.value)}
/>
</div>
<Categories
script={script}
setScript={setScript}
categories={categories}
/>
<div>
<Label>Config Path</Label>
<Input
placeholder="Path to config file"
value={script.config_path || ""}
onChange={(e) => updateScript("config_path", e.target.value || null)}
/>
</div>
<Categories script={script} setScript={setScript} categories={categories} />
<div className="flex gap-2">
<div className="flex flex-col gap-2 w-full">
<Label>Date Created</Label>
@@ -209,10 +201,7 @@ export default function JSONGenerator() {
<PopoverTrigger asChild className="flex-1">
<Button
variant={"outline"}
className={cn(
"pl-3 text-left font-normal w-full",
!script.date_created && "text-muted-foreground",
)}
className={cn("pl-3 text-left font-normal w-full", !script.date_created && "text-muted-foreground")}
>
{formattedDate || <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
@@ -230,38 +219,26 @@ 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>
<SelectContent>
<SelectItem value="ct">LXC Container</SelectItem>
<SelectItem value="vm">Virtual Machine</SelectItem>
<SelectItem value="misc">Miscellaneous</SelectItem>
<SelectItem value="pve">PVE-Tool</SelectItem>
<SelectItem value="addon">Add-On</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="w-full flex gap-5">
<div className="flex items-center space-x-2">
<Switch
checked={script.updateable}
onCheckedChange={(checked) =>
updateScript("updateable", checked)
}
/>
<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>
@@ -269,12 +246,7 @@ 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
@@ -285,17 +257,10 @@ export default function JSONGenerator() {
<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}
/>
<InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
<h3 className="text-xl font-semibold">Default Credentials</h3>
<Input
placeholder="Username"
@@ -317,34 +282,21 @@ export default function JSONGenerator() {
})
}
/>
<Note
script={script}
setScript={setScript}
setIsValid={setIsValid}
setZodErrors={setZodErrors}
/>
<Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
</form>
</div>
<div className="w-1/2 p-4 bg-background overflow-y-auto">
{validationAlert}
<div className="relative">
<div className="absolute right-2 top-2 flex gap-1">
<Button
size="icon"
variant="outline"
onClick={handleCopy}
>
<Button size="icon" variant="outline" onClick={handleCopy}>
{isCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</Button>
<Button
size="icon"
variant="outline"
onClick={handleDownload}
>
<Button size="icon" variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
</Button>
</div>
<pre className="mt-4 p-4 bg-secondary rounded shadow overflow-x-scroll">
{JSON.stringify(script, null, 2)}
</pre>

View File

@@ -1,5 +1,6 @@
import Footer from "@/components/Footer";
import Navbar from "@/components/Navbar";
import QueryProvider from "@/components/query-provider"; // HINZUGEFÜGT
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { analytics, basePath } from "@/config/siteConfig";
@@ -81,7 +82,9 @@ export default function RootLayout({
<div className="flex min-h-screen flex-col justify-center">
<div className="flex w-full justify-center">
<div className="w-full max-w-7xl ">
<NuqsAdapter>{children}</NuqsAdapter>
<QueryProvider>
<NuqsAdapter>{children}</NuqsAdapter>
</QueryProvider>
<Toaster richColors />
</div>
</div>

View File

@@ -1,4 +1,5 @@
"use client";
import FAQ from "@/components/FAQ";
import AnimatedGradientText from "@/components/ui/animated-gradient-text";
import { Button } from "@/components/ui/button";
import { CardFooter } from "@/components/ui/card";
@@ -34,99 +35,109 @@ export default function Page() {
}, [theme]);
return (
<div className="w-full mt-16">
<Particles
className="absolute inset-0 -z-40"
quantity={100}
ease={80}
color={color}
refresh
/>
<div className="container mx-auto">
<div className="flex h-[80vh] flex-col items-center justify-center gap-4 py-20 lg:py-40">
<Dialog>
<DialogTrigger>
<div>
<AnimatedGradientText>
<div
className={cn(
`absolute inset-0 block size-full animate-gradient bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:var(--bg-size)_100%] [border-radius:inherit] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]`,
`p-px ![mask-composite:subtract]`,
)}
/>
<Separator className="mx-2 h-4" orientation="vertical" />
<span
className={cn(
`animate-gradient bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
`inline`,
)}
>
Develop Instance
</span>
</AnimatedGradientText>
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Thank You!</DialogTitle>
<DialogDescription>
A big thank you to tteck and the many contributors who have
made this project possible. Your hard work is truly
appreciated by the entire Proxmox community!
</DialogDescription>
</DialogHeader>
<CardFooter className="flex flex-col gap-2">
<Button className="w-full" variant="outline" asChild>
<a
href="https://github.com/tteck"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<FaGithub className="mr-2 h-4 w-4" /> Tteck&apos;s GitHub
</a>
</Button>
<Button className="w-full" asChild>
<a
href={`https://github.com/community-scripts/${basePath}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<ExternalLink className="mr-2 h-4 w-4" /> Proxmox Helper
Scripts
</a>
</Button>
</CardFooter>
</DialogContent>
</Dialog>
<>
<div className="w-full mt-16">
<Particles className="absolute inset-0 -z-40" quantity={100} ease={80} color={color} refresh />
<div className="container mx-auto">
<div className="flex h-[80vh] flex-col items-center justify-center gap-4 py-20 lg:py-40">
<Dialog>
<DialogTrigger>
<div>
<AnimatedGradientText>
<div
className={cn(
`absolute inset-0 block size-full animate-gradient bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:var(--bg-size)_100%] [border-radius:inherit] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]`,
`p-px ![mask-composite:subtract]`,
)}
/>
<Separator className="mx-2 h-4" orientation="vertical" />
<span
className={cn(
`animate-gradient bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
`inline`,
)}
>
Scripts by tteck
</span>
</AnimatedGradientText>
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Thank You!</DialogTitle>
<DialogDescription>
A big thank you to tteck and the many contributors who have made this project possible. Your hard
work is truly appreciated by the entire Proxmox community!
</DialogDescription>
</DialogHeader>
<CardFooter className="flex flex-col gap-2">
<Button className="w-full" variant="outline" asChild>
<a
href="https://github.com/tteck"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<FaGithub className="mr-2 h-4 w-4" /> Tteck&apos;s GitHub
</a>
</Button>
<Button className="w-full" asChild>
<a
href={`https://github.com/community-scripts/${basePath}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<ExternalLink className="mr-2 h-4 w-4" /> Proxmox Helper Scripts
</a>
</Button>
</CardFooter>
</DialogContent>
</Dialog>
<div className="flex flex-col gap-4">
<h1 className="max-w-2xl text-center text-3xl font-semibold tracking-tighter md:text-7xl">
Beta Scripts
</h1>
<div className="max-w-2xl gap-2 flex flex-col text-center sm:text-lg text-sm leading-relaxed tracking-tight text-muted-foreground md:text-xl">
<p>
On this Website you can find a collection of scripts that are under development and only for testing.
We do not provide any support for these scripts when run in production, but you can help us by testing them and providing feedback.
</p>
</div>
</div>
<div className="flex flex-row gap-3">
<Link href="/scripts">
<Button
size="lg"
variant="expandIcon"
Icon={CustomArrowRightIcon}
iconPlacement="right"
className="hover:"
>
View Scripts
</Button>
</Link>
</div>
</div>
</div>
</div>
);
<div className="flex flex-col gap-4">
<h1 className="max-w-2xl text-center text-3xl font-semibold tracking-tighter md:text-7xl">
Make managing your Homelab a breeze
</h1>
<div className="max-w-2xl gap-2 flex flex-col text-center sm:text-lg text-sm leading-relaxed tracking-tight text-muted-foreground md:text-xl">
<p>
We are a community-driven initiative that simplifies the setup of Proxmox Virtual Environment (VE).
</p>
<p>
With 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>
</div>
<div className="flex flex-row gap-3">
<Link href="/scripts">
<Button
size="lg"
variant="expandIcon"
Icon={CustomArrowRightIcon}
iconPlacement="right"
className="hover:"
>
View Scripts
</Button>
</Link>
</div>
</div>
{/* FAQ Section */}
<div className="py-20" id="faq">
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold tracking-tighter md:text-5xl mb-4">Frequently Asked Questions</h2>
<p className="text-muted-foreground text-lg">
Find answers to common questions about our Proxmox VE scripts
</p>
</div>
<FAQ />
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useEffect, useState } from "react";
import { fetchCategories } from "@/lib/data";
import { Category, Script } from "@/lib/types";
import { ScriptItem } from "@/app/scripts/_components/ScriptItem";
import { Loader2, RefreshCw } from "lucide-react";
function getRandomScript(categories: Category[]): Script | 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];
}
export default function RandomScriptPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [randomScript, setRandomScript] = useState<Script | null>(null);
const [loading, setLoading] = useState(true);
// Fetch categories/scripts on mount
useEffect(() => {
setLoading(true);
fetchCategories()
.then((cats) => {
setCategories(cats);
setRandomScript(getRandomScript(cats));
})
.finally(() => setLoading(false));
}, []);
// Handler to re-roll a new random script
const reroll = () => {
setRandomScript(getRandomScript(categories));
};
return (
<div className="mb-3">
<div className="mt-20 flex flex-col items-center sm:px-4 xl:px-0">
<div className="w-full max-w-5xl flex flex-col items-center">
<div className="w-full flex justify-between items-center mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Random Script</h1>
<button
onClick={reroll}
className="flex items-center gap-2 rounded-lg bg-accent/30 px-4 py-2 text-base font-medium hover:bg-accent/50 transition-colors"
title="Pick another random script"
disabled={loading || categories.length === 0}
>
<RefreshCw className="h-5 w-5" />
Re-Roll
</button>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center w-full h-64">
<Loader2 className="h-10 w-10 animate-spin" />
</div>
) : randomScript ? (
<ScriptItem item={randomScript} setSelectedScript={() => { }} />
) : (
<div className="text-center text-muted-foreground">
No scripts available.
</div>
)}
</div>
</div>
</div>
);
}

View File

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

View File

@@ -46,17 +46,17 @@ export default function ScriptAccordion({
);
if (category) {
setExpandedItem(category.name);
handleSelected(selectedScript);
}
}
}, [selectedScript, items]);
}, [selectedScript, items, handleSelected]);
return (
<Accordion
type="single"
value={expandedItem}
onValueChange={handleAccordionChange}
collapsible
className="overflow-y-scroll max-h-[calc(100vh-220px)] overflow-x-hidden mt-3 p-2"
className="overflow-y-scroll max-h-[calc(100vh-225px)] overflow-x-hidden mt-3 p-2"
>
{items.map((category) => (
<AccordionItem

View File

@@ -1,12 +1,5 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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";
@@ -23,7 +16,8 @@ export const getDisplayValueFromType = (type: string) => {
return "LXC";
case "vm":
return "VM";
case "misc":
case "pve":
case "addon":
return "";
default:
return "";
@@ -35,7 +29,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
const latestScripts = useMemo(() => {
if (!items) return [];
const scripts = items.flatMap((category) => category.scripts || []);
// Filter out duplicates by slug
@@ -47,8 +41,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
});
return Array.from(uniqueScriptsMap.values()).sort(
(a, b) =>
new Date(b.date_created).getTime() - new Date(a.date_created).getTime(),
(a, b) => new Date(b.date_created).getTime() - new Date(a.date_created).getTime(),
);
}, [items]);
@@ -59,7 +52,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
const goToPreviousPage = () => {
setPage((prevPage) => prevPage - 1);
};
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = page * ITEMS_PER_PAGE;
@@ -74,18 +67,12 @@ export function LatestScripts({ items }: { items: Category[] }) {
<h2 className="text-lg font-semibold">Newest Scripts</h2>
<div className="flex items-center justify-end gap-1">
{page > 1 && (
<div
className="cursor-pointer select-none p-2 text-sm font-semibold"
onClick={goToPreviousPage}
>
<div className="cursor-pointer select-none p-2 text-sm font-semibold" onClick={goToPreviousPage}>
Previous
</div>
)}
{endIndex < latestScripts.length && (
<div
onClick={goToNextPage}
className="cursor-pointer select-none p-2 text-sm font-semibold"
>
<div onClick={goToNextPage} className="cursor-pointer select-none p-2 text-sm font-semibold">
{page === 1 ? "More.." : "Next"}
</div>
)}
@@ -94,10 +81,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{latestScripts.slice(startIndex, endIndex).map((script) => (
<Card
key={script.slug}
className="min-w-[250px] flex-1 flex-grow bg-accent/30"
>
<Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="flex h-16 w-16 min-w-16 items-center justify-center rounded-lg bg-accent p-1">
@@ -107,10 +91,7 @@ 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>
@@ -126,9 +107,7 @@ export function LatestScripts({ items }: { items: Category[] }) {
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="line-clamp-3 text-card-foreground">
{script.description}
</CardDescription>
<CardDescription className="line-clamp-3 text-card-foreground">{script.description}</CardDescription>
</CardContent>
<CardFooter className="">
<Button asChild variant="outline">
@@ -151,9 +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);
}, []);
@@ -166,10 +143,7 @@ export function MostViewedScripts({ items }: { items: Category[] }) {
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{mostViewedScripts.map((script) => (
<Card
key={script.slug}
className="min-w-[250px] flex-1 flex-grow bg-accent/30"
>
<Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="flex size-16 min-w-16 items-center justify-center rounded-lg bg-accent p-1">
@@ -179,10 +153,7 @@ 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>

View File

@@ -1,168 +1,176 @@
"use client";
import { Separator } from "@/components/ui/separator";
import { extractDate } from "@/lib/time";
import { Script, AppVersion } from "@/lib/types";
import { fetchVersions } from "@/lib/data";
import { AppVersion, Script } from "@/lib/types";
import { X } from "lucide-react";
import Image from "next/image";
import { Separator } from "@/components/ui/separator";
import { basePath } from "@/config/siteConfig";
import { useVersions } from "@/hooks/useVersions";
import { cleanSlug } from "@/lib/utils/resource-utils";
import { Suspense } from "react";
import { ResourceDisplay } from "./ResourceDisplay";
import { getDisplayValueFromType } from "./ScriptInfoBlocks";
import Alerts from "./ScriptItems/Alerts";
import Buttons from "./ScriptItems/Buttons";
import DefaultPassword from "./ScriptItems/DefaultPassword";
import DefaultSettings from "./ScriptItems/DefaultSettings";
import Description from "./ScriptItems/Description";
import InstallCommand from "./ScriptItems/InstallCommand";
import ConfigFile from "./ScriptItems/ConfigFile";
import InterFaces from "./ScriptItems/InterFaces";
import Tooltips from "./ScriptItems/Tooltips";
import { basePath } from "@/config/siteConfig";
import { useEffect, useState } from "react";
interface ScriptItemProps {
item: Script;
setSelectedScript: (script: string | null) => void;
item: Script;
setSelectedScript: (script: string | null) => void;
}
function ScriptItem({
item,
setSelectedScript,
}: ScriptItemProps) {
function ScriptHeader({ item }: { item: Script }) {
const defaultInstallMethod = item.install_methods?.[0];
const os = defaultInstallMethod?.resources?.os || "Proxmox Node";
const version = defaultInstallMethod?.resources?.version || "";
const closeScript = () => {
window.history.pushState({}, document.title, window.location.pathname);
setSelectedScript(null);
};
const [versions, setVersions] = useState<AppVersion[]>([]);
useEffect(() => {
fetchVersions()
.then((fetchedVersions) => {
console.log("Fetched Versions: ", fetchedVersions);
if (Array.isArray(fetchedVersions)) {
setVersions(fetchedVersions);
} else if (fetchedVersions && typeof fetchedVersions === "object") {
setVersions([fetchedVersions]);
} else {
setVersions([]);
}
})
.catch((error) => console.error("Error fetching versions:", error));
}, []);
const defaultInstallMethod = item.install_methods?.[0];
const os = defaultInstallMethod?.resources?.os || "Proxmox Node";
const version = defaultInstallMethod?.resources?.version || "";
const [linksVisible, setLinksVisible] = useState<boolean>(false);
return (
<div className="mr-7 mt-0 flex w-full min-w-fit">
<div className="flex w-full min-w-fit">
<div className="flex w-full flex-col">
<div className="flex h-[36px] min-w-max items-center justify-between">
<h2 className="text-lg font-semibold">Selected Script</h2>
<X onClick={closeScript} className="cursor-pointer" />
</div>
<div className="rounded-lg border bg-accent/20 p-4">
<div className="flex justify-between">
<div className="flex">
<Image
className="h-32 w-32 rounded-lg bg-accent/60 object-contain p-3 shadow-md"
src={item.logo || `/${basePath}/logo.png`}
width={400}
onError={(e) =>
((e.currentTarget as HTMLImageElement).src =
`/${basePath}/logo.png`)
}
height={400}
alt={item.name}
unoptimized
/>
<div className="ml-4 flex flex-col justify-between">
<div className="flex h-full w-full flex-col mb-4">
<div>
<h1 className="text-lg font-semibold">
{item.name} {getDisplayValueFromType(item.type)}
</h1>
<p className="w-full text-sm text-muted-foreground">
Date added: {extractDate(item.date_created)}
</p>
<p className="text-sm text-muted-foreground">
Default OS: {os} {version}
</p>
</div>
<div>{versions.length === 0 ? (<p>Loading versions...</p>) :
(<>
<p className="text-l text-foreground">Version:</p>
<p className="text-l text-muted-foreground">{versions.find((v) =>
v.name === item.slug.replace(/[^a-z0-9]/g, '') ||
v.name.includes(item.slug.replace(/[^a-z0-9]/g, '')) ||
v.name.replace(/[^a-z0-9]/g, '') === item.slug.replace(/[^a-z0-9]/g, '')
)?.version || "No Version information found"
}</p>
<p className="text-l text-foreground">Latest changes:</p>
<p className="text-l text-muted-foreground">
{(() => {
const matchedVersion = versions.find((v) =>
v.name === item.slug.replace(/[^a-z0-9]/g, '') ||
v.name.includes(item.slug.replace(/[^a-z0-9]/g, '')) ||
v.name.replace(/[^a-z0-9]/g, '') === item.slug.replace(/[^a-z0-9]/g, '')
);
return matchedVersion?.date ?
extractDate(matchedVersion.date as unknown as string) :
"No date information found"
})()}
</p>
</>)
}
</div>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-4 ml-auto">
<DefaultSettings item={item} />
<InterFaces item={item} />
<div>
<>
<button
onClick={() => setLinksVisible(!linksVisible)}
className="flex items-right justify-right gap-1 mb-2 rounded-md border border-accent bg-accent/20 px-2 py-1 text-l hover:bg-accent w-30"
>
Show Links {linksVisible ? '▲' : '▼'}
</button>
{linksVisible && <Buttons item={item} />}
</>
</div>
</div>
</div>
<div>
<div className="mt-4">
<Description item={item} />
<Alerts item={item} />
</div>
<div className="mt-4 rounded-lg border bg-accent/50">
<div className="flex gap-3 px-4 py-2">
<h2 className="text-lg font-semibold">
How to {item.type == "misc" ? "use" : "install"}
</h2>
<Tooltips item={item} />
</div>
<Separator className="w-full"></Separator>
<InstallCommand item={item} />
</div>
</div>
<DefaultPassword item={item} />
</div>
</div>
</div>
return (
<div className="flex flex-col lg:flex-row gap-6 w-full">
<div className="flex flex-col md:flex-row gap-6 flex-grow">
<div className="flex-shrink-0">
<Image
className="h-32 w-32 rounded-xl bg-gradient-to-br from-accent/40 to-accent/60 object-contain p-3 shadow-lg transition-transform hover:scale-105"
src={item.logo || `/${basePath}/logo.png`}
width={400}
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
height={400}
alt={item.name}
unoptimized
/>
</div>
);
<div className="flex flex-col justify-between flex-grow space-y-4">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
{item.name}
<VersionInfo item={item} />
<span className="inline-flex items-center rounded-md bg-accent/30 px-2 py-1 text-sm">
{getDisplayValueFromType(item.type)}
</span>
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>Added {extractDate(item.date_created)}</span>
<span></span>
<span className=" capitalize">
{os} {version}
</span>
</div>
</div>
{/* <VersionInfo item={item} /> */}
</div>
<div className="flex flex-col gap-2 text-sm text-muted-foreground">
{defaultInstallMethod?.resources && (
<ResourceDisplay
title="Default"
cpu={defaultInstallMethod.resources.cpu}
ram={defaultInstallMethod.resources.ram}
hdd={defaultInstallMethod.resources.hdd}
/>
)}
{item.install_methods.find((method) => method.type === "alpine")?.resources && (
<ResourceDisplay
title="Alpine"
{...item.install_methods.find((method) => method.type === "alpine")!.resources!}
/>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 justify-between">
<InterFaces item={item} />
<div className="flex justify-end">
<Buttons item={item} />
</div>
</div>
</div>
);
}
export default ScriptItem;
function VersionInfo({ item }: { item: Script }) {
const { data: versions = [], isLoading } = useVersions();
if (isLoading || versions.length === 0) {
return <p className="text-sm text-muted-foreground">Loading versions...</p>;
}
const matchedVersion = versions.find((v: AppVersion) => {
const cleanName = v.name.replace(/[^a-z0-9]/gi, "").toLowerCase();
return cleanName === cleanSlug(item.slug) || cleanName.includes(cleanSlug(item.slug));
});
if (!matchedVersion) return null;
return <span className="font-medium text-sm">{matchedVersion.version}</span>;
}
export function ScriptItem({ item, setSelectedScript }: ScriptItemProps) {
const closeScript = () => {
window.history.pushState({}, document.title, window.location.pathname);
setSelectedScript(null);
};
return (
<div className="w-full max-w-5xl mx-auto">
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight text-foreground/90">Selected Script</h2>
<button
onClick={closeScript}
className="rounded-full p-2 text-muted-foreground hover:bg-card/50 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="rounded-xl border border-border/40 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} />
</Suspense>
<Description item={item} />
<Alerts item={item} />
<div className="mt-4 rounded-lg border shadow-sm">
<div className="flex gap-3 px-4 py-2 bg-accent/25">
<h2 className="text-lg font-semibold">
How to {item.type === "pve" ? "use" : item.type === "addon" ? "apply" : "install"}
</h2>
<Tooltips item={item} />
</div>
<Separator />
<div className="">
<InstallCommand item={item} />
</div>
<Separator />
<div className="flex gap-3 px-4 py-2 bg-accent/25">
<h2 className="text-lg font-semibold">
Location of config file
</h2>
</div>
<Separator />
<div className="">
<ConfigFile item={item} />
</div>
</div>
<DefaultPassword item={item} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -14,7 +14,7 @@ export default function Alerts({ item }: { item: Script }) {
<>
{item?.notes?.length > 0 &&
item.notes.map((note: NoteProps, index: number) => (
<div key={index} className="mt-4 flex flex-col gap-2">
<div key={index} className="mt-4 flex flex-col shadow-sm gap-2">
<p
className={cn(
"inline-flex items-center gap-2 rounded-lg border p-2 pl-4 text-sm",

View File

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

View File

@@ -0,0 +1,10 @@
import ConfigCopyButton from "@/components/ui/config-copy-button";
import { Script } from "@/lib/types";
export default function ConfigFile({ item }: { item: Script }) {
return (
<div className="px-4 pb-4">
<ConfigCopyButton>{item.config_path ? item.config_path : "No config path set"}</ConfigCopyButton>
</div>
);
}

View File

@@ -14,24 +14,19 @@ export default function DefaultPassword({ item }: { item: Script }) {
};
return (
<div className="mt-4 rounded-lg border bg-accent/50">
<div className="flex gap-3 px-4 py-2">
<div className="mt-4 rounded-lg border shadow-sm">
<div className="flex gap-3 px-4 py-2 bg-accent/25">
<h2 className="text-lg font-semibold">Default Login Credentials</h2>
</div>
<Separator className="w-full" />
<div className="flex flex-col gap-2 p-4">
<p className="mb-2 text-sm">
You can use the following credentials to login to the {item.name}{" "}
{item.type}.
You can use the following credentials to login to the {item.name} {item.type}.
</p>
{["username", "password"].map((type) => (
<div key={type} className="text-sm">
{type.charAt(0).toUpperCase() + type.slice(1)}:{" "}
<Button
variant="secondary"
size="null"
onClick={() => copyCredential(type as "username" | "password")}
>
<Button variant="secondary" size="null" onClick={() => copyCredential(type as "username" | "password")}>
{item.default_credentials[type as "username" | "password"]}
</Button>
</div>

View File

@@ -1,51 +1,29 @@
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`;
const getDisplayValueFromRAM = (ram: number) => (ram >= 1024 ? `${Math.floor(ram / 1024)}GB` : `${ram}MB`);
const ResourceDisplay = ({
settings,
title,
}: {
settings: (typeof item.install_methods)[0];
title: string;
}) => {
const ResourceDisplay = ({ settings, title }: { settings: (typeof item.install_methods)[0]; title: string }) => {
const { cpu, ram, hdd } = settings.resources;
return (
<div>
<h2 className="text-md font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">CPU: {cpu}vCPU</p>
<p className="text-sm text-muted-foreground">
RAM: {getDisplayValueFromRAM(ram ?? 0)}
</p>
<p className="text-sm text-muted-foreground">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);
const hasDefaultSettings = defaultSettings?.resources && Object.values(defaultSettings.resources).some(Boolean);
return (
<>
{hasDefaultSettings && (
<ResourceDisplay settings={defaultSettings} title="Default settings" />
)}
{defaultAlpineSettings && (
<ResourceDisplay
settings={defaultAlpineSettings}
title="Default Alpine settings"
/>
)}
</>
<div className="space-y-4 flex-col flex">
{hasDefaultSettings && <ResourceDisplay settings={defaultSettings} title="Default settings" />}
{defaultAlpineSettings && <ResourceDisplay settings={defaultAlpineSettings} title="Default Alpine settings" />}
</div>
);
}

View File

@@ -1,21 +1,11 @@
import TextCopyBlock from "@/components/TextCopyBlock";
import { Script } from "@/lib/types";
import { AlertColors } from "@/config/siteConfig";
import { AlertCircle, NotepadText } from "lucide-react";
import { cn } from "@/lib/utils";
export default function Description({ item }: { item: Script }) {
return (
<div className="p-2">
<h2 className="mb-2 max-w-prose text-lg font-semibold">Description</h2>
<p className={cn(
"inline-flex items-center gap-2 rounded-lg border p-2 pl-4 text-lg pr-4",
AlertColors["warning"],
)} >
<AlertCircle className="h-4 min-h-4 w-4 min-w-4" />
<span>Only use for testing, not in production!</span>
</p>
<p className="text-sm text-muted-foreground pt-4">
<p className="text-sm text-muted-foreground">
{TextCopyBlock(item.description)}
</p>
</div>

View File

@@ -5,85 +5,73 @@ import { Script } from "@/lib/types";
import { getDisplayValueFromType } from "../ScriptInfoBlocks";
const getInstallCommand = (scriptPath = "", isAlpine = false) => {
const url = `https://github.com/community-scripts/${basePath}/raw/main/${scriptPath}`;
return isAlpine
? `bash -c "$(curl -fsSL ${url})"`
: `bash -c "$(curl -fsSL ${url})"`;
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 alpineScript = item.install_methods.find((method) => method.type === "alpine");
const defaultScript = item.install_methods.find(
(method) => method.type === "default",
);
const defaultScript = item.install_methods.find((method) => method.type === "default");
const renderInstructions = (isAlpine = false) => (
const renderInstructions = (isAlpine = false) => (
<>
<p className="text-sm mt-2">
{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 ? (
<>
<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 == "misc" ? (
<>
To use the {item.name} script, run the command below in the shell.
</>
) : (
<>
{" "}
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>
)}
{renderInstructions()}
<CodeCopyButton>{getInstallCommand(defaultScript.script)}</CodeCopyButton>
</>
);
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>
);
) : null}
</div>
);
}

View File

@@ -4,39 +4,18 @@ import { Script } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ClipboardIcon } from "lucide-react";
const CopyButton = ({
label,
value,
}: {
label: string;
value: string | number;
}) => (
<span
className={cn(
buttonVariants({ size: "sm", variant: "secondary" }),
"flex items-center gap-2",
)}
>
{value}
<ClipboardIcon
onClick={() => handleCopy(label, String(value))}
className="size-4 cursor-pointer"
/>
</span>
);
export default function InterFaces({ item }: { item: Script }) {
return (
<div className="flex flex-col gap-2">
{item.interface_port !== null ? (
<div className="flex items-center justify-end">
<h2 className="mr-2 text-end text-l font-semibold">
{"Default Interface:"}
</h2>{" "}
<CopyButton label="default interface" value={item.interface_port} />
</div>
) : null}
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

@@ -4,42 +4,42 @@ import type { Category, Script } from "@/lib/types";
import ScriptAccordion from "./ScriptAccordion";
const Sidebar = ({
items,
selectedScript,
setSelectedScript,
items,
selectedScript,
setSelectedScript,
}: {
items: Category[];
selectedScript: string | null;
setSelectedScript: (script: string | null) => void;
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[]);
const filteredItems = items.filter(category => category.scripts && category.scripts.length > 0);
const filteredItems = items.filter(category => category.scripts.length > 0);
const uniqueScripts = filteredItems.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-72 flex-col sm:max-w-72">
<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={filteredItems}
selectedScript={selectedScript}
setSelectedScript={setSelectedScript}
/>
</div>
</div>
);
return (
<div className="flex min-w-72 flex-col sm:max-w-72">
<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={filteredItems}
selectedScript={selectedScript}
setSelectedScript={setSelectedScript}
/>
</div>
</div>
);
};
export default Sidebar;

View File

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

View File

@@ -2,7 +2,7 @@
export const dynamic = "force-static";
import ScriptItem from "@/app/scripts/_components/ScriptItem";
import { ScriptItem } from "@/app/scripts/_components/ScriptItem";
import { fetchCategories } from "@/lib/data";
import { Category, Script } from "@/lib/types";
import { Loader2 } from "lucide-react";

View File

@@ -6,8 +6,9 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import { basePath } from "@/config/siteConfig";
import { fetchCategories } from "@/lib/data";
import { Category } from "@/lib/types";
import { Category, Script } from "@/lib/types";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { useRouter } from "next/navigation";
@@ -15,27 +16,35 @@ import React from "react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { DialogTitle } from "./ui/dialog";
import { basePath } from "@/config/siteConfig";
import { Sparkles } from "lucide-react"; // <- Hinzugefügt
export const formattedBadge = (type: string) => {
switch (type) {
case "vm":
return <Badge className="text-blue-500/75 border-blue-500/75">VM</Badge>;
case "ct":
return (
<Badge className="text-yellow-500/75 border-yellow-500/75">LXC</Badge>
);
case "misc":
return <Badge className="text-green-500/75 border-green-500/75">MISC</Badge>;
return <Badge className="text-yellow-500/75 border-yellow-500/75">LXC</Badge>;
case "pve":
return <Badge className="text-orange-500/75 border-orange-500/75">PVE</Badge>;
case "addon":
return <Badge className="text-green-500/75 border-green-500/75">ADDON</Badge>;
}
return null;
};
// random Script
function getRandomScript(categories: Category[]): Script | 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];
}
export default function CommandMenu() {
const [open, setOpen] = React.useState(false);
const [links, setLinks] = React.useState<Category[]>([]);
const router = useRouter();
const [isLoading, setIsLoading] = React.useState(false);
const router = useRouter();
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
@@ -45,7 +54,6 @@ export default function CommandMenu() {
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
@@ -63,35 +71,65 @@ export default function CommandMenu() {
});
};
const openRandomScript = async () => {
if (links.length === 0) {
setIsLoading(true);
try {
const categories = await fetchCategories();
setLinks(categories);
const randomScript = getRandomScript(categories);
if (randomScript) {
router.push(`/scripts?id=${randomScript.slug}`);
}
} finally {
setIsLoading(false);
}
} else {
const randomScript = getRandomScript(links);
if (randomScript) {
router.push(`/scripts?id=${randomScript.slug}`);
}
}
};
return (
<>
<Button
variant="outline"
className={cn(
"relative h-9 w-full justify-start rounded-[0.5rem] bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-64",
)}
onClick={() => {
fetchSortedCategories();
setOpen(true);
}}
>
<span className="inline-flex">Search scripts...</span>
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.45rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>K
</kbd>
</Button>
<div className="flex gap-2">
<Button
variant="outline"
className={cn(
"relative h-9 w-full justify-start rounded-[0.5rem] bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-64",
)}
onClick={() => {
fetchSortedCategories();
setOpen(true);
}}
>
<span className="inline-flex">Search scripts...</span>
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.45rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>K
</kbd>
</Button>
<Button
variant="outline"
size="icon"
onClick={openRandomScript}
title="Open random script"
disabled={isLoading}
className="h-9 w-9"
>
<Sparkles className="h-5 w-5" />
</Button>
</div>
<CommandDialog open={open} onOpenChange={setOpen}>
<DialogTitle className="sr-only">Search scripts</DialogTitle>
<CommandInput placeholder="Search for a script..." />
<CommandList>
<CommandEmpty>
{isLoading ? "Loading..." : "No scripts found."}
</CommandEmpty>
<CommandEmpty>{isLoading ? "Loading..." : "No scripts found."}</CommandEmpty>
{links.map((category) => (
<CommandGroup
key={`category:${category.name}`}
heading={category.name}
>
<CommandGroup key={`category:${category.name}`} heading={category.name}>
{category.scripts.map((script) => (
<CommandItem
key={`script:${script.slug}`}
@@ -104,10 +142,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

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

View File

@@ -6,13 +6,13 @@ import { cn } from "@/lib/utils";
export default function Footer() {
return (
<div className="supports-backdrop-blur:bg-background/90 mt-auto border-t w-full flex justify-between border-border bg-background/40 py-6 backdrop-blur-lg">
<div className="supports-backdrop-blur:bg-background/90 mt-auto border-t w-full flex justify-between border-border bg-background/40 py-4 backdrop-blur-lg">
<div className="mx-6 w-full flex justify-between text-xs sm:text-sm text-muted-foreground">
<div className="flex items-center">
<p>
Website built by the community. The source code is available on{" "}
<Link
href={`https://github.com/community-scripts/${basePath}`}
href={`https://github.com/community-scripts/${basePath}/frontend`}
target="_blank"
rel="noreferrer"
className="font-semibold underline-offset-2 duration-300 hover:underline"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
export const FAQ_Items = [
{
title: "Why do you use tarballs instead of git pull for installation?",
content:
"Our LXC scripts install applications using release tarballs. Tarballs contain stable code versions tested for release. Using `git pull` directly fetches the latest development code, which might be unstable or contain bugs. Tarballs offer a more reliable installation.",
},
{
title: "Why do the scripts install applications using HTTP by default?",
content:
"Our LXC scripts install applications using HTTP by default. Setting up HTTPS often requires manual configuration specific to your setup, which our automated scripts cannot handle reliably. If an application requires HTTPS, the script will configure it. For others, you need to enable HTTPS yourself, often following the application's official documentation.",
},
{
title: "Where can I find documentation for the installed application?",
content:
"We link to the official documentation for each application whenever possible. You can usually find this link on the script's information page for the specific LXC script. If you notice a missing link for an application that has official docs, please report it so we can add it.",
},
{
title: "What should I do if I find a bug in an LXC script?",
content:
"Our LXC scripts are maintained by volunteers in their free time. Bugs can occur due to our errors or changes in the applications themselves. If you find a bug in one of our LXC scripts, please report it on our GitHub issues page. Your feedback helps us improve the scripts.",
},
{
title: "Why isn't the application updating to the very latest version?",
content:
"Updates via our LXC scripts might not pull the absolute latest version for a few reasons:\n- A bug in the application's release naming on GitHub.\n- A bug in our update script.\n- We intentionally pinned the version. This happens if a newer version has breaking changes or serious bugs that could affect your data or LXC stability. We wait for fixes before allowing the update.",
},
{
title: 'Why am I getting a "502 Bad Gateway" error?',
content:
'A "502 Bad Gateway" error usually means the application inside the LXC is not running or responding correctly. Check the application\'s logs first. If you use a reverse proxy, check its logs too. If you still have problems after checking the logs, report the issue, providing details from the logs.',
},
{
title: "What should I do if a script fails during execution?",
content:
"If an LXC script fails, run it again using Verbose mode. Standard mode hides detailed output for neatness, showing only progress. Verbose mode displays all messages, which helps you (and us) diagnose the error. Include this verbose output if you report the issue.",
},
];

View File

@@ -46,7 +46,7 @@ export const mostPopularScripts = ["post-pve-install", "docker", "homeassistant"
export const analytics = {
url: "analytics.proxmoxve-scripts.com",
token: "b60d3032-1a11-4244-a100-81d26c5c49a7",
token: "aefee1b9-2a12-4ac2-9d82-a63113edc62e",
};
export const AlertColors = {

View File

@@ -0,0 +1,21 @@
"use client";
import { fetchVersions } from "@/lib/data";
import { AppVersion } from "@/lib/types";
import { useQuery } from "@tanstack/react-query";
export function useVersions() {
return useQuery<AppVersion[]>({
queryKey: ["versions"],
queryFn: async () => {
const fetchedVersions = await fetchVersions();
if (Array.isArray(fetchedVersions)) {
return fetchedVersions;
}
if (fetchedVersions && typeof fetchedVersions === "object") {
return [fetchedVersions];
}
return [];
},
});
}

View File

@@ -10,9 +10,9 @@ export const fetchCategories = async () => {
};
export const fetchVersions = async () => {
const response = await fetch(`api/versions`);
if (!response.ok) {
throw new Error(`Failed to fetch versions: ${response.statusText}`);
}
return response.json();
const response = await fetch(`api/versions`);
if (!response.ok) {
throw new Error(`Failed to fetch versions: ${response.statusText}`);
}
return response.json();
};

View File

@@ -5,7 +5,7 @@ export type Script = {
slug: string;
categories: number[];
date_created: string;
type: "vm" | "ct" | "misc";
type: "vm" | "ct" | "pve" | "addon";
updateable: boolean;
privileged: boolean;
interface_port: number | null;
@@ -13,7 +13,7 @@ export type Script = {
website: string | null;
logo: string | null;
description: string;
version: string;
config_path: string | null;
install_methods: {
type: "default" | "alpine";
script: string;
@@ -48,12 +48,6 @@ export type Metadata = {
categories: Category[];
};
export interface AppVersion {
name: string;
version: string;
date: Date;
}
export interface Version {
name: string;
slug: string;
@@ -63,3 +57,9 @@ export interface OperatingSystem {
name: string;
versions: Version[];
}
export interface AppVersion {
name: string;
version: string;
date: Date;
}

View File

@@ -0,0 +1,7 @@
export function getDisplayValueFromRAM(ram: number): string {
return ram >= 1024 ? `${Math.floor(ram / 1024)}GB` : `${ram}MB`;
}
export function cleanSlug(slug: string): string {
return slug.replace(/[^a-z0-9]/gi, "").toLowerCase();
}