116 Commits

Author SHA1 Message Date
CanbiZ (MickLesk)
4b0fcf7d9d Update print statement from 'Hello' to 'Goodbye' 2026-03-18 12:36:33 +01:00
github-actions[bot]
66fd840baa Delete split-pro (ct) after migration to ProxmoxVE (#1586)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-18 07:44:11 +00:00
CanbiZ (MickLesk)
6b58bd1bf9 test 2026-03-16 18:46:39 +01:00
CanbiZ (MickLesk)
f7a42823a4 Update discourse-install.sh 2026-03-16 18:25:17 +01:00
CanbiZ (MickLesk)
11c34baf7a Update simplelogin-install.sh 2026-03-16 18:12:32 +01:00
CanbiZ (MickLesk)
1f2d8159d0 Update simplelogin-install.sh 2026-03-16 18:00:50 +01:00
CanbiZ (MickLesk)
d382697368 Update discourse-install.sh 2026-03-16 17:53:52 +01:00
CanbiZ (MickLesk)
817b0ea517 Split Discourse services; update SimpleLogin setup
Add DISCOURSE_PUMA_BIND to Discourse env and switch the discourse.service to load EnvironmentFile=/opt/discourse/.env. Create a separate systemd unit for Sidekiq (discourse-sidekiq.service), enable both services, and adjust messaging to "Services". In simplelogin install: remove git from apt list, bump PostgreSQL setup from 16 to 17, and export FLASK_APP=server before running migrations/initialization so Flask commands run with the correct app context.
2026-03-16 17:50:37 +01:00
CanbiZ (MickLesk)
9413782993 Merge branch 'main' of https://github.com/community-scripts/ProxmoxVED 2026-03-16 17:50:32 +01:00
github-actions[bot]
f727d7979c Delete gluetun (ct) after migration to ProxmoxVE (#1583)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-16 16:45:57 +00:00
CanbiZ (MickLesk)
6fd1b2e96d Enable vector and load .env in installers
Discourse install: add PG_DB_EXTENSIONS="vector" to setup_postgresql_db and remove the separate CREATE EXTENSION psql call; also remove curl and git from the apt install list and drop the automatic admin bootstrap messaging. SimpleLogin install: source /opt/simplelogin/.env before running migrations and add EnvironmentFile=/opt/simplelogin/.env to the systemd unit files (gunicorn, email_handler, job_runner) so services inherit environment variables.
2026-03-16 17:40:49 +01:00
github-actions[bot]
b3ca9efbed Delete anytype-server (ct) after migration to ProxmoxVE (#1582)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-16 16:34:07 +00:00
CanbiZ (MickLesk)
faf5c6d7ff Update Postgres version and SimpleLogin mail env
Bump PostgreSQL target from 16 to 17 in discourse-install.sh and make pg_hba.conf lookup dynamic (uses find) instead of a hardcoded path, ensuring compatibility with systems where the data directory path differs. Also add EMAIL_SERVERS_WITH_PRIORITY and POSTFIX_SERVER entries to the SimpleLogin .env to configure local mail delivery (set to localhost).
2026-03-16 17:27:09 +01:00
CanbiZ (MickLesk)
0e1759dc7c Update gluetun-install.sh 2026-03-16 17:24:03 +01:00
CanbiZ (MickLesk)
9d7f643bd4 sdf 2026-03-16 17:14:50 +01:00
CanbiZ (MickLesk)
34d1c7693d fixes 2026-03-16 17:12:33 +01:00
CanbiZ (MickLesk)
b056d30f9d fixes pg_cron 2026-03-16 16:57:55 +01:00
CanbiZ (MickLesk)
af6481368c Update isponsorblocktv-install.sh 2026-03-16 16:54:18 +01:00
CanbiZ (MickLesk)
7d411e30d7 fixes 2026-03-16 16:50:49 +01:00
github-actions[bot]
a22f30f864 Delete yamtrack (ct) after migration to ProxmoxVE (#1581)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-15 22:12:39 +00:00
github-actions[bot]
9fe0e94025 Delete test (ct) after migration to ProxmoxVE (#1560)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-12 14:22:14 +00:00
Michel Roegl-Brunner
3a7a195fdc chage worflow 2026-03-12 15:17:15 +01:00
Michel Roegl-Brunner
69fe411bb2 test case 2026-03-12 15:08:15 +01:00
Michel Roegl-Brunner
27f39963ec Change workflow to no longer include jsons 2026-03-12 15:06:00 +01:00
tremor021
a4492fd26d Wakapi add json 2026-03-12 14:35:47 +01:00
tremor021
fd2162d527 Wakapi updates 2026-03-12 14:30:13 +01:00
Michel Roegl-Brunner
5bf7c84f29 remove/change workflows 2026-03-12 14:28:30 +01:00
CanbiZ (MickLesk)
b8c147ebfc fixes 2026-03-12 14:27:06 +01:00
tremor021
d742795ecd Wakapi updates 2026-03-12 14:24:57 +01:00
CanbiZ (MickLesk)
c87e326387 refactor: move json/ to top-level, remove frontend/, update workflows
- Move frontend/public/json/ to json/ (top-level)
- Update all workflow path references to json/
- Delete frontend-cicd.yml workflow
- Delete frontend/ directory entirely
- Clean up .gitattributes (remove frontend entries)
2026-03-12 14:11:07 +01:00
CanbiZ (MickLesk)
aacbe238ba Update cron-update-lxcs.sh 2026-03-12 14:06:07 +01:00
tremor021
f8c8cffda1 Wakapi fixes 2026-03-12 13:57:01 +01:00
tremor021
c53b4e1d32 add Wakapi script 2026-03-12 13:38:32 +01:00
CanbiZ (MickLesk)
1cbc30e578 Switch repo URLs to raw.githubusercontent.com
Update script and license links to use GitHub raw URLs. Replace git.community-scripts.org references with https://raw.githubusercontent.com/... for script fetching and https://github.com/... for the LICENSE link in tools/pve/cron-update-lxcs.sh and tools/pve/update-lxcs-cron.sh so the curl examples and REPO_URL point to GitHub-hosted raw content.
2026-03-12 11:35:21 +01:00
CanbiZ (MickLesk)
73b8a02a8c Update update-lxcs-cron.sh 2026-03-12 11:31:59 +01:00
CanbiZ (MickLesk)
a6e0de632f cron 2026-03-12 11:27:07 +01:00
CanbiZ (MickLesk)
ec875e6e62 Run app install live during image build
Change VM app deployer to run CT install scripts during image customization (virt-customize --run) instead of via a first-boot systemd service. Updated docs to reflect live installation, new update workflow (curl the ct/<app>.sh inside the VM), and new troubleshooting/reinstall guidance. misc/vm-app.func now injects function libs, runs a temporary wrapper inside virt-customize (with logging and error handling), removes the first-boot service and update wrapper injection, and updates summary/status messages to mark apps as pre-installed.
2026-03-12 09:43:43 +01:00
CanbiZ (MickLesk)
be8e4483fc Update vm-app.func 2026-03-12 09:22:12 +01:00
CanbiZ (MickLesk)
37e53c19ec force gitea sync 2026-03-12 09:18:29 +01:00
CanbiZ (MickLesk)
19cc18037c vm app-deployer 2026-03-12 09:06:41 +01:00
CanbiZ (MickLesk)
e581096d53 Merge pull request #1557 from dereckhall/fix/unifi-os-server-vm
Fix and improve UniFi OS Server VM script
2026-03-12 07:24:52 +01:00
Dereck
7a211fd407 Address third CodeRabbit review feedback
- Remove unused send_line_to_vm function (replaced by virt-customize)
- Quote $VMID and add guard in cleanup_vmid
- Guard cleanup() against unset TEMP_DIR and quote variable
2026-03-12 00:54:39 -04:00
Dereck
1db68f32b5 Address second CodeRabbit review feedback
- Quote $VMID in error_handler for robustness
- Reduce port 11443 wait loop from 15min to 5min and update message
2026-03-12 00:45:49 -04:00
Dereck
3da60ba0a2 Address CodeRabbit review feedback
- Remove unused CLOUDINIT_PASSWORD variable
- Separate local declaration from assignment in get_image_url()
- Add retry loop for apt-get install (matches apt-get update pattern)
- Fix timeout message to match actual loop duration (~5-6 min)
2026-03-12 00:30:01 -04:00
Dereck
afa4e4ef07 Fix and improve UniFi OS Server VM script
Bug fixes:
- Add ~20 missing fi statements throughout advanced_settings(), check_root(),
  arch_check(), ssh_check(), select_os(), start_script(), etc.
- Fix pve_check() missing elif/else/fi structure
- Fix DISK_SIZE unbound variable, initialized before machine type dialog
- Fix error_handler() with ${VMID:-} guard to prevent unbound variable error

Architecture improvement:
- Migrate from send_line_to_vm serial console approach to virt-customize with
  a first-boot systemd service, consistent with other VM scripts
- First-boot service handles: clock sync (NTP + HTTP fallback), package
  installation, swap setup, and UniFi OS installer execution

New features:
- Root password prompt with confirmation
- SSH public key support
- SSH enabled by default
- Cloud-init password override with user-set password
- Port 11443 readiness check after VM boot
- Elapsed time counter during wait loops
2026-03-12 00:22:10 -04:00
Michel Roegl-Brunner
91572c9c8c Test workflow 2026-03-11 15:32:00 +01:00
Michel Roegl-Brunner
c9a2bf916c Test workflow 2026-03-11 15:31:10 +01:00
Michel Roegl-Brunner
262a3a72f4 Test workflow 2026-03-11 15:29:54 +01:00
Michel Roegl-Brunner
2a79253118 Test 2026-03-11 14:12:59 +01:00
Michel Roegl-Brunner
5a273d91ed Delete frontend/public/json/test.json 2026-03-11 14:09:20 +01:00
Michel Roegl-Brunner
36e5863726 test workflow 2026-03-11 14:04:59 +01:00
Michel Roegl-Brunner
909f37ab7a Append workflow 2026-03-11 14:04:26 +01:00
CanbiZ (MickLesk)
66013146ab Update build target, add /gluetun link & env
Switch the Go build invocation to target the cmd/gluetun package directory (./cmd/gluetun/) in both ct/gluetun.sh and install/gluetun-install.sh, and ensure modules are downloaded during install (go mod download). In the installer, create /opt/gluetun-data and add a symlink /gluetun -> /opt/gluetun-data, update STORAGE/PUBLICIP/PORT file paths to use /gluetun, add PPROF_ENABLED=no to the default .env, and add UnsetEnvironment=USER to the systemd unit to avoid inheriting the USER environment. These changes standardize build behavior and relocate runtime data to a consistent /gluetun path.
2026-03-11 11:44:26 +01:00
Tobias
0c2dccba2d Update pull_request_template.md 2026-03-11 10:00:28 +01:00
Michel Roegl-Brunner
aadeab7568 Add GitHub Pages redirect workflow 2026-03-11 08:41:08 +01:00
Michel Roegl-Brunner
0f2eaa664c Merge pull request #1516 from steveonjava/submit/protonmail-bridge-proxmoxved
feat: add Proton Mail Bridge
2026-03-11 08:30:02 +01:00
Tobias
69be85f81b Update unmet-pr-close.yml 2026-03-10 22:14:45 +01:00
Tobias
597cb21870 Create unmet-pr-close.yml 2026-03-10 22:12:37 +01:00
MickLesk
c18000e1fa switch route 2026-03-10 19:01:49 +01:00
MickLesk
ad8a5ccd60 harmonize package-lock shit 2026-03-10 18:57:46 +01:00
MickLesk
8d92578756 add gluetun 2026-03-10 18:48:35 +01:00
MickLesk
6c2aafab12 fix validation 2026-03-10 18:47:02 +01:00
MickLesk
8c3f67536b add versitygw 2026-03-10 18:35:57 +01:00
Michel Roegl-Brunner
195c064c71 Add workflow to update script timestamps on .sh changes
This workflow updates the timestamps of scripts in PocketBase whenever .sh files are changed in specified directories.
2026-03-10 15:49:54 +01:00
Michel Roegl-Brunner
5aa9cfe213 Install git and GitHub CLI dependencies
Added installation of git and GitHub CLI as dependencies.
2026-03-10 15:43:26 +01:00
Tobias
c65429f2f8 fix: correcting file write 2026-03-10 10:06:10 +01:00
Stephen Chin
fc6cf2b11e Fixed heredoc syntax 2026-03-10 01:40:55 -07:00
vhsdream
c6cf0d92c2 add git 2026-03-09 15:18:26 -04:00
vhsdream
b73bb2cdb0 add build-essential 2026-03-09 15:13:13 -04:00
vhsdream
237897342c fix dep 2026-03-09 14:59:45 -04:00
vhsdream
6c3d5797dd Add Invidious 2026-03-09 14:44:31 -04:00
Stephen Chin
320886faad Fixed EOFs to match style guidelines 2026-03-09 02:03:07 -07:00
Stephen Chin
e488a16077 Apply suggestion from @CrazyWolf13
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-09 02:01:01 -07:00
Stephen Chin
5d17a0baa6 Removed comment 2026-03-09 02:00:16 -07:00
Stephen Chin
8aee8f0af7 Fixes for review comments. 2026-03-09 01:08:53 -07:00
Stephen Chin
f7efc94f34 Improved display of message 2026-03-09 00:43:38 -07:00
Stephen Chin
a7f7d961fb Added missing .socket postfixes 2026-03-08 23:09:07 -07:00
Stephen Chin
5b496a28d2 Removed extraneous .service postfixes and improved messages. 2026-03-08 23:08:32 -07:00
Stephen Chin
fb687c5d22 Use protonmailbridge-configure for both initialization and later configuration. 2026-03-08 18:30:30 -07:00
Stephen Chin
46e0107392 Use protonmailbridge-configure for both initialization and later configuration. 2026-03-08 18:29:29 -07:00
Stephen Chin
70344e5f8f Use protonmailbridge-configure for both initialization and later configuration. 2026-03-08 18:28:35 -07:00
vhsdream
3d7322bf72 OxiCloud: add RUSTFLAGS 2026-03-08 20:59:24 -04:00
Stephen Chin
0bed845d6d Fixed the update script to leave services in the same state they were before it was called. 2026-03-08 17:36:45 -07:00
Stephen Chin
54ba42afce Converted to heredoc style 2026-03-08 15:47:41 -07:00
Stephen Chin
bed6361769 Updated the end of install messages to be more consistent. 2026-03-08 15:21:45 -07:00
Stephen Chin
1e32c49bf0 Added exit code value 2026-03-08 15:02:58 -07:00
Stephen Chin
7df0d109e7 Changed ram to 1024 to match the json 2026-03-08 15:02:24 -07:00
Stephen Chin
70a40bb958 Changed to use the github raw content instead of gitea server 2026-03-08 15:00:24 -07:00
Stephen Chin
11c7f88a3c Apply suggestions from code review
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 14:43:39 -07:00
Stephen Chin
172a72c9ef Apply suggestion from @CrazyWolf13
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 14:42:32 -07:00
Stephen Chin
42ee142ebf Apply suggestion from @CrazyWolf13
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 14:42:19 -07:00
Stephen Chin
69596eb7f3 Removed check for user already existing 2026-03-08 14:41:13 -07:00
Stephen Chin
4931c39349 Apply suggestion from @CrazyWolf13
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 14:39:58 -07:00
Stephen Chin
7977f5589d Added GitHuib username in parenthesis 2026-03-08 14:04:54 -07:00
Stephen Chin
546ab51547 Updated memory requirements to 1GB 2026-03-08 14:03:59 -07:00
Stephen Chin
daecf37e3b Added GitHub username in parenthesis 2026-03-08 14:01:50 -07:00
Stephen Chin
f87a90b959 Remove extra message
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 13:59:14 -07:00
Stephen Chin
86af319d32 Remove types on service stop
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 13:58:28 -07:00
Stephen Chin
63622ece38 Removed types on service start
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 13:57:07 -07:00
Stephen Chin
8e0f6fcb21 Removed extra messages.
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 13:55:02 -07:00
Stephen Chin
269060341b Switched back to ${APP}
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 13:53:18 -07:00
Stephen Chin
bfdb602826 Removed description
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-08 13:52:42 -07:00
vhsdream
7193f4802e OxiCloud: properly export DB URL, fix BASE_URL sed 2026-03-08 12:09:53 -04:00
Stephen Chin
6e26f7d4c8 Update protonmail-bridge-install.sh 2026-03-07 21:40:28 -08:00
Stephen Chin
8bb917572c Update protonmail-bridge.sh 2026-03-07 21:39:01 -08:00
github-actions[bot]
5d3900e3b1 Delete immichframe (ct) after migration to ProxmoxVE (#1546)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-07 22:47:00 +00:00
Stephen Chin
8293bcaa02 Update protonmail-bridge-install.sh
Comments removed
2026-03-07 14:43:40 -08:00
Stephen Chin
c71157316b Changed fetch location 2026-03-07 14:42:47 -08:00
Stephen Chin
c5d10eaaaf Removed section headers 2026-03-07 14:39:53 -08:00
Stephen Chin
f1300e25d7 Update ct/protonmail-bridge.sh
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-07 14:36:33 -08:00
Stephen Chin
2ce43a7e0f Update install/protonmail-bridge-install.sh
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-07 14:35:58 -08:00
Stephen Chin
41792306a2 Update install/protonmail-bridge-install.sh
Co-authored-by: Tobias <96661824+CrazyWolf13@users.noreply.github.com>
2026-03-07 14:35:33 -08:00
Stephen Chin
a3a0f6df56 Update install/protonmail-bridge-install.sh
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-07 14:35:03 -08:00
Stephen Chin
575c542833 Update ct/protonmail-bridge.sh
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-07 14:34:54 -08:00
Stephen Chin
fb3186640c Update ct/protonmail-bridge.sh
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-07 14:34:07 -08:00
Stephen Chin
7ac2a1c3d6 feat: add Proton Mail Bridge 2026-02-25 23:22:09 -08:00
206 changed files with 3558 additions and 19573 deletions

7
.gitattributes vendored
View File

@@ -10,11 +10,6 @@
# Treat Golang files as Go (for /api/)
api/**/*.go linguist-language=Go
# ---------------------------------------
# Treat frontend as JavaScript/TypeScript (optional)
frontend/**/*.ts linguist-language=TypeScript
frontend/**/*.js linguist-language=JavaScript
# ---------------------------------------
# Exclude documentation from stats
*.md linguist-documentation
@@ -26,7 +21,7 @@ SECURITY.md linguist-documentation
# ---------------------------------------
# Exclude generated/config files
*.json linguist-generated
frontend/public/json/*.json linguist-generated=false
json/*.json linguist-generated=false
*.lock linguist-generated
*.yml linguist-generated
*.yaml linguist-generated

2
.github/autolabeler-config.json generated vendored
View File

@@ -97,7 +97,7 @@
{
"fileStatus": "modified",
"includeGlobs": [
"frontend/public/json/**"
"json/**"
],
"excludeGlobs": []
}

3
.github/pull_request_template.md generated vendored
View File

@@ -49,3 +49,6 @@ Link: #
- [ ] The application has **600+ GitHub stars**
- [ ] Official **release tarballs** are published
- [ ] I understand that not all scripts will be accepted due to various reasons and criteria by the community-scripts ORG
## 🌐 Source
<!-- Add any sources and github links. -->

View File

@@ -47,7 +47,7 @@ jobs:
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "GitHub Actions[bot]"
git checkout -b update_versions || git checkout update_versions
git add frontend/public/json/versions.json
git add json/versions.json
if git diff --cached --quiet; then
echo "No changes detected."
echo "changed=false" >> "$GITHUB_ENV"

View File

@@ -46,7 +46,7 @@ jobs:
run: |
page=1
projects_file="project_json"
output_file="frontend/public/json/versions.json"
output_file="json/versions.json"
echo "[]" > $output_file
@@ -95,7 +95,7 @@ jobs:
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "GitHub Actions[bot]"
git checkout -b update_versions || git checkout update_versions
git add frontend/public/json/versions.json
git add json/versions.json
if git diff --cached --quiet; then
echo "No changes detected."
echo "changed=false" >> "$GITHUB_ENV"

View File

@@ -11,8 +11,8 @@ permissions:
pull-requests: write
env:
SOURCES_FILE: frontend/public/json/version-sources.json
VERSIONS_FILE: frontend/public/json/versions.json
SOURCES_FILE: json/version-sources.json
VERSIONS_FILE: json/versions.json
jobs:
update-versions:

View File

@@ -7,6 +7,7 @@ on:
permissions:
issues: write
actions: write
jobs:
post_to_discord:
@@ -59,19 +60,19 @@ jobs:
FILES=(
"ct/${TITLE}.sh"
"install/${TITLE}-install.sh"
"frontend/public/json/${TITLE}.json"
"json/${TITLE}.json"
)
;;
vm)
FILES=(
"vm/${TITLE}.sh"
"frontend/public/json/${TITLE}.json"
"json/${TITLE}.json"
)
;;
addon)
FILES=(
"tools/addon/${TITLE}.sh"
"frontend/public/json/${TITLE}.json"
"json/${TITLE}.json"
)
;;
pve)
@@ -122,7 +123,7 @@ jobs:
JSON_FILE=""
case "$SCRIPT_TYPE" in
ct|vm|addon)
JSON_FILE="frontend/public/json/${TITLE}.json"
JSON_FILE="json/${TITLE}.json"
;;
esac
@@ -217,3 +218,10 @@ jobs:
echo -e "$MESSAGE" > comment.txt
sed -i '/Discussion & issue tracking:/,$d' comment.txt
gh issue comment ${{ github.event.issue.number }} --repo ${{ github.repository }} --body-file comment.txt
- name: Push script JSON to PocketBase
if: env.SCRIPT_TYPE == 'ct' || env.SCRIPT_TYPE == 'vm' || env.SCRIPT_TYPE == 'addon'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run push_json_to_pocketbase.yml --repo ${{ github.repository }} -f script_slug=${{ env.TITLE }}

View File

@@ -124,20 +124,20 @@ jobs:
rm -f "ct/${TITLE}.sh"
rm -f "ct/headers/${TITLE}"
rm -f "install/${TITLE}-install.sh"
rm -f "frontend/public/json/${TITLE}.json"
rm -f "json/${TITLE}.json"
# Also try alpine variant
if [[ "$TITLE" == alpine-* ]]; then
stripped="${TITLE#alpine-}"
rm -f "frontend/public/json/${stripped}.json"
rm -f "json/${stripped}.json"
fi
;;
vm)
rm -f "vm/${TITLE}.sh"
rm -f "frontend/public/json/${TITLE}.json"
rm -f "json/${TITLE}.json"
;;
addon)
rm -f "tools/addon/${TITLE}.sh"
rm -f "frontend/public/json/${TITLE}.json"
rm -f "json/${TITLE}.json"
;;
pve)
rm -f "tools/pve/${TITLE}.sh"

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

@@ -1,77 +0,0 @@
# Based on https://github.com/actions/starter-workflows/blob/main/pages/nextjs.yml
name: Frontend CI/CD
on:
push:
branches: ["main"]
paths:
- frontend/**
pull_request:
branches: ["main"]
types: [opened, synchronize, reopened, edited]
paths:
- frontend/**
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
if: github.repository == 'community-scripts/ProxmoxVED'
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend # Set default working directory for all run steps
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci --prefer-offline --legacy-peer-deps
- name: Run tests
run: npm run test
- name: Configure Next.js for pages
uses: actions/configure-pages@v5
with:
static_site_generator: next
- name: Build with Next.js
run: npm run build
- name: Upload artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
with:
path: frontend/out
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.repository == 'community-scripts/ProxmoxVED'
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -100,26 +100,6 @@ jobs:
files_found="false"
missing_files+="install/${script_name}-install.sh "
fi
# JSON check with alpine fallback
json_file="frontend/public/json/${script_name}.json"
if [[ ! -f "$json_file" ]]; then
if [[ "$script_name" == alpine-* ]]; then
stripped_name="${script_name#alpine-}"
alt_json="frontend/public/json/${stripped_name}.json"
if [[ -f "$alt_json" ]]; then
echo "Using alpine fallback JSON: $alt_json"
echo "json_fallback=$alt_json" >> $GITHUB_OUTPUT
else
echo "json file not found: $json_file"
files_found="false"
missing_files+="$json_file "
fi
else
echo "json file not found: $json_file"
files_found="false"
missing_files+="$json_file "
fi
fi
;;
vm)
if [[ ! -f "vm/${script_name}.sh" ]]; then
@@ -127,11 +107,6 @@ jobs:
files_found="false"
missing_files+="vm/${script_name}.sh "
fi
# JSON is optional for VMs but check anyway
json_file="frontend/public/json/${script_name}.json"
if [[ ! -f "$json_file" ]]; then
echo "json file not found (optional): $json_file"
fi
;;
addon)
if [[ ! -f "tools/addon/${script_name}.sh" ]]; then
@@ -139,11 +114,6 @@ jobs:
files_found="false"
missing_files+="tools/addon/${script_name}.sh "
fi
# JSON is optional for addons
json_file="frontend/public/json/${script_name}.json"
if [[ ! -f "$json_file" ]]; then
echo "json file not found (optional): $json_file"
fi
;;
pve)
if [[ ! -f "tools/pve/${script_name}.sh" ]]; then
@@ -190,7 +160,6 @@ jobs:
run: |
script_name="${{ steps.list_issues.outputs.script_name }}"
script_type="${{ steps.list_issues.outputs.script_type }}"
json_fallback="${{ steps.check_files.outputs.json_fallback }}"
git clone https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/community-scripts/ProxmoxVE.git ProxmoxVE
cd ProxmoxVE
@@ -227,13 +196,6 @@ jobs:
cp ../ct/headers/${script_name} ct/headers/ 2>/dev/null || true
cp ../install/${script_name}-install.sh install/
# Handle JSON with alpine fallback
if [[ -n "$json_fallback" ]]; then
cp ../${json_fallback} frontend/public/json/ || true
else
cp ../frontend/public/json/${script_name}.json frontend/public/json/ 2>/dev/null || true
fi
# Update URLs in ct script
sed -i "s|https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func|https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func|" ct/${script_name}.sh
sed -i "s|https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/misc/build.func|https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func|" ct/${script_name}.sh
@@ -242,7 +204,6 @@ jobs:
;;
vm)
cp ../vm/${script_name}.sh vm/
cp ../frontend/public/json/${script_name}.json frontend/public/json/ 2>/dev/null || true
# Update URLs in vm script
sed -i "s|https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func|https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func|" vm/${script_name}.sh
@@ -251,7 +212,6 @@ jobs:
addon)
mkdir -p tools/addon
cp ../tools/addon/${script_name}.sh tools/addon/
cp ../frontend/public/json/${script_name}.json frontend/public/json/ 2>/dev/null || true
# Update URLs in addon script
sed -i "s|community-scripts/ProxmoxVED|community-scripts/ProxmoxVE|g" tools/addon/${script_name}.sh

39
.github/workflows/push-to-gitea.yml generated vendored
View File

@@ -1,39 +0,0 @@
name: Sync to Gitea
on:
push:
branches:
- main
jobs:
sync:
if: github.repository == 'community-scripts/ProxmoxVED'
runs-on: ubuntu-latest
steps:
- name: Checkout source repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set Git identity for actions
run: |
git config --global user.name "Push From Github"
git config --global user.email "actions@github.com"
- name: Add Gitea remote
run: git remote add gitea https://$GITEA_USER:$GITEA_TOKEN@git.community-scripts.org/community-scripts/ProxmoxVED.git
env:
GITEA_USER: ${{ secrets.GITEA_USERNAME }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
- name: Pull Gitea changes
run: |
git fetch gitea
git merge --strategy=ours gitea/main
env:
GITEA_USER: ${{ secrets.GITEA_USERNAME }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
- name: Push to Gitea
run: git push gitea main --force
env:
GITEA_USER: ${{ secrets.GITEA_USERNAME }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@@ -1,11 +1,12 @@
name: Push JSON changes to PocketBase
on:
push:
branches:
- main
paths:
- "frontend/public/json/**"
workflow_dispatch:
inputs:
script_slug:
description: 'Script slug (e.g. my-app)'
required: true
type: string
jobs:
push-json:
@@ -16,23 +17,23 @@ jobs:
with:
fetch-depth: 0
- name: Get changed JSON files with slug
- name: Get JSON file for script
id: changed
run: |
changed=$(git diff --name-only "${{ github.event.before }}" "${{ github.event.after }}" -- frontend/public/json/ | grep '\.json$' || true)
with_slug=""
for f in $changed; do
[[ -f "$f" ]] || continue
jq -e '.slug' "$f" >/dev/null 2>&1 && with_slug="$with_slug $f"
done
with_slug=$(echo $with_slug | xargs -n1)
if [[ -z "$with_slug" ]]; then
echo "No app JSON files changed (or no files with slug)."
script_slug="${{ github.event.inputs.script_slug }}"
file="json/${script_slug}.json"
if [[ ! -f "$file" ]]; then
echo "No JSON file at $file."
echo "count=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "$with_slug" > changed_app_jsons.txt
echo "count=$(echo "$with_slug" | wc -w)" >> "$GITHUB_OUTPUT"
if ! jq -e '.slug' "$file" >/dev/null 2>&1; then
echo "File $file has no .slug."
echo "count=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "$file" > changed_app_jsons.txt
echo "count=1" >> "$GITHUB_OUTPUT"
- name: Push to PocketBase
if: steps.changed.outputs.count != '0'
@@ -96,7 +97,7 @@ jobs:
const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records';
let categoryIdToName = {};
try {
const metadata = JSON.parse(fs.readFileSync('frontend/public/json/metadata.json', 'utf8'));
const metadata = JSON.parse(fs.readFileSync('json/metadata.json', 'utf8'));
(metadata.categories || []).forEach(function(cat) { categoryIdToName[cat.id] = cat.name; });
} catch (e) { console.warn('Could not load metadata.json:', e.message); }
let typeValueToId = {};
@@ -125,22 +126,32 @@ jobs:
var osVersionToId = {};
try {
const res = await request(apiBase + '/collections/z_ref_note_types/records?perPage=500', { headers: { 'Authorization': token } });
if (res.ok) JSON.parse(res.body).items?.forEach(function(item) { if (item.type != null) noteTypeToId[item.type] = item.id; });
if (res.ok) JSON.parse(res.body).items?.forEach(function(item) {
if (item.type != null) { noteTypeToId[item.type] = item.id; noteTypeToId[item.type.toLowerCase()] = item.id; }
});
} catch (e) { console.warn('z_ref_note_types:', e.message); }
try {
const res = await request(apiBase + '/collections/z_ref_install_method_types/records?perPage=500', { headers: { 'Authorization': token } });
if (res.ok) JSON.parse(res.body).items?.forEach(function(item) { if (item.type != null) installMethodTypeToId[item.type] = item.id; });
if (res.ok) JSON.parse(res.body).items?.forEach(function(item) {
if (item.type != null) { installMethodTypeToId[item.type] = item.id; installMethodTypeToId[item.type.toLowerCase()] = item.id; }
});
} catch (e) { console.warn('z_ref_install_method_types:', e.message); }
try {
const res = await request(apiBase + '/collections/z_ref_os/records?perPage=500', { headers: { 'Authorization': token } });
if (res.ok) JSON.parse(res.body).items?.forEach(function(item) { if (item.os != null) osToId[item.os] = item.id; });
if (res.ok) JSON.parse(res.body).items?.forEach(function(item) {
if (item.os != null) { osToId[item.os] = item.id; osToId[item.os.toLowerCase()] = item.id; }
});
} catch (e) { console.warn('z_ref_os:', e.message); }
try {
const res = await request(apiBase + '/collections/z_ref_os_version/records?perPage=500&expand=os', { headers: { 'Authorization': token } });
if (res.ok) {
(JSON.parse(res.body).items || []).forEach(function(item) {
var osName = item.expand && item.expand.os && item.expand.os.os != null ? item.expand.os.os : null;
if (osName != null && item.version != null) osVersionToId[osName + '|' + item.version] = item.id;
if (osName != null && item.version != null) {
var key = osName + '|' + item.version;
osVersionToId[key] = item.id;
osVersionToId[osName.toLowerCase() + '|' + item.version] = item.id;
}
});
}
} catch (e) { console.warn('z_ref_os_version:', e.message); }
@@ -185,23 +196,27 @@ jobs:
var noteIds = [];
for (var i = 0; i < (data.notes || []).length; i++) {
var note = data.notes[i];
var typeId = noteTypeToId[note.type];
if (typeId == null) continue;
var typeId = noteTypeToId[note.type] || (note.type && noteTypeToId[note.type.toLowerCase()]);
if (typeId == null) {
console.warn('Note type not in z_ref_note_types:', note.type);
continue;
}
var postRes = await request(notesCollUrl, {
method: 'POST',
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ text: note.text || '', type: typeId })
body: JSON.stringify({ text: note.text || '', type: typeId, script: scriptId })
});
if (postRes.ok) noteIds.push(JSON.parse(postRes.body).id);
else console.error('script_notes POST failed:', postRes.statusCode, postRes.body);
}
var installMethodIds = [];
for (var j = 0; j < (data.install_methods || []).length; j++) {
var im = data.install_methods[j];
var typeId = installMethodTypeToId[im.type];
var typeId = installMethodTypeToId[im.type] || (im.type && installMethodTypeToId[im.type.toLowerCase()]);
var res = im.resources || {};
var osId = osToId[res.os];
var osId = osToId[res.os] || (res.os && osToId[res.os.toLowerCase()]);
var osVersionKey = (res.os != null && res.version != null) ? res.os + '|' + res.version : null;
var osVersionId = osVersionKey ? osVersionToId[osVersionKey] : null;
var osVersionId = osVersionKey ? (osVersionToId[osVersionKey] || osVersionToId[res.os.toLowerCase() + '|' + res.version]) : null;
var imBody = {
script: scriptId,
resources_cpu: res.cpu != null ? res.cpu : 0,
@@ -217,6 +232,7 @@ jobs:
body: JSON.stringify(imBody)
});
if (imPostRes.ok) installMethodIds.push(JSON.parse(imPostRes.body).id);
else console.error('script_install_methods POST failed:', imPostRes.statusCode, imPostRes.body);
}
return { noteIds: noteIds, installMethodIds: installMethodIds };
}
@@ -225,6 +241,7 @@ jobs:
payload.notes = resolved.noteIds;
payload.install_methods = resolved.installMethodIds;
console.log('Updating', file, '(slug=' + data.slug + ')');
console.log('Created', resolved.noteIds.length, 'notes,', resolved.installMethodIds.length, 'install_methods for slug=' + data.slug);
const r = await request(recordsUrl + '/' + existingId, {
method: 'PATCH',
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
@@ -241,12 +258,19 @@ jobs:
if (!r.ok) throw new Error('POST failed: ' + r.body);
var scriptId = JSON.parse(r.body).id;
var resolved = await resolveNotesAndInstallMethods(scriptId);
console.log('Created', resolved.noteIds.length, 'notes,', resolved.installMethodIds.length, 'install_methods for slug=' + data.slug);
var patchPayload = {};
for (var k in payload) patchPayload[k] = payload[k];
patchPayload.notes = resolved.noteIds;
patchPayload.install_methods = resolved.installMethodIds;
var patchRes = await request(recordsUrl + '/' + scriptId, {
method: 'PATCH',
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ install_methods: resolved.installMethodIds, notes: resolved.noteIds })
body: JSON.stringify(patchPayload)
});
if (!patchRes.ok) throw new Error('PATCH relations failed: ' + patchRes.body);
var patched = JSON.parse(patchRes.body);
console.log('Script linked: notes=' + (patched.notes && patched.notes.length) + ', install_methods=' + (patched.install_methods && patched.install_methods.length));
}
}
console.log('Done.');

View File

@@ -1,7 +1,7 @@
#!/bin/bash
INPUT_FILE=".github/workflows/scripts/repos.txt"
OUTPUT_FILE="frontend/public/json/versions.json"
OUTPUT_FILE="json/versions.json"
TMP_FILE="releases_tmp.json"
if [ -f "$OUTPUT_FILE" ]; then

41
.github/workflows/trigger_github_pages_redirect.yml generated vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Pages Redirect
on:
workflow_dispatch:
permissions:
pages: write
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create redirect page
run: |
mkdir site
cat <<EOF > site/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=https://community-scripts.org/">
<link rel="canonical" href="https://community-scripts.org/">
<title>Redirecting...</title>
</head>
<body>
Redirecting...
</body>
</html>
EOF
- uses: actions/upload-pages-artifact@v3
with:
path: site
- name: Deploy
uses: actions/deploy-pages@v4

89
.github/workflows/unmet-pr-close.yml generated vendored Normal file
View File

@@ -0,0 +1,89 @@
name: PR Script Requirements Check
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
pull-requests: write
contents: read
jobs:
validate-script-requirements:
runs-on: ubuntu-latest
steps:
- name: Validate new script requirements
uses: actions/github-script@v7
with:
script: |
const body = context.payload.pull_request.body || "";
const lines = body.split("\n");
function checkboxChecked(line) {
return /\[\s*x\s*\]/i.test(line);
}
function findLine(text) {
return lines.find(l => l.includes(text));
}
// detect if "New script" is checked
const newScriptLine = findLine("🆕 **New script**");
if (!newScriptLine || !checkboxChecked(newScriptLine)) {
console.log("Not a new script PR — skipping requirement check.");
return;
}
const requirements = [
"The application is **at least 6 months old**",
"The application is **actively maintained**",
"The application has **600+ GitHub stars**",
"Official **release tarballs** are published",
"I understand that not all scripts will be accepted"
];
const missing = [];
for (const req of requirements) {
const line = findLine(req);
if (!line || !checkboxChecked(line)) {
missing.push(req);
}
}
if (missing.length > 0) {
let list = "";
for (const m of missing) {
list += "- " + m + "\n";
}
const message =
"❌ **Pull Request Closed Application Requirements Not Met**\n\n" +
"This pull request is marked as **🆕 New script**, but the required application criteria were not confirmed.\n\n" +
"The following requirement confirmations are missing:\n\n" +
list +
"\nNew application submissions must meet the project requirements before being considered.\n" +
"Please wait until the application satisfies the criteria before submitting a new PR.\n\n" +
"---\n\n" +
"⚠ **Maintainer note**\n\n" +
"The team periodically reviews closed submissions. If a project is still considered valuable to the ecosystem, maintainers may reopen the PR even if it does not fully meet the thresholds.\n\n" +
"**Please do not ping or repeatedly contact maintainers to reopen PRs.**";
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
state: "closed"
});
core.setFailed("Application requirements checklist incomplete.");
}

View File

@@ -1,218 +0,0 @@
name: Update GitHub Versions (New)
on:
workflow_dispatch:
schedule:
# Runs 4x daily: 00:00, 06:00, 12:00, 18:00 UTC
- cron: "0 0,6,12,18 * * *"
permissions:
contents: write
pull-requests: write
env:
VERSIONS_FILE: frontend/public/json/github-versions.json
jobs:
update-github-versions:
if: github.repository == 'community-scripts/ProxmoxVED'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
ref: main
- name: Extract GitHub versions from install scripts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "========================================="
echo " Extracting GitHub versions from scripts"
echo "========================================="
# Initialize versions array
versions_json="[]"
# Function to add a version entry
add_version() {
local slug="$1"
local repo="$2"
local version="$3"
local pinned="$4"
local date="$5"
versions_json=$(echo "$versions_json" | jq \
--arg slug "$slug" \
--arg repo "$repo" \
--arg version "$version" \
--argjson pinned "$pinned" \
--arg date "$date" \
'. += [{"slug": $slug, "repo": $repo, "version": $version, "pinned": $pinned, "date": $date}]')
}
# Get list of slugs from JSON files
echo ""
echo "=== Scanning JSON files for slugs ==="
for json_file in frontend/public/json/*.json; do
[[ ! -f "$json_file" ]] && continue
# Skip non-app JSON files
basename_file=$(basename "$json_file")
case "$basename_file" in
metadata.json|versions.json|github-versions.json|dependency-check.json|update-apps.json)
continue
;;
esac
# Extract slug from JSON
slug=$(jq -r '.slug // empty' "$json_file" 2>/dev/null)
[[ -z "$slug" ]] && continue
# Find corresponding install script
install_script="install/${slug}-install.sh"
[[ ! -f "$install_script" ]] && continue
# Look for fetch_and_deploy_gh_release calls
# Pattern: fetch_and_deploy_gh_release "app" "owner/repo" ["mode"] ["version"]
while IFS= read -r line; do
# Skip commented lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
# Extract repo and version from fetch_and_deploy_gh_release
if [[ "$line" =~ fetch_and_deploy_gh_release[[:space:]]+\"[^\"]*\"[[:space:]]+\"([^\"]+)\"([[:space:]]+\"([^\"]+)\")?([[:space:]]+\"([^\"]+)\")? ]]; then
repo="${BASH_REMATCH[1]}"
mode="${BASH_REMATCH[3]:-tarball}"
pinned_version="${BASH_REMATCH[5]:-latest}"
# Check if version is pinned (not "latest" and not empty)
is_pinned=false
target_version=""
if [[ -n "$pinned_version" && "$pinned_version" != "latest" ]]; then
is_pinned=true
target_version="$pinned_version"
fi
# Fetch version from GitHub
if [[ "$is_pinned" == "true" ]]; then
# For pinned versions, verify it exists and get date
response=$(gh api "repos/${repo}/releases/tags/${target_version}" 2>/dev/null || echo '{}')
if echo "$response" | jq -e '.tag_name' > /dev/null 2>&1; then
version=$(echo "$response" | jq -r '.tag_name')
date=$(echo "$response" | jq -r '.published_at // empty')
add_version "$slug" "$repo" "$version" "true" "$date"
echo "[$slug] ✓ $version (pinned)"
else
echo "[$slug] ⚠ pinned version $target_version not found"
fi
else
# Fetch latest release
response=$(gh api "repos/${repo}/releases/latest" 2>/dev/null || echo '{}')
if echo "$response" | jq -e '.tag_name' > /dev/null 2>&1; then
version=$(echo "$response" | jq -r '.tag_name')
date=$(echo "$response" | jq -r '.published_at // empty')
add_version "$slug" "$repo" "$version" "false" "$date"
echo "[$slug] ✓ $version"
else
# Try tags as fallback
version=$(gh api "repos/${repo}/tags" --jq '.[0].name // empty' 2>/dev/null || echo "")
if [[ -n "$version" ]]; then
add_version "$slug" "$repo" "$version" "false" ""
echo "[$slug] ✓ $version (from tags)"
else
echo "[$slug] ⚠ no version found"
fi
fi
fi
break # Only first match per script
fi
done < <(grep 'fetch_and_deploy_gh_release' "$install_script" 2>/dev/null || true)
done
# Save versions file
echo "$versions_json" | jq --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{generated: $date, versions: (. | sort_by(.slug))}' > "$VERSIONS_FILE"
total=$(echo "$versions_json" | jq 'length')
echo ""
echo "========================================="
echo " Total versions extracted: $total"
echo "========================================="
- name: Check for changes
id: check-changes
run: |
# Check if file is new (untracked) or has changes
if [[ ! -f "$VERSIONS_FILE" ]]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Versions file was not created"
elif ! git ls-files --error-unmatch "$VERSIONS_FILE" &>/dev/null; then
# File exists but is not tracked - it's new
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "New file created: $VERSIONS_FILE"
elif git diff --quiet "$VERSIONS_FILE" 2>/dev/null; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "No changes detected"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Changes detected:"
git diff --stat "$VERSIONS_FILE" 2>/dev/null || true
fi
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH_NAME="automated/update-github-versions-$(date +%Y%m%d)"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "GitHub Actions[bot]"
# Check if branch exists and delete it
git push origin --delete "$BRANCH_NAME" 2>/dev/null || true
git checkout -b "$BRANCH_NAME"
git add "$VERSIONS_FILE"
git commit -m "chore: update github-versions.json
Total versions: $(jq '.versions | length' "$VERSIONS_FILE")
Pinned versions: $(jq '[.versions[] | select(.pinned == true)] | length' "$VERSIONS_FILE")
Generated: $(jq -r '.generated' "$VERSIONS_FILE")"
git push origin "$BRANCH_NAME" --force
# Check if PR already exists
existing_pr=$(gh pr list --head "$BRANCH_NAME" --state open --json number --jq '.[0].number // empty')
if [[ -n "$existing_pr" ]]; then
echo "PR #$existing_pr already exists, updating..."
else
gh pr create \
--title "[Automated] Update GitHub versions" \
--body "This PR updates version information from GitHub releases.
## How it works
1. Scans all JSON files in \`frontend/public/json/\` for slugs
2. Finds corresponding \`install/{slug}-install.sh\` scripts
3. Extracts \`fetch_and_deploy_gh_release\` calls
4. Fetches latest (or pinned) version from GitHub
## Stats
- Total versions: $(jq '.versions | length' "$VERSIONS_FILE")
- Pinned versions: $(jq '[.versions[] | select(.pinned == true)] | length' "$VERSIONS_FILE")
- Latest versions: $(jq '[.versions[] | select(.pinned == false)] | length' "$VERSIONS_FILE")
---
*Automatically generated from install scripts*" \
--base main \
--head "$BRANCH_NAME" \
--label "automated pr"
fi

167
.github/workflows/update-timestamp-on-db.yml generated vendored Normal file
View File

@@ -0,0 +1,167 @@
name: Update script timestamp on .sh changes
on:
push:
branches:
- main
paths:
- "ct/**/*.sh"
- "install/**/*.sh"
- "tools/**/*.sh"
- "turnkey/**/*.sh"
- "vm/**/*.sh"
jobs:
update-script-timestamp:
runs-on: self-hosted
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get changed .sh files and derive slugs
id: slugs
run: |
changed=$(git diff --name-only "${{ github.event.before }}" "${{ github.event.after }}" -- ct/ install/ tools/ turnkey/ vm/ | grep '\.sh$' || true)
if [[ -z "$changed" ]]; then
echo "No .sh files changed in ct/, install/, tools/, turnkey/, or vm/."
echo "count=0" >> "$GITHUB_OUTPUT"
exit 0
fi
declare -A seen
slugs=""
for f in $changed; do
[[ -f "$f" ]] || continue
base="${f##*/}"
base="${base%.sh}"
if [[ "$f" == install/* && "$base" == *-install ]]; then
slug="${base%-install}"
else
slug="$base"
fi
if [[ -z "${seen[$slug]:-}" ]]; then
seen[$slug]=1
slugs="$slugs $slug"
fi
done
slugs=$(echo $slugs | xargs -n1 | sort -u)
if [[ -z "$slugs" ]]; then
echo "No slugs to update."
echo "count=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "$slugs" > changed_slugs.txt
echo "count=$(echo "$slugs" | wc -w)" >> "$GITHUB_OUTPUT"
- name: Parse PR number from merge commit
id: pr
run: |
re='#([0-9]+)'
if [[ "$COMMIT_MSG" =~ $re ]]; then
echo "number=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
else
echo "number=" >> "$GITHUB_OUTPUT"
fi
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
- name: Update script timestamps in PocketBase
if: steps.slugs.outputs.count != '0'
env:
POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }}
POCKETBASE_COLLECTION: ${{ secrets.POCKETBASE_COLLECTION }}
POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }}
POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
PR_URL: ${{ steps.pr.outputs.number != '' && format('{0}/{1}/pull/{2}', github.server_url, github.repository, steps.pr.outputs.number) || '' }}
run: |
node << 'ENDSCRIPT'
(async function() {
const fs = require('fs');
const https = require('https');
const http = require('http');
const url = require('url');
function request(fullUrl, opts) {
return new Promise(function(resolve, reject) {
const u = url.parse(fullUrl);
const isHttps = u.protocol === 'https:';
const body = opts.body;
const options = {
hostname: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: u.path,
method: opts.method || 'GET',
headers: opts.headers || {}
};
if (body) options.headers['Content-Length'] = Buffer.byteLength(body);
const lib = isHttps ? https : http;
const req = lib.request(options, function(res) {
let data = '';
res.on('data', function(chunk) { data += chunk; });
res.on('end', function() {
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: data });
});
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
const raw = process.env.POCKETBASE_URL.replace(/\/$/, '');
const apiBase = /\/api$/i.test(raw) ? raw : raw + '/api';
const coll = process.env.POCKETBASE_COLLECTION;
const slugsText = fs.readFileSync('changed_slugs.txt', 'utf8').trim();
const slugs = slugsText ? slugsText.split(/\s+/).filter(Boolean) : [];
if (slugs.length === 0) {
console.log('No slugs to update.');
return;
}
const authUrl = apiBase + '/collections/users/auth-with-password';
const authBody = JSON.stringify({
identity: process.env.POCKETBASE_ADMIN_EMAIL,
password: process.env.POCKETBASE_ADMIN_PASSWORD
});
const authRes = await request(authUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: authBody
});
if (!authRes.ok) {
throw new Error('Auth failed: ' + authRes.body);
}
const token = JSON.parse(authRes.body).token;
const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records';
for (const slug of slugs) {
const filter = "(slug='" + slug.replace(/'/g, "''") + "')";
const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', {
headers: { 'Authorization': token }
});
const list = JSON.parse(listRes.body);
const record = list.items && list.items[0];
if (!record) {
console.log('Slug not in DB, skipping: ' + slug);
continue;
}
const patchRes = await request(recordsUrl + '/' + record.id, {
method: 'PATCH',
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: record.name || record.slug,
last_update_commit: process.env.PR_URL || process.env.COMMIT_URL || ''
})
});
if (!patchRes.ok) {
console.warn('PATCH failed for slug ' + slug + ': ' + patchRes.body);
continue;
}
console.log('Updated timestamp for slug: ' + slug);
}
console.log('Done.');
})().catch(e => { console.error(e); process.exit(1); });
ENDSCRIPT
shell: bash

75
ct/alpine-wakapi.sh Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func)
# Copyright (c) 2021-2026 community-scripts ORG
# Author: Slaviša Arežina (tremor021)
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# Source: https://wakapi.dev/ | https://github.com/muety/wakapi
APP="Alpine-Wakapi"
var_tags="${var_tags:-code;time-tracking}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-512}"
var_disk="${var_disk:-4}"
var_os="${var_os:-alpine}"
var_version="${var_version:-3.23}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/wakapi ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
RELEASE=$(curl -s https://api.github.com/repos/muety/wakapi/releases/latest | grep "tag_name" | awk '{print substr($2, 2, length($2)-3) }')
if [ "${RELEASE}" != "$(cat ~/.wakapi 2>/dev/null)" ] || [ ! -f ~/.wakapi ]; then
msg_info "Stopping Wakapi Service"
$STD rc-service wakapi stop
msg_ok "Stopped Wakapi Service"
msg_info "Updating Wakapi LXC"
$STD apk -U upgrade
msg_ok "Updated Wakapi LXC"
msg_info "Creating backup"
mkdir -p /opt/wakapi-backup
cp /opt/wakapi/config.yml /opt/wakapi/wakapi_db.db /opt/wakapi-backup/
msg_ok "Created backup"
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "wakapi" "muety/wakapi" "tarball"
msg_info "Configuring Wakapi"
cd /opt/wakapi
$STD go mod download
$STD go build -o wakapi
cp /opt/wakapi-backup/config.yml /opt/wakapi/
cp /opt/wakapi-backup/wakapi_db.db /opt/wakapi/
rm -rf /opt/wakapi-backup
msg_ok "Configured Wakapi"
msg_info "Starting Service"
$STD rc-service wakapi start
msg_ok "Started Service"
msg_ok "Updated successfully"
else
msg_ok "No update required. ${APP} is already at ${RELEASE}"
fi
exit 0
}
start
build_container
description
msg_ok "Completed successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:3000${CL}"

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func)
# Copyright (c) 2021-2026 community-scripts ORG
# Author: MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# Source: https://anytype.io
APP="Anytype-Server"
var_tags="${var_tags:-notes;productivity;sync}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-4096}"
var_disk="${var_disk:-16}"
var_os="${var_os:-ubuntu}"
var_version="${var_version:-24.04}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -f /opt/anytype/any-sync-bundle ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
if check_for_gh_release "anytype" "grishy/any-sync-bundle"; then
msg_info "Stopping Service"
systemctl stop anytype
msg_ok "Stopped Service"
msg_info "Backing up Data"
cp -r /opt/anytype/data /opt/anytype_data_backup
msg_ok "Backed up Data"
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "anytype" "grishy/any-sync-bundle" "prebuild" "latest" "/opt/anytype" "any-sync-bundle_*_linux_amd64.tar.gz"
chmod +x /opt/anytype/any-sync-bundle
msg_info "Restoring Data"
cp -r /opt/anytype_data_backup/. /opt/anytype/data
rm -rf /opt/anytype_data_backup
msg_ok "Restored Data"
msg_info "Starting Service"
systemctl start anytype
msg_ok "Started Service"
msg_ok "Updated successfully!"
fi
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:33010${CL}"
echo -e "${INFO}${YW} Client config file:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}/opt/anytype/data/client-config.yml${CL}"

View File

@@ -1,75 +0,0 @@
#!/usr/bin/env bash
COMMUNITY_SCRIPTS_URL="${COMMUNITY_SCRIPTS_URL:-https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main}"
source <(curl -fsSL "$COMMUNITY_SCRIPTS_URL/misc/build.func")
# Copyright (c) 2021-2026 community-scripts ORG
# Author: Thiago Canozzo Lahr (tclahr)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://github.com/immichFrame/ImmichFrame
APP="ImmichFrame"
var_tags="${var_tags:-photos;slideshow}"
var_cpu="${var_cpu:-1}"
var_ram="${var_ram:-1024}"
var_disk="${var_disk:-8}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/immichframe ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
if check_for_gh_release "immichframe" "immichFrame/ImmichFrame"; then
msg_info "Stopping Service"
systemctl stop immichframe
msg_ok "Stopped Service"
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "immichframe" "immichFrame/ImmichFrame" "tarball" "latest" "/tmp/immichframe"
msg_info "Building Application"
cd /tmp/immichframe
$STD dotnet publish ImmichFrame.WebApi/ImmichFrame.WebApi.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--output /opt/immichframe
cd /tmp/immichframe/immichFrame.Web
$STD npm ci --silent
$STD npm run build
rm -rf /opt/immichframe/wwwroot/*
cp -r build/* /opt/immichframe/wwwroot
rm -rf /tmp/immichframe
chown -R immichframe:immichframe /opt/immichframe
msg_ok "Application Built"
msg_info "Starting Service"
systemctl start immichframe
msg_ok "Started Service"
msg_ok "Updated successfully!"
fi
exit
}
start
build_container
description
msg_ok "Completed successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:8080${CL}"
echo -e "${INFO}${YW} Configuration file location:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}/opt/immichframe/Config/Settings.yml${CL}"
echo -e "${INFO}${YW} Edit the config file and set ImmichServerUrl and ApiKey before use!${CL}"

71
ct/invidious.sh Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func)
# Copyright (c) 2021-2026 community-scripts ORG
# Author: vhsdream
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# Source: https://github.com/iv-org/invidious
APP="Invidious"
var_tags="${var_tags:-streaming}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-4}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/invidious ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
if check_for_gh_release "Invidious" "iv-org/invidious"; then
msg_info "Stopping services"
$STD systemctl stop invidious-companion invidious
msg_ok "Stopped services"
msg_info "Backing up config"
cp /opt/invidious/config/config.yml /opt/invidious-config.yml
msg_ok "Backed up config"
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "Invidious" "iv-org/invidious" "tarball" "latest" "/opt/invidious"
if check_for_gh_release "Invidious-Companion" "iv-org/invidious-companion"; then
CLEAN_INSTALL fetch_and_deploy_gh_release "Invidious-Companion" "iv-org/invidious-companion" "prebuild" "latest" "/opt/invidious-companion" "invidious_companion-x86_64-unknown-linux-gnu.tar.gz"
fi
msg_info "Updating Invidious"
PG_DB_PASS="$(sed -n '/Password:/s/[^:]*:[[:space:]]//p' ~/oxicloud.creds)"
cd /opt/oxicloud
export DATABASE_URL="postgres://oxicloud:${PG_DB_PASS}@localhost/oxicloud"
export RUSTFLAGS="-C target-cpu=native"
$STD cargo build --release
mv target/release/oxicloud /usr/bin/oxicloud && chmod +x /usr/bin/oxicloud
msg_ok "Updated Invidious"
msg_info "Starting Invidious"
$STD systemctl start oxicloud
msg_ok "Started Invidious"
msg_ok "Updated successfully!"
fi
exit
}
start
build_container
description
msg_ok "Completed successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:8086${CL}"

View File

@@ -2,7 +2,7 @@
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func)
# Copyright (c) 2021-2026 community-scripts ORG
# Author: Matthew Stern (sternma)
# Author: Matthew Stern (sternma) | MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# Source: https://github.com/dmunozv04/iSponsorBlockTV
@@ -35,27 +35,7 @@ function update_script() {
systemctl stop isponsorblocktv
msg_ok "Stopped Service"
if [[ -d /var/lib/isponsorblocktv ]]; then
msg_info "Backing up Data"
cp -r /var/lib/isponsorblocktv /var/lib/isponsorblocktv_data_backup
msg_ok "Backed up Data"
fi
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "isponsorblocktv" "dmunozv04/iSponsorBlockTV"
msg_info "Setting up iSponsorBlockTV"
$STD python3 -m venv /opt/isponsorblocktv/venv
$STD /opt/isponsorblocktv/venv/bin/pip install --upgrade pip
$STD /opt/isponsorblocktv/venv/bin/pip install /opt/isponsorblocktv
msg_ok "Set up iSponsorBlockTV"
if [[ -d /var/lib/isponsorblocktv_data_backup ]]; then
msg_info "Restoring Data"
rm -rf /var/lib/isponsorblocktv
cp -r /var/lib/isponsorblocktv_data_backup /var/lib/isponsorblocktv
rm -rf /var/lib/isponsorblocktv_data_backup
msg_ok "Restored Data"
fi
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "isponsorblocktv" "dmunozv04/iSponsorBlockTV" "singlefile" "latest" "/opt/isponsorblocktv" "iSponsorBlockTV-*-linux"
msg_info "Starting Service"
systemctl start isponsorblocktv

View File

@@ -40,8 +40,10 @@ function update_script() {
RUST_TOOLCHAIN=$TOOLCHAIN setup_rust
msg_info "Updating OxiCloud"
PG_DB_PASS="$(sed -n '/Password:/s/[^:]*:[[:space:]]//p' ~/oxicloud.creds)"
cd /opt/oxicloud
export DATABASE_URL="postgres://${PG_DB_USER}:${PG_DB_PASS}@localhost/${PG_DB_NAME}"
export DATABASE_URL="postgres://oxicloud:${PG_DB_PASS}@localhost/oxicloud"
export RUSTFLAGS="-C target-cpu=native"
$STD cargo build --release
mv target/release/oxicloud /usr/bin/oxicloud && chmod +x /usr/bin/oxicloud
msg_ok "Updated OxiCloud"

79
ct/protonmail-bridge.sh Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func)
# Copyright (c) 2021-2026 community-scripts ORG
# Author: Stephen Chin (steveonjava)
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# Source: https://github.com/ProtonMail/proton-bridge
APP="ProtonMail-Bridge"
var_tags="${var_tags:-mail;proton}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-1024}"
var_disk="${var_disk:-8}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -x /usr/bin/protonmail-bridge ]]; then
msg_error "No ${APP} Installation Found!"
exit 1
fi
if check_for_gh_release "protonmail-bridge" "ProtonMail/proton-bridge"; then
local -a bridge_units=(
protonmail-bridge
protonmail-bridge-imap.socket
protonmail-bridge-smtp.socket
protonmail-bridge-imap-proxy
protonmail-bridge-smtp-proxy
)
local unit
declare -A was_active
for unit in "${bridge_units[@]}"; do
if systemctl is-active --quiet "$unit" 2>/dev/null; then
was_active["$unit"]=1
else
was_active["$unit"]=0
fi
done
msg_info "Stopping Services"
systemctl stop protonmail-bridge-imap.socket protonmail-bridge-smtp.socket protonmail-bridge-imap-proxy protonmail-bridge-smtp-proxy protonmail-bridge
msg_ok "Stopped Services"
fetch_and_deploy_gh_release "protonmail-bridge" "ProtonMail/proton-bridge" "binary"
if [[ -f /home/protonbridge/.protonmailbridge-initialized ]]; then
msg_info "Starting Services"
for unit in "${bridge_units[@]}"; do
if [[ "${was_active[$unit]:-0}" == "1" ]]; then
systemctl start "$unit"
fi
done
msg_ok "Started Services"
else
msg_ok "Initialization not completed. Services remain disabled."
fi
msg_ok "Updated successfully!"
fi
exit
}
start
build_container
description
msg_ok "Completed successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW}One-time configuration is required before Bridge services are enabled.${CL}"
echo -e "${INFO}${YW}Run this command in the container: protonmailbridge-configure${CL}"

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func)
# Copyright (c) 2021-2026 community-scripts ORG
# Author: johanngrobe
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://github.com/oss-apps/split-pro
APP="Split-Pro"
var_tags="${var_tags:-finance;expense-sharing}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-4096}"
var_disk="${var_disk:-6}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/split-pro ]]; then
msg_error "No Split Pro Installation Found!"
exit
fi
if check_for_gh_release "split-pro" "oss-apps/split-pro"; then
msg_info "Stopping Service"
systemctl stop split-pro
msg_ok "Stopped Service"
msg_info "Backing up Data"
cp /opt/split-pro/.env /opt/split-pro.env
msg_ok "Backed up Data"
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "split-pro" "oss-apps/split-pro" "tarball" "latest" "/opt/split-pro"
msg_info "Building Application"
cd /opt/split-pro
$STD pnpm install --frozen-lockfile
$STD pnpm build
cp /opt/split-pro.env /opt/split-pro/.env
rm -f /opt/split-pro.env
ln -sf /opt/split-pro_data/uploads /opt/split-pro/uploads
$STD pnpm exec prisma migrate deploy
msg_ok "Built Application"
msg_info "Starting Service"
systemctl start split-pro
msg_ok "Started Service"
msg_ok "Updated successfully!"
fi
exit
}
start
build_container
description
msg_ok "Completed successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:3000${CL}"

54
ct/versitygw.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func)
# Copyright (c) 2021-2026 community-scripts ORG
# Author: MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# Source: https://github.com/versity/versitygw
APP="VersityGW"
var_tags="${var_tags:-s3;storage;gateway}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-8}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -f /usr/bin/versitygw ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
if check_for_gh_release "versitygw" "versity/versitygw"; then
msg_info "Stopping Service"
systemctl stop versitygw@gateway
msg_ok "Stopped Service"
fetch_and_deploy_gh_release "versitygw" "versity/versitygw" "binary"
msg_info "Starting Service"
systemctl start versitygw@gateway
msg_ok "Started Service"
msg_ok "Updated successfully!"
fi
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:7070${CL}"

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env bash
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/misc/build.func)
# Copyright (c) 2021-2026 community-scripts ORG
# Author: MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVED/raw/main/LICENSE
# Source: https://github.com/FuzzyGrim/Yamtrack
APP="Yamtrack"
var_tags="${var_tags:-media;tracker;movies;anime}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-8}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /opt/yamtrack ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
if check_for_gh_release "yamtrack" "FuzzyGrim/Yamtrack"; then
msg_info "Stopping Services"
systemctl stop yamtrack yamtrack-celery
msg_ok "Stopped Services"
msg_info "Backing up Data"
cp /opt/yamtrack/src/.env /opt/yamtrack_env.bak
msg_ok "Backed up Data"
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "yamtrack" "FuzzyGrim/Yamtrack" "tarball"
msg_info "Installing Python Dependencies"
cd /opt/yamtrack
$STD uv venv .venv
$STD uv pip install --no-cache-dir -r requirements.txt
msg_ok "Installed Python Dependencies"
msg_info "Restoring Data"
cp /opt/yamtrack_env.bak /opt/yamtrack/src/.env
rm -f /opt/yamtrack_env.bak
msg_ok "Restored Data"
msg_info "Updating Yamtrack"
cd /opt/yamtrack/src
$STD /opt/yamtrack/.venv/bin/python manage.py migrate
$STD /opt/yamtrack/.venv/bin/python manage.py collectstatic --noinput
msg_ok "Updated Yamtrack"
msg_info "Updating Nginx Configuration"
cp /opt/yamtrack/nginx.conf /etc/nginx/nginx.conf
sed -i 's|user abc;|user www-data;|' /etc/nginx/nginx.conf
sed -i 's|/yamtrack/staticfiles/|/opt/yamtrack/src/staticfiles/|' /etc/nginx/nginx.conf
$STD systemctl reload nginx
msg_ok "Updated Nginx Configuration"
msg_info "Starting Services"
systemctl start yamtrack yamtrack-celery
msg_ok "Started Services"
msg_ok "Updated successfully!"
fi
exit
}
start
build_container
description
msg_ok "Completed Successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:8000${CL}"

View File

@@ -705,7 +705,7 @@ cleanup_lxc
- [ ] `motd_ssh`, `customize`, `cleanup_lxc` at the end
- [ ] No custom download/version-check logic
- [ ] No default `(Patience)` text in msg_info labels
- [ ] JSON metadata file created in `frontend/public/json/<appname>.json`
- [ ] JSON metadata file created in `json/<appname>.json`
---
@@ -727,7 +727,7 @@ cleanup_lxc
## <20> JSON Metadata Files
Every application requires a JSON metadata file in `frontend/public/json/<appname>.json`.
Every application requires a JSON metadata file in `json/<appname>.json`.
### JSON Structure

158
docs/vm/APP_DEPLOYER_VM.md Normal file
View File

@@ -0,0 +1,158 @@
# App Deployer VM
Deploy LXC applications inside a full Virtual Machine instead of an LXC container.
## Overview
The App Deployer VM bridges the gap between CT install scripts (`install/*.sh`) and VM infrastructure. It leverages the existing install scripts — originally designed for LXC containers — and runs them **live during image build** via `virt-customize --run`.
### Supported Operating Systems
| OS | Version | Codename | Cloud-Init |
| ------ | --------- | -------- | ---------- |
| Debian | 13 | Trixie | Optional |
| Debian | 12 | Bookworm | Optional |
| Ubuntu | 24.04 LTS | Noble | Required |
| Ubuntu | 22.04 LTS | Jammy | Required |
## Usage
### Create a new App VM (interactive)
```bash
bash -c "$(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/vm/app-deployer-vm.sh)"
```
### Pre-select application
```bash
APP_SELECT=yamtrack bash -c "$(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/vm/app-deployer-vm.sh)"
```
### Update the application later (inside the VM)
```bash
bash -c "$(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/ct/<app>.sh)"
```
For example, to update Yamtrack:
```bash
bash -c "$(curl -fsSL https://git.community-scripts.org/community-scripts/ProxmoxVED/raw/branch/main/ct/yamtrack.sh)"
```
## How It Works
### Installation Flow
```
┌─────────────────────────────────────┐
│ Proxmox Host │
│ │
│ 1. Select app (e.g. Yamtrack) │
│ 2. Select OS (e.g. Debian 13) │
│ 3. Configure VM resources │
│ 4. Download cloud image │
│ 5. virt-customize: │
│ - Install base packages │
│ - Inject install.func │
│ - Inject tools.func │
│ - Run install script LIVE │
│ (virt-customize --run) │
│ - Configure hostname & SSH │
│ 6. Create VM (qm create) │
│ 7. Import customized disk │
│ 8. Start VM │
│ │
│ → Application pre-installed! │
└─────────────────────────────────────┘
```
### Update Flow
```
┌─────────────────────────────────────┐
│ Inside the VM (SSH or console) │
│ │
│ bash -c "$(curl -fsSL │
│ $COMMUNITY_SCRIPTS_URL/ │
│ ct/<app>.sh)" │
│ │
│ → start() detects no pveversion │
│ → Shows update/settings menu │
│ → Runs update_script() │
└─────────────────────────────────────┘
```
The update mechanism reuses the existing CT script logic. Since `pveversion` is not available inside the VM, the `start()` function automatically enters the update/settings mode — exactly the same as running updates in LXC containers.
## Architecture
### Files
| File | Purpose |
| ----------------------- | ------------------------------------------- |
| `vm/app-deployer-vm.sh` | Main user-facing script |
| `misc/vm-app.func` | Core library for VM app deployment |
| `misc/vm-core.func` | Shared VM functions (colors, spinner, etc.) |
| `misc/cloud-init.func` | Cloud-Init configuration (optional) |
### Key Design Decisions
1. **Install scripts run unmodified** — The same `install/*.sh` scripts that work in LXC containers work inside VMs. The environment (`FUNCTIONS_FILE_PATH`, exports) is replicated identically.
2. **Image customization via `virt-customize`** — All dependencies are installed and the app install script runs live inside the qcow2 image during build. No SSH or guest agent required during setup.
3. **Live installation** — The install script runs during image build (not on first boot), so the application is ready immediately when the VM starts.
4. **Update via CT script URL** — Run the same `bash -c "$(curl ...ct/<app>.sh)"` command inside the VM, just like in an LXC container.
### Environment Variables (set during image build)
| Variable | Description |
| ----------------------- | ---------------------------------- |
| `FUNCTIONS_FILE_PATH` | Full contents of `install.func` |
| `APPLICATION` | App display name (e.g. "Yamtrack") |
| `app` | App identifier (e.g. "yamtrack") |
| `VERBOSE` | "no" (silent mode) |
| `SSH_ROOT` | "yes" |
| `PCT_OSTYPE` | OS type (debian/ubuntu) |
| `PCT_OSVERSION` | OS version (12/13/22.04/24.04) |
| `COMMUNITY_SCRIPTS_URL` | Repository base URL |
| `DEPLOY_TARGET` | "vm" (distinguishes from LXC) |
### VM Directory Structure
```
/opt/community-scripts/
├── install.func # Function library
└── tools.func # Helper functions
```
## Limitations
- **Alpine-based apps**: Currently only Debian/Ubuntu VMs are supported. Alpine install scripts are not compatible.
- **LXC-specific features**: Some CT features (FUSE, TUN, GPU passthrough) are configured differently in VMs.
- **`cleanup_lxc`**: This function works fine in VMs (it only cleans package caches), but the name is LXC-centric.
## Troubleshooting
### Check build log
If the installation fails during image build, check the log on the Proxmox host:
```bash
cat /tmp/vm-app-install.log
```
### Re-run installation
Re-build the VM from scratch — since the app is installed during image build, there is no in-VM reinstall mechanism. Simply delete the VM and run the deployer again.
### Verify installation worked
After the VM boots, SSH in and check if the application service is running:
```bash
systemctl status <app-service-name>
```

View File

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

39
frontend/.gitignore vendored
View File

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

View File

@@ -1,5 +0,0 @@
dist
node_modules
.next
build
.contentlayer

View File

@@ -1,3 +0,0 @@
{
"plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-organize-imports"]
}

View File

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

View File

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

View File

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

10816
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

93
frontend/package.json generated
View File

@@ -1,93 +0,0 @@
{
"name": "proxmox-helper-scripts-website",
"version": "1.0.0",
"license": "MIT",
"private": true,
"author": {
"name": "Bram Suurd",
"url": "https://github.com/community-scripts"
},
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest",
"deploy": "next build && touch out/.nojekyll && git add out/ && git commit -m \"Deploy\" && git subtree push --prefix out origin gh-pages",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@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.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.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.5.7",
"next-themes": "^0.3.0",
"nuqs": "^2.4.1",
"pocketbase": "^0.21.5",
"prettier-plugin-organize-imports": "^4.1.0",
"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",
"react-icons": "^5.5.0",
"react-simple-typewriter": "^5.0.1",
"sharp": "^0.33.5",
"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.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.29.0",
"@typescript-eslint/parser": "^8.29.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.23.0",
"eslint-config-next": "15.0.2",
"jsdom": "^25.0.1",
"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.8.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.1"
},
"overrides": {
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -1,40 +0,0 @@
{
"name": "ImmichFrame",
"slug": "immichframe",
"categories": [
13
],
"date_created": "2026-02-26",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 8080,
"documentation": null,
"config_path": "/opt/immichframe/Config/Settings.yml",
"website": null,
"logo": "https://github.com/selfhst/icons/blob/main/webp/immich-frame.webp",
"description": "ImmichFrame is a digital photo frame web application that connects to your Immich server and displays your photos as a fullscreen slideshow.",
"install_methods": [
{
"type": "default",
"script": "ct/immichframe.sh",
"resources": {
"cpu": 1,
"ram": 1024,
"hdd": 8,
"os": "Debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "After installation, edit `/opt/immichframe/Config/Settings.yml` and set ImmichServerUrl and ApiKey. Then restart the service with `systemctl restart immichframe`.",
"type": "info"
}
]
}

View File

@@ -1,44 +0,0 @@
{
"name": "Split Pro",
"slug": "split-pro",
"categories": [
12
],
"date_created": "2026-02-12",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3000,
"documentation": "https://github.com/oss-apps/split-pro/blob/main/docker/README.md",
"website": "https://github.com/oss-apps/split-pro",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/splitpro.webp",
"config_path": "/opt/split-pro/.env",
"description": "SplitPro is a self-hosted, open source way to share expenses with friends. It is designed as a replacement for Splitwise.",
"install_methods": [
{
"type": "default",
"script": "ct/split-pro.sh",
"resources": {
"cpu": 2,
"ram": 4096,
"hdd": 6,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Before first use you must configure email credentials or authentication (OAuth/OIDC) provider in `/opt/split-pro/.env` and restart the service `systemctl restart split-pro`.",
"type": "warning"
},
{
"text": "Receipt uploads are stored in `/opt/split-pro_data/uploads`",
"type": "info"
}
]
}

View File

@@ -1,44 +0,0 @@
{
"name": "Yamtrack",
"slug": "yamtrack",
"categories": [
13
],
"date_created": "2026-02-22",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 8000,
"documentation": "https://github.com/FuzzyGrim/Yamtrack/wiki",
"website": "https://github.com/FuzzyGrim/Yamtrack",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/yamtrack.webp",
"config_path": "/opt/yamtrack/src/.env",
"description": "Yamtrack is a self-hosted media tracker for movies, TV shows, anime, manga, video games, books, comics, and board games with multi-user support and Celery-powered background tasks.",
"install_methods": [
{
"type": "default",
"script": "ct/yamtrack.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 8,
"os": "Debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Set API keys (TMDB_API, MAL_API, IGDB_ID, IGDB_SECRET) in /opt/yamtrack/src/.env to enable media search from external providers.",
"type": "info"
},
{
"text": "If using a reverse proxy, set the URLS variable in .env to your external URL (e.g., URLS=https://yamtrack.example.com).",
"type": "warning"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,206 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import FilterComponent from "../../components/FilterComponent";
interface DataModel {
status: string;
type: string;
nsapp: string;
os_type: string;
disk_size: number;
core_count: number;
ram_size: number;
method: string;
pve_version: string;
created_at: string;
}
const DataFetcher: React.FC = () => {
const [data, setData] = useState<DataModel[]>([]);
const [filteredData, setFilteredData] = useState<DataModel[]>([]);
const [filters, setFilters] = useState<Record<string, any>>({});
const [loading, setLoading] = useState<boolean>(true);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPaginatedData = async () => {
setLoading(true);
try {
const response = await fetch(`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage === 0 ? '' : itemsPerPage}`);
if (!response.ok) throw new Error(`Failed to fetch data: ${response.statusText}`);
const result: DataModel[] = await response.json();
setData(result);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
fetchPaginatedData();
}, [currentPage, itemsPerPage]);
const applyFilters = async (column: string, operator: string, value: any) => {
setFilters((prev) => {
const updatedFilters = { ...prev };
if (!updatedFilters[column]) updatedFilters[column] = [];
// Prevent duplicate filters
const alreadyExists = updatedFilters[column].some((filter: { operator: string; value: any }) =>
filter.operator === operator && filter.value === value
);
if (!alreadyExists) {
updatedFilters[column].push({ operator, value });
}
return updatedFilters;
});
};
const removeFilter = (column: string, index: number) => {
setFilters((prev) => {
const updatedFilters = { ...prev };
updatedFilters[column] = updatedFilters[column].filter((_: any, i: number) => i !== index);
// If no filters remain, remove the column entry
if (updatedFilters[column].length === 0) delete updatedFilters[column];
return updatedFilters;
});
};
useEffect(() => {
let filtered = [...data];
Object.keys(filters).forEach((key) => {
if (!filters[key] || filters[key].length === 0) return;
filtered = filtered.filter((item) => {
const itemValue = item[key as keyof DataModel];
return filters[key].some(({ operator, value }: { operator: string; value: any }) => {
if (typeof itemValue === "number") {
value = parseFloat(value);
if (operator === "greater") return itemValue > value;
if (operator === "greater or equal") return itemValue >= value;
if (operator === "less") return itemValue < value;
if (operator === "less or equal") return itemValue <= value;
}
if (typeof itemValue === "string") {
if (operator === "equals") return itemValue.toLowerCase() === value.toLowerCase();
if (operator === "not equals") return itemValue.toLowerCase() !== value.toLowerCase();
if (operator === "contains") return itemValue.toLowerCase().includes(value.toLowerCase());
if (operator === "does not contain") return !itemValue.toLowerCase().includes(value.toLowerCase());
}
return false;
});
});
});
setFilteredData(filtered);
}, [filters, data]);
const columns: { key: string; type: "text" | "number"; label: string }[] = [
{ key: "status", type: "text", label: "Status" },
{ key: "type", type: "text", label: "Type" },
{ key: "nsapp", type: "text", label: "NS App" },
{ key: "os_type", type: "text", label: "OS Type" },
{ key: "disk_size", type: "number", label: "Disk Size" },
{ key: "core_count", type: "number", label: "CPU Cores" },
{ key: "ram_size", type: "number", label: "RAM Size" },
{ key: "method", type: "text", label: "Method" },
{ key: "pve_version", type: "text", label: "PVE Version" },
{ key: "created_at", type: "text", label: "Created At" }
];
return (
<div className="p-6 mt-20">
<h1 className="text-2xl font-bold mb-4 text-center">Created LXCs</h1>
<table className="min-w-full table-auto border-collapse">
<thead>
<tr>
{columns.map(({ key, type, label }) => (
<th key={key} className="px-4 py-2 border-b text-left">
<div className="flex items-center space-x-2">
<span className="font-semibold">{label}</span>
<FilterComponent
column={key}
type={type}
activeFilters={filters[key] || []}
onApplyFilter={applyFilters}
onRemoveFilter={removeFilter}
allData={data}
/>
</div>
</th>
))}
</tr>
</thead>
{/* Filters Row - Displays below headers */}
<thead>
<tr>
{columns.map(({ key, label }) => (
<th key={key} className="px-4 py-2 border-b text-left">
{filters[key] && filters[key].length > 0 ? (
<div className="flex flex-wrap gap-1">
{filters[key].map((filter: { operator: string; value: any }, index: number) => (
<div key={`${key}-${filter.value}-${index}`} className="bg-gray-800 text-white px-2 py-1 rounded flex items-center">
<span className="text-sm italic">
{filter.operator} <b>&quot;{filter.value}&quot;</b>
</span>
<button className="text-red-500 ml-2" onClick={() => removeFilter(key, index)}>
</button>
</div>
))}
</div>
) : (
<span className="text-gray-500"></span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{filteredData.length > 0 ? (
filteredData.map((item, index) => (
<tr key={index}>
<td className="px-4 py-2 border-b">{item.status}</td>
<td className="px-4 py-2 border-b">{item.type}</td>
<td className="px-4 py-2 border-b">{item.nsapp}</td>
<td className="px-4 py-2 border-b">{item.os_type}</td>
<td className="px-4 py-2 border-b">{item.disk_size}</td>
<td className="px-4 py-2 border-b">{item.core_count}</td>
<td className="px-4 py-2 border-b">{item.ram_size}</td>
<td className="px-4 py-2 border-b">{item.method}</td>
<td className="px-4 py-2 border-b">{item.pve_version}</td>
<td className="px-4 py-2 border-b">{item.created_at}</td>
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-4 py-2 text-center text-gray-500">
No results found
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export default DataFetcher;

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

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

View File

@@ -1,226 +0,0 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { OperatingSystems } from "@/config/siteConfig";
import { PlusCircle, Trash2 } from "lucide-react";
import { memo, useCallback, useRef } from "react";
import { z } from "zod";
import { InstallMethodSchema, ScriptSchema, type Script } from "../_schemas/schemas";
type InstallMethodProps = {
script: Script;
setScript: (value: Script | ((prevState: Script) => Script)) => void;
setIsValid: (isValid: boolean) => void;
setZodErrors: (zodErrors: z.ZodError | null) => void;
};
function InstallMethod({ script, setScript, setIsValid, setZodErrors }: InstallMethodProps) {
const cpuRefs = useRef<(HTMLInputElement | null)[]>([]);
const ramRefs = useRef<(HTMLInputElement | null)[]>([]);
const hddRefs = useRef<(HTMLInputElement | null)[]>([]);
const addInstallMethod = useCallback(() => {
setScript((prev) => {
const { type, slug } = prev;
const newMethodType = "default";
let scriptPath = "";
if (type === "pve") {
scriptPath = `tools/pve/${slug}.sh`;
} else if (type === "addon") {
scriptPath = `tools/addon/${slug}.sh`;
} else {
scriptPath = `${type}/${slug}.sh`;
}
const method = InstallMethodSchema.parse({
type: newMethodType,
script: scriptPath,
resources: {
cpu: null,
ram: null,
hdd: null,
os: null,
version: null,
},
});
return {
...prev,
install_methods: [...prev.install_methods, method],
};
});
}, [setScript]);
const updateInstallMethod = useCallback(
(
index: number,
key: keyof Script["install_methods"][number],
value: Script["install_methods"][number][keyof Script["install_methods"][number]],
) => {
setScript((prev) => {
const updatedMethods = prev.install_methods.map((method, i) => {
if (i === index) {
const updatedMethod = { ...method, [key]: value };
if (key === "type") {
updatedMethod.script =
value === "alpine" ? `${prev.type}/alpine-${prev.slug}.sh` : `${prev.type}/${prev.slug}.sh`;
// Set OS to Alpine and reset version if type is alpine
if (value === "alpine") {
updatedMethod.resources.os = "Alpine";
updatedMethod.resources.version = null;
}
}
return updatedMethod;
}
return method;
});
const updated = {
...prev,
install_methods: updatedMethods,
};
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
if (!result.success) {
setZodErrors(result.error);
} else {
setZodErrors(null);
}
return updated;
});
},
[setScript, setIsValid, setZodErrors],
);
const removeInstallMethod = useCallback(
(index: number) => {
setScript((prev) => ({
...prev,
install_methods: prev.install_methods.filter((_, i) => i !== index),
}));
},
[setScript],
);
return (
<>
<h3 className="text-xl font-semibold">Install Methods</h3>
{script.install_methods.map((method, index) => (
<div key={index} className="space-y-2 border p-4 rounded">
<Select value={method.type} onValueChange={(value) => updateInstallMethod(index, "type", value)}>
<SelectTrigger>
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="alpine">Alpine</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Input
ref={(el) => {
cpuRefs.current[index] = el;
}}
placeholder="CPU in Cores"
type="number"
value={method.resources.cpu || ""}
onChange={(e) =>
updateInstallMethod(index, "resources", {
...method.resources,
cpu: e.target.value ? Number(e.target.value) : null,
})
}
/>
<Input
ref={(el) => {
ramRefs.current[index] = el;
}}
placeholder="RAM in MB"
type="number"
value={method.resources.ram || ""}
onChange={(e) =>
updateInstallMethod(index, "resources", {
...method.resources,
ram: e.target.value ? Number(e.target.value) : null,
})
}
/>
<Input
ref={(el) => {
hddRefs.current[index] = el;
}}
placeholder="HDD in GB"
type="number"
value={method.resources.hdd || ""}
onChange={(e) =>
updateInstallMethod(index, "resources", {
...method.resources,
hdd: e.target.value ? Number(e.target.value) : null,
})
}
/>
</div>
<div className="flex gap-2">
<Select
value={method.resources.os || undefined}
onValueChange={(value) =>
updateInstallMethod(index, "resources", {
...method.resources,
os: value || null,
version: null, // Reset version when OS changes
})
}
disabled={method.type === "alpine"}
>
<SelectTrigger>
<SelectValue placeholder="OS" />
</SelectTrigger>
<SelectContent>
{OperatingSystems.map((os) => (
<SelectItem key={os.name} value={os.name}>
{os.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={method.resources.version || undefined}
onValueChange={(value) =>
updateInstallMethod(index, "resources", {
...method.resources,
version: value || null,
})
}
disabled={method.type === "alpine"}
>
<SelectTrigger>
<SelectValue placeholder="Version" />
</SelectTrigger>
<SelectContent>
{OperatingSystems.find((os) => os.name === method.resources.os)?.versions.map((version) => (
<SelectItem key={version.slug} value={version.name}>
{version.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="destructive" size="sm" type="button" onClick={() => removeInstallMethod(index)}>
<Trash2 className="mr-2 h-4 w-4" /> Remove Install Method
</Button>
</div>
))}
<Button type="button" size="sm" disabled={script.install_methods.length >= 2} onClick={addInstallMethod}>
<PlusCircle className="mr-2 h-4 w-4" /> Add Install Method
</Button>
</>
);
}
export default memo(InstallMethod);

View File

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

View File

@@ -1,46 +0,0 @@
import { z } from "zod";
export const InstallMethodSchema = z.object({
type: z.enum(["default", "alpine"], {
errorMap: () => ({ message: "Type must be either 'default' or 'alpine'" })
}),
script: z.string().min(1, "Script content cannot be empty"),
resources: z.object({
cpu: z.number().nullable(),
ram: z.number().nullable(),
hdd: z.number().nullable(),
os: z.string().nullable(),
version: z.string().nullable(),
}),
});
const NoteSchema = z.object({
text: z.string().min(1, "Note text cannot be empty"),
type: z.string().min(1, "Note type cannot be empty"),
});
export const ScriptSchema = z.object({
name: z.string().min(1, "Name is required"),
slug: z.string().min(1, "Slug is required"),
categories: z.array(z.number()),
date_created: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").min(1, "Date is required"),
type: z.enum(["vm", "ct", "pve", "addon", "turnkey"], {
errorMap: () => ({ message: "Type must be either 'vm', 'ct', 'pve', 'addon' or 'turnkey'" })
}),
updateable: z.boolean(),
privileged: z.boolean(),
interface_port: z.number().nullable(),
documentation: z.string().nullable(),
website: z.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(),
password: z.string().nullable(),
}),
notes: z.array(NoteSchema),
});
export type Script = z.infer<typeof ScriptSchema>;

View File

@@ -1,307 +0,0 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { fetchCategories } from "@/lib/data";
import { Category } from "@/lib/types";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { CalendarIcon, Check, Clipboard, Download } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import Categories from "./_components/Categories";
import InstallMethod from "./_components/InstallMethod";
import Note from "./_components/Note";
import { ScriptSchema, type Script } from "./_schemas/schemas";
const initialScript: Script = {
name: "",
slug: "",
categories: [],
date_created: "",
type: "ct",
updateable: false,
privileged: false,
interface_port: null,
documentation: null,
config_path: "",
website: null,
logo: null,
description: "",
install_methods: [],
default_credentials: {
username: null,
password: null,
},
notes: [],
};
export default function JSONGenerator() {
const [script, setScript] = useState<Script>(initialScript);
const [isCopied, setIsCopied] = useState(false);
const [isValid, setIsValid] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [zodErrors, setZodErrors] = useState<z.ZodError | null>(null);
useEffect(() => {
fetchCategories()
.then(setCategories)
.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 };
if (updated.slug && updated.type) {
updated.install_methods = updated.install_methods.map((method) => {
let scriptPath = "";
if (updated.type === "pve") {
scriptPath = `tools/pve/${updated.slug}.sh`;
} else if (updated.type === "addon") {
scriptPath = `tools/addon/${updated.slug}.sh`;
} else if (method.type === "alpine") {
scriptPath = `${updated.type}/alpine-${updated.slug}.sh`;
} else {
scriptPath = `${updated.type}/${updated.slug}.sh`;
}
return {
...method,
script: scriptPath,
};
});
}
const result = ScriptSchema.safeParse(updated);
setIsValid(result.success);
setZodErrors(result.success ? null : result.error);
return updated;
});
}, []);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(JSON.stringify(script, null, 2));
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
toast.success("Copied metadata to clipboard");
}, [script]);
const handleDownload = useCallback(() => {
const jsonString = JSON.stringify(script, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${script.slug || "script"}.json`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
}, [script]);
const handleDateSelect = useCallback(
(date: Date | undefined) => {
updateScript("date_created", format(date || new Date(), "yyyy-MM-dd"));
},
[updateScript],
);
const formattedDate = useMemo(
() => (script.date_created ? format(script.date_created, "PPP") : undefined),
[script.date_created],
);
const validationAlert = useMemo(
() => (
<Alert className={cn("text-black", isValid ? "bg-green-100" : "bg-red-100")}>
<AlertTitle>{isValid ? "Valid JSON" : "Invalid JSON"}</AlertTitle>
<AlertDescription>
{isValid
? "The current JSON is valid according to the schema."
: "The current JSON does not match the required schema."}
</AlertDescription>
{zodErrors && (
<div className="mt-2 space-y-1">
{zodErrors.errors.map((error, index) => (
<AlertDescription key={index} className="p-1 text-red-500">
{error.path.join(".")} - {error.message}
</AlertDescription>
))}
</div>
)}
</Alert>
),
[isValid, zodErrors],
);
return (
<div className="flex h-screen mt-20">
<div className="w-1/2 p-4 overflow-y-auto">
<h2 className="text-2xl font-bold mb-4">JSON Generator</h2>
<form className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>
Name <span className="text-red-500">*</span>
</Label>
<Input placeholder="Example" value={script.name} onChange={(e) => updateScript("name", e.target.value)} />
</div>
<div>
<Label>
Slug <span className="text-red-500">*</span>
</Label>
<Input placeholder="example" value={script.slug} onChange={(e) => updateScript("slug", e.target.value)} />
</div>
</div>
<div>
<Label>
Logo <span className="text-red-500">*</span>
</Label>
<Input
placeholder="Full logo URL"
value={script.logo || ""}
onChange={(e) => updateScript("logo", e.target.value || null)}
/>
</div>
<div>
<Label>
Description <span className="text-red-500">*</span>
</Label>
<Textarea
placeholder="Example"
value={script.description}
onChange={(e) => updateScript("description", e.target.value)}
/>
</div>
<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>
<Popover>
<PopoverTrigger asChild className="flex-1">
<Button
variant={"outline"}
className={cn("pl-3 text-left font-normal w-full", !script.date_created && "text-muted-foreground")}
>
{formattedDate || <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={new Date(script.date_created)}
onSelect={handleDateSelect}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="flex flex-col gap-2 w-full">
<Label>Type</Label>
<Select value={script.type} onValueChange={(value) => updateScript("type", value)}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ct">LXC Container</SelectItem>
<SelectItem value="vm">Virtual Machine</SelectItem>
<SelectItem value="pve">PVE-Tool</SelectItem>
<SelectItem value="addon">Add-On</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="w-full flex gap-5">
<div className="flex items-center space-x-2">
<Switch checked={script.updateable} onCheckedChange={(checked) => updateScript("updateable", checked)} />
<label>Updateable</label>
</div>
<div className="flex items-center space-x-2">
<Switch checked={script.privileged} onCheckedChange={(checked) => updateScript("privileged", checked)} />
<label>Privileged</label>
</div>
</div>
<Input
placeholder="Interface Port"
type="number"
value={script.interface_port || ""}
onChange={(e) => updateScript("interface_port", e.target.value ? Number(e.target.value) : null)}
/>
<div className="flex gap-2">
<Input
placeholder="Website URL"
value={script.website || ""}
onChange={(e) => updateScript("website", e.target.value || null)}
/>
<Input
placeholder="Documentation URL"
value={script.documentation || ""}
onChange={(e) => updateScript("documentation", e.target.value || null)}
/>
</div>
<InstallMethod script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
<h3 className="text-xl font-semibold">Default Credentials</h3>
<Input
placeholder="Username"
value={script.default_credentials.username || ""}
onChange={(e) =>
updateScript("default_credentials", {
...script.default_credentials,
username: e.target.value || null,
})
}
/>
<Input
placeholder="Password"
value={script.default_credentials.password || ""}
onChange={(e) =>
updateScript("default_credentials", {
...script.default_credentials,
password: e.target.value || null,
})
}
/>
<Note script={script} setScript={setScript} setIsValid={setIsValid} setZodErrors={setZodErrors} />
</form>
</div>
<div className="w-1/2 p-4 bg-background overflow-y-auto">
{validationAlert}
<div className="relative">
<div className="absolute right-2 top-2 flex gap-1">
<Button size="icon" variant="outline" onClick={handleCopy}>
{isCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
</Button>
<Button size="icon" variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
</Button>
</div>
<pre className="mt-4 p-4 bg-secondary rounded shadow overflow-x-scroll">
{JSON.stringify(script, null, 2)}
</pre>
</div>
</div>
</div>
);
}

View File

@@ -1,98 +0,0 @@
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";
import "@/styles/globals.css";
import { Inter } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import React from "react";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "Proxmox VE Helper-Scripts DEVELOP",
generator: "Next.js",
applicationName: "Proxmox VE Helper-Scripts DEVELOP",
referrer: "origin-when-cross-origin",
keywords: [
"Proxmox VE",
"Helper-Scripts",
"tteck",
"helper",
"scripts",
"proxmox",
"VE",
"Development",
],
authors: { name: "Bram Suurd" },
creator: "Bram Suurd",
publisher: "Bram Suurd",
description:
"A Front-end for the Proxmox VE Helper-Scripts (DEVELOP) Repository. Featuring over 200+ scripts to help you manage your Proxmox VE environment.",
favicon: "/app/favicon.ico",
formatDetection: {
email: false,
address: false,
telephone: false,
},
metadataBase: new URL(`https://community-scripts.github.io/${basePath}/`),
openGraph: {
title: "Proxmox VE Helper-Scripts DEVELOP",
description:
"A Front-end for the Proxmox VE Helper-Scripts (Community) Repository. Featuring over 200+ scripts to help you manage your Proxmox VE environment.",
url: "/defaultimg.png",
images: [
{
url: `https://community-scripts.github.io/${basePath}/defaultimg.png`,
},
],
locale: "en_US",
type: "website",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
defer
src={`https://${analytics.url}/script.js`}
data-website-id={analytics.token}
></script>
<link rel="canonical" href={metadata.metadataBase.href} />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="preconnect" href="https://api.github.com" />
</head>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<div className="flex w-full flex-col justify-center">
<Navbar />
<div className="flex min-h-screen flex-col justify-center">
<div className="flex w-full justify-center">
<div className="w-full max-w-7xl ">
<QueryProvider>
<NuqsAdapter>{children}</NuqsAdapter>
</QueryProvider>
<Toaster richColors />
</div>
</div>
<Footer />
</div>
</div>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -1,28 +0,0 @@
import { basePath } from "@/config/siteConfig";
import type { MetadataRoute } from "next";
export const generateStaticParams = () => {
return [];
};
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Proxmox VE Helper-Scripts Development",
short_name: "Proxmox VE Helper-Scripts Development",
description:
"A Re-designed Front-end for the Proxmox VE Helper-Scripts Repository. Featuring over 200+ scripts to help you manage your Proxmox VE environment.",
theme_color: "#030712",
background_color: "#030712",
display: "standalone",
orientation: "portrait",
scope: `${basePath}`,
start_url: `${basePath}`,
icons: [
{
src: "logo.png",
sizes: "512x512",
type: "image/png",
},
],
};
}

View File

@@ -1,20 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
export default function NotFoundPage() {
return (
<div className="flex h-screen w-full flex-col items-center justify-center gap-5 bg-background px-4 md:px-6">
<div className="space-y-2 text-center">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
404
</h1>
<p className="text-muted-foreground md:text-xl">
Oops, the page you are looking for could not be found.
</p>
</div>
<Button onClick={() => window.history.back()} variant="secondary">
Go Back
</Button>
</div>
);
}

View File

@@ -1,143 +0,0 @@
"use client";
import FAQ from "@/components/FAQ";
import AnimatedGradientText from "@/components/ui/animated-gradient-text";
import { Button } from "@/components/ui/button";
import { CardFooter } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import Particles from "@/components/ui/particles";
import { Separator } from "@/components/ui/separator";
import { basePath } from "@/config/siteConfig";
import { cn } from "@/lib/utils";
import { ArrowRightIcon, ExternalLink } from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa";
function CustomArrowRightIcon() {
return <ArrowRightIcon className="h-4 w-4" width={1} />;
}
export default function Page() {
const { theme } = useTheme();
const [color, setColor] = useState("#000000");
useEffect(() => {
setColor(theme === "dark" ? "#ffffff" : "#000000");
}, [theme]);
return (
<>
<div className="w-full mt-16">
<Particles className="absolute inset-0 -z-40" quantity={100} ease={80} color={color} refresh />
<div className="container mx-auto">
<div className="flex h-[80vh] flex-col items-center justify-center gap-4 py-20 lg:py-40">
<Dialog>
<DialogTrigger>
<div>
<AnimatedGradientText>
<div
className={cn(
`absolute inset-0 block size-full animate-gradient bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:var(--bg-size)_100%] [border-radius:inherit] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]`,
`p-px ![mask-composite:subtract]`,
)}
/>
<Separator className="mx-2 h-4" orientation="vertical" />
<span
className={cn(
`animate-gradient bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
`inline`,
)}
>
Scripts by tteck
</span>
</AnimatedGradientText>
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Thank You!</DialogTitle>
<DialogDescription>
A big thank you to tteck and the many contributors who have made this project possible. Your hard
work is truly appreciated by the entire Proxmox community!
</DialogDescription>
</DialogHeader>
<CardFooter className="flex flex-col gap-2">
<Button className="w-full" variant="outline" asChild>
<a
href="https://github.com/tteck"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<FaGithub className="mr-2 h-4 w-4" /> Tteck&apos;s GitHub
</a>
</Button>
<Button className="w-full" asChild>
<a
href={`https://github.com/community-scripts/${basePath}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center"
>
<ExternalLink className="mr-2 h-4 w-4" /> Proxmox Helper Scripts
</a>
</Button>
</CardFooter>
</DialogContent>
</Dialog>
<div className="flex flex-col gap-4">
<h1 className="max-w-2xl text-center text-3xl font-semibold tracking-tighter md:text-7xl">
Make managing your Homelab a breeze
</h1>
<div className="max-w-2xl gap-2 flex flex-col text-center sm:text-lg text-sm leading-relaxed tracking-tight text-muted-foreground md:text-xl">
<p>
We are a community-driven initiative that simplifies the setup of Proxmox Virtual Environment (VE).
</p>
<p>
With 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

@@ -1,68 +0,0 @@
"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

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

View File

@@ -1,42 +0,0 @@
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

@@ -1,132 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { formattedBadge } from "@/components/CommandMenu";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Category } from "@/lib/types";
import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { basePath } from "@/config/siteConfig";
export default function ScriptAccordion({
items,
selectedScript,
setSelectedScript,
}: {
items: Category[];
selectedScript: string | null;
setSelectedScript: (script: string | null) => void;
}) {
const [expandedItem, setExpandedItem] = useState<string | undefined>(
undefined,
);
const linkRefs = useRef<{ [key: string]: HTMLAnchorElement | null }>({});
const handleAccordionChange = (value: string | undefined) => {
setExpandedItem(value);
};
const handleSelected = useCallback(
(slug: string) => {
setSelectedScript(slug);
},
[setSelectedScript],
);
useEffect(() => {
if (selectedScript) {
const category = items.find((category) =>
category.scripts.some((script) => script.slug === selectedScript),
);
if (category) {
setExpandedItem(category.name);
handleSelected(selectedScript);
}
}
}, [selectedScript, items, handleSelected]);
return (
<Accordion
type="single"
value={expandedItem}
onValueChange={handleAccordionChange}
collapsible
className="overflow-y-scroll max-h-[calc(100vh-225px)] overflow-x-hidden mt-3 p-2"
>
{items.map((category) => (
<AccordionItem
key={category.id + ":category"}
value={category.name}
className={cn("sm:text-md flex flex-col border-none", {
"rounded-lg bg-accent/30": expandedItem === category.name,
})}
>
<AccordionTrigger
className={cn(
"duration-250 rounded-lg transition ease-in-out hover:-translate-y-1 hover:scale-105 hover:bg-accent",
)}
>
<div className="mr-2 flex w-full items-center justify-between">
<span className="pl-2 text-left">{category.name} </span>
<span className="rounded-full bg-gray-200 px-2 py-1 text-xs text-muted-foreground hover:no-underline dark:bg-blue-800/20">
{category.scripts.length}
</span>
</div>{" "}
</AccordionTrigger>
<AccordionContent
data-state={expandedItem === category.name ? "open" : "closed"}
className="pt-0"
>
{category.scripts
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((script, index) => (
<div key={index}>
<Link
href={{
pathname: "/scripts",
query: { id: script.slug },
}}
prefetch={false}
className={`flex cursor-pointer items-center justify-between gap-1 px-1 py-1 text-muted-foreground hover:rounded-lg hover:bg-accent/60 hover:dark:bg-accent/20 ${
selectedScript === script.slug
? "rounded-lg bg-accent font-semibold dark:bg-accent/30 dark:text-white"
: ""
}`}
onClick={() => handleSelected(script.slug)}
ref={(el) => {
linkRefs.current[script.slug] = el;
}}
>
<div className="flex items-center">
<Image
src={script.logo || `/${basePath}/logo.png`}
height={16}
width={16}
unoptimized
onError={(e) =>
((e.currentTarget as HTMLImageElement).src =
`/${basePath}/logo.png`)
}
alt={script.name}
className="mr-1 w-4 h-4 rounded-full"
/>
<span className="flex items-center gap-2">
{script.name}
</span>
</div>
{formattedBadge(script.type)}
</Link>
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
}

View File

@@ -1,194 +0,0 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { basePath, mostPopularScripts } from "@/config/siteConfig";
import { extractDate } from "@/lib/time";
import { Category, Script } from "@/lib/types";
import { CalendarPlus } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
const ITEMS_PER_PAGE = 3;
export const getDisplayValueFromType = (type: string) => {
switch (type) {
case "ct":
return "LXC";
case "vm":
return "VM";
case "pve":
case "addon":
return "";
default:
return "";
}
};
export function LatestScripts({ items }: { items: Category[] }) {
const [page, setPage] = useState(1);
const latestScripts = useMemo(() => {
if (!items) return [];
const scripts = items.flatMap((category) => category.scripts || []);
// Filter out duplicates by slug
const uniqueScriptsMap = new Map<string, Script>();
scripts.forEach((script) => {
if (!uniqueScriptsMap.has(script.slug)) {
uniqueScriptsMap.set(script.slug, script);
}
});
return Array.from(uniqueScriptsMap.values()).sort(
(a, b) => new Date(b.date_created).getTime() - new Date(a.date_created).getTime(),
);
}, [items]);
const goToNextPage = () => {
setPage((prevPage) => prevPage + 1);
};
const goToPreviousPage = () => {
setPage((prevPage) => prevPage - 1);
};
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = page * ITEMS_PER_PAGE;
if (!items) {
return null;
}
return (
<div className="">
{latestScripts.length > 0 && (
<div className="flex w-full items-center justify-between">
<h2 className="text-lg font-semibold">Newest Scripts</h2>
<div className="flex items-center justify-end gap-1">
{page > 1 && (
<div className="cursor-pointer select-none p-2 text-sm font-semibold" onClick={goToPreviousPage}>
Previous
</div>
)}
{endIndex < latestScripts.length && (
<div onClick={goToNextPage} className="cursor-pointer select-none p-2 text-sm font-semibold">
{page === 1 ? "More.." : "Next"}
</div>
)}
</div>
</div>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{latestScripts.slice(startIndex, endIndex).map((script) => (
<Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="flex h-16 w-16 min-w-16 items-center justify-center rounded-lg bg-accent p-1">
<Image
src={script.logo || `/${basePath}/logo.png`}
unoptimized
height={64}
width={64}
alt=""
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
className="h-11 w-11 object-contain"
/>
</div>
<div className="flex flex-col">
<p className="text-lg line-clamp-1">
{script.name} {getDisplayValueFromType(script.type)}
</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarPlus className="h-4 w-4" />
{extractDate(script.date_created)}
</p>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="line-clamp-3 text-card-foreground">{script.description}</CardDescription>
</CardContent>
<CardFooter className="">
<Button asChild variant="outline">
<Link
href={{
pathname: "/scripts",
query: { id: script.slug },
}}
>
View Script
</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
);
}
export function MostViewedScripts({ items }: { items: Category[] }) {
const mostViewedScripts = items.reduce((acc: Script[], category) => {
const foundScripts = category.scripts.filter((script) => mostPopularScripts.includes(script.slug));
return acc.concat(foundScripts);
}, []);
return (
<div className="">
{mostViewedScripts.length > 0 && (
<>
<h2 className="text-lg font-semibold">Most Viewed Scripts</h2>
</>
)}
<div className="min-w flex w-full flex-row flex-wrap gap-4">
{mostViewedScripts.map((script) => (
<Card key={script.slug} className="min-w-[250px] flex-1 flex-grow bg-accent/30">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="flex size-16 min-w-16 items-center justify-center rounded-lg bg-accent p-1">
<Image
unoptimized
src={script.logo || `/${basePath}/logo.png`}
height={64}
width={64}
alt=""
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
className="h-11 w-11 object-contain"
/>
</div>
<div className="flex flex-col">
<p className="line-clamp-1 text-lg">
{script.name} {getDisplayValueFromType(script.type)}
</p>
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<CalendarPlus className="h-4 w-4" />
{extractDate(script.date_created)}
</p>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="line-clamp-3 text-card-foreground break-words">
{script.description}
</CardDescription>
</CardContent>
<CardFooter className="">
<Button asChild variant="outline">
<Link
href={{
pathname: "/scripts",
query: { id: script.slug },
}}
prefetch={false}
>
View Script
</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
);
}

View File

@@ -1,176 +0,0 @@
"use client";
import { extractDate } from "@/lib/time";
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 Description from "./ScriptItems/Description";
import InstallCommand from "./ScriptItems/InstallCommand";
import ConfigFile from "./ScriptItems/ConfigFile";
import InterFaces from "./ScriptItems/InterFaces";
import Tooltips from "./ScriptItems/Tooltips";
interface ScriptItemProps {
item: Script;
setSelectedScript: (script: string | null) => void;
}
function ScriptHeader({ item }: { item: Script }) {
const defaultInstallMethod = item.install_methods?.[0];
const os = defaultInstallMethod?.resources?.os || "Proxmox Node";
const version = defaultInstallMethod?.resources?.version || "";
return (
<div className="flex flex-col lg:flex-row gap-6 w-full">
<div className="flex flex-col md:flex-row gap-6 flex-grow">
<div className="flex-shrink-0">
<Image
className="h-32 w-32 rounded-xl bg-gradient-to-br from-accent/40 to-accent/60 object-contain p-3 shadow-lg transition-transform hover:scale-105"
src={item.logo || `/${basePath}/logo.png`}
width={400}
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
height={400}
alt={item.name}
unoptimized
/>
</div>
<div className="flex flex-col justify-between flex-grow space-y-4">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
{item.name}
<VersionInfo item={item} />
<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>
);
}
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

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

View File

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

View File

@@ -1,10 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Script } from "@/lib/types";
import { CircleHelp } from "lucide-react";
import React from "react";
interface TooltipProps {
variant: "warning" | "success";
label: string;
content: string;
}
const TooltipBadge: React.FC<TooltipProps> = ({ variant, label, content }) => (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger className="flex items-center">
<Badge variant={variant} className="flex items-center gap-1">
{label} <CircleHelp className="size-3" />
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-sm max-w-64">
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export default function Tooltips({ item }: { item: Script }) {
return (
<div className="flex items-center gap-2">
{item.privileged && (
<TooltipBadge
variant="warning"
label="Privileged"
content="This script will be run in a privileged LXC"
/>
)}
{item.updateable && (
<TooltipBadge
variant="success"
label="Updateable"
content={`To Update ${item.name}, run the command below (or type update) in the LXC Console.`}
/>
)}
</div>
);
}

View File

@@ -1,45 +0,0 @@
"use client";
import type { Category, Script } from "@/lib/types";
import ScriptAccordion from "./ScriptAccordion";
const Sidebar = ({
items,
selectedScript,
setSelectedScript,
}: {
items: Category[];
selectedScript: string | null;
setSelectedScript: (script: string | null) => void;
}) => {
const filteredItems = items.filter(category => category.scripts && 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>
);
};
export default Sidebar;

View File

@@ -1,13 +0,0 @@
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

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

View File

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

View File

@@ -1,193 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from "chart.js";
import ChartDataLabels from "chartjs-plugin-datalabels";
import { BarChart3, PieChart } from "lucide-react";
import React, { useState } from "react";
import { Pie } from "react-chartjs-2";
ChartJS.register(ArcElement, ChartTooltip, Legend, ChartDataLabels);
interface ApplicationChartProps {
data: { nsapp: string }[];
}
const ITEMS_PER_PAGE = 20;
const CHART_COLORS = [
"#ff6384",
"#36a2eb",
"#ffce56",
"#4bc0c0",
"#9966ff",
"#ff9f40",
"#4dc9f6",
"#f67019",
"#537bc4",
"#acc236",
"#166a8f",
"#00a950",
"#58595b",
"#8549ba",
];
export default function ApplicationChart({ data }: ApplicationChartProps) {
const [isChartOpen, setIsChartOpen] = useState(false);
const [isTableOpen, setIsTableOpen] = useState(false);
const [chartStartIndex, setChartStartIndex] = useState(0);
const [tableLimit, setTableLimit] = useState(ITEMS_PER_PAGE);
// Calculate application counts
const appCounts = data.reduce((acc, item) => {
acc[item.nsapp] = (acc[item.nsapp] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const sortedApps = Object.entries(appCounts)
.sort(([, a], [, b]) => b - a);
const chartApps = sortedApps.slice(
chartStartIndex,
chartStartIndex + ITEMS_PER_PAGE
);
const chartData = {
labels: chartApps.map(([name]) => name),
datasets: [
{
data: chartApps.map(([, count]) => count),
backgroundColor: CHART_COLORS,
},
],
};
const chartOptions = {
plugins: {
legend: { display: false },
datalabels: {
color: "white",
font: { weight: "bold" as const },
formatter: (value: number, context: any) => {
const label = context.chart.data.labels?.[context.dataIndex];
return `${label}\n(${value})`;
},
},
},
responsive: true,
maintainAspectRatio: false,
};
return (
<div className="mt-6 flex justify-center gap-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsChartOpen(true)}
>
<PieChart className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>Open Chart View</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsTableOpen(true)}
>
<BarChart3 className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>Open Table View</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={isChartOpen} onOpenChange={setIsChartOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Applications Distribution</DialogTitle>
</DialogHeader>
<div className="h-[60vh] w-full">
<Pie data={chartData} options={chartOptions} />
</div>
<div className="flex justify-center gap-4">
<Button
variant="outline"
onClick={() => setChartStartIndex(Math.max(0, chartStartIndex - ITEMS_PER_PAGE))}
disabled={chartStartIndex === 0}
>
Previous {ITEMS_PER_PAGE}
</Button>
<Button
variant="outline"
onClick={() => setChartStartIndex(chartStartIndex + ITEMS_PER_PAGE)}
disabled={chartStartIndex + ITEMS_PER_PAGE >= sortedApps.length}
>
Next {ITEMS_PER_PAGE}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={isTableOpen} onOpenChange={setIsTableOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Applications Count</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Application</TableHead>
<TableHead className="text-right">Count</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedApps.slice(0, tableLimit).map(([name, count]) => (
<TableRow key={name}>
<TableCell>{name}</TableCell>
<TableCell className="text-right">{count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{tableLimit < sortedApps.length && (
<Button
variant="outline"
className="w-full"
onClick={() => setTableLimit(prev => prev + ITEMS_PER_PAGE)}
>
Load More
</Button>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,163 +0,0 @@
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { basePath } from "@/config/siteConfig";
import { fetchCategories } from "@/lib/data";
import { Category, Script } from "@/lib/types";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React from "react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { DialogTitle } from "./ui/dialog";
import { Sparkles } from "lucide-react"; // <- 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 "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 [isLoading, setIsLoading] = React.useState(false);
const router = useRouter();
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
fetchSortedCategories();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const fetchSortedCategories = () => {
setIsLoading(true);
fetchCategories()
.then((categories) => {
setLinks(categories);
setIsLoading(false);
})
.catch((error) => {
setIsLoading(false);
console.error(error);
});
};
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 (
<>
<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>
{links.map((category) => (
<CommandGroup key={`category:${category.name}`} heading={category.name}>
{category.scripts.map((script) => (
<CommandItem
key={`script:${script.slug}`}
value={`${script.slug}-${script.name}`}
onSelect={() => {
setOpen(false);
router.push(`/scripts?id=${script.slug}`);
}}
>
<div className="flex gap-2" onClick={() => setOpen(false)}>
<Image
src={script.logo || `/${basePath}/logo.png`}
onError={(e) => ((e.currentTarget as HTMLImageElement).src = `/${basePath}/logo.png`)}
unoptimized
width={16}
height={16}
alt=""
className="h-5 w-5"
/>
<span>{script.name}</span>
<span>{formattedBadge(script.type)}</span>
</div>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
</>
);
}

View File

@@ -1,29 +0,0 @@
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

@@ -1,185 +0,0 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
interface FilterProps {
column: string;
type: "text" | "number";
activeFilters: { operator: string; value: any }[];
onApplyFilter: (column: string, operator: string, value: any) => Promise<void>;
onRemoveFilter: (column: string, index: number) => void;
allData: any[];
}
const FilterComponent: React.FC<FilterProps> = ({ column, type, activeFilters, onApplyFilter, onRemoveFilter, allData }) => {
const [filters, setFilters] = useState<{ operator: string; value: any }[]>([
{ operator: "equals", value: "" }
]);
const [showFilter, setShowFilter] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const operators = {
text: ["equals", "not equals", "contains", "does not contain", "is empty"],
number: ["equals", "not equals", "greater", "greater or equal", "less", "less or equal"]
};
useEffect(() => {
setFilters(activeFilters.length > 0 ? activeFilters : [{ operator: "equals", value: "" }]);
}, [activeFilters]);
const updateFilter = (index: number, key: "operator" | "value", newValue: string | number) => {
setFilters((prevFilters) => {
const updatedFilters = [...prevFilters];
updatedFilters[index][key] = newValue;
if (key === "value" && type === "text") {
handleAutocomplete(newValue as string);
}
return updatedFilters;
});
if (key === "value") {
setTimeout(() => setShowSuggestions(false), 100); // Vorschläge ausblenden, sobald Wert gesetzt wird
}
};
const handleAutocomplete = (input: string) => {
let filteredSuggestions: string[] = [];
const uniqueValues = [...new Set(allData.map((item) => item[column]?.toString()))];
if (!input) {
filteredSuggestions = uniqueValues;
} else {
filteredSuggestions = uniqueValues.filter((value) =>
value && value.toLowerCase().includes(input.toLowerCase())
);
}
setSuggestions(filteredSuggestions.slice(0, 5));
setShowSuggestions(true);
};
const applyFilters = async () => {
setLoading(true);
for (const filter of filters) {
await onApplyFilter(column, filter.operator, filter.value);
}
setLoading(false);
setShowFilter(false);
setSuggestions([]); // Close suggestions after applying filter
};
const resetFilters = () => {
setFilters([{ operator: "equals", value: "" }]);
setShowFilter(false);
setSuggestions([]);
};
return (
<div className="relative inline-block text-left">
<button
onClick={() => setShowFilter(!showFilter)}
className="ml-2 p-1 rounded bg-gray-800 hover:bg-gray-600 transition text-white"
>
🔽
</button>
{showFilter && (
<div
ref={dropdownRef}
className="absolute left-0 mt-2 bg-white dark:bg-gray-900 text-black dark:text-white border border-gray-300 dark:border-gray-700 shadow-lg rounded-lg w-56 p-4 z-50"
>
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium">Filter by {column}</label>
<button onClick={resetFilters} className="text-red-500 hover:text-red-700 transition">
</button>
</div>
{filters.map((filter, index) => (
<div key={index} className="mb-2 p-2 border rounded relative">
<select
value={filter.operator}
onChange={(e) => updateFilter(index, "operator", e.target.value)}
className="w-full p-1 border rounded bg-gray-100 dark:bg-gray-800 text-black dark:text-white"
>
{operators[type].map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
<div className="relative flex items-center">
<input
type={type === "number" ? "number" : "text"}
value={filters[index].value}
onChange={(e) => updateFilter(index, "value", e.target.value)}
className="w-full mt-2 p-1 border rounded"
onFocus={() => handleAutocomplete("")} // Zeige Vorschläge an
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} // Verhindert sofortiges Schließen
/>
{type === "text" && (
<button
onClick={() => handleAutocomplete("")}
className="ml-2 bg-gray-300 dark:bg-gray-600 px-2 py-1 rounded text-gray-800 dark:text-gray-200"
>
🔽
</button>
)}
</div>
{showSuggestions && suggestions.length > 0 && (
<ul className="absolute top-full left-0 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 mt-1 rounded shadow-lg z-50">
{suggestions.map((suggestion, i) => (
<li
key={i}
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault(); // Verhindert, dass das Input-Feld sofort das Blur-Event auslöst
updateFilter(index, "value", suggestion);
setSuggestions([]); // Vorschläge ausblenden
setShowSuggestions(false);
}}
onClick={() => setFilters([{ operator: filters[index].operator, value: suggestion }])} // Setzt den Wert im Input zurück
>
{suggestion}
</li>
))}
</ul>
)}
</div>
))}
<button onClick={() => setFilters([...filters, { operator: "equals", value: "" }])}
className="w-full bg-gray-500 hover:bg-gray-600 text-white p-1 rounded"
>
+ Add Another Filter
</button>
<button
onClick={applyFilters}
disabled={loading}
className={`w-full p-2 rounded-md font-semibold mt-3 transition ${loading
? "bg-blue-300 text-gray-700 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 text-white"
}`}
>
{loading ? "Applying..." : "Apply"}
</button>
</div>
)}
</div>
);
};
export default FilterComponent;

View File

@@ -1,43 +0,0 @@
import { basePath } from "@/config/siteConfig";
import Link from "next/link";
import { FileJson, Server, ExternalLink } from "lucide-react";
import { buttonVariants } from "./ui/button";
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-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}/frontend`}
target="_blank"
rel="noreferrer"
className="font-semibold underline-offset-2 duration-300 hover:underline"
data-umami-event="View Website Source Code on Github"
>
GitHub
</Link>
.
</p>
</div>
<div className="sm:flex hidden">
<Link
href="/json-editor"
className={cn(buttonVariants({ variant: "link" }), "text-muted-foreground flex items-center gap-2")}
>
<FileJson className="h-4 w-4" /> JSON Editor
</Link>
<Link
href="/data"
className={cn(buttonVariants({ variant: "link" }), "text-muted-foreground flex items-center gap-2")}
>
<Server className="h-4 w-4" /> API Data
</Link>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,59 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,26 +0,0 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
export default function AnimatedGradientText({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"group relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-2xl bg-white/40 px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#8fdfff1f] backdrop-blur-sm transition-shadow duration-500 ease-out [--bg-size:300%] hover:shadow-[inset_0_-5px_10px_#8fdfff3f] dark:bg-black/40",
className,
)}
>
<div
className={`absolute inset-0 block h-full w-full animate-gradient bg-gradient-to-r from-[#ffaa40]/50 via-[#9c40ff]/50 to-[#ffaa40]/50 bg-[length:var(--bg-size)_100%] p-[1px] [border-radius:inherit] ![mask-composite:subtract] [mask:linear-gradient(#fff_0_0)_content-box,linear-gradient(#fff_0_0)]`}
/>
{children}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

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