Compare commits

..

60 Commits

Author SHA1 Message Date
github-actions[bot]
20d5e70d65 chore: add VERSION v0.4.9 2025-10-24 20:39:06 +00:00
Michel Roegl-Brunner
68b6fc81bd Merge pull request #247 from community-scripts/fix/script-card-click-issues
Fix: Autosync disable functionality and multiple instance issues
2025-10-24 22:36:37 +02:00
Michel Roegl-Brunner
16947cedf5 Remove debug logs from autosync service
- Remove excessive debug logging from scheduleAutoSync and stopAutoSync
- Keep essential logging for troubleshooting
- Clean up console output for production use
2025-10-24 22:30:26 +02:00
Michel Roegl-Brunner
eca0cb57f9 Fix TypeScript linting errors in autoSyncService
- Add JSDoc type definitions for settings object
- Fix appriseUrls undefined checks
- Fix null assignment for lastAutoSyncError
- Add proper type annotations for error fields

All TypeScript linting errors are now resolved.
2025-10-24 22:30:15 +02:00
Michel Roegl-Brunner
011cbd23b2 Fix autosync continuing to run after being disabled
- Add destroy() method to properly stop cron jobs
- Add additional safety checks in cron job execution
- Add debugging logs to track cron job lifecycle
- Ensure isRunning is set to false even when no cron job exists
- Add null check for cronJob before execution

This should prevent autosync from continuing to run after being disabled.
2025-10-24 22:29:37 +02:00
Michel Roegl-Brunner
19e18b4ebf Fix multiple autosync instances running simultaneously
- Add global lock to prevent multiple autosync instances from running
- Add initialization check to prevent multiple service creation
- Add global lock checks in cron job execution
- Prevent multiple notifications from being sent
- Fix TypeScript errors with error field types

This fixes the issue where 6+ autosync instances were running simultaneously,
causing multiple notifications and rate limit issues.
2025-10-24 22:27:40 +02:00
Michel Roegl-Brunner
ffef6313d4 Add error reporting for GitHub rate limit errors
- Add specific error handling for GitHub API rate limit (403) errors
- Create RateLimitError with proper error name for identification
- Store last sync error and error time in settings for UI display
- Add error status display in autosync settings modal
- Show user-friendly error messages with GitHub token suggestion
- Clear error status on successful sync
- Update both GitHubJsonService and GitHubService with rate limit error handling

This provides better user feedback when GitHub API rate limits are exceeded
and guides users to set up a GitHub token for higher rate limits.
2025-10-24 22:23:59 +02:00
Michel Roegl-Brunner
7b4daf8754 Fix autosync continuing to run after being disabled
- Add defensive check in cron job execution to stop if autosync is disabled
- Ensure isRunning flag is set to false when stopping autosync
- Add logging to show when autosync is disabled and not scheduling
- Fix both API route and TRPC router to properly stop service
- Prevent multiple cron jobs from running simultaneously

This fixes the issue where autosync would continue running even after
being disabled in the GUI, causing rate limit errors and unwanted syncs.
2025-10-24 22:20:13 +02:00
Michel Roegl-Brunner
fdeda6c77a Add GitHub token authentication to sync services
- Add GitHub token authentication to GitHubJsonService for API calls
- Add GitHub token authentication to GitHubService for API calls
- Update fetchFromGitHub methods to use GITHUB_TOKEN from .env
- Update downloadJsonFile methods to use GitHub token for raw file downloads
- Add proper error handling for rate limit exceeded (403) errors
- Add console logging to show when token is/isn't being used
- Improve error messages to suggest setting GITHUB_TOKEN for higher rate limits

This ensures that when a GitHub token is specified in .env, it will be used
for all GitHub API calls during sync operations, providing higher rate limits
and better reliability.
2025-10-24 22:16:31 +02:00
Michel Roegl-Brunner
5acaf144fb Fix autosync toggle disable functionality
- Fix service instance management to use global instance for stopping autosync
- Add automatic saving when toggle is changed (no manual save required)
- Fix validation issue where custom sync type without cron expression caused 400 error
- Add comprehensive debugging and error handling
- Ensure .env file is properly updated with AUTO_SYNC_ENABLED value
- Improve service lifecycle management with proper state cleanup
- Add fallback logic for invalid sync interval configurations

Resolves issue where disabling autosync in GUI didn't update .env file or stop service
2025-10-24 22:15:24 +02:00
Michel Roegl-Brunner
926032e83b Fix auto-sync service not stopping when disabled
- Use global auto-sync service instance instead of creating new instances
- This ensures stopAutoSync() is called on the actual running service
- Fix .env file parsing to handle comments and empty lines properly
- Update both TRPC and REST API endpoints to use global instance
- Auto-sync service will now properly stop when disabled in settings
2025-10-24 22:09:16 +02:00
Michel Roegl-Brunner
8fc9b27f55 Fix missing access import in scriptDownloader.js
- Add missing 'access' import from 'fs/promises'
- This was causing checkScriptExists method to fail with 'access is not defined' error
- Now properly detects when script files exist locally
- Fixes issue where UI shows 'Load Script' instead of 'Install' button for downloaded scripts
2025-10-24 22:07:46 +02:00
Michel Roegl-Brunner
9a8cff3227 Add missing checkScriptExists method to scriptDownloader.js
- Add checkScriptExists method that was missing from JavaScript implementation
- This method is required by the checkScriptFiles API endpoint
- Method checks if CT script and install script files exist locally
- Returns ctExists, installExists, and files array
- Fixes issue where UI shows 'Load Script' instead of 'Install' button for downloaded scripts
2025-10-24 22:06:34 +02:00
Michel Roegl-Brunner
e40bd1f6a3 Fix CT script source line replacement
- Implement modifyScriptContent method to replace GitHub source line with local source
- Replace 'source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func)'
  with 'SCRIPT_DIR="." \nsource "/../core/build.func"'
- This ensures CT scripts use local build.func instead of downloading from GitHub
- Applied to all CT scripts during download process
- Tested with 2fauth script - replacement works correctly
2025-10-24 22:04:23 +02:00
Michel Roegl-Brunner
6c982050da Fix notification to show script names grouped by category
- Store full script objects instead of just names in results.newScripts and results.updatedScripts
- This allows groupScriptsByCategory to properly group scripts by their categories
- Notification will now show actual script names grouped by category instead of 'undefined synced, undefined up to date'
- Script objects contain name, slug, and categories fields needed for proper grouping
2025-10-24 22:02:56 +02:00
Michel Roegl-Brunner
b40f5b788c Fix script downloader placeholder files issue
- Delete stub scriptDownloader.js that contained placeholder implementation
- Implement real JavaScript script downloader with GitHub fetch functionality
- Fix incremental JSON sync to only process newly synced files
- Add proper error handling and file structure management
- Support all script types (ct/, tools/, vm/, vw/) with directory preservation
- Download install scripts for CT scripts
- Re-enable auto-sync service to use real implementation

Scripts now download real content from GitHub instead of placeholders.
2025-10-24 21:58:35 +02:00
Michel Roegl-Brunner
2c3fdf5544 Fix script card click issues and missing Install button
- Fix type signature mismatch in handleCardClick functions
  - Updated ScriptsGrid.tsx and DownloadedScriptsTab.tsx to accept ScriptCardType instead of { slug: string }
  - This resolves the modal not opening when clicking script cards

- Fix server-side import issues
  - Updated autoSyncService.js to import from githubJsonService.ts instead of .js
  - Fixed path aliases in githubJsonService.ts to use relative imports
  - Updated scripts.ts to import from TypeScript service files directly

- Fix missing Install button
  - Resolved scriptDownloaderService.checkScriptExists method not being available
  - Install button now appears when script files exist locally

- Remove debug logging
  - Cleaned up temporary console.log statements and debug UI elements

All script card interactions now work properly:
- Cards open detail modal when clicked
- Install button appears when appropriate
- Server-side API calls work correctly
2025-10-24 21:22:46 +02:00
github-actions[bot]
74dd29b87b chore: add VERSION v0.4.8 (#239)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-24 11:00:54 +00:00
Michel Roegl-Brunner
8a07fb781c Remove sync upstream JSONs step from publish_release.yml
Removed the sync upstream JSONs step from the workflow.
2025-10-24 12:59:53 +02:00
Michel Roegl-Brunner
79f17236db Merge pull request #238 from community-scripts/feat/auto_sync_lxc
feat: Add comprehensive auto-sync functionality
2025-10-24 12:58:56 +02:00
Michel Roegl-Brunner
83ab60ec2d Merge pull request #233 from community-scripts/dependabot/npm_and_yarn/tailwindcss-4.1.16
build(deps-dev): Bump tailwindcss from 4.1.15 to 4.1.16
2025-10-24 12:57:25 +02:00
Michel Roegl-Brunner
cd2b00b704 fix: Resolve linter errors in autoSyncService.js
- Added @ts-ignore comment for scriptDownloaderService.initializeConfig() call
- Added explicit JSDoc type annotations for forEach callback parameters
- Fixed 'implicitly has an any type' errors for catId and scriptName parameters
- All linter errors resolved while maintaining functionality

The categorization feature is now fully functional with clean, type-safe code.
2025-10-24 12:57:08 +02:00
Michel Roegl-Brunner
7817ce3d8e feat: Add script categorization to auto-sync notifications
- Added loadCategories() method to load category definitions from metadata.json
- Added groupScriptsByCategory() method to group scripts by their categories
- Modified scriptDownloaderService to return full script objects instead of just names
- Updated notification format to show scripts grouped by category with proper formatting
- Scripts are now displayed as:
  **Category Name:**
  • Script Name 1
  • Script Name 2

This provides much better organization in notifications, making it easier to
see what types of scripts were downloaded or updated.
2025-10-24 12:53:07 +02:00
Michel Roegl-Brunner
82b2012f50 fix: Import Buffer explicitly for ES module compatibility
- Added import { Buffer } from 'buffer' to githubJsonService.js
- Fixed 'require is not defined' error when using Buffer.from() in ES modules
- Auto-sync now works correctly through both direct execution and web API

The Buffer global is not available in ES module context, so it needs to be
explicitly imported. This fixes the sync errors that were occurring when
the auto-sync service was called through the web interface.
2025-10-24 12:48:22 +02:00
Michel Roegl-Brunner
bb4eb2964b fix: Remove CommonJS require() calls in ES module context
- Fixed 'require is not defined' error in githubJsonService.js
- Imported utimesSync from fs module instead of using require('fs').utimesSync
- Auto-sync now works without ES module errors

The JSON sync now completes successfully without any require() errors.
2025-10-24 12:46:50 +02:00
Michel Roegl-Brunner
a6a02a15fe fix: Auto-sync file detection and script downloader initialization
- Fixed statSync import in githubJsonService.js
- Added proper initialization of scriptDownloaderService before use
- Fixed local file detection - now correctly finds 411 local files instead of 0
- Auto-sync now properly shows 'Files to sync: 0, Up-to-date: 404' instead of downloading all
- Added debugging output to track file detection process

The auto-sync now correctly detects existing files and only syncs what's actually new or changed.
2025-10-24 12:43:50 +02:00
Michel Roegl-Brunner
bf5b602fd1 fix: Custom cron input and auto-sync rescheduling
- Fixed custom cron input field to be properly editable with autoFocus
- Added helpful cron examples and better validation feedback
- Fixed cron validation to work with 5-field expressions (node-cron format)
- Added auto-sync rescheduling when settings are saved via API route
- Improved user experience with better error handling and examples

The custom cron input now works properly and auto-sync will reschedule
immediately when settings are saved, including custom cron expressions.
2025-10-24 12:41:24 +02:00
Michel Roegl-Brunner
4d12a51b05 fix: Auto-sync now only downloads truly new scripts
- Fixed isScriptDownloaded logic to check ALL script files before considering a script downloaded
- Modified auto-sync to filter and only process scripts that haven't been downloaded before
- Added proper logging to show how many new scripts were found vs total scripts
- Made isScriptDownloaded method public in TypeScript version

This ensures auto-sync only downloads scripts that are actually new,
not re-downloading existing scripts or processing unchanged content.
2025-10-24 12:33:00 +02:00
Michel Roegl-Brunner
e0bea6c6e0 feat: Add comprehensive auto-sync functionality
 New Features:
- Auto-sync service with configurable intervals (15min, 30min, 1hour, 6hours, 12hours, 24hours, custom cron)
- Automatic JSON file synchronization from GitHub repositories
- Auto-download new scripts when JSON files are updated
- Auto-update existing scripts when newer versions are available
- Apprise notification service integration for sync status updates
- Comprehensive error handling and logging

🔧 Technical Implementation:
- AutoSyncService: Core scheduling and execution logic
- GitHubJsonService: Handles JSON file synchronization from GitHub
- AppriseService: Sends notifications via multiple channels (Discord, Telegram, Email, Slack, etc.)
- ScriptDownloaderService: Manages automatic script downloads and updates
- Settings API: RESTful endpoints for auto-sync configuration
- UI Integration: Settings modal with auto-sync configuration options

📋 Configuration Options:
- Enable/disable auto-sync functionality
- Flexible scheduling (predefined intervals or custom cron expressions)
- Selective script processing (new downloads, updates, or both)
- Notification settings with multiple Apprise URL support
- Environment-based configuration with .env file persistence

🎯 Benefits:
- Keeps script repository automatically synchronized
- Reduces manual maintenance overhead
- Provides real-time notifications of sync status
- Supports multiple notification channels
- Configurable to match different deployment needs

This feature significantly enhances the automation capabilities of PVE Scripts Local,
making it a truly hands-off solution for script management.
2025-10-24 12:28:44 +02:00
dependabot[bot]
2f1b738164 build(deps-dev): Bump tailwindcss from 4.1.15 to 4.1.16
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.1.15 to 4.1.16.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.16/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-24 10:28:06 +00:00
Michel Roegl-Brunner
3639dd231a Merge pull request #234 from community-scripts/dependabot/npm_and_yarn/prisma-6.18.0
build(deps-dev): Bump prisma from 6.17.1 to 6.18.0
2025-10-24 12:26:46 +02:00
Michel Roegl-Brunner
86f55069e6 Merge pull request #237 from community-scripts/update/tools.func
fix: Container ID display not showing after whiptail input and update tools.func
2025-10-24 09:21:51 +02:00
Michel Roegl-Brunner
5faa7f3646 remove debian 2025-10-24 09:20:00 +02:00
Michel Roegl-Brunner
bc52256301 fix: Container ID display not showing after whiptail input
- Move echo statement outside whiptail output capture block
- Fix output redirection interference with Container ID display
- Ensure Container ID is properly displayed regardless of user input
- Consolidate duplicate echo statements into single display
2025-10-24 09:19:34 +02:00
Michel Roegl-Brunner
9e66cdd7ea Cleanup 2025-10-24 08:51:05 +02:00
dependabot[bot]
931c9cedf1 build(deps-dev): Bump prisma from 6.17.1 to 6.18.0
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 6.17.1 to 6.18.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/6.18.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 6.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-23 19:26:58 +00:00
Michel Roegl-Brunner
83a51db265 Merge pull request #230 from community-scripts/dependabot/npm_and_yarn/eslint-config-next-16.0.0 2025-10-23 13:39:24 +02:00
Michel Roegl-Brunner
1b09d1494e Merge pull request #231 from community-scripts/dependabot/npm_and_yarn/superjson-2.2.3 2025-10-23 13:38:56 +02:00
dependabot[bot]
02fe842995 build(deps): Bump superjson from 2.2.2 to 2.2.3
Bumps [superjson](https://github.com/blitz-js/superjson) from 2.2.2 to 2.2.3.
- [Release notes](https://github.com/blitz-js/superjson/releases)
- [Commits](https://github.com/blitz-js/superjson/commits)

---
updated-dependencies:
- dependency-name: superjson
  dependency-version: 2.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-22 19:27:06 +00:00
dependabot[bot]
a464c845c2 build(deps-dev): Bump eslint-config-next from 15.5.6 to 16.0.0
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 15.5.6 to 16.0.0.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.0.0/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-22 19:27:00 +00:00
Michel Roegl-Brunner
5a69f58770 Merge pull request #226 from community-scripts/dependabot/npm_and_yarn/types/node-24.9.1 2025-10-21 21:54:35 +02:00
dependabot[bot]
48c243f624 build(deps-dev): Bump @types/node from 24.9.0 to 24.9.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.9.0 to 24.9.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.9.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 19:28:07 +00:00
github-actions[bot]
7af0717b1b chore: add VERSION v0.4.7 (#225)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-21 14:34:32 +00:00
Michel Roegl-Brunner
9977d390ac fix update.sh and env.example 2025-10-21 16:30:56 +02:00
Michel Roegl-Brunner
ea5e801718 fix update.sh and env.example 2025-10-21 15:46:58 +02:00
Michel Roegl-Brunner
7d54481f75 fix update.sh and env.example 2025-10-21 15:41:50 +02:00
Michel Roegl-Brunner
fbc6a9362e fix update.sh and env.example 2025-10-21 15:34:11 +02:00
Michel Roegl-Brunner
6b534474c4 Fix update.sh 2025-10-21 14:34:51 +02:00
Michel Roegl-Brunner
5bfbaca732 Fix update.sh 2025-10-21 14:33:24 +02:00
github-actions[bot]
a3d0141950 chore: add VERSION v0.4.6 (#220)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-21 11:21:33 +00:00
Michel Roegl-Brunner
529cb92e3c Fix Update 2025-10-21 13:16:14 +02:00
Michel Roegl-Brunner
b175c709f0 Fix update 2025-10-21 11:00:12 +02:00
Michel Roegl-Brunner
bf908eef66 Merge pull request #215 from community-scripts/dependabot/npm_and_yarn/npm_and_yarn-fd296dbd23 2025-10-21 07:09:55 +02:00
dependabot[bot]
4adf052db4 build(deps-dev): Bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.1.9 to 7.1.11
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.11
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 22:11:33 +00:00
dependabot[bot]
2158ea42ba build(deps-dev): Bump @tailwindcss/postcss from 4.1.14 to 4.1.15 (#214) 2025-10-20 23:16:58 +02:00
dependabot[bot]
5fc1205ca5 build(deps-dev): Bump tailwindcss from 4.1.14 to 4.1.15 (#213) 2025-10-20 23:16:45 +02:00
Michel Roegl-Brunner
d928058f97 Merge pull request #212 from community-scripts/dependabot/npm_and_yarn/types/node-24.9.0 2025-10-20 23:16:31 +02:00
dependabot[bot]
11e1704116 build(deps-dev): Bump jsdom from 27.0.0 to 27.0.1 (#211) 2025-10-20 23:16:19 +02:00
dependabot[bot]
26829ce355 build(deps-dev): Bump typescript-eslint from 8.46.1 to 8.46.2 (#210) 2025-10-20 23:16:06 +02:00
dependabot[bot]
deffba0969 build(deps-dev): Bump @types/node from 24.8.1 to 24.9.0
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.8.1 to 24.9.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 19:48:08 +00:00
82 changed files with 9532 additions and 7743 deletions

View File

@@ -26,4 +26,13 @@ AUTH_PASSWORD_HASH=
AUTH_ENABLED=false
AUTH_SETUP_COMPLETED=false
JWT_SECRET=
DATABASE_URL="file:./data/database.sqlite"
DATABASE_URL="file:/opt/ProxmoxVE-Local/data/settings.db"
AUTO_SYNC_ENABLED=false
SYNC_INTERVAL_TYPE=
SYNC_INTERVAL_PREDEFINED=
AUTO_DOWNLOAD_NEW=
AUTO_UPDATE_EXISTING=
NOTIFICATION_ENABLED=
APPRISE_URLS=
LAST_AUTO_SYNC=
SYNC_INTERVAL_CRON=

View File

@@ -46,35 +46,6 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "chore: add VERSION $version" --allow-empty
- name: Sync upstream JSONs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tmp_dir=$(mktemp -d)
api_url="https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
# Fetch file list (no subfolders)
curl -sSL -H "Authorization: token $GH_TOKEN" "$api_url" \
| jq -r '.[] | select(.type=="file") | .name' > "$tmp_dir/files.txt"
# Download each file
while IFS= read -r name; do
curl -sSL -H "Authorization: token $GH_TOKEN" \
"https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/frontend/public/json/$name" \
-o "$tmp_dir/$name"
done < "$tmp_dir/files.txt"
mkdir -p json
rsync -a --delete "$tmp_dir/" json/
# Stage and amend commit to include JSON updates (and VERSION)
git add json VERSION
if ! git diff --cached --quiet; then
git commit --amend --no-edit
fi
- name: Push changes
run: |
git push --force-with-lease --set-upstream origin "update-version-${{ steps.draft.outputs.tag_name }}"

3
.gitignore vendored
View File

@@ -37,6 +37,9 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
update.log
server.log
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env

View File

@@ -1 +1 @@
0.4.6
0.4.9

View File

@@ -1,44 +0,0 @@
{
"name": "Frigate",
"slug": "frigate",
"categories": [
15
],
"date_created": "2024-05-02",
"type": "ct",
"updateable": false,
"privileged": true,
"interface_port": 5000,
"documentation": "https://docs.frigate.video/",
"website": "https://frigate.video/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/frigate.webp",
"config_path": "",
"description": "Frigate is an open source NVR built around real-time AI object detection. All processing is performed locally on your own hardware, and your camera feeds never leave your home.",
"install_methods": [
{
"type": "default",
"script": "ct/frigate.sh",
"resources": {
"cpu": 4,
"ram": 4096,
"hdd": 20,
"os": "debian",
"version": "11"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Discussions (explore more advanced methods): `https://github.com/tteck/Proxmox/discussions/2711`",
"type": "info"
},
{
"text": "go2rtc Interface port:`1984`",
"type": "info"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "13"
"version": "12"
}
},
{

View File

@@ -12,7 +12,7 @@
"documentation": "https://docs.openarchiver.com/",
"config_path": "/opt/openarchiver/.env",
"website": "https://openarchiver.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/OpenArchiver.webp",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/open-archiver.webp",
"description": "Open Archiver is a secure, self-hosted email archiving solution, and it's completely open source. Get an email archiver that enables full-text search across email and attachments. Create a permanent, searchable, and compliant mail archive from Google Workspace, Microsoft 35, and any IMAP server.",
"install_methods": [
{

View File

@@ -19,8 +19,8 @@
"type": "default",
"script": "ct/paperless-ai.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"cpu": 4,
"ram": 4096,
"hdd": 20,
"os": "debian",
"version": "13"

View File

@@ -1,29 +1,164 @@
[
{
"name": "apache/cassandra",
"version": "5.0.6-tentative",
"date": "2025-10-21T11:42:35Z"
},
{
"name": "evcc-io/evcc",
"version": "0.209.3",
"date": "2025-10-21T10:53:07Z"
},
{
"name": "grafana/grafana",
"version": "v11.5.10",
"date": "2025-10-21T10:46:47Z"
},
{
"name": "zabbix/zabbix",
"version": "7.0.20rc1",
"date": "2025-10-21T09:14:14Z"
},
{
"name": "n8n-io/n8n",
"version": "n8n@1.115.3",
"date": "2025-10-14T14:40:17Z"
},
{
"name": "VictoriaMetrics/VictoriaMetrics",
"version": "pmm-6401-v1.128.0",
"date": "2025-10-21T08:30:52Z"
},
{
"name": "crowdsecurity/crowdsec",
"version": "v1.7.1",
"date": "2025-10-15T10:44:03Z"
},
{
"name": "meilisearch/meilisearch",
"version": "prototype-v1.24.0.ignore-embedding-failures-2",
"date": "2025-10-21T07:36:22Z"
},
{
"name": "Jackett/Jackett",
"version": "v0.24.166",
"date": "2025-10-21T05:54:33Z"
},
{
"name": "openobserve/openobserve",
"version": "v0.16.0-rc1",
"date": "2025-10-21T00:37:47Z"
},
{
"name": "jeedom/core",
"version": "4.4.20",
"date": "2025-10-21T00:27:05Z"
},
{
"name": "steveiliop56/tinyauth",
"version": "v4.0.1",
"date": "2025-10-15T16:53:55Z"
},
{
"name": "documenso/documenso",
"version": "v1.13.0",
"date": "2025-10-21T00:21:04Z"
},
{
"name": "goauthentik/authentik",
"version": "version/2025.10.0-rc2",
"date": "2025-10-21T00:19:36Z"
},
{
"name": "TwiN/gatus",
"version": "v5.27.1",
"date": "2025-10-21T00:01:36Z"
},
{
"name": "henrygd/beszel",
"version": "v0.14.1",
"date": "2025-10-20T22:10:56Z"
},
{
"name": "keycloak/keycloak",
"version": "26.4.1",
"date": "2025-10-16T07:21:53Z"
},
{
"name": "booklore-app/booklore",
"version": "v1.8.1",
"date": "2025-10-20T20:53:56Z"
},
{
"name": "coder/code-server",
"version": "v4.105.1",
"date": "2025-10-20T20:19:23Z"
},
{
"name": "pymedusa/Medusa",
"version": "v1.0.23",
"date": "2025-10-20T19:51:33Z"
},
{
"name": "chrisbenincasa/tunarr",
"version": "v0.23.0-alpha.16",
"date": "2025-10-20T19:29:20Z"
},
{
"name": "MediaBrowser/Emby.Releases",
"version": "4.9.1.80",
"date": "2025-09-30T20:25:16Z"
},
{
"name": "kyantech/Palmr",
"version": "v3.2.4-beta",
"date": "2025-10-20T17:58:55Z"
},
{
"name": "tailscale/tailscale",
"version": "v1.91.0-pre",
"date": "2025-10-20T16:18:51Z"
},
{
"name": "louislam/uptime-kuma",
"version": "2.0.1",
"date": "2025-10-20T16:11:12Z"
},
{
"name": "msgbyte/tianji",
"version": "v1.29.2",
"date": "2025-10-20T16:07:20Z"
},
{
"name": "chrisvel/tududi",
"version": "v0.84.1",
"date": "2025-10-20T15:32:24Z"
},
{
"name": "rclone/rclone",
"version": "v1.71.2",
"date": "2025-10-20T15:25:52Z"
},
{
"name": "Graylog2/graylog2-server",
"version": "7.0.0-rc.1",
"date": "2025-10-20T11:53:31Z"
},
{
"name": "Kareadita/Kavita",
"version": "v0.8.8",
"date": "2025-10-20T11:26:24Z"
},
{
"name": "wizarrrr/wizarr",
"version": "v2025.10.4",
"date": "2025-10-20T10:45:54Z"
},
{
"name": "apache/cassandra",
"version": "cassandra-4.0.19",
"date": "2025-10-20T09:08:49Z"
},
{
"name": "jupyter/notebook",
"version": "@jupyter-notebook/ui-components@7.5.0-beta.1",
"date": "2025-10-20T07:01:38Z"
},
{
"name": "Jackett/Jackett",
"version": "v0.24.159",
"date": "2025-10-20T05:53:23Z"
},
{
"name": "inventree/InvenTree",
"version": "1.0.7",
@@ -49,16 +184,6 @@
"version": "v1.0.0-beta27",
"date": "2025-10-20T00:38:13Z"
},
{
"name": "jeedom/core",
"version": "4.4.20",
"date": "2025-10-20T00:27:05Z"
},
{
"name": "steveiliop56/tinyauth",
"version": "v4.0.1",
"date": "2025-10-15T16:53:55Z"
},
{
"name": "seriousm4x/UpSnap",
"version": "5.2.3",
@@ -99,11 +224,6 @@
"version": "v2.14.5.4836",
"date": "2025-10-08T15:30:50Z"
},
{
"name": "henrygd/beszel",
"version": "v0.14.0",
"date": "2025-10-18T23:54:15Z"
},
{
"name": "BerriAI/litellm",
"version": "v1.78.5.rc.1",
@@ -124,11 +244,6 @@
"version": "v2025-10-18",
"date": "2025-10-18T20:35:54Z"
},
{
"name": "chrisvel/tududi",
"version": "v0.84",
"date": "2025-10-18T19:39:04Z"
},
{
"name": "moghtech/komodo",
"version": "v1.19.5",
@@ -154,21 +269,6 @@
"version": "v0.9.0",
"date": "2025-10-18T17:03:56Z"
},
{
"name": "booklore-app/booklore",
"version": "v1.8.0",
"date": "2025-10-18T16:22:25Z"
},
{
"name": "chrisbenincasa/tunarr",
"version": "v0.23.0-alpha.14",
"date": "2025-10-18T15:43:53Z"
},
{
"name": "msgbyte/tianji",
"version": "v1.29.1",
"date": "2025-10-18T13:14:21Z"
},
{
"name": "TasmoAdmin/TasmoAdmin",
"version": "v4.3.2",
@@ -199,11 +299,6 @@
"version": "v25.4",
"date": "2025-10-09T10:27:01Z"
},
{
"name": "TwiN/gatus",
"version": "v5.27.0",
"date": "2025-10-18T02:44:26Z"
},
{
"name": "9001/copyparty",
"version": "v1.19.17",
@@ -219,26 +314,11 @@
"version": "2025.10.3",
"date": "2025-10-17T21:15:07Z"
},
{
"name": "coder/code-server",
"version": "v4.105.0",
"date": "2025-10-17T19:55:55Z"
},
{
"name": "MediaBrowser/Emby.Releases",
"version": "4.9.1.80",
"date": "2025-09-30T20:25:16Z"
},
{
"name": "forgejo/forgejo",
"version": "v13.0.1",
"date": "2025-10-17T18:54:16Z"
},
{
"name": "keycloak/keycloak",
"version": "26.4.1",
"date": "2025-10-16T07:21:53Z"
},
{
"name": "grokability/snipe-it",
"version": "v8.3.4",
@@ -254,6 +334,11 @@
"version": "v2.40.1",
"date": "2025-10-17T13:42:04Z"
},
{
"name": "neo4j/neo4j",
"version": "5.26.14",
"date": "2025-10-17T12:38:22Z"
},
{
"name": "mattermost/mattermost",
"version": "server/public/v0.1.21",
@@ -369,11 +454,6 @@
"version": "v2.13.1",
"date": "2025-10-15T13:29:37Z"
},
{
"name": "meilisearch/meilisearch",
"version": "prototype-docker-alpine-3-22-v8",
"date": "2025-10-15T13:20:20Z"
},
{
"name": "TandoorRecipes/recipes",
"version": "2.3.3",
@@ -384,11 +464,6 @@
"version": "jenkins-2.528.1",
"date": "2025-10-15T12:51:20Z"
},
{
"name": "Graylog2/graylog2-server",
"version": "7.0.0-beta.5",
"date": "2025-10-15T11:43:16Z"
},
{
"name": "cockpit-project/cockpit",
"version": "349",
@@ -399,11 +474,6 @@
"version": "v0.14.1",
"date": "2024-08-29T22:32:51Z"
},
{
"name": "openobserve/openobserve",
"version": "v0.15.2",
"date": "2025-10-15T07:42:29Z"
},
{
"name": "wavelog/wavelog",
"version": "2.1.2",
@@ -419,21 +489,11 @@
"version": "2025.10.0",
"date": "2025-10-14T19:07:37Z"
},
{
"name": "evcc-io/evcc",
"version": "0.209.2",
"date": "2025-10-14T18:55:44Z"
},
{
"name": "crafty-controller/crafty-4",
"version": "v4.5.5",
"date": "2025-10-14T18:48:36Z"
},
{
"name": "tailscale/tailscale",
"version": "v1.88.4",
"date": "2025-10-14T17:57:52Z"
},
{
"name": "plankanban/planka",
"version": "planka-1.1.0",
@@ -449,11 +509,6 @@
"version": "v1.140.0",
"date": "2025-10-14T15:57:12Z"
},
{
"name": "n8n-io/n8n",
"version": "n8n@1.115.3",
"date": "2025-10-14T14:40:17Z"
},
{
"name": "rogerfar/rdt-client",
"version": "v2.0.119",
@@ -584,11 +639,6 @@
"version": "11.0.1",
"date": "2025-10-09T12:34:15Z"
},
{
"name": "documenso/documenso",
"version": "v1.12.10",
"date": "2025-10-09T04:32:35Z"
},
{
"name": "rabbitmq/rabbitmq-server",
"version": "v4.1.4",
@@ -639,11 +689,6 @@
"version": "v0.15.1",
"date": "2025-10-07T20:30:56Z"
},
{
"name": "VictoriaMetrics/VictoriaMetrics",
"version": "pmm-6401-v1.127.0",
"date": "2025-10-07T14:31:32Z"
},
{
"name": "thecfu/scraparr",
"version": "v2.2.5",
@@ -714,11 +759,6 @@
"version": "8.2.2",
"date": "2025-10-03T06:22:38Z"
},
{
"name": "kyantech/Palmr",
"version": "v3.2.3-beta",
"date": "2025-10-02T13:48:14Z"
},
{
"name": "actualbudget/actual",
"version": "v25.10.0",
@@ -739,11 +779,6 @@
"version": "v5.41.4",
"date": "2025-09-30T22:26:11Z"
},
{
"name": "zabbix/zabbix",
"version": "7.4.3",
"date": "2025-09-30T21:49:53Z"
},
{
"name": "mongodb/mongo",
"version": "r8.2.1",
@@ -764,21 +799,11 @@
"version": "v1.7.4",
"date": "2025-09-30T13:34:30Z"
},
{
"name": "neo4j/neo4j",
"version": "4.4.46",
"date": "2025-09-30T13:21:24Z"
},
{
"name": "thomiceli/opengist",
"version": "v1.11.1",
"date": "2025-09-30T00:24:16Z"
},
{
"name": "goauthentik/authentik",
"version": "version/2025.8.4",
"date": "2025-09-30T00:03:11Z"
},
{
"name": "influxdata/telegraf",
"version": "v1.36.2",
@@ -849,11 +874,6 @@
"version": "1.2.39",
"date": "2025-09-25T15:57:02Z"
},
{
"name": "rclone/rclone",
"version": "v1.71.1",
"date": "2025-09-24T16:32:16Z"
},
{
"name": "alexta69/metube",
"version": "2025.09.24",
@@ -869,11 +889,6 @@
"version": "v2.0.10",
"date": "2025-09-24T08:33:37Z"
},
{
"name": "grafana/grafana",
"version": "v12.2.0",
"date": "2025-09-23T23:47:02Z"
},
{
"name": "postgres/postgres",
"version": "REL_18_0",
@@ -1014,11 +1029,6 @@
"version": "0.6",
"date": "2025-09-05T06:05:04Z"
},
{
"name": "louislam/uptime-kuma",
"version": "2.0.0-beta.2-temp",
"date": "2025-03-28T08:45:58Z"
},
{
"name": "healthchecks/healthchecks",
"version": "v3.11.2",
@@ -1224,11 +1234,6 @@
"version": "250707-d28b3101e",
"date": "2025-07-07T15:15:21Z"
},
{
"name": "Kareadita/Kavita",
"version": "v0.8.7",
"date": "2025-07-05T20:08:58Z"
},
{
"name": "qbittorrent/qBittorrent",
"version": "release-5.1.2",
@@ -1479,11 +1484,6 @@
"version": "v0.7.3",
"date": "2024-12-15T10:18:06Z"
},
{
"name": "pymedusa/Medusa",
"version": "v1.0.22",
"date": "2024-12-13T12:22:19Z"
},
{
"name": "phpipam/phpipam",
"version": "v1.7.3",

908
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,12 +36,16 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.9",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cron-validator": "^1.2.0",
"dotenv": "^17.2.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.546.0",
"next": "^15.5.6",
"node-cron": "^3.0.3",
"node-pty": "^1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -51,36 +55,37 @@
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"strip-ansi": "^7.1.2",
"superjson": "^2.2.1",
"superjson": "^2.2.3",
"tailwind-merge": "^3.3.1",
"ws": "^8.18.3",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@tailwindcss/postcss": "^4.1.15",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.8",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.8.1",
"@types/node": "^24.9.1",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
"eslint-config-next": "^15.5.6",
"jsdom": "^27.0.0",
"eslint-config-next": "^16.0.0",
"jsdom": "^27.0.1",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^6.17.1",
"tailwindcss": "^4.1.14",
"prisma": "^6.18.0",
"tailwindcss": "^4.1.16",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.1",
"typescript-eslint": "^8.46.2",
"vitest": "^3.2.4"
},
"ct3aMetadata": {
@@ -90,4 +95,4 @@
"overrides": {
"prismjs": "^1.30.0"
}
}
}

View File

@@ -439,17 +439,14 @@ advanced_settings() {
exit_script
fi
done
if CT_ID=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Container ID" 8 58 "$NEXTID" --title "CONTAINER ID" 3>&1 1>&2 2>&3); then
if [ -z "$CT_ID" ]; then
CT_ID="$NEXTID"
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
else
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
fi
else
exit_script
fi
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
while true; do
if CT_NAME=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Hostname" 8 58 "$NSAPP" --title "HOSTNAME" 3>&1 1>&2 2>&3); then

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"documentation": "https://docs.bunkerweb.io/latest/",
"website": "https://www.bunkerweb.io/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/bunkerweb.webp",
"config_path": "/opt/bunkerweb/variables.env",
"config_path": "/etc/bunkerweb/variables.env",
"description": "BunkerWeb is a security-focused web server that enhances web application protection. It guards against common web vulnerabilities like SQL injection, XSS, and CSRF. It features simple setup and configuration using a YAML file, customizable security rules, and provides detailed logs for traffic monitoring and threat detection.",
"install_methods": [
{

48
scripts/json/execute.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "PVE LXC Execute Command",
"slug": "lxc-execute",
"categories": [
1
],
"date_created": "2025-09-18",
"type": "pve",
"updateable": false,
"privileged": false,
"interface_port": null,
"documentation": null,
"website": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/proxmox.webp",
"config_path": "",
"description": "This script allows administrators to execute a custom command inside one or multiple LXC containers on a Proxmox VE node. Containers can be selectively excluded via an interactive checklist. If a container is stopped, the script will automatically start it, run the command, and then shut it down again. Only Debian and Ubuntu based containers are supported.",
"install_methods": [
{
"type": "default",
"script": "tools/pve/execute.sh",
"resources": {
"cpu": null,
"ram": null,
"hdd": null,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Execute within the Proxmox shell.",
"type": "info"
},
{
"text": "Non-Debian/Ubuntu containers will be skipped automatically.",
"type": "info"
},
{
"text": "Stopped containers will be started temporarily to run the command, then shut down again.",
"type": "warning"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 10,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -12,7 +12,7 @@
"documentation": "https://github.com/HydroshieldMKII/Guardian/blob/main/README.md",
"config_path": "/opt/guardian/.env",
"website": "https://github.com/HydroshieldMKII/Guardian",
"logo": null,
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/guardian-plex.webp",
"description": "Guardian is a lightweight companion app for Plex that lets you monitor, approve or block devices in real time. It helps you enforce per-user or global policies, stop unwanted sessions automatically and grant temporary access - all through a simple web interface.",
"install_methods": [
{

View File

@@ -21,7 +21,7 @@
"resources": {
"cpu": 2,
"ram": 2048,
"hdd": 8,
"hdd": 16,
"os": "ubuntu",
"version": "24.04"
}

40
scripts/json/jotty.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "jotty",
"slug": "jotty",
"categories": [
12
],
"date_created": "2025-10-21",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3000,
"documentation": "https://github.com/fccview/jotty/blob/main/README.md",
"website": "https://github.com/fccview/jotty",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/jotty.webp",
"config_path": "/opt/jotty/.env",
"description": "A simple, self-hosted app for your checklists and notes. Tired of bloated, cloud-based to-do apps? jotty is a lightweight alternative for managing your personal checklists and notes. It's built with Next.js 14, is easy to deploy, and keeps all your data on your own server.",
"install_methods": [
{
"type": "default",
"script": "ct/jotty.sh",
"resources": {
"cpu": 2,
"ram": 3072,
"hdd": 6,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "jotty was previously named rwMarkable",
"type": "info"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -1,186 +1,186 @@
{
"categories": [
{
"name": "Proxmox & Virtualization",
"id": 1,
"sort_order": 1.0,
"description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively.",
"icon": "server"
},
{
"name": "Operating Systems",
"id": 2,
"sort_order": 2.0,
"description": "Scripts for deploying and managing various operating systems.",
"icon": "monitor"
},
{
"name": "Containers & Docker",
"id": 3,
"sort_order": 3.0,
"description": "Solutions for containerization using Docker and related technologies.",
"icon": "box"
},
{
"name": "Network & Firewall",
"id": 4,
"sort_order": 4.0,
"description": "Enhance network security and configure firewalls with ease.",
"icon": "shield"
},
{
"name": "Adblock & DNS",
"id": 5,
"sort_order": 5.0,
"description": "Optimize your network with DNS and ad-blocking solutions.",
"icon": "ban"
},
{
"name": "Authentication & Security",
"id": 6,
"sort_order": 6.0,
"description": "Secure your infrastructure with authentication and security tools.",
"icon": "lock"
},
{
"name": "Backup & Recovery",
"id": 7,
"sort_order": 7.0,
"description": "Reliable backup and recovery scripts to protect your data.",
"icon": "archive"
},
{
"name": "Databases",
"id": 8,
"sort_order": 8.0,
"description": "Deploy and manage robust database systems with ease.",
"icon": "database"
},
{
"name": "Monitoring & Analytics",
"id": 9,
"sort_order": 9.0,
"description": "Monitor system performance and analyze data seamlessly.",
"icon": "bar-chart"
},
{
"name": "Dashboards & Frontends",
"id": 10,
"sort_order": 10.0,
"description": "Create interactive dashboards and user-friendly frontends.",
"icon": "layout"
},
{
"name": "Files & Downloads",
"id": 11,
"sort_order": 11.0,
"description": "Manage file sharing and downloading solutions efficiently.",
"icon": "download"
},
{
"name": "Documents & Notes",
"id": 12,
"sort_order": 12.0,
"description": "Organize and manage documents and note-taking tools.",
"icon": "file-text"
},
{
"name": "Media & Streaming",
"id": 13,
"sort_order": 13.0,
"description": "Stream and manage media effortlessly across devices.",
"icon": "play"
},
{
"name": "*Arr Suite",
"id": 14,
"sort_order": 14.0,
"description": "Automated media management with the popular *Arr suite tools.",
"icon": "tv"
},
{
"name": "NVR & Cameras",
"id": 15,
"sort_order": 15.0,
"description": "Manage network video recorders and camera setups.",
"icon": "camera"
},
{
"name": "IoT & Smart Home",
"id": 16,
"sort_order": 16.0,
"description": "Control and automate IoT devices and smart home systems.",
"icon": "home"
},
{
"name": "ZigBee, Z-Wave & Matter",
"id": 17,
"sort_order": 17.0,
"description": "Solutions for ZigBee, Z-Wave, and Matter-based device management.",
"icon": "radio"
},
{
"name": "MQTT & Messaging",
"id": 18,
"sort_order": 18.0,
"description": "Set up reliable messaging and MQTT-based communication systems.",
"icon": "message-circle"
},
{
"name": "Automation & Scheduling",
"id": 19,
"sort_order": 19.0,
"description": "Automate tasks and manage scheduling with powerful tools.",
"icon": "clock"
},
{
"name": "AI / Coding & Dev-Tools",
"id": 20,
"sort_order": 20.0,
"description": "Leverage AI and developer tools for smarter coding workflows.",
"icon": "code"
},
{
"name": "Webservers & Proxies",
"id": 21,
"sort_order": 21.0,
"description": "Deploy and configure web servers and proxy solutions.",
"icon": "globe"
},
{
"name": "Bots & ChatOps",
"id": 22,
"sort_order": 22.0,
"description": "Enhance collaboration with bots and ChatOps integrations.",
"icon": "bot"
},
{
"name": "Finance & Budgeting",
"id": 23,
"sort_order": 23.0,
"description": "Track expenses and manage budgets efficiently.",
"icon": "dollar-sign"
},
{
"name": "Gaming & Leisure",
"id": 24,
"sort_order": 24.0,
"description": "Scripts for gaming servers and leisure-related tools.",
"icon": "gamepad-2"
},
{
"name": "Business & ERP",
"id": 25,
"sort_order": 25.0,
"description": "Streamline business operations with ERP and management tools.",
"icon": "building"
},
{
"name": "Miscellaneous",
"id": 0,
"sort_order": 99.0,
"description": "General scripts and tools that don't fit into other categories.",
"icon": "more-horizontal"
}
]
}
"categories": [
{
"name": "Proxmox & Virtualization",
"id": 1,
"sort_order": 1.0,
"description": "Tools and scripts to manage Proxmox VE and virtualization platforms effectively.",
"icon": "server"
},
{
"name": "Operating Systems",
"id": 2,
"sort_order": 2.0,
"description": "Scripts for deploying and managing various operating systems.",
"icon": "monitor"
},
{
"name": "Containers & Docker",
"id": 3,
"sort_order": 3.0,
"description": "Solutions for containerization using Docker and related technologies.",
"icon": "box"
},
{
"name": "Network & Firewall",
"id": 4,
"sort_order": 4.0,
"description": "Enhance network security and configure firewalls with ease.",
"icon": "shield"
},
{
"name": "Adblock & DNS",
"id": 5,
"sort_order": 5.0,
"description": "Optimize your network with DNS and ad-blocking solutions.",
"icon": "ban"
},
{
"name": "Authentication & Security",
"id": 6,
"sort_order": 6.0,
"description": "Secure your infrastructure with authentication and security tools.",
"icon": "lock"
},
{
"name": "Backup & Recovery",
"id": 7,
"sort_order": 7.0,
"description": "Reliable backup and recovery scripts to protect your data.",
"icon": "archive"
},
{
"name": "Databases",
"id": 8,
"sort_order": 8.0,
"description": "Deploy and manage robust database systems with ease.",
"icon": "database"
},
{
"name": "Monitoring & Analytics",
"id": 9,
"sort_order": 9.0,
"description": "Monitor system performance and analyze data seamlessly.",
"icon": "bar-chart"
},
{
"name": "Dashboards & Frontends",
"id": 10,
"sort_order": 10.0,
"description": "Create interactive dashboards and user-friendly frontends.",
"icon": "layout"
},
{
"name": "Files & Downloads",
"id": 11,
"sort_order": 11.0,
"description": "Manage file sharing and downloading solutions efficiently.",
"icon": "download"
},
{
"name": "Documents & Notes",
"id": 12,
"sort_order": 12.0,
"description": "Organize and manage documents and note-taking tools.",
"icon": "file-text"
},
{
"name": "Media & Streaming",
"id": 13,
"sort_order": 13.0,
"description": "Stream and manage media effortlessly across devices.",
"icon": "play"
},
{
"name": "*Arr Suite",
"id": 14,
"sort_order": 14.0,
"description": "Automated media management with the popular *Arr suite tools.",
"icon": "tv"
},
{
"name": "NVR & Cameras",
"id": 15,
"sort_order": 15.0,
"description": "Manage network video recorders and camera setups.",
"icon": "camera"
},
{
"name": "IoT & Smart Home",
"id": 16,
"sort_order": 16.0,
"description": "Control and automate IoT devices and smart home systems.",
"icon": "home"
},
{
"name": "ZigBee, Z-Wave & Matter",
"id": 17,
"sort_order": 17.0,
"description": "Solutions for ZigBee, Z-Wave, and Matter-based device management.",
"icon": "radio"
},
{
"name": "MQTT & Messaging",
"id": 18,
"sort_order": 18.0,
"description": "Set up reliable messaging and MQTT-based communication systems.",
"icon": "message-circle"
},
{
"name": "Automation & Scheduling",
"id": 19,
"sort_order": 19.0,
"description": "Automate tasks and manage scheduling with powerful tools.",
"icon": "clock"
},
{
"name": "AI / Coding & Dev-Tools",
"id": 20,
"sort_order": 20.0,
"description": "Leverage AI and developer tools for smarter coding workflows.",
"icon": "code"
},
{
"name": "Webservers & Proxies",
"id": 21,
"sort_order": 21.0,
"description": "Deploy and configure web servers and proxy solutions.",
"icon": "globe"
},
{
"name": "Bots & ChatOps",
"id": 22,
"sort_order": 22.0,
"description": "Enhance collaboration with bots and ChatOps integrations.",
"icon": "bot"
},
{
"name": "Finance & Budgeting",
"id": 23,
"sort_order": 23.0,
"description": "Track expenses and manage budgets efficiently.",
"icon": "dollar-sign"
},
{
"name": "Gaming & Leisure",
"id": 24,
"sort_order": 24.0,
"description": "Scripts for gaming servers and leisure-related tools.",
"icon": "gamepad-2"
},
{
"name": "Business & ERP",
"id": 25,
"sort_order": 25.0,
"description": "Streamline business operations with ERP and management tools.",
"icon": "building"
},
{
"name": "Miscellaneous",
"id": 0,
"sort_order": 99.0,
"description": "General scripts and tools that don't fit into other categories.",
"icon": "more-horizontal"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 8,
"os": "debian",
"version": "13"
"version": "12"
}
},
{

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 6,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -23,7 +23,7 @@
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -0,0 +1,40 @@
{
"name": "Open-Archiver",
"slug": "open-archiver",
"categories": [
7
],
"date_created": "2025-10-18",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 3000,
"documentation": "https://docs.openarchiver.com/",
"config_path": "/opt/openarchiver/.env",
"website": "https://openarchiver.com/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/open-archiver.webp",
"description": "Open Archiver is a secure, self-hosted email archiving solution, and it's completely open source. Get an email archiver that enables full-text search across email and attachments. Create a permanent, searchable, and compliant mail archive from Google Workspace, Microsoft 35, and any IMAP server.",
"install_methods": [
{
"type": "default",
"script": "ct/open-archiver.sh",
"resources": {
"cpu": 2,
"ram": 3072,
"hdd": 8,
"os": "debian",
"version": "13"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "Data directory is: `/opt/openarchiver-data`. If you have a lot of email, you might consider mounting external storage to this directory.",
"type": "info"
}
]
}

View File

@@ -23,7 +23,7 @@
"ram": 8192,
"hdd": 25,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -19,8 +19,8 @@
"type": "default",
"script": "ct/paperless-ai.sh",
"resources": {
"cpu": 2,
"ram": 2048,
"cpu": 4,
"ram": 4096,
"hdd": 20,
"os": "debian",
"version": "13"

View File

@@ -23,7 +23,7 @@
"ram": 1024,
"hdd": 4,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

View File

@@ -23,7 +23,7 @@
"ram": 512,
"hdd": 2,
"os": "debian",
"version": "13"
"version": "12"
}
}
],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"ram": 2048,
"hdd": 5,
"os": "Debian",
"version": "12"
"version": "13"
}
}
],

View File

@@ -8,6 +8,11 @@ import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database-prisma.js';
import { initializeAutoSync, setupGracefulShutdown } from './src/server/lib/autoSyncInit.js';
import dotenv from 'dotenv';
// Load environment variables from .env file
dotenv.config();
// Fallback minimal global error handlers for Node runtime (avoid TS import)
function registerGlobalErrorHandlers() {
if (registerGlobalErrorHandlers._registered) return;
@@ -976,5 +981,11 @@ app.prepare().then(() => {
.listen(port, hostname, () => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server running on ws://${hostname}:${port}/ws/script-execution`);
// Initialize auto-sync service
initializeAutoSync();
// Setup graceful shutdown handlers
setupGracefulShutdown();
});
});

View File

@@ -1,27 +1,21 @@
"use client";
'use client';
import { useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { useAuth } from "./AuthProvider";
import { Lock, User, AlertCircle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { useAuth } from './AuthProvider';
import { Lock, User, AlertCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface AuthModalProps {
isOpen: boolean;
}
export function AuthModal({ isOpen }: AuthModalProps) {
const { t } = useTranslation("authModal");
useRegisterModal(isOpen, {
id: "auth-modal",
allowEscape: false,
onClose: () => null,
});
useRegisterModal(isOpen, { id: 'auth-modal', allowEscape: false, onClose: () => null });
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -31,49 +25,44 @@ export function AuthModal({ isOpen }: AuthModalProps) {
setError(null);
const success = await login(username, password);
if (!success) {
setError(t("error"));
setError('Invalid username or password');
}
setIsLoading(false);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3">
<Lock className="text-primary h-8 w-8" />
<h2 className="text-card-foreground text-2xl font-bold">
{t("title")}
</h2>
<Lock className="h-8 w-8 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">Authentication Required</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-muted-foreground mb-6 text-center">
{t("description")}
<p className="text-muted-foreground text-center mb-6">
Please enter your credentials to access the application.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="username"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("username.label")}
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
Username
</label>
<div className="relative">
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder={t("username.placeholder")}
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
@@ -84,18 +73,15 @@ export function AuthModal({ isOpen }: AuthModalProps) {
</div>
<div>
<label
htmlFor="password"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("password.label")}
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
Password
</label>
<div className="relative">
<Lock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder={t("password.placeholder")}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
@@ -106,7 +92,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
</div>
{error && (
<div className="bg-error/10 text-error-foreground border-error/20 flex items-center gap-2 rounded-md border p-3">
<div className="flex items-center gap-2 p-3 bg-error/10 text-error-foreground border border-error/20 rounded-md">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
@@ -117,7 +103,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
disabled={isLoading || !username.trim() || !password.trim()}
className="w-full"
>
{isLoading ? t("actions.signingIn") : t("actions.signIn")}
{isLoading ? 'Signing In...' : 'Sign In'}
</Button>
</form>
</div>

View File

@@ -1,108 +1,93 @@
"use client";
'use client';
import React from "react";
import { useTranslation } from "@/lib/i18n/useTranslation";
import React from 'react';
interface BadgeProps {
variant:
| "type"
| "updateable"
| "privileged"
| "status"
| "note"
| "execution-mode";
variant: 'type' | 'updateable' | 'privileged' | 'status' | 'note' | 'execution-mode';
type?: string;
noteType?: "info" | "warning" | "error";
status?: "success" | "failed" | "in_progress";
executionMode?: "local" | "ssh";
noteType?: 'info' | 'warning' | 'error';
status?: 'success' | 'failed' | 'in_progress';
executionMode?: 'local' | 'ssh';
children: React.ReactNode;
className?: string;
}
export function Badge({
variant,
type,
noteType,
status,
executionMode,
children,
className = "",
}: BadgeProps) {
export function Badge({ variant, type, noteType, status, executionMode, children, className = '' }: BadgeProps) {
const getTypeStyles = (scriptType: string) => {
switch (scriptType.toLowerCase()) {
case "ct":
return "bg-primary/10 text-primary border-primary/20";
case "addon":
return "bg-primary/10 text-primary border-primary/20";
case "vm":
return "bg-success/10 text-success border-success/20";
case "pve":
return "bg-warning/10 text-warning border-warning/20";
case 'ct':
return 'bg-primary/10 text-primary border-primary/20';
case 'addon':
return 'bg-primary/10 text-primary border-primary/20';
case 'vm':
return 'bg-success/10 text-success border-success/20';
case 'pve':
return 'bg-warning/10 text-warning border-warning/20';
default:
return "bg-muted text-muted-foreground border-border";
return 'bg-muted text-muted-foreground border-border';
}
};
const getVariantStyles = () => {
switch (variant) {
case "type":
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles("unknown")}`;
case "updateable":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20";
case "privileged":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20";
case "status":
case 'type':
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`;
case 'updateable':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20';
case 'privileged':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
case 'status':
switch (status) {
case "success":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20";
case "failed":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error border border-error/20";
case "in_progress":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20";
case 'success':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20';
case 'failed':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error border border-error/20';
case 'in_progress':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20';
default:
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border";
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
}
case "execution-mode":
case 'execution-mode':
switch (executionMode) {
case "local":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20";
case "ssh":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20";
case 'local':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
case 'ssh':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
default:
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border";
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
}
case "note":
case 'note':
switch (noteType) {
case "warning":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20";
case "error":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20";
case 'warning':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20';
case 'error':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
default:
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20";
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
}
default:
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border";
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
}
};
// Format the text for type badges
const formatText = () => {
if (variant === "type" && type) {
if (variant === 'type' && type) {
switch (type.toLowerCase()) {
case "ct":
return "LXC";
case "addon":
return "ADDON";
case "vm":
return "VM";
case "pve":
return "PVE";
case 'ct':
return 'LXC';
case 'addon':
return 'ADDON';
case 'vm':
return 'VM';
case 'pve':
return 'PVE';
default:
return type.toUpperCase();
}
@@ -111,79 +96,45 @@ export function Badge({
};
return (
<span className={`${getVariantStyles()} ${className}`}>{formatText()}</span>
<span className={`${getVariantStyles()} ${className}`}>
{formatText()}
</span>
);
}
// Convenience components for common use cases
export const TypeBadge = ({
type,
className,
}: {
type: string;
className?: string;
}) => (
export const TypeBadge = ({ type, className }: { type: string; className?: string }) => (
<Badge variant="type" type={type} className={className}>
{type}
</Badge>
);
export const UpdateableBadge = ({ className }: { className?: string }) => {
const { t } = useTranslation("badge");
return (
<Badge variant="updateable" className={className}>
{t("updateable")}
</Badge>
);
};
export const UpdateableBadge = ({ className }: { className?: string }) => (
<Badge variant="updateable" className={className}>
Updateable
</Badge>
);
export const PrivilegedBadge = ({ className }: { className?: string }) => {
const { t } = useTranslation("badge");
return (
<Badge variant="privileged" className={className}>
{t("privileged")}
</Badge>
);
};
export const PrivilegedBadge = ({ className }: { className?: string }) => (
<Badge variant="privileged" className={className}>
Privileged
</Badge>
);
export const StatusBadge = ({
status,
children,
className,
}: {
status: "success" | "failed" | "in_progress";
children: React.ReactNode;
className?: string;
}) => (
export const StatusBadge = ({ status, children, className }: { status: 'success' | 'failed' | 'in_progress'; children: React.ReactNode; className?: string }) => (
<Badge variant="status" status={status} className={className}>
{children}
</Badge>
);
export const ExecutionModeBadge = ({
mode,
children,
className,
}: {
mode: "local" | "ssh";
children: React.ReactNode;
className?: string;
}) => (
export const ExecutionModeBadge = ({ mode, children, className }: { mode: 'local' | 'ssh'; children: React.ReactNode; className?: string }) => (
<Badge variant="execution-mode" executionMode={mode} className={className}>
{children}
</Badge>
);
export const NoteBadge = ({
noteType,
children,
className,
}: {
noteType: "info" | "warning" | "error";
children: React.ReactNode;
className?: string;
}) => (
export const NoteBadge = ({ noteType, children, className }: { noteType: 'info' | 'warning' | 'error'; children: React.ReactNode; className?: string }) => (
<Badge variant="note" noteType={noteType} className={className}>
{children}
</Badge>
);
);

View File

@@ -1,8 +1,7 @@
"use client";
'use client';
import { useState } from "react";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { useState } from 'react';
import { ContextualHelpIcon } from './ContextualHelpIcon';
interface CategorySidebarProps {
categories: string[];
@@ -13,509 +12,218 @@ interface CategorySidebarProps {
}
// Icon mapping for categories
const CategoryIcon = ({
iconName,
className = "w-5 h-5",
}: {
iconName: string;
className?: string;
}) => {
const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; className?: string }) => {
const iconMap: Record<string, React.ReactElement> = {
server: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
),
monitor: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
box: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
shield: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
"shield-check": (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
key: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z" />
</svg>
),
archive: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
),
database: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
),
"chart-bar": (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
template: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
),
"folder-open": (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
),
"document-text": (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
film: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m0 0V1.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V4m-3 0H9m3 0v16a1 1 0 01-1 1H8a1 1 0 01-1-1V4m6 0h2a2 2 0 012 2v12a2 2 0 01-2 2h-2V4z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m0 0V1.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V4m-3 0H9m3 0v16a1 1 0 01-1 1H8a1 1 0 01-1-1V4m6 0h2a2 2 0 012 2v12a2 2 0 01-2 2h-2V4z" />
</svg>
),
download: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
"video-camera": (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
),
home: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
wifi: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
),
"chat-alt": (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
clock: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
code: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
"external-link": (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
),
sparkles: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
),
"currency-dollar": (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
),
puzzle: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
),
office: (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
),
};
return (
iconMap[iconName] ?? (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z"
/>
</svg>
)
return iconMap[iconName] ?? (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z" />
</svg>
);
};
export function CategorySidebar({
categories,
categoryCounts,
totalScripts,
selectedCategory,
onCategorySelect,
export function CategorySidebar({
categories,
categoryCounts,
totalScripts,
selectedCategory,
onCategorySelect
}: CategorySidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const { t } = useTranslation("categorySidebar");
const formatCategoryLabel = (category: string) => {
const defaultLabel = category.replace(/[_-]/g, " ");
return t(`categories.${category}`, { fallback: defaultLabel });
};
const formatCategoryTooltip = (categoryLabel: string, count: number) =>
t("tooltips.category", { values: { category: categoryLabel, count } });
// Category to icon mapping (based on metadata.json)
const categoryIconMapping: Record<string, string> = {
"Proxmox & Virtualization": "server",
"Operating Systems": "monitor",
"Containers & Docker": "box",
"Network & Firewall": "shield",
"Adblock & DNS": "shield-check",
"Authentication & Security": "key",
"Backup & Recovery": "archive",
Databases: "database",
"Monitoring & Analytics": "chart-bar",
"Dashboards & Frontends": "template",
"Files & Downloads": "folder-open",
"Documents & Notes": "document-text",
"Media & Streaming": "film",
"*Arr Suite": "download",
"NVR & Cameras": "video-camera",
"IoT & Smart Home": "home",
"ZigBee, Z-Wave & Matter": "wifi",
"MQTT & Messaging": "chat-alt",
"Automation & Scheduling": "clock",
"AI / Coding & Dev-Tools": "code",
"Webservers & Proxies": "external-link",
"Bots & ChatOps": "sparkles",
"Finance & Budgeting": "currency-dollar",
"Gaming & Leisure": "puzzle",
"Business & ERP": "office",
Miscellaneous: "box",
'Proxmox & Virtualization': 'server',
'Operating Systems': 'monitor',
'Containers & Docker': 'box',
'Network & Firewall': 'shield',
'Adblock & DNS': 'shield-check',
'Authentication & Security': 'key',
'Backup & Recovery': 'archive',
'Databases': 'database',
'Monitoring & Analytics': 'chart-bar',
'Dashboards & Frontends': 'template',
'Files & Downloads': 'folder-open',
'Documents & Notes': 'document-text',
'Media & Streaming': 'film',
'*Arr Suite': 'download',
'NVR & Cameras': 'video-camera',
'IoT & Smart Home': 'home',
'ZigBee, Z-Wave & Matter': 'wifi',
'MQTT & Messaging': 'chat-alt',
'Automation & Scheduling': 'clock',
'AI / Coding & Dev-Tools': 'code',
'Webservers & Proxies': 'external-link',
'Bots & ChatOps': 'sparkles',
'Finance & Budgeting': 'currency-dollar',
'Gaming & Leisure': 'puzzle',
'Business & ERP': 'office',
'Miscellaneous': 'box'
};
// Sort categories by count (descending) and then alphabetically
const sortedCategories = categories
.map((category) => [category, categoryCounts[category] ?? 0] as const)
.map(category => [category, categoryCounts[category] ?? 0] as const)
.sort(([a, countA], [b, countB]) => {
if (countB !== countA) return countB - countA;
return a.localeCompare(b);
});
return (
<div
className={`bg-card border-border rounded-lg border shadow-md transition-all duration-300 ${
isCollapsed ? "w-16" : "w-full lg:w-80"
}`}
>
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-full lg:w-80'
}`}>
{/* Header */}
<div className="border-border flex items-center justify-between border-b p-4">
<div className="flex items-center justify-between p-4 border-b border-border">
{!isCollapsed && (
<div className="flex w-full items-center justify-between">
<div className="flex items-center justify-between w-full">
<div>
<h3 className="text-foreground text-lg font-semibold">
{t("headerTitle")}
</h3>
<p className="text-muted-foreground text-sm">
{t("totalScripts", { values: { count: totalScripts } })}
</p>
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
</div>
<ContextualHelpIcon
section="available-scripts"
tooltip={t("helpTooltip")}
/>
<ContextualHelpIcon section="available-scripts" tooltip="Help with categories" />
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="hover:bg-muted rounded-lg p-2 transition-colors"
title={isCollapsed ? t("actions.expand") : t("actions.collapse")}
className="p-2 rounded-lg hover:bg-muted transition-colors"
title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
>
<svg
className={`text-muted-foreground h-5 w-5 transition-transform ${
isCollapsed ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
<svg
className={`w-5 h-5 text-muted-foreground transition-transform ${
isCollapsed ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
@@ -527,26 +235,24 @@ export function CategorySidebar({
{/* "All Categories" option */}
<button
onClick={() => onCategorySelect(null)}
className={`flex w-full items-center justify-between rounded-lg p-3 text-left transition-colors ${
selectedCategory === null
? "bg-primary/10 text-primary border-primary/20 border"
: "hover:bg-accent text-muted-foreground"
}`}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
selectedCategory === null
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
>
<div className="flex items-center space-x-3">
<CategoryIcon
iconName="template"
className={`h-5 w-5 ${selectedCategory === null ? "text-primary" : "text-muted-foreground"}`}
<CategoryIcon
iconName="template"
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground'}`}
/>
<span className="font-medium">{t("all.label")}</span>
<span className="font-medium">All Categories</span>
</div>
<span
className={`rounded-full px-2 py-1 text-sm ${
selectedCategory === null
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
<span className={`text-sm px-2 py-1 rounded-full ${
selectedCategory === null
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{totalScripts}
</span>
</button>
@@ -554,32 +260,31 @@ export function CategorySidebar({
{/* Individual Categories */}
{sortedCategories.map(([category, count]) => {
const isSelected = selectedCategory === category;
const categoryLabel = formatCategoryLabel(category);
return (
<button
key={category}
onClick={() => onCategorySelect(category)}
className={`flex w-full items-center justify-between rounded-lg p-3 text-left transition-colors ${
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
isSelected
? "bg-primary/10 text-primary border-primary/20 border"
: "hover:bg-accent text-muted-foreground"
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
>
<div className="flex items-center space-x-3">
<CategoryIcon
iconName={categoryIconMapping[category] ?? "box"}
className={`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
<CategoryIcon
iconName={categoryIconMapping[category] ?? 'box'}
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`}
/>
<span className="font-medium">{categoryLabel}</span>
<span className="font-medium capitalize">
{category.replace(/[_-]/g, ' ')}
</span>
</div>
<span
className={`rounded-full px-2 py-1 text-sm ${
isSelected
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
<span className={`text-sm px-2 py-1 rounded-full ${
isSelected
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{count}
</span>
</button>
@@ -591,71 +296,66 @@ export function CategorySidebar({
{/* Collapsed state - show only icons with counters and tooltips */}
{isCollapsed && (
<div className="flex flex-row space-x-2 overflow-x-auto p-2 lg:flex-col lg:space-y-2 lg:space-x-0 lg:overflow-x-visible">
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
{/* "All Categories" option */}
<div className="group relative">
<button
onClick={() => onCategorySelect(null)}
className={`relative flex h-12 w-12 flex-col items-center justify-center rounded-lg transition-colors ${
selectedCategory === null
? "bg-primary/10 text-primary border-primary/20 border"
: "hover:bg-accent text-muted-foreground"
}`}
>
<CategoryIcon
iconName="template"
className={`h-5 w-5 ${selectedCategory === null ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}
/>
<span
className={`mt-1 rounded px-1 text-xs ${
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
selectedCategory === null
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
>
>
<CategoryIcon
iconName="template"
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className={`text-xs mt-1 px-1 rounded ${
selectedCategory === null
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{totalScripts}
</span>
</button>
{/* Tooltip */}
<div className="bg-popover text-popover-foreground pointer-events-none absolute top-1/2 left-full z-50 ml-2 hidden -translate-y-1/2 transform rounded px-2 py-1 text-sm whitespace-nowrap opacity-0 transition-opacity group-hover:opacity-100 lg:block">
{t("all.tooltip", { values: { count: totalScripts } })}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-popover text-popover-foreground text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
All Categories ({totalScripts})
</div>
</div>
{/* Individual Categories */}
{sortedCategories.map(([category, count]) => {
const isSelected = selectedCategory === category;
const categoryLabel = formatCategoryLabel(category);
return (
<div key={category} className="group relative">
<button
onClick={() => onCategorySelect(category)}
className={`relative flex h-12 w-12 flex-col items-center justify-center rounded-lg transition-colors ${
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
isSelected
? "bg-primary/10 text-primary border-primary/20 border"
: "hover:bg-accent text-muted-foreground"
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
>
<CategoryIcon
iconName={categoryIconMapping[category] ?? "box"}
className={`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}
<CategoryIcon
iconName={categoryIconMapping[category] ?? 'box'}
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span
className={`mt-1 rounded px-1 text-xs ${
isSelected
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
<span className={`text-xs mt-1 px-1 rounded ${
isSelected
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
{count}
</span>
</button>
{/* Tooltip */}
<div className="bg-popover text-popover-foreground pointer-events-none absolute top-1/2 left-full z-50 ml-2 hidden -translate-y-1/2 transform rounded px-2 py-1 text-sm whitespace-nowrap opacity-0 transition-opacity group-hover:opacity-100 lg:block">
{formatCategoryTooltip(categoryLabel, count)}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-popover text-popover-foreground text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
{category} ({count})
</div>
</div>
);
@@ -664,4 +364,4 @@ export function CategorySidebar({
)}
</div>
);
}
}

View File

@@ -1,10 +1,9 @@
"use client";
'use client';
import { useMemo, useState } from "react";
import { Button } from "./ui/button";
import { AlertTriangle, Info } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { useMemo, useState } from 'react';
import { Button } from './ui/button';
import { AlertTriangle, Info } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface ConfirmationModalProps {
isOpen: boolean;
@@ -12,7 +11,7 @@ interface ConfirmationModalProps {
onConfirm: () => void;
title: string;
message: string;
variant: "simple" | "danger";
variant: 'simple' | 'danger';
confirmText?: string; // What the user must type for danger variant
confirmButtonText?: string;
cancelButtonText?: string;
@@ -26,20 +25,14 @@ export function ConfirmationModal({
message,
variant,
confirmText,
confirmButtonText,
cancelButtonText,
confirmButtonText = 'Confirm',
cancelButtonText = 'Cancel'
}: ConfirmationModalProps) {
const { t } = useTranslation("confirmationModal");
const { t: tc } = useTranslation("common.actions");
const [typedText, setTypedText] = useState("");
const isDanger = variant === "danger";
const [typedText, setTypedText] = useState('');
const isDanger = variant === 'danger';
const allowEscape = useMemo(() => !isDanger, [isDanger]);
// Use provided button texts or fallback to translations
const finalConfirmText = confirmButtonText ?? tc("confirm");
const finalCancelText = cancelButtonText ?? tc("cancel");
useRegisterModal(isOpen, { id: "confirmation-modal", allowEscape, onClose });
useRegisterModal(isOpen, { id: 'confirmation-modal', allowEscape, onClose });
if (!isOpen) return null;
const isConfirmEnabled = isDanger ? typedText === confirmText : true;
@@ -47,74 +40,62 @@ export function ConfirmationModal({
const handleConfirm = () => {
if (isConfirmEnabled) {
onConfirm();
setTypedText(""); // Reset for next time
setTypedText(''); // Reset for next time
}
};
const handleClose = () => {
onClose();
setTypedText(""); // Reset when closing
setTypedText(''); // Reset when closing
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3">
{isDanger ? (
<AlertTriangle className="text-error h-8 w-8" />
<AlertTriangle className="h-8 w-8 text-error" />
) : (
<Info className="text-info h-8 w-8" />
<Info className="h-8 w-8 text-info" />
)}
<h2 className="text-card-foreground text-2xl font-bold">{title}</h2>
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-muted-foreground mb-6 text-sm">{message}</p>
<p className="text-sm text-muted-foreground mb-6">
{message}
</p>
{/* Type-to-confirm input for danger variant */}
{isDanger && confirmText && (
<div className="mb-6">
<label className="text-foreground mb-2 block text-sm font-medium">
{
t("typeToConfirm", { values: { text: confirmText } }).split(
confirmText,
)[0]
}
<code className="bg-muted rounded px-2 py-1 text-sm">
{confirmText}
</code>
{
t("typeToConfirm", { values: { text: confirmText } }).split(
confirmText,
)[1]
}
<label className="block text-sm font-medium text-foreground mb-2">
Type <code className="bg-muted px-2 py-1 rounded text-sm">{confirmText}</code> to confirm:
</label>
<input
type="text"
value={typedText}
onChange={(e) => setTypedText(e.target.value)}
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 focus:ring-2 focus:outline-none"
placeholder={t("placeholder", {
values: { text: confirmText },
})}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder={`Type "${confirmText}" here`}
autoComplete="off"
/>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col justify-end gap-3 sm:flex-row">
<div className="flex flex-col sm:flex-row justify-end gap-3">
<Button
onClick={handleClose}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
{finalCancelText}
{cancelButtonText}
</Button>
<Button
onClick={handleConfirm}
@@ -123,7 +104,7 @@ export function ConfirmationModal({
size="default"
className="w-full sm:w-auto"
>
{finalConfirmText}
{confirmButtonText}
</Button>
</div>
</div>

View File

@@ -356,7 +356,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
}
}, [selectedCategory]);
const handleCardClick = (scriptCard: { slug: string }) => {
const handleCardClick = (scriptCard: ScriptCardType) => {
// All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug);
setIsModalOpen(true);

View File

@@ -1,10 +1,9 @@
"use client";
'use client';
import { useEffect } from "react";
import { Button } from "./ui/button";
import { AlertCircle, CheckCircle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { useEffect } from 'react';
import { Button } from './ui/button';
import { AlertCircle, CheckCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface ErrorModalProps {
isOpen: boolean;
@@ -12,7 +11,7 @@ interface ErrorModalProps {
title: string;
message: string;
details?: string;
type?: "error" | "success";
type?: 'error' | 'success';
}
export function ErrorModal({
@@ -21,11 +20,9 @@ export function ErrorModal({
title,
message,
details,
type = "error",
type = 'error'
}: ErrorModalProps) {
const { t } = useTranslation("errorModal");
const { t: tc } = useTranslation("common.actions");
useRegisterModal(isOpen, { id: "error-modal", allowEscape: true, onClose });
useRegisterModal(isOpen, { id: 'error-modal', allowEscape: true, onClose });
// Auto-close after 10 seconds
useEffect(() => {
if (isOpen) {
@@ -39,47 +36,41 @@ export function ErrorModal({
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-lg rounded-lg border shadow-xl">
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-lg w-full border border-border">
{/* Header */}
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3">
{type === "success" ? (
<CheckCircle className="text-success h-8 w-8" />
{type === 'success' ? (
<CheckCircle className="h-8 w-8 text-success" />
) : (
<AlertCircle className="text-error h-8 w-8" />
<AlertCircle className="h-8 w-8 text-error" />
)}
<h2 className="text-foreground text-xl font-semibold">{title}</h2>
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-foreground mb-4 text-sm">{message}</p>
<p className="text-sm text-foreground mb-4">{message}</p>
{details && (
<div
className={`rounded-lg p-3 ${
type === "success"
? "bg-success/10 border-success/20 border"
: "bg-error/10 border-error/20 border"
}`}
>
<p
className={`mb-1 text-xs font-medium ${
type === "success"
? "text-success-foreground"
: "text-error-foreground"
}`}
>
{type === "success"
? t("detailsLabel")
: t("errorDetailsLabel")}
<div className={`rounded-lg p-3 ${
type === 'success'
? 'bg-success/10 border border-success/20'
: 'bg-error/10 border border-error/20'
}`}>
<p className={`text-xs font-medium mb-1 ${
type === 'success'
? 'text-success-foreground'
: 'text-error-foreground'
}`}>
{type === 'success' ? 'Details:' : 'Error Details:'}
</p>
<pre
className={`text-xs break-words whitespace-pre-wrap ${
type === "success" ? "text-success/80" : "text-error/80"
}`}
>
<pre className={`text-xs whitespace-pre-wrap break-words ${
type === 'success'
? 'text-success/80'
: 'text-error/80'
}`}>
{details}
</pre>
</div>
@@ -87,9 +78,9 @@ export function ErrorModal({
</div>
{/* Footer */}
<div className="border-border flex justify-end gap-3 border-t p-6">
<div className="flex justify-end gap-3 p-6 border-t border-border">
<Button variant="outline" onClick={onClose}>
{tc("close")}
Close
</Button>
</div>
</div>

View File

@@ -1,38 +1,41 @@
"use client";
'use client';
import { useState, useEffect } from 'react';
import type { Server } from '../../types/server';
import { Button } from './ui/button';
import { ColorCodedDropdown } from './ColorCodedDropdown';
import { SettingsModal } from './SettingsModal';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useState, useEffect } from "react";
import type { Server } from "../../types/server";
import { Button } from "./ui/button";
import { ColorCodedDropdown } from "./ColorCodedDropdown";
import { SettingsModal } from "./SettingsModal";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface ExecutionModeModalProps {
isOpen: boolean;
onClose: () => void;
onExecute: (mode: "local" | "ssh", server?: Server) => void;
onExecute: (mode: 'local' | 'ssh', server?: Server) => void;
scriptName: string;
}
export function ExecutionModeModal({
isOpen,
onClose,
onExecute,
scriptName,
}: ExecutionModeModalProps) {
const { t } = useTranslation("executionModeModal");
useRegisterModal(isOpen, {
id: "execution-mode-modal",
allowEscape: true,
onClose,
});
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) {
useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
useEffect(() => {
if (isOpen) {
void fetchServers();
}
}, [isOpen]);
// Auto-select server when exactly one server is available
useEffect(() => {
if (isOpen && !loading && servers.length === 1) {
setSelectedServer(servers[0] ?? null);
}
}, [isOpen, loading, servers]);
// Refresh servers when settings modal closes
const handleSettingsModalClose = () => {
setSettingsModalOpen(false);
@@ -44,78 +47,56 @@ export function ExecutionModeModal({
setLoading(true);
setError(null);
try {
const response = await fetch("/api/servers");
const response = await fetch('/api/servers');
if (!response.ok) {
throw new Error(t("errors.fetchFailed"));
throw new Error('Failed to fetch servers');
}
const data = await response.json();
// Sort servers by name alphabetically
const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? "").localeCompare(b.name ?? ""),
const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '')
);
setServers(sortedServers);
} catch (err) {
setError(err instanceof Error ? err.message : t("errors.fetchFailed"));
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
void fetchServers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Auto-select server when exactly one server is available
useEffect(() => {
if (isOpen && !loading && servers.length === 1) {
setSelectedServer(servers[0] ?? null);
}
}, [isOpen, loading, servers]);
const handleExecute = () => {
if (!selectedServer) {
setError(t("errors.noServerSelected"));
setError('Please select a server for SSH execution');
return;
}
onExecute("ssh", selectedServer);
onExecute('ssh', selectedServer);
onClose();
};
const handleServerSelect = (server: Server | null) => {
setSelectedServer(server);
};
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="border-border flex items-center justify-between border-b p-6">
<h2 className="text-foreground text-xl font-bold">{t("title")}</h2>
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Select Server</h2>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
@@ -123,72 +104,60 @@ export function ExecutionModeModal({
{/* Content */}
<div className="p-6">
{error && (
<div className="bg-destructive/10 border-destructive/20 mb-4 rounded-md border p-3">
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="text-destructive h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-destructive text-sm">{error}</p>
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
</div>
)}
{loading ? (
<div className="py-8 text-center">
<div className="border-primary inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground mt-2 text-sm">
{t("loadingServers")}
</p>
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
</div>
) : servers.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
<p className="text-sm">{t("noServersConfigured")}</p>
<p className="mt-1 text-xs">{t("addServersHint")}</p>
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No servers configured</p>
<p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
<Button
onClick={() => setSettingsModalOpen(true)}
variant="outline"
size="sm"
className="mt-3"
>
{t("openServerSettings")}
Open Server Settings
</Button>
</div>
) : servers.length === 1 ? (
/* Single Server Confirmation View */
<div className="space-y-6">
<div className="text-center">
<h3 className="text-foreground mb-2 text-lg font-medium">
{t("installConfirmation.title")}
<h3 className="text-lg font-medium text-foreground mb-2">
Install Script Confirmation
</h3>
<p className="text-muted-foreground text-sm">
{t("installConfirmation.description", {
values: { scriptName },
})}
<p className="text-sm text-muted-foreground">
Do you want to install &quot;{scriptName}&quot; on the following server?
</p>
</div>
<div className="bg-muted/50 border-border rounded-lg border p-4">
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="bg-success h-3 w-3 rounded-full"></div>
<div className="w-3 h-3 bg-success rounded-full"></div>
</div>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{selectedServer?.name ?? t("unnamedServer")}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{selectedServer?.name ?? 'Unnamed Server'}
</p>
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
{selectedServer?.ip}
</p>
</div>
@@ -197,15 +166,19 @@ export function ExecutionModeModal({
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button onClick={onClose} variant="outline" size="default">
{t("actions.cancel")}
<Button
onClick={onClose}
variant="outline"
size="default"
>
Cancel
</Button>
<Button
onClick={handleExecute}
variant="default"
size="default"
>
{t("actions.install")}
Install
</Button>
</div>
</div>
@@ -213,44 +186,41 @@ export function ExecutionModeModal({
/* Multiple Servers Selection View */
<div className="space-y-6">
<div className="mb-6">
<h3 className="text-foreground mb-2 text-lg font-medium">
{t("multipleServers.title", { values: { scriptName } })}
<h3 className="text-lg font-medium text-foreground mb-2">
Select server to execute &quot;{scriptName}&quot;
</h3>
</div>
{/* Server Selection */}
<div className="mb-6">
<label
htmlFor="server"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("multipleServers.selectServerLabel")}
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
Select Server
</label>
<ColorCodedDropdown
servers={servers}
selectedServer={selectedServer}
onServerSelect={handleServerSelect}
placeholder={t("multipleServers.placeholder")}
placeholder="Select a server..."
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button onClick={onClose} variant="outline" size="default">
{t("actions.cancel")}
<Button
onClick={onClose}
variant="outline"
size="default"
>
Cancel
</Button>
<Button
onClick={handleExecute}
disabled={!selectedServer}
variant="default"
size="default"
className={
!selectedServer
? "bg-muted-foreground cursor-not-allowed"
: ""
}
className={!selectedServer ? 'bg-muted-foreground cursor-not-allowed' : ''}
>
{t("actions.runOnServer")}
Run on Server
</Button>
</div>
</div>
@@ -260,9 +230,9 @@ export function ExecutionModeModal({
</div>
{/* Server Settings Modal */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={handleSettingsModalClose}
<SettingsModal
isOpen={settingsModalOpen}
onClose={handleSettingsModalClose}
/>
</>
);

View File

@@ -1,19 +1,9 @@
"use client";
import { useState } from "react";
import React, { useState } from "react";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import {
Package,
Monitor,
Wrench,
Server,
FileText,
Calendar,
RefreshCw,
Filter,
} from "lucide-react";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
export interface FilterState {
searchQuery: string;
@@ -34,10 +24,10 @@ interface FilterBarProps {
}
const SCRIPT_TYPES = [
{ value: "ct", labelKey: "types.options.ct", Icon: Package },
{ value: "vm", labelKey: "types.options.vm", Icon: Monitor },
{ value: "addon", labelKey: "types.options.addon", Icon: Wrench },
{ value: "pve", labelKey: "types.options.pve", Icon: Server },
{ value: "ct", label: "LXC Container", Icon: Package },
{ value: "vm", label: "Virtual Machine", Icon: Monitor },
{ value: "addon", label: "Add-on", Icon: Wrench },
{ value: "pve", label: "PVE Host", Icon: Server },
];
export function FilterBar({
@@ -49,7 +39,6 @@ export function FilterBar({
saveFiltersEnabled = false,
isLoadingFilters = false,
}: FilterBarProps) {
const { t } = useTranslation("filterBar");
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
@@ -75,55 +64,50 @@ export function FilterBar({
filters.sortOrder !== "asc";
const getUpdatableButtonText = () => {
if (filters.showUpdatable === null) return t("updatable.all");
if (filters.showUpdatable === true) {
return t("updatable.yes", { values: { count: updatableCount } });
}
return t("updatable.no");
if (filters.showUpdatable === null) return "Updatable: All";
if (filters.showUpdatable === true)
return `Updatable: Yes (${updatableCount})`;
return "Updatable: No";
};
const getTypeButtonText = () => {
if (filters.selectedTypes.length === 0) return t("types.all");
if (filters.selectedTypes.length === 0) return "All Types";
if (filters.selectedTypes.length === 1) {
const type = SCRIPT_TYPES.find(
(t) => t.value === filters.selectedTypes[0],
);
return type ? t(type.labelKey) : filters.selectedTypes[0];
return type?.label ?? filters.selectedTypes[0];
}
return t("types.multiple", {
values: { count: filters.selectedTypes.length },
});
return `${filters.selectedTypes.length} Types`;
};
return (
<div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
{/* Loading State */}
{isLoadingFilters && (
<div className="mb-4 flex items-center justify-center py-2">
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
<span>{t("loading")}</span>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span>Loading saved filters...</span>
</div>
</div>
)}
{/* Filter Header */}
{!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between">
<h3 className="text-foreground text-lg font-medium">{t("header")}</h3>
<ContextualHelpIcon
section="available-scripts"
tooltip={t("helpTooltip")}
/>
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
</div>
)}
{/* Search Bar */}
<div className="mb-4">
<div className="relative w-full max-w-md">
<div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="text-muted-foreground h-5 w-5"
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -138,17 +122,17 @@ export function FilterBar({
</div>
<input
type="text"
placeholder={t("search.placeholder")}
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="border-input bg-background text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-primary block w-full rounded-lg border py-3 pr-10 pl-10 text-sm leading-5 focus:ring-2 focus:outline-none"
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<Button
onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 pr-3"
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
>
<svg
className="h-5 w-5"
@@ -169,7 +153,7 @@ export function FilterBar({
</div>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3">
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
@@ -183,12 +167,12 @@ export function FilterBar({
}}
variant="outline"
size="default"
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true
? "border-success/20 bg-success/10 text-success border"
: "border-destructive/20 bg-destructive/10 text-destructive border"
? "border border-success/20 bg-success/10 text-success"
: "border border-destructive/20 bg-destructive/10 text-destructive"
}`}
>
<RefreshCw className="h-4 w-4" />
@@ -201,10 +185,10 @@ export function FilterBar({
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline"
size="default"
className={`flex w-full items-center justify-center space-x-2 ${
className={`w-full flex items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "border-primary/20 bg-primary/10 text-primary border"
: "border border-primary/20 bg-primary/10 text-primary"
}`}
>
<Filter className="h-4 w-4" />
@@ -225,14 +209,14 @@ export function FilterBar({
</Button>
{isTypeDropdownOpen && (
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border shadow-lg">
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2">
{SCRIPT_TYPES.map((type) => {
const IconComponent = type.Icon;
return (
<label
key={type.value}
className="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2"
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
>
<input
type="checkbox"
@@ -253,17 +237,17 @@ export function FilterBar({
});
}
}}
className="border-input text-primary focus:ring-primary rounded"
className="rounded border-input text-primary focus:ring-primary"
/>
<IconComponent className="h-4 w-4" />
<span className="text-muted-foreground text-sm">
{t(type.labelKey)}
<span className="text-sm text-muted-foreground">
{type.label}
</span>
</label>
);
})}
</div>
<div className="border-border border-t p-2">
<div className="border-t border-border p-2">
<Button
onClick={() => {
updateFilters({ selectedTypes: [] });
@@ -271,9 +255,9 @@ export function FilterBar({
}}
variant="ghost"
size="sm"
className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start"
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
>
{t("actions.clearAllTypes")}
Clear all
</Button>
</div>
</div>
@@ -286,18 +270,14 @@ export function FilterBar({
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
variant="outline"
size="default"
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-2 sm:w-auto"
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{filters.sortBy === "name" ? (
<FileText className="h-4 w-4" />
) : (
<Calendar className="h-4 w-4" />
)}
<span>
{filters.sortBy === "name"
? t("sort.byName")
: t("sort.byCreated")}
</span>
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
<svg
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
fill="none"
@@ -314,35 +294,31 @@ export function FilterBar({
</Button>
{isSortDropdownOpen && (
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-full rounded-lg border shadow-lg sm:w-48">
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2">
<button
onClick={() => {
updateFilters({ sortBy: "name" });
setIsSortDropdownOpen(false);
}}
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
filters.sortBy === "name"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
}`}
>
<FileText className="h-4 w-4" />
<span className="text-sm">{t("sort.byName")}</span>
<span className="text-sm">By Name</span>
</button>
<button
onClick={() => {
updateFilters({ sortBy: "created" });
setIsSortDropdownOpen(false);
}}
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
filters.sortBy === "created"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
}`}
>
<Calendar className="h-4 w-4" />
<span className="text-sm">{t("sort.byCreated")}</span>
<span className="text-sm">By Created Date</span>
</button>
</div>
</div>
@@ -358,7 +334,7 @@ export function FilterBar({
}
variant="outline"
size="default"
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-1 sm:w-auto"
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{filters.sortOrder === "asc" ? (
<>
@@ -376,9 +352,7 @@ export function FilterBar({
/>
</svg>
<span>
{filters.sortBy === "created"
? t("sort.oldestFirst")
: t("sort.aToZ")}
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
</span>
</>
) : (
@@ -397,9 +371,7 @@ export function FilterBar({
/>
</svg>
<span>
{filters.sortBy === "created"
? t("sort.newestFirst")
: t("sort.zToA")}
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
</span>
</>
)}
@@ -407,38 +379,30 @@ export function FilterBar({
</div>
{/* Filter Summary and Clear All */}
<div className="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex items-center gap-4">
<div className="text-muted-foreground text-sm">
<div className="text-sm text-muted-foreground">
{filteredCount === totalScripts ? (
<span>
{t("summary.showingAll", { values: { count: totalScripts } })}
</span>
<span>Showing all {totalScripts} scripts</span>
) : (
<span>
{t("summary.showingFiltered", {
values: { filtered: filteredCount, total: totalScripts },
})}{" "}
{filteredCount} of {totalScripts} scripts{" "}
{hasActiveFilters && (
<span className="text-info font-medium">
{t("summary.filteredSuffix")}
<span className="font-medium text-info">
(filtered)
</span>
)}
</span>
)}
</div>
{/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && (
<div className="text-success flex items-center space-x-1 text-xs">
<div className="flex items-center space-x-1 text-xs text-success">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>{t("persistence.enabled")}</span>
<span>Filters are being saved automatically</span>
</div>
)}
</div>
@@ -448,7 +412,7 @@ export function FilterBar({
onClick={clearAllFilters}
variant="ghost"
size="sm"
className="text-error hover:bg-error/10 hover:text-error-foreground flex w-full items-center justify-center space-x-1 sm:w-auto sm:justify-start"
className="flex items-center space-x-1 text-error hover:bg-error/10 hover:text-error-foreground w-full sm:w-auto justify-center sm:justify-start"
>
<svg
className="h-4 w-4"
@@ -463,7 +427,7 @@ export function FilterBar({
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span>{t("actions.clearFilters")}</span>
<span>Clear all filters</span>
</Button>
)}
</div>

View File

@@ -1,9 +1,8 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Button } from "./ui/button";
import { ExternalLink, FileText } from "lucide-react";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { ExternalLink, FileText } from 'lucide-react';
interface FooterProps {
onOpenReleaseNotes: () => void;
@@ -11,43 +10,41 @@ interface FooterProps {
export function Footer({ onOpenReleaseNotes }: FooterProps) {
const { data: versionData } = api.version.getCurrentVersion.useQuery();
const { t } = useTranslation("footer");
const currentYear = new Date().getFullYear();
return (
<footer className="border-border bg-muted/30 sticky bottom-0 mt-auto border-t py-3 backdrop-blur-sm">
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-3 backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="text-muted-foreground flex flex-col items-center justify-between gap-2 text-sm sm:flex-row">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t("copyright", { values: { year: currentYear } })}</span>
<span>© 2024 PVE Scripts Local</span>
{versionData?.success && versionData.version && (
<Button
variant="ghost"
size="sm"
onClick={onOpenReleaseNotes}
className="hover:text-foreground h-auto p-1 text-xs"
className="h-auto p-1 text-xs hover:text-foreground"
>
v{versionData.version}
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onOpenReleaseNotes}
className="hover:text-foreground h-auto p-2 text-xs"
className="h-auto p-2 text-xs hover:text-foreground"
>
<FileText className="mr-1 h-3 w-3" />
{t("releaseNotes")}
<FileText className="h-3 w-3 mr-1" />
Release Notes
</Button>
<Button
variant="ghost"
size="sm"
asChild
className="hover:text-foreground h-auto p-2 text-xs"
className="h-auto p-2 text-xs hover:text-foreground"
>
<a
href="https://github.com/community-scripts/ProxmoxVE-Local"
@@ -56,7 +53,7 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
className="flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
{t("github")}
GitHub
</a>
</Button>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,765 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Toggle } from "./ui/toggle";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { useTheme } from "./ThemeProvider";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "~/lib/i18n/useTranslation";
interface GeneralSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function GeneralSettingsModal({
isOpen,
onClose,
}: GeneralSettingsModalProps) {
useRegisterModal(isOpen, {
id: "general-settings-modal",
allowEscape: true,
onClose,
});
const { t, locale, setLocale, availableLocales } = useTranslation("settings");
const { theme, setTheme } = useTheme();
const [activeTab, setActiveTab] = useState<"general" | "github" | "auth">(
"general",
);
const [githubToken, setGithubToken] = useState("");
const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null);
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Auth state
const [authUsername, setAuthUsername] = useState("");
const [authPassword, setAuthPassword] = useState("");
const [authConfirmPassword, setAuthConfirmPassword] = useState("");
const [authEnabled, setAuthEnabled] = useState(false);
const [authHasCredentials, setAuthHasCredentials] = useState(false);
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
// Load existing settings when modal opens
useEffect(() => {
if (isOpen) {
void loadGithubToken();
void loadSaveFilter();
void loadSavedFilters();
void loadAuthCredentials();
void loadColorCodingSetting();
}
}, [isOpen]);
const loadGithubToken = async () => {
setIsLoading(true);
try {
const response = await fetch("/api/settings/github-token");
if (response.ok) {
const data = await response.json();
setGithubToken((data.token as string) ?? "");
}
} catch (error) {
console.error("Error loading GitHub token:", error);
} finally {
setIsLoading(false);
}
};
const loadSaveFilter = async () => {
try {
const response = await fetch("/api/settings/save-filter");
if (response.ok) {
const data = await response.json();
setSaveFilter((data.enabled as boolean) ?? false);
}
} catch (error) {
console.error("Error loading save filter setting:", error);
}
};
const saveSaveFilter = async (enabled: boolean) => {
try {
const response = await fetch("/api/settings/save-filter", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setSaveFilter(enabled);
setMessage({ type: "success", text: "Save filter setting updated!" });
// If disabling save filters, clear saved filters
if (!enabled) {
await clearSavedFilters();
}
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to save setting",
});
}
} catch {
setMessage({ type: "error", text: "Failed to save setting" });
}
};
const loadSavedFilters = async () => {
try {
const response = await fetch("/api/settings/filters");
if (response.ok) {
const data = await response.json();
setSavedFilters(data.filters);
}
} catch (error) {
console.error("Error loading saved filters:", error);
}
};
const clearSavedFilters = async () => {
try {
const response = await fetch("/api/settings/filters", {
method: "DELETE",
});
if (response.ok) {
setSavedFilters(null);
setMessage({ type: "success", text: "Saved filters cleared!" });
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to clear filters",
});
}
} catch {
setMessage({ type: "error", text: "Failed to clear filters" });
}
};
const saveGithubToken = async () => {
setIsSaving(true);
setMessage(null);
try {
const response = await fetch("/api/settings/github-token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token: githubToken }),
});
if (response.ok) {
setMessage({
type: "success",
text: "GitHub token saved successfully!",
});
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to save token",
});
}
} catch {
setMessage({ type: "error", text: "Failed to save token" });
} finally {
setIsSaving(false);
}
};
const loadColorCodingSetting = async () => {
try {
const response = await fetch("/api/settings/color-coding");
if (response.ok) {
const data = await response.json();
setColorCodingEnabled(Boolean(data.enabled));
}
} catch (error) {
console.error("Error loading color coding setting:", error);
}
};
const saveColorCodingSetting = async (enabled: boolean) => {
try {
const response = await fetch("/api/settings/color-coding", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setColorCodingEnabled(enabled);
setMessage({
type: "success",
text: "Color coding setting saved successfully",
});
setTimeout(() => setMessage(null), 3000);
} else {
setMessage({
type: "error",
text: "Failed to save color coding setting",
});
setTimeout(() => setMessage(null), 3000);
}
} catch (error) {
console.error("Error saving color coding setting:", error);
setMessage({
type: "error",
text: "Failed to save color coding setting",
});
setTimeout(() => setMessage(null), 3000);
}
};
const loadAuthCredentials = async () => {
setAuthLoading(true);
try {
const response = await fetch("/api/settings/auth-credentials");
if (response.ok) {
const data = (await response.json()) as {
username: string;
enabled: boolean;
hasCredentials: boolean;
setupCompleted: boolean;
};
setAuthUsername(data.username ?? "");
setAuthEnabled(data.enabled ?? false);
setAuthHasCredentials(data.hasCredentials ?? false);
setAuthSetupCompleted(data.setupCompleted ?? false);
}
} catch (error) {
console.error("Error loading auth credentials:", error);
} finally {
setAuthLoading(false);
}
};
const saveAuthCredentials = async () => {
if (authPassword !== authConfirmPassword) {
setMessage({ type: "error", text: "Passwords do not match" });
return;
}
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch("/api/settings/auth-credentials", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: authUsername,
password: authPassword,
enabled: authEnabled,
}),
});
if (response.ok) {
setMessage({
type: "success",
text: "Authentication credentials updated successfully!",
});
setAuthPassword("");
setAuthConfirmPassword("");
void loadAuthCredentials();
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to save credentials",
});
}
} catch {
setMessage({ type: "error", text: "Failed to save credentials" });
} finally {
setAuthLoading(false);
}
};
const toggleAuthEnabled = async (enabled: boolean) => {
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch("/api/settings/auth-credentials", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setAuthEnabled(enabled);
setMessage({
type: "success",
text: `Authentication ${enabled ? "enabled" : "disabled"} successfully!`,
});
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to update auth status",
});
}
} catch {
setMessage({ type: "error", text: "Failed to update auth status" });
} finally {
setAuthLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-2 backdrop-blur-sm sm:p-4">
<div className="bg-card max-h-[95vh] w-full max-w-4xl overflow-hidden rounded-lg shadow-xl sm:max-h-[90vh]">
{/* Header */}
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6">
<div className="flex items-center gap-2">
<h2 className="text-card-foreground text-xl font-bold sm:text-2xl">
Settings
</h2>
<ContextualHelpIcon
section="general-settings"
tooltip="Help with General Settings"
/>
</div>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg
className="h-5 w-5 sm:h-6 sm:w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
{/* Tabs */}
<div className="border-border border-b">
<nav className="flex flex-col space-y-1 px-4 sm:flex-row sm:space-y-0 sm:space-x-8 sm:px-6">
<Button
onClick={() => setActiveTab("general")}
variant="ghost"
size="null"
className={`w-full border-b-2 px-1 py-3 text-sm font-medium sm:w-auto sm:py-4 ${
activeTab === "general"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:border-border border-transparent"
}`}
>
General
</Button>
<Button
onClick={() => setActiveTab("github")}
variant="ghost"
size="null"
className={`w-full border-b-2 px-1 py-3 text-sm font-medium sm:w-auto sm:py-4 ${
activeTab === "github"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:border-border border-transparent"
}`}
>
GitHub
</Button>
<Button
onClick={() => setActiveTab("auth")}
variant="ghost"
size="null"
className={`w-full border-b-2 px-1 py-3 text-sm font-medium sm:w-auto sm:py-4 ${
activeTab === "auth"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:border-border border-transparent"
}`}
>
Authentication
</Button>
</nav>
</div>
{/* Content */}
<div className="max-h-[calc(95vh-180px)] overflow-y-auto p-4 sm:max-h-[calc(90vh-200px)] sm:p-6">
{activeTab === "general" && (
<div className="space-y-4 sm:space-y-6">
<h3 className="text-foreground mb-3 text-base font-medium sm:mb-4 sm:text-lg">
General Settings
</h3>
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
Configure general application preferences and behavior.
</p>
<div className="space-y-4">
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">Theme</h4>
<p className="text-muted-foreground mb-4 text-sm">
Choose your preferred color theme for the application.
</p>
<div className="flex items-center justify-between">
<div>
<p className="text-foreground text-sm font-medium">
Current Theme
</p>
<p className="text-muted-foreground text-xs">
{theme === "light" ? "Light mode" : "Dark mode"}
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => setTheme("light")}
variant={theme === "light" ? "default" : "outline"}
size="sm"
>
Light
</Button>
<Button
onClick={() => setTheme("dark")}
variant={theme === "dark" ? "default" : "outline"}
size="sm"
>
Dark
</Button>
</div>
</div>
</div>
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
Save Filters
</h4>
<p className="text-muted-foreground mb-4 text-sm">
Save your configured script filters.
</p>
<Toggle
checked={saveFilter}
onCheckedChange={saveSaveFilter}
label="Enable filter saving"
/>
{saveFilter && (
<div className="bg-muted mt-4 rounded-lg p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-foreground text-sm font-medium">
Saved Filters
</p>
<p className="text-muted-foreground text-xs">
{savedFilters
? "Filters are currently saved"
: "No filters saved yet"}
</p>
{savedFilters && (
<div className="text-muted-foreground mt-2 text-xs">
<div>
Search: {savedFilters.searchQuery ?? "None"}
</div>
<div>
Types: {savedFilters.selectedTypes?.length ?? 0}{" "}
selected
</div>
<div>
Sort: {savedFilters.sortBy} (
{savedFilters.sortOrder})
</div>
</div>
)}
</div>
{savedFilters && (
<Button
onClick={clearSavedFilters}
variant="outline"
size="sm"
className="text-error hover:text-error/80"
>
Clear
</Button>
)}
</div>
</div>
)}
</div>
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
Server Color Coding
</h4>
<p className="text-muted-foreground mb-4 text-sm">
Enable color coding for servers to visually distinguish them
throughout the application.
</p>
<Toggle
checked={colorCodingEnabled}
onCheckedChange={saveColorCodingSetting}
label="Enable server color coding"
/>
</div>
</div>
</div>
)}
{activeTab === "github" && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-foreground mb-3 text-base font-medium sm:mb-4 sm:text-lg">
GitHub Integration
</h3>
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
Configure GitHub integration for script management and
updates.
</p>
<div className="space-y-4">
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
GitHub Personal Access Token
</h4>
<p className="text-muted-foreground mb-4 text-sm">
Save a GitHub Personal Access Token to circumvent GitHub
API rate limits.
</p>
<div className="space-y-3">
<div>
<label
htmlFor="github-token"
className="text-foreground mb-1 block text-sm font-medium"
>
Token
</label>
<Input
id="github-token"
type="password"
placeholder="Enter your GitHub Personal Access Token"
value={githubToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setGithubToken(e.target.value)
}
disabled={isLoading || isSaving}
className="w-full"
/>
</div>
{message && (
<div
className={`rounded-md p-3 text-sm ${
message.type === "success"
? "bg-success/10 text-success-foreground border-success/20 border"
: "bg-error/10 text-error-foreground border-error/20 border"
}`}
>
{message.text}
</div>
)}
<div className="flex gap-2">
<Button
onClick={saveGithubToken}
disabled={
isSaving || isLoading || !githubToken.trim()
}
className="flex-1"
>
{isSaving ? "Saving..." : "Save Token"}
</Button>
<Button
onClick={loadGithubToken}
disabled={isLoading || isSaving}
variant="outline"
>
{isLoading ? "Loading..." : "Refresh"}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === "auth" && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-foreground mb-3 text-base font-medium sm:mb-4 sm:text-lg">
Authentication Settings
</h3>
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
Configure authentication to secure access to your application.
</p>
<div className="space-y-4">
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
Authentication Status
</h4>
<p className="text-muted-foreground mb-4 text-sm">
{authSetupCompleted
? authHasCredentials
? `Authentication is ${authEnabled ? "enabled" : "disabled"}. Current username: ${authUsername}`
: `Authentication is ${authEnabled ? "enabled" : "disabled"}. No credentials configured.`
: "Authentication setup has not been completed yet."}
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-foreground text-sm font-medium">
Enable Authentication
</p>
<p className="text-muted-foreground text-xs">
{authEnabled
? "Authentication is required on every page load"
: "Authentication is optional"}
</p>
</div>
<Toggle
checked={authEnabled}
onCheckedChange={toggleAuthEnabled}
disabled={authLoading || !authSetupCompleted}
label="Enable authentication"
/>
</div>
</div>
</div>
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
Update Credentials
</h4>
<p className="text-muted-foreground mb-4 text-sm">
Change your username and password for authentication.
</p>
<div className="space-y-3">
<div>
<label
htmlFor="auth-username"
className="text-foreground mb-1 block text-sm font-medium"
>
Username
</label>
<Input
id="auth-username"
type="text"
placeholder="Enter username"
value={authUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAuthUsername(e.target.value)
}
disabled={authLoading}
className="w-full"
minLength={3}
/>
</div>
<div>
<label
htmlFor="auth-password"
className="text-foreground mb-1 block text-sm font-medium"
>
New Password
</label>
<Input
id="auth-password"
type="password"
placeholder="Enter new password"
value={authPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAuthPassword(e.target.value)
}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
<div>
<label
htmlFor="auth-confirm-password"
className="text-foreground mb-1 block text-sm font-medium"
>
Confirm Password
</label>
<Input
id="auth-confirm-password"
type="password"
placeholder="Confirm new password"
value={authConfirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAuthConfirmPassword(e.target.value)
}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
{message && (
<div
className={`rounded-md p-3 text-sm ${
message.type === "success"
? "bg-success/10 text-success-foreground border-success/20 border"
: "bg-error/10 text-error-foreground border-error/20 border"
}`}
>
{message.text}
</div>
)}
<div className="flex gap-2">
<Button
onClick={saveAuthCredentials}
disabled={
authLoading ||
!authUsername.trim() ||
!authPassword.trim() ||
!authConfirmPassword.trim()
}
className="flex-1"
>
{authLoading ? "Saving..." : "Update Credentials"}
</Button>
<Button
onClick={loadAuthCredentials}
disabled={authLoading}
variant="outline"
>
{authLoading ? "Loading..." : "Refresh"}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,40 +1,38 @@
"use client";
'use client';
import { useState } from "react";
import { HelpModal } from "./HelpModal";
import { Button } from "./ui/button";
import { HelpCircle } from "lucide-react";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { useState } from 'react';
import { HelpModal } from './HelpModal';
import { Button } from './ui/button';
import { HelpCircle } from 'lucide-react';
interface HelpButtonProps {
initialSection?: string;
}
export function HelpButton({ initialSection }: HelpButtonProps) {
const { t } = useTranslation("helpButton");
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="text-muted-foreground text-sm font-medium">
{t("needHelp")}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
Need help?
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title={t("openHelp")}
title="Open Help"
>
<HelpCircle className="mr-2 h-5 w-5" />
{t("help")}
<HelpCircle className="w-5 h-5 mr-2" />
Help
</Button>
</div>
<HelpModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
<HelpModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
initialSection={initialSection}
/>
</>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { HelpCircle, Server, Settings, RefreshCw, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface HelpModalProps {
@@ -11,7 +11,7 @@ interface HelpModalProps {
initialSection?: string;
}
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
@@ -23,6 +23,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings },
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
{ id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock },
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
@@ -185,6 +186,101 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
</div>
);
case 'auto-sync':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">Auto-Sync</h3>
<p className="text-muted-foreground mb-6">
Configure automatic synchronization of scripts with configurable intervals and notifications.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">What Is Auto-Sync?</h4>
<p className="text-sm text-muted-foreground mb-2">
Auto-sync automatically synchronizes script metadata from the ProxmoxVE GitHub repository at specified intervals,
and optionally downloads/updates scripts and sends notifications.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Automatic JSON Sync:</strong> Downloads latest script metadata periodically</li>
<li> <strong>Auto-Download:</strong> Automatically download new scripts when available</li>
<li> <strong>Auto-Update:</strong> Automatically update existing scripts to newer versions</li>
<li> <strong>Notifications:</strong> Send notifications when sync completes</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Sync Intervals</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Predefined:</strong> Choose from common intervals (15min, 30min, 1hour, 6hours, 12hours, 24hours)</li>
<li> <strong>Custom Cron:</strong> Use cron expressions for advanced scheduling</li>
<li> <strong>Examples:</strong>
<ul className="ml-4 mt-1 space-y-1">
<li> <code>0 */6 * * *</code> - Every 6 hours</li>
<li> <code>0 0 * * *</code> - Daily at midnight</li>
<li> <code>0 9 * * 1</code> - Every Monday at 9 AM</li>
</ul>
</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Auto-Download Options</h4>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Auto-download new scripts:</strong> Automatically download scripts that haven't been downloaded yet</li>
<li>• <strong>Auto-update existing scripts:</strong> Automatically update scripts that have newer versions available</li>
<li>• <strong>Selective Control:</strong> Enable/disable each option independently</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Notifications (Apprise)</h4>
<p className="text-sm text-muted-foreground mb-2">
Send notifications when sync completes using Apprise, which supports 80+ notification services.
If you want any other notification service, please open an issue on the GitHub repository.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li>• <strong>Apprise Server:</strong> <code>http://YOUR_APPRISE_SERVER/notify/apprise</code></li>
</ul>
<p className="text-xs text-muted-foreground mt-2">
See the <a href="https://github.com/caronc/apprise" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Apprise documentation</a> for more supported services.
</p>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Setup Guide</h4>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Enable auto-sync in the General Settings → Auto-Sync tab</li>
<li>Choose your sync interval (predefined or custom cron)</li>
<li>Configure auto-download options if desired</li>
<li>Set up notifications by adding Apprise URLs</li>
<li>Test your notification setup using the "Test Notification" button</li>
<li>Save your settings to activate auto-sync</li>
</ol>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Cron Expression Help</h4>
<p className="text-sm text-muted-foreground mb-2">
Cron expressions have 5 fields: minute hour day month weekday
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• <strong>Minute:</strong> 0-59 or *</li>
<li>• <strong>Hour:</strong> 0-23 or *</li>
<li>• <strong>Day:</strong> 1-31 or *</li>
<li>• <strong>Month:</strong> 1-12 or *</li>
<li>• <strong>Weekday:</strong> 0-6 (Sunday=0) or *</li>
</ul>
<p className="text-xs text-muted-foreground mt-2">
Use <a href="https://crontab.guru" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">crontab.guru</a> to test and learn cron expressions.
</p>
</div>
</div>
</div>
);
case 'available-scripts':
return (
<div className="space-y-6">

View File

@@ -1,50 +0,0 @@
"use client";
import { useCallback } from "react";
import { Languages } from "lucide-react";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { type Locale, locales } from "~/lib/i18n/config";
import { Button } from "./ui/button";
interface LanguageToggleProps {
className?: string;
showLabel?: boolean;
}
function getNextLocale(current: Locale): Locale {
const orderedLocales = [...locales];
const currentIndex = orderedLocales.indexOf(current);
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + 1) % orderedLocales.length;
const fallback = orderedLocales[0] ?? current;
return orderedLocales[nextIndex] ?? fallback;
}
export function LanguageToggle({
className = "",
showLabel = false,
}: LanguageToggleProps) {
const { locale, setLocale, t } = useTranslation("common.language");
const nextLocale = getNextLocale(locale);
const handleToggle = useCallback(() => {
setLocale(nextLocale);
}, [nextLocale, setLocale]);
return (
<Button
variant="ghost"
size="icon"
onClick={handleToggle}
className={`text-muted-foreground hover:text-foreground transition-colors ${className}`}
aria-label={t("switch")}
>
<Languages className="h-4 w-4" />
{showLabel && (
<span className="ml-2 text-sm">{locale.toUpperCase()}</span>
)}
</Button>
);
}

View File

@@ -1,8 +1,7 @@
"use client";
'use client';
import { Loader2 } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { Loader2 } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface LoadingModalProps {
isOpen: boolean;
@@ -10,29 +9,26 @@ interface LoadingModalProps {
}
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
const { t } = useTranslation("loadingModal");
useRegisterModal(isOpen, {
id: "loading-modal",
allowEscape: false,
onClose: () => null,
});
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: false, onClose: () => null });
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border p-8 shadow-xl">
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border p-8">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="text-primary h-12 w-12 animate-spin" />
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
</div>
<div className="text-center">
<h3 className="text-card-foreground mb-2 text-lg font-semibold">
{t("processing")}
<h3 className="text-lg font-semibold text-card-foreground mb-2">
Processing
</h3>
<p className="text-muted-foreground text-sm">{action}</p>
<p className="text-muted-foreground mt-2 text-xs">
{t("pleaseWait")}
<p className="text-sm text-muted-foreground">
{action}
</p>
<p className="text-xs text-muted-foreground mt-2">
Please wait...
</p>
</div>
</div>
@@ -40,3 +36,4 @@ export function LoadingModal({ isOpen, action }: LoadingModalProps) {
</div>
);
}

View File

@@ -1,10 +1,9 @@
"use client";
'use client';
import { useState } from "react";
import { X, Copy, Check, Server, Globe } from "lucide-react";
import { Button } from "./ui/button";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { useState } from 'react';
import { X, Copy, Check, Server, Globe } from 'lucide-react';
import { Button } from './ui/button';
import { useRegisterModal } from './modal/ModalStackProvider';
interface PublicKeyModalProps {
isOpen: boolean;
@@ -14,19 +13,8 @@ interface PublicKeyModalProps {
serverIp: string;
}
export function PublicKeyModal({
isOpen,
onClose,
publicKey,
serverName,
serverIp,
}: PublicKeyModalProps) {
const { t } = useTranslation("publicKeyModal");
useRegisterModal(isOpen, {
id: "public-key-modal",
allowEscape: true,
onClose,
});
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
useRegisterModal(isOpen, { id: 'public-key-modal', allowEscape: true, onClose });
const [copied, setCopied] = useState(false);
const [commandCopied, setCommandCopied] = useState(false);
@@ -41,31 +29,31 @@ export function PublicKeyModal({
setTimeout(() => setCopied(false), 2000);
} else {
// Fallback for older browsers or non-HTTPS
const textArea = document.createElement("textarea");
const textArea = document.createElement('textarea');
textArea.value = publicKey;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (fallbackError) {
console.error("Fallback copy failed:", fallbackError);
console.error('Fallback copy failed:', fallbackError);
// If all else fails, show the key in an alert
alert(t("copyFallback") + publicKey);
alert('Please manually copy this key:\n\n' + publicKey);
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error("Failed to copy to clipboard:", error);
console.error('Failed to copy to clipboard:', error);
// Fallback: show the key in an alert
alert(t("copyFallback") + publicKey);
alert('Please manually copy this key:\n\n' + publicKey);
}
};
@@ -79,46 +67,44 @@ export function PublicKeyModal({
setTimeout(() => setCommandCopied(false), 2000);
} else {
// Fallback for older browsers or non-HTTPS
const textArea = document.createElement("textarea");
const textArea = document.createElement('textarea');
textArea.value = command;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
document.execCommand('copy');
setCommandCopied(true);
setTimeout(() => setCommandCopied(false), 2000);
} catch (fallbackError) {
console.error("Fallback copy failed:", fallbackError);
alert(t("copyCommandFallback") + command);
console.error('Fallback copy failed:', fallbackError);
alert('Please manually copy this command:\n\n' + command);
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error("Failed to copy command to clipboard:", error);
alert(t("copyCommandFallback") + command);
console.error('Failed to copy command to clipboard:', error);
alert('Please manually copy this command:\n\n' + command);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl">
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */}
<div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<div className="bg-info/10 rounded-lg p-2">
<Server className="text-info h-6 w-6" />
<div className="p-2 bg-info/10 rounded-lg">
<Server className="h-6 w-6 text-info" />
</div>
<div>
<h2 className="text-card-foreground text-xl font-semibold">
{t("title")}
</h2>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
<h2 className="text-xl font-semibold text-card-foreground">SSH Public Key</h2>
<p className="text-sm text-muted-foreground">Add this key to your server&apos;s authorized_keys</p>
</div>
</div>
<Button
@@ -132,14 +118,14 @@ export function PublicKeyModal({
</div>
{/* Content */}
<div className="space-y-6 p-6">
<div className="p-6 space-y-6">
{/* Server Info */}
<div className="bg-muted/50 flex items-center gap-4 rounded-lg p-4">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Server className="h-4 w-4" />
<span className="font-medium">{serverName}</span>
</div>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-4 w-4" />
<span>{serverIp}</span>
</div>
@@ -147,39 +133,19 @@ export function PublicKeyModal({
{/* Instructions */}
<div className="space-y-2">
<h3 className="text-foreground font-medium">
{t("instructions.title")}
</h3>
<ol className="text-muted-foreground list-inside list-decimal space-y-1 text-sm">
<li>{t("instructions.step1")}</li>
<li>
{t("instructions.step2")}{" "}
<code className="bg-muted rounded px-1">
ssh root@{serverIp}
</code>
</li>
<li>
{t("instructions.step3")}{" "}
<code className="bg-muted rounded px-1">
echo &quot;&lt;paste-key&gt;&quot; &gt;&gt;
~/.ssh/authorized_keys
</code>
</li>
<li>
{t("instructions.step4")}{" "}
<code className="bg-muted rounded px-1">
chmod 600 ~/.ssh/authorized_keys
</code>
</li>
<h3 className="font-medium text-foreground">Instructions:</h3>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Copy the public key below</li>
<li>SSH into your server: <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
<li>Add the key to authorized_keys: <code className="bg-muted px-1 rounded">echo &quot;&lt;paste-key&gt;&quot; &gt;&gt; ~/.ssh/authorized_keys</code></li>
<li>Set proper permissions: <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
</ol>
</div>
{/* Public Key */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-foreground text-sm font-medium">
{t("publicKeyLabel")}
</label>
<label className="text-sm font-medium text-foreground">Public Key:</label>
<Button
variant="outline"
size="sm"
@@ -189,12 +155,12 @@ export function PublicKeyModal({
{copied ? (
<>
<Check className="h-4 w-4" />
{t("actions.copied")}
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
{t("actions.copy")}
Copy
</>
)}
</Button>
@@ -202,17 +168,15 @@ export function PublicKeyModal({
<textarea
value={publicKey}
readOnly
className="bg-card text-foreground border-border focus:ring-ring focus:border-ring min-h-[60px] w-full resize-none rounded-md border px-3 py-2 font-mono text-xs shadow-sm focus:ring-2 focus:outline-none"
placeholder={t("placeholder")}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[60px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Public key will appear here..."
/>
</div>
{/* Quick Command */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-foreground text-sm font-medium">
{t("quickCommandLabel")}
</label>
<label className="text-sm font-medium text-foreground">Quick Add Command:</label>
<Button
variant="outline"
size="sm"
@@ -222,30 +186,30 @@ export function PublicKeyModal({
{commandCopied ? (
<>
<Check className="h-4 w-4" />
{t("actions.copied")}
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
{t("actions.copyCommand")}
Copy Command
</>
)}
</Button>
</div>
<div className="bg-muted/50 border-border rounded-md border p-3">
<code className="text-foreground font-mono text-sm break-all">
<div className="p-3 bg-muted/50 rounded-md border border-border">
<code className="text-sm font-mono text-foreground break-all">
echo &quot;{publicKey}&quot; &gt;&gt; ~/.ssh/authorized_keys
</code>
</div>
<p className="text-muted-foreground text-xs">
{t("quickCommandHint")}
<p className="text-xs text-muted-foreground">
Copy and paste this command directly into your server terminal to add the key to authorized_keys
</p>
</div>
{/* Footer */}
<div className="border-border flex justify-end gap-3 border-t pt-4">
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{t("actions.close")}
Close
</Button>
</div>
</div>

View File

@@ -1,13 +1,11 @@
"use client";
'use client';
import { useState } from "react";
import { api } from "~/trpc/react";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { useState } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon';
export function ResyncButton() {
const { t } = useTranslation("resyncButton");
const [isResyncing, setIsResyncing] = useState(false);
const [lastSync, setLastSync] = useState<Date | null>(null);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
@@ -17,22 +15,20 @@ export function ResyncButton() {
setIsResyncing(false);
setLastSync(new Date());
if (data.success) {
setSyncMessage(data.message ?? t("messages.success"));
setSyncMessage(data.message ?? 'Scripts synced successfully');
// Reload the page after successful sync
setTimeout(() => {
window.location.reload();
}, 2000); // Wait 2 seconds to show the success message
} else {
setSyncMessage(data.error ?? t("messages.failed"));
setSyncMessage(data.error ?? 'Failed to sync scripts');
// Clear message after 3 seconds for errors
setTimeout(() => setSyncMessage(null), 3000);
}
},
onError: (error) => {
setIsResyncing(false);
setSyncMessage(
t("messages.error", { values: { message: error.message } }),
);
setSyncMessage(`Error: ${error.message}`);
setTimeout(() => setSyncMessage(null), 3000);
},
});
@@ -44,11 +40,11 @@ export function ResyncButton() {
};
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="text-muted-foreground text-sm font-medium">
{t("syncDescription")}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
Sync scripts with ProxmoxVE repo
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-2">
<Button
onClick={handleResync}
@@ -59,51 +55,34 @@ export function ResyncButton() {
>
{isResyncing ? (
<>
<div className="mr-2 h-5 w-5 animate-spin rounded-full border-b-2 border-white"></div>
<span>{t("syncing")}</span>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
<span>Syncing...</span>
</>
) : (
<>
<svg
className="mr-2 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{t("syncJsonFiles")}</span>
<span>Sync Json Files</span>
</>
)}
</Button>
<ContextualHelpIcon
section="sync-button"
tooltip={t("helpTooltip")}
/>
<ContextualHelpIcon section="sync-button" tooltip="Help with Sync Button" />
</div>
{lastSync && (
<div className="text-muted-foreground text-xs">
{t("lastSync", { values: { time: lastSync.toLocaleTimeString() } })}
<div className="text-xs text-muted-foreground">
Last sync: {lastSync.toLocaleTimeString()}
</div>
)}
</div>
{syncMessage && (
<div
className={`rounded-lg px-3 py-1 text-sm ${
syncMessage.includes("Error") ||
syncMessage.includes("Failed") ||
syncMessage.includes("Fehler")
? "bg-error/10 text-error"
: "bg-success/10 text-success"
}`}
>
<div className={`text-sm px-3 py-1 rounded-lg ${
syncMessage.includes('Error') || syncMessage.includes('Failed')
? 'bg-error/10 text-error'
: 'bg-success/10 text-success'
}`}>
{syncMessage}
</div>
)}

View File

@@ -1,10 +1,9 @@
"use client";
'use client';
import { useState } from "react";
import Image from "next/image";
import type { ScriptCard } from "~/types/script";
import { TypeBadge, UpdateableBadge } from "./Badge";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { useState } from 'react';
import Image from 'next/image';
import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from './Badge';
interface ScriptCardProps {
script: ScriptCard;
@@ -13,13 +12,7 @@ interface ScriptCardProps {
onToggleSelect?: (slug: string) => void;
}
export function ScriptCard({
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardProps) {
const { t } = useTranslation("scriptCard");
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
@@ -35,36 +28,32 @@ export function ScriptCard({
return (
<div
className="bg-card border-border hover:border-primary relative flex h-full cursor-pointer flex-col rounded-lg border shadow-md transition-shadow duration-200 hover:shadow-lg"
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
onClick={() => onClick(script)}
>
{/* Checkbox in top-left corner */}
{onToggleSelect && (
<div className="absolute top-2 left-2 z-10">
<div
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
isSelected
? "bg-primary border-primary text-primary-foreground"
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
<div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
}`}
onClick={handleCheckboxClick}
>
{isSelected && (
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</div>
</div>
)}
<div className="flex flex-1 flex-col p-6">
<div className="p-6 flex-1 flex flex-col">
{/* Header with logo and name */}
<div className="mb-4 flex items-start space-x-4">
<div className="flex items-start space-x-4 mb-4">
<div className="flex-shrink-0">
{script.logo && !imageError ? (
<Image
@@ -72,41 +61,37 @@ export function ScriptCard({
alt={`${script.name} logo`}
width={48}
height={48}
className="h-12 w-12 rounded-lg object-contain"
className="w-12 h-12 rounded-lg object-contain"
onError={handleImageError}
/>
) : (
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
<span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || "?"}
{script.name?.charAt(0)?.toUpperCase() || '?'}
</span>
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-foreground truncate text-lg font-semibold">
{script.name || t("unnamedScript")}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground truncate">
{script.name || 'Unnamed Script'}
</h3>
<div className="mt-2 space-y-2">
{/* Type and Updateable status on first row */}
<div className="flex flex-wrap items-center gap-1 space-x-2">
<TypeBadge type={script.type ?? "unknown"} />
<div className="flex items-center space-x-2 flex-wrap gap-1">
<TypeBadge type={script.type ?? 'unknown'} />
{script.updateable && <UpdateableBadge />}
</div>
{/* Download Status */}
<div className="flex items-center space-x-1">
<div
className={`h-2 w-2 rounded-full ${
script.isDownloaded ? "bg-success" : "bg-error"
}`}
></div>
<span
className={`text-xs font-medium ${
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? t("downloaded") : t("notDownloaded")}
<div className={`w-2 h-2 rounded-full ${
script.isDownloaded ? 'bg-success' : 'bg-error'
}`}></div>
<span className={`text-xs font-medium ${
script.isDownloaded ? 'text-success' : 'text-error'
}`}>
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
</span>
</div>
</div>
@@ -114,8 +99,8 @@ export function ScriptCard({
</div>
{/* Description */}
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
{script.description || t("noDescription")}
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || 'No description available'}
</p>
{/* Footer with website link */}
@@ -125,22 +110,12 @@ export function ScriptCard({
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium"
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1"
onClick={(e) => e.stopPropagation()}
>
<span>{t("website")}</span>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
<span>Website</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>

View File

@@ -574,7 +574,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}, []);
const handleCardClick = (scriptCard: { slug: string }) => {
const handleCardClick = (scriptCard: ScriptCardType) => {
// All scripts are GitHub scripts, open modal
setSelectedSlug(scriptCard.slug);
setIsModalOpen(true);

View File

@@ -1,29 +1,27 @@
"use client";
'use client';
import { useState } from "react";
import { SettingsModal } from "./SettingsModal";
import { Button } from "./ui/button";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { useState } from 'react';
import { SettingsModal } from './SettingsModal';
import { Button } from './ui/button';
export function ServerSettingsButton() {
const { t } = useTranslation("serverSettingsButton");
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="text-muted-foreground text-sm font-medium">
{t("description")}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
Add and manage PVE Servers:
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title={t("buttonTitle")}
title="Add PVE Server"
>
<svg
className="mr-2 h-5 w-5"
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -42,7 +40,7 @@ export function ServerSettingsButton() {
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{t("buttonLabel")}
Manage PVE Servers
</Button>
</div>

View File

@@ -1,30 +1,28 @@
"use client";
'use client';
import { useState } from "react";
import { GeneralSettingsModal } from "./GeneralSettingsModal";
import { Button } from "./ui/button";
import { Settings } from "lucide-react";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { useState } from 'react';
import { GeneralSettingsModal } from './GeneralSettingsModal';
import { Button } from './ui/button';
import { Settings } from 'lucide-react';
export function SettingsButton() {
const { t } = useTranslation("settingsButton");
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="text-muted-foreground text-sm font-medium">
{t("description")}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
Application Settings:
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title={t("buttonTitle")}
title="Open Settings"
>
<Settings className="mr-2 h-5 w-5" />
{t("buttonLabel")}
<Settings className="w-5 h-5 mr-2" />
Settings
</Button>
</div>

View File

@@ -1,12 +1,11 @@
"use client";
'use client';
import { useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Toggle } from "./ui/toggle";
import { Lock, User, Shield, AlertCircle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Toggle } from './ui/toggle';
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
interface SetupModalProps {
isOpen: boolean;
@@ -14,15 +13,10 @@ interface SetupModalProps {
}
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
const { t } = useTranslation("setupModal");
useRegisterModal(isOpen, {
id: "setup-modal",
allowEscape: true,
onClose: () => null,
});
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
useRegisterModal(isOpen, { id: 'setup-modal', allowEscape: true, onClose: () => null });
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [enableAuth, setEnableAuth] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -34,31 +28,31 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
// Only validate passwords if authentication is enabled
if (enableAuth && password !== confirmPassword) {
setError(t("errors.passwordMismatch"));
setError('Passwords do not match');
setIsLoading(false);
return;
}
try {
const response = await fetch("/api/auth/setup", {
method: "POST",
const response = await fetch('/api/auth/setup', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: enableAuth ? username : undefined,
password: enableAuth ? password : undefined,
enabled: enableAuth,
body: JSON.stringify({
username: enableAuth ? username : undefined,
password: enableAuth ? password : undefined,
enabled: enableAuth
}),
});
if (response.ok) {
// If authentication is enabled, automatically log in the user
if (enableAuth) {
const loginResponse = await fetch("/api/auth/login", {
method: "POST",
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
@@ -68,7 +62,7 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
onComplete();
} else {
// Setup succeeded but login failed, still complete setup
console.warn("Setup completed but auto-login failed");
console.warn('Setup completed but auto-login failed');
onComplete();
}
} else {
@@ -76,131 +70,119 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
onComplete();
}
} else {
const errorData = (await response.json()) as { error: string };
setError(errorData.error ?? t("errors.setupFailed"));
const errorData = await response.json() as { error: string };
setError(errorData.error ?? 'Failed to setup authentication');
}
} catch (error) {
console.error("Setup error:", error);
setError(t("errors.setupFailed"));
console.error('Setup error:', error);
setError('Failed to setup authentication');
}
setIsLoading(false);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */}
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="flex items-center gap-3">
<Shield className="text-success h-8 w-8" />
<h2 className="text-card-foreground text-2xl font-bold">
{t("title")}
</h2>
<Shield className="h-8 w-8 text-success" />
<h2 className="text-2xl font-bold text-card-foreground">Setup Authentication</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-muted-foreground mb-6 text-center">
{t("description")}
<p className="text-muted-foreground text-center mb-6">
Set up authentication to secure your application. This will be required for future access.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="setup-username"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("username.label")}
<label htmlFor="setup-username" className="block text-sm font-medium text-foreground mb-2">
Username
</label>
<div className="relative">
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
id="setup-username"
type="text"
placeholder={t("username.placeholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={3}
/>
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="setup-username"
type="text"
placeholder="Choose a username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={3}
/>
</div>
</div>
<div>
<label
htmlFor="setup-password"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("password.label")}
<label htmlFor="setup-password" className="block text-sm font-medium text-foreground mb-2">
Password
</label>
<div className="relative">
<Lock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
id="setup-password"
type="password"
placeholder={t("password.placeholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="setup-password"
type="password"
placeholder="Choose a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
</div>
</div>
<div>
<label
htmlFor="confirm-password"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("confirmPassword.label")}
<label htmlFor="confirm-password" className="block text-sm font-medium text-foreground mb-2">
Confirm Password
</label>
<div className="relative">
<Lock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
id="confirm-password"
type="password"
placeholder={t("confirmPassword.placeholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
</div>
</div>
<div className="border-border bg-muted/30 rounded-lg border p-4">
<div className="p-4 border border-border rounded-lg bg-muted/30">
<div className="flex items-center justify-between">
<div>
<h4 className="text-foreground mb-1 font-medium">
{t("enableAuth.title")}
</h4>
<p className="text-muted-foreground text-sm">
{enableAuth
? t("enableAuth.descriptionEnabled")
: t("enableAuth.descriptionDisabled")}
<h4 className="font-medium text-foreground mb-1">Enable Authentication</h4>
<p className="text-sm text-muted-foreground">
{enableAuth
? 'Authentication will be required on every page load'
: 'Authentication will be optional (can be enabled later in settings)'
}
</p>
</div>
<Toggle
checked={enableAuth}
onCheckedChange={setEnableAuth}
disabled={isLoading}
label={t("enableAuth.label")}
label="Enable authentication"
/>
</div>
</div>
{error && (
<div className="bg-error/10 text-error-foreground border-error/20 flex items-center gap-2 rounded-md border p-3">
<div className="flex items-center gap-2 p-3 bg-error/10 text-error-foreground border border-error/20 rounded-md">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
@@ -209,15 +191,12 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
<Button
type="submit"
disabled={
isLoading ||
(enableAuth &&
(!username.trim() ||
!password.trim() ||
!confirmPassword.trim()))
isLoading ||
(enableAuth && (!username.trim() || !password.trim() || !confirmPassword.trim()))
}
className="w-full"
>
{isLoading ? t("actions.settingUp") : t("actions.completeSetup")}
{isLoading ? 'Setting Up...' : 'Complete Setup'}
</Button>
</form>
</div>

View File

@@ -1,66 +1,64 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef } from "react";
interface VersionDisplayProps {
onOpenReleaseNotes?: () => void;
}
// Loading overlay component with log streaming
function LoadingOverlay({
isNetworkError = false,
logs = [],
}: {
isNetworkError?: boolean;
function LoadingOverlay({
isNetworkError = false,
logs = []
}: {
isNetworkError?: boolean;
logs?: string[];
}) {
const { t } = useTranslation("versionDisplay.loadingOverlay");
const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-card border-border mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border p-8 shadow-2xl">
<div className="bg-card rounded-lg p-8 shadow-2xl border border-border max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="text-primary h-12 w-12 animate-spin" />
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
</div>
<div className="text-center">
<h3 className="text-card-foreground mb-2 text-lg font-semibold">
{isNetworkError
? t("serverRestarting")
: t("updatingApplication")}
<h3 className="text-lg font-semibold text-card-foreground mb-2">
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
</h3>
<p className="text-muted-foreground text-sm">
{isNetworkError
? t("serverRestartingMessage")
: t("updatingMessage")}
<p className="text-sm text-muted-foreground">
{isNetworkError
? 'The server is restarting after the update...'
: 'Please stand by while we update your application...'
}
</p>
<p className="text-muted-foreground mt-2 text-xs">
{isNetworkError ? t("serverRestartingNote") : t("updatingNote")}
<p className="text-xs text-muted-foreground mt-2">
{isNetworkError
? 'This may take a few moments. The page will reload automatically.'
: 'The server will restart automatically when complete.'
}
</p>
</div>
{/* Log output */}
{logs.length > 0 && (
<div className="bg-card border-border text-chart-2 terminal-output mt-4 max-h-60 w-full overflow-y-auto rounded-lg border p-4 font-mono text-xs">
<div className="w-full mt-4 bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-60 overflow-y-auto terminal-output">
{logs.map((log, index) => (
<div
key={index}
className="mb-1 break-words whitespace-pre-wrap"
>
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
{log}
</div>
))}
@@ -69,15 +67,9 @@ function LoadingOverlay({
)}
<div className="flex space-x-1">
<div className="bg-primary h-2 w-2 animate-bounce rounded-full"></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.2s" }}
></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
@@ -85,36 +77,25 @@ function LoadingOverlay({
);
}
export function VersionDisplay({
onOpenReleaseNotes,
}: VersionDisplayProps = {}) {
const { t } = useTranslation("versionDisplay");
const { t: tOverlay } = useTranslation("versionDisplay.loadingOverlay");
const {
data: versionStatus,
isLoading,
error,
} = api.version.getVersionStatus.useQuery();
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
const [isUpdating, setIsUpdating] = useState(false);
const [updateResult, setUpdateResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
const [isNetworkError, setIsNetworkError] = useState(false);
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
const [shouldSubscribe, setShouldSubscribe] = useState(false);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const lastLogTimeRef = useRef<number>(Date.now());
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
const executeUpdate = api.version.executeUpdate.useMutation({
onSuccess: (result) => {
setUpdateResult({ success: result.success, message: result.message });
if (result.success) {
// Start subscribing to update logs
setShouldSubscribe(true);
setUpdateLogs([tOverlay("updateStarted")]);
setUpdateLogs(['Update started...']);
} else {
setIsUpdating(false);
}
@@ -122,38 +103,75 @@ export function VersionDisplay({
onError: (error) => {
setUpdateResult({ success: false, message: error.message });
setIsUpdating(false);
},
}
});
// Poll for update logs
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(
undefined,
{
enabled: shouldSubscribe,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
},
);
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
enabled: shouldSubscribe,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
});
// Update logs when data changes
useEffect(() => {
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
if (updateLogsData.isComplete) {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
}
}, [updateLogsData]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
if (!shouldSubscribe) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// Only start reconnection if we've been updating for at least 3 minutes
// and no logs for 60 seconds (very conservative fallback)
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
// Start trying to reconnect
startReconnectAttempts();
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
// Attempt to reconnect and reload page when server is back
const startReconnectAttempts = useCallback(() => {
const startReconnectAttempts = () => {
if (reconnectIntervalRef.current) return;
setUpdateLogs((prev) => [...prev, tOverlay("reconnecting")]);
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
reconnectIntervalRef.current = setInterval(() => {
void (async () => {
try {
// Try to fetch the root path to check if server is back
const response = await fetch("/", { method: "HEAD" });
const response = await fetch('/', { method: 'HEAD' });
if (response.ok || response.status === 200) {
setUpdateLogs((prev) => [...prev, tOverlay("serverBackOnline")]);
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
// Clear interval and reload
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
}
setTimeout(() => {
window.location.reload();
}, 1000);
@@ -163,60 +181,7 @@ export function VersionDisplay({
}
})();
}, 2000);
}, [tOverlay]);
// Update logs when data changes
useEffect(() => {
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
if (updateLogsData.isComplete) {
setUpdateLogs((prev) => [...prev, tOverlay("updateComplete")]);
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
}
}, [updateLogsData, tOverlay, startReconnectAttempts]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
if (!shouldSubscribe) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// Only start reconnection if we've been updating for at least 3 minutes
// and no logs for 60 seconds (very conservative fallback)
const hasBeenUpdatingLongEnough =
updateStartTime && Date.now() - updateStartTime > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
if (
hasBeenUpdatingLongEnough &&
noLogsForAWhile &&
isUpdating &&
!isNetworkError
) {
setIsNetworkError(true);
setUpdateLogs((prev) => [...prev, tOverlay("serverRestarting2")]);
// Start trying to reconnect
startReconnectAttempts();
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [
shouldSubscribe,
isUpdating,
updateStartTime,
isNetworkError,
tOverlay,
startReconnectAttempts,
]);
};
// Cleanup reconnect interval on unmount
useEffect(() => {
@@ -242,7 +207,7 @@ export function VersionDisplay({
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="animate-pulse">
{t("loading")}
Loading...
</Badge>
</div>
);
@@ -252,104 +217,88 @@ export function VersionDisplay({
return (
<div className="flex items-center gap-2">
<Badge variant="destructive">
v{versionStatus?.currentVersion ?? t("unknownVersion")}
v{versionStatus?.currentVersion ?? 'Unknown'}
</Badge>
<span className="text-muted-foreground text-xs">
{t("unableToCheck")}
<span className="text-xs text-muted-foreground">
(Unable to check for updates)
</span>
</div>
);
}
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } =
versionStatus;
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } = versionStatus;
return (
<>
{/* Loading overlay */}
{isUpdating && (
<LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />
)}
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-2">
<Badge
variant={isUpToDate ? "default" : "secondary"}
className={`text-xs ${onOpenReleaseNotes ? "cursor-pointer transition-opacity hover:opacity-80" : ""}`}
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
<Badge
variant={isUpToDate ? "default" : "secondary"}
className={`text-xs ${onOpenReleaseNotes ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
onClick={onOpenReleaseNotes}
>
v{currentVersion}
</Badge>
{updateAvailable && releaseInfo && (
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-3">
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
<div className="flex items-center gap-2">
<Button
onClick={handleUpdate}
disabled={isUpdating}
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
className="text-xs h-6 px-2"
>
{isUpdating ? (
<>
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
<span className="hidden sm:inline">
{t("update.updating")}
</span>
<span className="sm:hidden">
{t("update.updatingShort")}
</span>
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
<span className="hidden sm:inline">Updating...</span>
<span className="sm:hidden">...</span>
</>
) : (
<>
<Download className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">
{t("update.updateNow")}
</span>
<span className="sm:hidden">
{t("update.updateNowShort")}
</span>
<Download className="h-3 w-3 mr-1" />
<span className="hidden sm:inline">Update Now</span>
<span className="sm:hidden">Update</span>
</>
)}
</Button>
<ContextualHelpIcon
section="update-system"
tooltip={t("helpTooltip")}
/>
<ContextualHelpIcon section="update-system" tooltip="Help with updates" />
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs">
{t("releaseNotes")}
</span>
<span className="text-xs text-muted-foreground">Release Notes:</span>
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs transition-colors"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="View latest release"
>
<ExternalLink className="h-3 w-3" />
</a>
</div>
{updateResult && (
<div
className={`rounded px-2 py-1 text-center text-xs ${
updateResult.success
? "bg-chart-2/20 text-chart-2 border-chart-2/30 border"
: "bg-destructive/20 text-destructive border-destructive/30 border"
}`}
>
<div className={`text-xs px-2 py-1 rounded text-center ${
updateResult.success
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
: 'bg-destructive/20 text-destructive border border-destructive/30'
}`}>
{updateResult.message}
</div>
)}
</div>
)}
{isUpToDate && (
<span className="text-chart-2 text-xs">{t("upToDate")}</span>
<span className="text-xs text-chart-2">
Up to date
</span>
)}
</div>
</>

View File

@@ -1,46 +1,43 @@
"use client";
'use client';
import React from "react";
import { Button } from "./ui/button";
import { Grid3X3, List } from "lucide-react";
import { useTranslation } from "@/lib/i18n/useTranslation";
import React from 'react';
import { Button } from './ui/button';
import { Grid3X3, List } from 'lucide-react';
interface ViewToggleProps {
viewMode: "card" | "list";
onViewModeChange: (mode: "card" | "list") => void;
viewMode: 'card' | 'list';
onViewModeChange: (mode: 'card' | 'list') => void;
}
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
const { t } = useTranslation("viewToggle");
return (
<div className="mb-6 flex justify-center">
<div className="bg-muted flex items-center space-x-1 rounded-lg p-1">
<div className="flex justify-center mb-6">
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
<Button
onClick={() => onViewModeChange("card")}
variant={viewMode === "card" ? "default" : "ghost"}
onClick={() => onViewModeChange('card')}
variant={viewMode === 'card' ? 'default' : 'ghost'}
size="sm"
className={`flex items-center space-x-2 ${
viewMode === "card"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
viewMode === 'card'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Grid3X3 className="h-4 w-4" />
<span className="text-sm">{t("cardView")}</span>
<span className="text-sm">Card View</span>
</Button>
<Button
onClick={() => onViewModeChange("list")}
variant={viewMode === "list" ? "default" : "ghost"}
onClick={() => onViewModeChange('list')}
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className={`flex items-center space-x-2 ${
viewMode === "list"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
viewMode === 'list'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<List className="h-4 w-4" />
<span className="text-sm">{t("listView")}</span>
<span className="text-sm">List View</span>
</Button>
</div>
</div>

View File

@@ -0,0 +1,396 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { isValidCron } from 'cron-validator';
export async function POST(request: NextRequest) {
try {
const settings = await request.json();
if (!settings || typeof settings !== 'object') {
return NextResponse.json(
{ error: 'Settings object is required' },
{ status: 400 }
);
}
// Handle test notification request
if (settings.testNotification) {
return await handleTestNotification();
}
// Handle manual sync trigger
if (settings.triggerManualSync) {
return await handleManualSync();
}
// Validate required fields for settings save
const requiredFields = [
'autoSyncEnabled',
'syncIntervalType',
'autoDownloadNew',
'autoUpdateExisting',
'notificationEnabled'
];
for (const field of requiredFields) {
if (!(field in settings)) {
return NextResponse.json(
{ error: `Missing required field: ${field}` },
{ status: 400 }
);
}
}
// Validate sync interval type
if (!['predefined', 'custom'].includes(settings.syncIntervalType)) {
return NextResponse.json(
{ error: 'syncIntervalType must be "predefined" or "custom"' },
{ status: 400 }
);
}
// Validate predefined interval
if (settings.syncIntervalType === 'predefined') {
const validIntervals = ['15min', '30min', '1hour', '6hours', '12hours', '24hours'];
if (!validIntervals.includes(settings.syncIntervalPredefined)) {
return NextResponse.json(
{ error: 'Invalid predefined interval' },
{ status: 400 }
);
}
}
// Validate custom cron expression
if (settings.syncIntervalType === 'custom') {
if (!settings.syncIntervalCron || typeof settings.syncIntervalCron !== 'string' || settings.syncIntervalCron.trim() === '') {
// Fallback to predefined if custom is selected but no cron expression
settings.syncIntervalType = 'predefined';
settings.syncIntervalPredefined = settings.syncIntervalPredefined || '1hour';
settings.syncIntervalCron = '';
} else if (!isValidCron(settings.syncIntervalCron, { seconds: false })) {
return NextResponse.json(
{ error: 'Invalid cron expression' },
{ status: 400 }
);
}
}
// Validate Apprise URLs if notifications are enabled
if (settings.notificationEnabled && settings.appriseUrls) {
try {
// Handle both array and JSON string formats
let urls;
if (Array.isArray(settings.appriseUrls)) {
urls = settings.appriseUrls;
} else if (typeof settings.appriseUrls === 'string') {
urls = JSON.parse(settings.appriseUrls);
} else {
return NextResponse.json(
{ error: 'Apprise URLs must be an array or JSON string' },
{ status: 400 }
);
}
if (!Array.isArray(urls)) {
return NextResponse.json(
{ error: 'Apprise URLs must be an array' },
{ status: 400 }
);
}
// Basic URL validation
for (const url of urls) {
if (typeof url !== 'string' || url.trim() === '') {
return NextResponse.json(
{ error: 'All Apprise URLs must be non-empty strings' },
{ status: 400 }
);
}
}
} catch (parseError) {
return NextResponse.json(
{ error: 'Invalid JSON format for Apprise URLs' },
{ status: 400 }
);
}
}
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
// Read existing .env file
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Auto-sync settings to add/update
const autoSyncSettings = {
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled ? 'true' : 'false',
'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined || '',
'SYNC_INTERVAL_CRON': settings.syncIntervalCron || '',
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew ? 'true' : 'false',
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting ? 'true' : 'false',
'NOTIFICATION_ENABLED': settings.notificationEnabled ? 'true' : 'false',
'APPRISE_URLS': Array.isArray(settings.appriseUrls) ? JSON.stringify(settings.appriseUrls) : (settings.appriseUrls || '[]'),
'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
};
// Update or add each setting
for (const [key, value] of Object.entries(autoSyncSettings)) {
const regex = new RegExp(`^${key}=.*$`, 'm');
const settingLine = `${key}="${value}"`;
if (regex.test(envContent)) {
// Replace existing setting
envContent = envContent.replace(regex, settingLine);
} else {
// Add new setting
envContent += (envContent.endsWith('\n') ? '' : '\n') + `${settingLine}\n`;
}
}
// Write back to .env file
fs.writeFileSync(envPath, envContent);
// Reschedule auto-sync service with new settings
try {
const { getAutoSyncService, setAutoSyncService } = await import('../../../../server/lib/autoSyncInit.js');
let autoSyncService = getAutoSyncService();
// If no global instance exists, create one
if (!autoSyncService) {
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService);
}
// Update the global service instance with new settings
autoSyncService.saveSettings(settings);
if (settings.autoSyncEnabled) {
autoSyncService.scheduleAutoSync();
} else {
autoSyncService.stopAutoSync();
// Ensure the service is completely stopped and won't restart
autoSyncService.isRunning = false;
// Also stop the global service instance if it exists
const { stopAutoSync: stopGlobalAutoSync } = await import('../../../../server/lib/autoSyncInit.js');
stopGlobalAutoSync();
}
} catch (error) {
console.error('Error rescheduling auto-sync service:', error);
// Don't fail the request if rescheduling fails
}
return NextResponse.json({
success: true,
message: 'Auto-sync settings saved successfully'
});
} catch (error) {
console.error('Error saving auto-sync settings:', error);
return NextResponse.json(
{ error: 'Failed to save auto-sync settings' },
{ status: 500 }
);
}
}
export async function GET() {
try {
// Path to the .env file
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json({
settings: {
autoSyncEnabled: false,
syncIntervalType: 'predefined',
syncIntervalPredefined: '1hour',
syncIntervalCron: '',
autoDownloadNew: false,
autoUpdateExisting: false,
notificationEnabled: false,
appriseUrls: [],
lastAutoSync: '',
lastAutoSyncError: null,
lastAutoSyncErrorTime: null
}
});
}
// Read .env file and extract auto-sync settings
const envContent = fs.readFileSync(envPath, 'utf8');
const settings = {
autoSyncEnabled: getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true',
syncIntervalType: getEnvValue(envContent, 'SYNC_INTERVAL_TYPE') || 'predefined',
syncIntervalPredefined: getEnvValue(envContent, 'SYNC_INTERVAL_PREDEFINED') || '1hour',
syncIntervalCron: getEnvValue(envContent, 'SYNC_INTERVAL_CRON') || '',
autoDownloadNew: getEnvValue(envContent, 'AUTO_DOWNLOAD_NEW') === 'true',
autoUpdateExisting: getEnvValue(envContent, 'AUTO_UPDATE_EXISTING') === 'true',
notificationEnabled: getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true',
appriseUrls: (() => {
try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue);
} catch {
return [];
}
})(),
lastAutoSync: getEnvValue(envContent, 'LAST_AUTO_SYNC') || '',
lastAutoSyncError: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR') || null,
lastAutoSyncErrorTime: getEnvValue(envContent, 'LAST_AUTO_SYNC_ERROR_TIME') || null
};
return NextResponse.json({ settings });
} catch (error) {
console.error('Error reading auto-sync settings:', error);
return NextResponse.json(
{ error: 'Failed to read auto-sync settings' },
{ status: 500 }
);
}
}
// Helper function to handle test notification
async function handleTestNotification() {
try {
// Load current settings
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json(
{ error: 'No auto-sync settings found' },
{ status: 404 }
);
}
const envContent = fs.readFileSync(envPath, 'utf8');
const notificationEnabled = getEnvValue(envContent, 'NOTIFICATION_ENABLED') === 'true';
const appriseUrls = (() => {
try {
const urlsValue = getEnvValue(envContent, 'APPRISE_URLS') || '[]';
return JSON.parse(urlsValue);
} catch {
return [];
}
})();
if (!notificationEnabled) {
return NextResponse.json(
{ error: 'Notifications are not enabled' },
{ status: 400 }
);
}
if (!appriseUrls || appriseUrls.length === 0) {
return NextResponse.json(
{ error: 'No Apprise URLs configured' },
{ status: 400 }
);
}
// Send test notification using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.testNotification();
if (result.success) {
return NextResponse.json({
success: true,
message: 'Test notification sent successfully'
});
} else {
return NextResponse.json(
{ error: result.message },
{ status: 500 }
);
}
} catch (error) {
console.error('Error sending test notification:', error);
return NextResponse.json(
{ error: 'Failed to send test notification' },
{ status: 500 }
);
}
}
// Helper function to handle manual sync trigger
async function handleManualSync() {
try {
// Load current settings
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) {
return NextResponse.json(
{ error: 'No auto-sync settings found' },
{ status: 404 }
);
}
const envContent = fs.readFileSync(envPath, 'utf8');
const autoSyncEnabled = getEnvValue(envContent, 'AUTO_SYNC_ENABLED') === 'true';
if (!autoSyncEnabled) {
return NextResponse.json(
{ error: 'Auto-sync is not enabled' },
{ status: 400 }
);
}
// Trigger manual sync using the auto-sync service
const { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.executeAutoSync() as any;
if (result && result.success) {
return NextResponse.json({
success: true,
message: 'Manual sync completed successfully',
result
});
} else {
return NextResponse.json(
{ error: result.message },
{ status: 500 }
);
}
} catch (error) {
console.error('Error triggering manual sync:', error);
return NextResponse.json(
{ error: 'Failed to trigger manual sync' },
{ status: 500 }
);
}
}
// Helper function to extract value from .env content
function getEnvValue(envContent: string, key: string): string {
// Try to match the pattern with quotes around the value (handles nested quotes)
const regex = new RegExp(`^${key}="(.+)"$`, 'm');
let match = regex.exec(envContent);
if (match && match[1]) {
let value = match[1];
// Remove extra quotes that might be around JSON values
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
return value;
}
// Try to match without quotes (fallback)
const regexNoQuotes = new RegExp(`^${key}=([^\\s]*)$`, 'm');
match = regexNoQuotes.exec(envContent);
if (match && match[1]) {
return match[1];
}
return '';
}

View File

@@ -2,10 +2,7 @@ import "~/styles/globals.css";
import { type Metadata, type Viewport } from "next";
import { Geist } from "next/font/google";
import { headers } from "next/headers";
import { LanguageProvider } from "~/lib/i18n/LanguageProvider";
import { defaultLocale, isLocale } from "~/lib/i18n/config";
import { TRPCReactProvider } from "~/trpc/react";
import { AuthProvider } from "./_components/AuthProvider";
import { AuthGuard } from "./_components/AuthGuard";
@@ -14,8 +11,7 @@ import { ModalStackProvider } from "./_components/modal/ModalStackProvider";
export const metadata: Metadata = {
title: "PVE Scripts local",
description:
"Manage and execute Proxmox helper scripts locally with live output streaming",
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
icons: [
{ rel: "icon", url: "/favicon.png", type: "image/png" },
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
@@ -34,44 +30,26 @@ const geist = Geist({
variable: "--font-jetbrains-mono",
});
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const headerList = await headers();
const cookieHeader = headerList.get("cookie");
let initialLocale = defaultLocale;
if (cookieHeader) {
const localeEntry = cookieHeader
.split(";")
.map((entry: string) => entry.trim())
.find((entry: string) => entry.startsWith("pve-locale="));
if (localeEntry) {
const value = localeEntry.split("=")[1];
if (isLocale(value)) {
initialLocale = value;
}
}
}
return (
<html lang={initialLocale} className={geist.variable}>
<body
<html lang="en" className={geist.variable}>
<body
className="bg-background text-foreground transition-colors"
suppressHydrationWarning={true}
>
<LanguageProvider initialLocale={initialLocale}>
<ThemeProvider>
<TRPCReactProvider>
<AuthProvider>
<ModalStackProvider>
<AuthGuard>{children}</AuthGuard>
</ModalStackProvider>
</AuthProvider>
</TRPCReactProvider>
</ThemeProvider>
</LanguageProvider>
<ThemeProvider>
<TRPCReactProvider>
<AuthProvider>
<ModalStackProvider>
<AuthGuard>
{children}
</AuthGuard>
</ModalStackProvider>
</AuthProvider>
</TRPCReactProvider>
</ThemeProvider>
</body>
</html>
);

View File

@@ -1,67 +1,47 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
import { ResyncButton } from "./_components/ResyncButton";
import { Terminal } from "./_components/Terminal";
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
import { SettingsButton } from "./_components/SettingsButton";
import { HelpButton } from "./_components/HelpButton";
import { VersionDisplay } from "./_components/VersionDisplay";
import { ThemeToggle } from "./_components/ThemeToggle";
import { LanguageToggle } from "./_components/LanguageToggle";
import { Button } from "./_components/ui/button";
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
import {
ReleaseNotesModal,
getLastSeenVersion,
} from "./_components/ReleaseNotesModal";
import { Footer } from "./_components/Footer";
import { Package, HardDrive, FolderOpen } from "lucide-react";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { api } from "~/trpc/react";
'use client';
import { useState, useRef, useEffect } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { ResyncButton } from './_components/ResyncButton';
import { Terminal } from './_components/Terminal';
import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { SettingsButton } from './_components/SettingsButton';
import { HelpButton } from './_components/HelpButton';
import { VersionDisplay } from './_components/VersionDisplay';
import { ThemeToggle } from './_components/ThemeToggle';
import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer';
import { Package, HardDrive, FolderOpen } from 'lucide-react';
import { api } from '~/trpc/react';
export default function Home() {
const { t } = useTranslation("layout");
const [runningScript, setRunningScript] = useState<{
path: string;
name: string;
mode?: "local" | "ssh";
server?: any;
} | null>(null);
const [activeTab, setActiveTab] = useState<
"scripts" | "downloaded" | "installed"
>(() => {
if (typeof window !== "undefined") {
const savedTab = localStorage.getItem("activeTab") as
| "scripts"
| "downloaded"
| "installed";
return savedTab || "scripts";
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
if (typeof window !== 'undefined') {
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
return savedTab || 'scripts';
}
return "scripts";
return 'scripts';
});
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
undefined,
);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts
const { data: scriptCardsData } =
api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } =
api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } =
api.installedScripts.getAllInstalledScripts.useQuery();
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Save active tab to localStorage whenever it changes
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("activeTab", activeTab);
if (typeof window !== 'undefined') {
localStorage.setItem('activeTab', activeTab);
}
}, [activeTab]);
@@ -70,12 +50,9 @@ export default function Home() {
if (versionData?.success && versionData.version) {
const currentVersion = versionData.version;
const lastSeenVersion = getLastSeenVersion();
// If we have a current version and either no last seen version or versions don't match
if (
currentVersion &&
(!lastSeenVersion || currentVersion !== lastSeenVersion)
) {
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
}
@@ -96,11 +73,11 @@ export default function Home() {
const scriptCounts = {
available: (() => {
if (!scriptCardsData?.success) return 0;
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach((script) => {
scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
@@ -108,40 +85,38 @@ export default function Home() {
}
}
});
return scriptMap.size;
})(),
downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach((script) => {
scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
const deduplicatedGithubScripts = Array.from(scriptMap.values());
const localScripts = localScriptsData.scripts ?? [];
// Count scripts that are both in deduplicated GitHub data and have local versions
return deduplicatedGithubScripts.filter((script) => {
return deduplicatedGithubScripts.filter(script => {
if (!script?.name) return false;
return localScripts.some((local) => {
return localScripts.some(local => {
if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, "");
return (
localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === (script.slug ?? "").toLowerCase()
);
const localName = local.name.replace(/\.sh$/, '');
return localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
});
}).length;
})(),
installed: installedScriptsData?.scripts?.length ?? 0,
installed: installedScriptsData?.scripts?.length ?? 0
};
const scrollToTerminal = () => {
@@ -149,20 +124,15 @@ export default function Home() {
// Get the element's position and scroll with a small offset for better mobile experience
const elementTop = terminalRef.current.offsetTop;
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
window.scrollTo({
top: elementTop - offset,
behavior: "smooth",
behavior: 'smooth'
});
}
};
const handleRunScript = (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
) => {
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100);
@@ -173,22 +143,21 @@ export default function Home() {
};
return (
<main className="bg-background min-h-screen">
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
<main className="min-h-screen bg-background">
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
{/* Header */}
<div className="mb-6 text-center sm:mb-8">
<div className="mb-2 flex items-start justify-between">
<div className="text-center mb-6 sm:mb-8">
<div className="flex justify-between items-start mb-2">
<div className="flex-1"></div>
<h1 className="text-foreground flex flex-1 items-center justify-center gap-2 text-2xl font-bold sm:gap-3 sm:text-3xl lg:text-4xl">
<span className="break-words">{t("title")}</span>
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
<span className="break-words">PVE Scripts Management</span>
</h1>
<div className="flex flex-1 justify-end gap-2">
<LanguageToggle />
<div className="flex-1 flex justify-end">
<ThemeToggle />
</div>
</div>
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base">
{t("tagline")}
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
Manage and execute Proxmox helper scripts locally with live output streaming
</p>
<div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
@@ -197,7 +166,7 @@ export default function Home() {
{/* Controls */}
<div className="mb-6 sm:mb-8">
<div className="bg-card border-border flex flex-col gap-4 rounded-lg border p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center sm:p-6">
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
<ServerSettingsButton />
<SettingsButton />
<ResyncButton />
@@ -207,75 +176,65 @@ export default function Home() {
{/* Tab Navigation */}
<div className="mb-6 sm:mb-8">
<div className="border-border border-b">
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
<div className="border-b border-border">
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab("scripts")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "scripts"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<Package className="h-4 w-4" />
<span className="hidden sm:inline">{t("tabs.available")}</span>
<span className="sm:hidden">{t("tabs.availableShort")}</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
<span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available}
</span>
<ContextualHelpIcon
section="available-scripts"
tooltip={t("help.availableTooltip")}
/>
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab("downloaded")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "downloaded"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'downloaded'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">{t("tabs.downloaded")}</span>
<span className="sm:hidden">{t("tabs.downloadedShort")}</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
<span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded}
</span>
<ContextualHelpIcon
section="downloaded-scripts"
tooltip={t("help.downloadedTooltip")}
/>
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab("installed")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "installed"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
<FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">{t("tabs.installed")}</span>
<span className="sm:hidden">{t("tabs.installedShort")}</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
<span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed}
</span>
<ContextualHelpIcon
section="installed-scripts"
tooltip={t("help.installedTooltip")}
/>
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
</Button>
</nav>
</div>
</div>
{/* Running Script Terminal */}
{runningScript && (
<div ref={terminalRef} className="mb-8">
@@ -289,15 +248,17 @@ export default function Home() {
)}
{/* Tab Content */}
{activeTab === "scripts" && (
{activeTab === 'scripts' && (
<ScriptsGrid onInstallScript={handleRunScript} />
)}
{activeTab === "downloaded" && (
{activeTab === 'downloaded' && (
<DownloadedScriptsTab onInstallScript={handleRunScript} />
)}
{activeTab === "installed" && <InstalledScriptsTab />}
{activeTab === 'installed' && (
<InstalledScriptsTab />
)}
</div>
{/* Footer */}

View File

@@ -1,142 +0,0 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { defaultLocale, isLocale, locales, type Locale } from "./config";
import { createTranslator, type TranslateOptions } from "./translator";
export interface LanguageContextValue {
locale: Locale;
availableLocales: readonly Locale[];
setLocale: (nextLocale: Locale) => void;
t: (key: string, options?: TranslateOptions) => string;
}
const STORAGE_KEY = "pve-locale";
const COOKIE_KEY = "pve-locale";
export const LanguageContext = createContext<LanguageContextValue | undefined>(
undefined,
);
interface LanguageProviderProps {
children: ReactNode;
initialLocale?: Locale;
}
function getInitialLocale(initialLocale?: Locale): Locale {
if (initialLocale && isLocale(initialLocale)) {
return initialLocale;
}
if (typeof window === "undefined") {
return defaultLocale;
}
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored && isLocale(stored)) {
return stored;
}
const browserLocale = window.navigator.language?.slice(0, 2).toLowerCase();
if (isLocale(browserLocale)) {
return browserLocale;
}
} catch (error) {
console.error("Failed to resolve initial locale", error);
}
return defaultLocale;
}
export function LanguageProvider({
children,
initialLocale,
}: LanguageProviderProps) {
const [locale, setLocaleState] = useState<Locale>(() =>
getInitialLocale(initialLocale),
);
const hasHydrated = useRef(false);
useEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.lang = locale;
}
try {
window.localStorage.setItem(STORAGE_KEY, locale);
document.cookie = `${COOKIE_KEY}=${locale}; path=/; max-age=31536000`;
} catch (error) {
console.error("Failed to persist locale", error);
}
}, [locale]);
useEffect(() => {
if (hasHydrated.current) {
return;
}
hasHydrated.current = true;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored && isLocale(stored) && stored !== locale) {
setLocaleState(stored);
return;
}
const browserLocale = window.navigator.language
?.slice(0, 2)
.toLowerCase();
if (isLocale(browserLocale) && browserLocale !== locale) {
setLocaleState(browserLocale);
}
} catch (error) {
console.error("Failed to hydrate locale from client settings", error);
}
}, [locale]);
const setLocale = useCallback((nextLocale: Locale) => {
if (!isLocale(nextLocale)) {
return;
}
setLocaleState(nextLocale);
}, []);
const translator = useMemo(() => createTranslator(locale), [locale]);
const value = useMemo<LanguageContextValue>(
() => ({
locale,
availableLocales: locales,
setLocale,
t: (key: string, options?: TranslateOptions) => translator(key, options),
}),
[locale, setLocale, translator],
);
return (
<LanguageContext.Provider value={value}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguageContext(): LanguageContextValue {
const context = useContext(LanguageContext);
if (!context) {
throw new Error(
"useLanguageContext must be used within a LanguageProvider",
);
}
return context;
}

View File

@@ -1,9 +0,0 @@
export const locales = ['en', 'de'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && (locales as readonly string[]).includes(value);
}

View File

@@ -1,443 +0,0 @@
import type { NestedMessages } from './types';
export const deMessages: NestedMessages = {
common: {
language: {
english: 'Englisch',
german: 'Deutsch',
switch: 'Sprache wechseln',
},
actions: {
cancel: 'Abbrechen',
close: 'Schließen',
confirm: 'Bestätigen',
save: 'Speichern',
delete: 'Löschen',
edit: 'Bearbeiten',
reset: 'Zurücksetzen',
search: 'Suchen',
retry: 'Erneut versuchen',
install: 'Installieren',
update: 'Aktualisieren',
download: 'Herunterladen',
details: 'Details',
},
status: {
loading: 'Lädt ...',
success: 'Erfolg',
error: 'Ein Fehler ist aufgetreten',
empty: 'Keine Daten verfügbar',
},
},
confirmationModal: {
typeToConfirm: 'Tippe {text} um zu bestätigen:',
placeholder: 'Tippe "{text}" hier ein',
},
errorModal: {
detailsLabel: 'Details:',
errorDetailsLabel: 'Fehlerdetails:',
},
versionDisplay: {
loading: 'Lädt...',
unknownVersion: 'Unbekannt',
unableToCheck: '(Kann nicht nach Updates suchen)',
upToDate: '✓ Aktuell',
releaseNotes: 'Versionshinweise:',
helpTooltip: 'Hilfe zu Updates',
update: {
updateNow: 'Jetzt aktualisieren',
updateNowShort: 'Update',
updating: 'Aktualisiere...',
updatingShort: '...',
},
loadingOverlay: {
serverRestarting: 'Server wird neu gestartet',
updatingApplication: 'Anwendung wird aktualisiert',
serverRestartingMessage: 'Der Server wird nach dem Update neu gestartet...',
updatingMessage: 'Bitte warten Sie, während wir Ihre Anwendung aktualisieren...',
serverRestartingNote: 'Dies kann einige Momente dauern. Die Seite wird automatisch neu geladen.',
updatingNote: 'Der Server wird nach Abschluss automatisch neu gestartet.',
updateStarted: 'Update gestartet...',
updateComplete: 'Update abgeschlossen! Server wird neu gestartet...',
serverRestarting2: 'Server wird neu gestartet... warte auf Wiederverbindung...',
reconnecting: 'Versuche Verbindung wiederherzustellen...',
serverBackOnline: 'Server ist wieder online! Seite wird neu geladen...',
},
},
loadingModal: {
processing: 'Verarbeite',
pleaseWait: 'Bitte warten...',
},
authModal: {
title: 'Authentifizierung erforderlich',
description: 'Bitte geben Sie Ihre Anmeldedaten ein, um auf die Anwendung zuzugreifen.',
username: {
label: 'Benutzername',
placeholder: 'Benutzername eingeben',
},
password: {
label: 'Passwort',
placeholder: 'Passwort eingeben',
},
error: 'Ungültiger Benutzername oder Passwort',
actions: {
signIn: 'Anmelden',
signingIn: 'Anmeldung läuft...',
},
},
setupModal: {
title: 'Authentifizierung einrichten',
description: 'Richten Sie die Authentifizierung ein, um Ihre Anwendung zu sichern. Diese wird für zukünftige Zugriffe erforderlich sein.',
username: {
label: 'Benutzername',
placeholder: 'Wählen Sie einen Benutzernamen',
},
password: {
label: 'Passwort',
placeholder: 'Wählen Sie ein Passwort',
},
confirmPassword: {
label: 'Passwort bestätigen',
placeholder: 'Bestätigen Sie Ihr Passwort',
},
enableAuth: {
title: 'Authentifizierung aktivieren',
descriptionEnabled: 'Authentifizierung wird bei jedem Seitenladevorgang erforderlich sein',
descriptionDisabled: 'Authentifizierung wird optional sein (kann später in den Einstellungen aktiviert werden)',
label: 'Authentifizierung aktivieren',
},
errors: {
passwordMismatch: 'Passwörter stimmen nicht überein',
setupFailed: 'Fehler beim Einrichten der Authentifizierung',
},
actions: {
completeSetup: 'Einrichtung abschließen',
settingUp: 'Wird eingerichtet...',
},
},
helpButton: {
needHelp: 'Brauchen Sie Hilfe?',
openHelp: 'Hilfe öffnen',
help: 'Hilfe',
},
resyncButton: {
syncDescription: 'Skripte mit ProxmoxVE-Repo synchronisieren',
syncing: 'Synchronisiere...',
syncJsonFiles: 'JSON-Dateien synchronisieren',
helpTooltip: 'Hilfe zum Sync-Button',
lastSync: 'Letzte Synchronisierung: {time}',
messages: {
success: 'Skripte erfolgreich synchronisiert',
failed: 'Fehler beim Synchronisieren der Skripte',
error: 'Fehler: {message}',
},
},
viewToggle: {
cardView: 'Karten-Ansicht',
listView: 'Listen-Ansicht',
},
scriptCard: {
unnamedScript: 'Unbenanntes Skript',
downloaded: 'Heruntergeladen',
notDownloaded: 'Nicht heruntergeladen',
noDescription: 'Keine Beschreibung verfügbar',
website: 'Webseite',
},
badge: {
updateable: 'Aktualisierbar',
privileged: 'Privilegiert',
},
serverSettingsButton: {
description: 'PVE-Server hinzufügen und verwalten:',
buttonTitle: 'PVE-Server hinzufügen',
buttonLabel: 'PVE-Server verwalten',
},
settingsButton: {
description: 'Anwendungseinstellungen:',
buttonTitle: 'Einstellungen öffnen',
buttonLabel: 'Einstellungen',
},
executionModeModal: {
title: 'Server auswählen',
loadingServers: 'Lade Server...',
noServersConfigured: 'Keine Server konfiguriert',
addServersHint: 'Fügen Sie Server in den Einstellungen hinzu, um Skripte auszuführen',
openServerSettings: 'Servereinstellungen öffnen',
installConfirmation: {
title: 'Skript-Installation bestätigen',
description: 'Möchten Sie "{scriptName}" auf folgendem Server installieren?',
},
unnamedServer: 'Unbenannter Server',
multipleServers: {
title: 'Server für "{scriptName}" auswählen',
selectServerLabel: 'Server auswählen',
placeholder: 'Wählen Sie einen Server...',
},
actions: {
cancel: 'Abbrechen',
install: 'Installieren',
runOnServer: 'Auf Server ausführen',
},
errors: {
noServerSelected: 'Bitte wählen Sie einen Server für die SSH-Ausführung',
fetchFailed: 'Fehler beim Abrufen der Server',
},
},
publicKeyModal: {
title: 'SSH Public Key',
subtitle: 'Fügen Sie diesen Schlüssel zu den authorized_keys Ihres Servers hinzu',
instructions: {
title: 'Anleitung:',
step1: 'Kopieren Sie den unten stehenden öffentlichen Schlüssel',
step2: 'SSH-Verbindung zu Ihrem Server herstellen:',
step3: 'Schlüssel zu authorized_keys hinzufügen:',
step4: 'Korrekte Berechtigungen setzen:',
},
publicKeyLabel: 'Öffentlicher Schlüssel:',
quickCommandLabel: 'Schnell-Hinzufügen-Befehl:',
quickCommandHint: 'Kopieren Sie diesen Befehl und fügen Sie ihn direkt in Ihr Server-Terminal ein, um den Schlüssel zu authorized_keys hinzuzufügen',
placeholder: 'Öffentlicher Schlüssel wird hier angezeigt...',
actions: {
copy: 'Kopieren',
copied: 'Kopiert!',
copyCommand: 'Befehl kopieren',
close: 'Schließen',
},
copyFallback: 'Bitte kopieren Sie diesen Schlüssel manuell:\n\n',
copyCommandFallback: 'Bitte kopieren Sie diesen Befehl manuell:\n\n',
},
layout: {
title: 'PVE Skriptverwaltung',
tagline: 'Verwalte und starte lokale Proxmox-Hilfsskripte mit Live-Ausgabe',
releaseNotes: 'Versionshinweise',
tabs: {
available: 'Verfügbare Skripte',
availableShort: 'Verfügbar',
downloaded: 'Heruntergeladene Skripte',
downloadedShort: 'Downloads',
installed: 'Installierte Skripte',
installedShort: 'Installiert',
},
help: {
availableTooltip: 'Hilfe zu Verfügbaren Skripten',
downloadedTooltip: 'Hilfe zu heruntergeladenen Skripten',
installedTooltip: 'Hilfe zu installierten Skripten',
},
},
footer: {
copyright: '© {year} PVE Scripts Local',
github: 'GitHub',
releaseNotes: 'Versionshinweise',
},
filterBar: {
loading: 'Gespeicherte Filter werden geladen...',
header: 'Skripte filtern',
helpTooltip: 'Hilfe zum Filtern und Suchen',
search: {
placeholder: 'Skripte durchsuchen...'
},
updatable: {
all: 'Aktualisierbar: Alle',
yes: 'Aktualisierbar: Ja ({count})',
no: 'Aktualisierbar: Nein'
},
types: {
all: 'Alle Typen',
multiple: '{count} Typen',
options: {
ct: 'LXC-Container',
vm: 'Virtuelle Maschine',
addon: 'Add-on',
pve: 'PVE-Host'
}
},
actions: {
clearAllTypes: 'Alle löschen',
clearFilters: 'Alle Filter löschen'
},
sort: {
byName: 'Nach Name',
byCreated: 'Nach Erstelldatum',
oldestFirst: 'Älteste zuerst',
newestFirst: 'Neueste zuerst',
aToZ: 'A-Z',
zToA: 'Z-A'
},
summary: {
showingAll: 'Alle {count} Skripte werden angezeigt',
showingFiltered: '{filtered} von {total} Skripten',
filteredSuffix: '(gefiltert)'
},
persistence: {
enabled: 'Filter werden automatisch gespeichert'
}
},
categorySidebar: {
headerTitle: 'Kategorien',
totalScripts: '{count} Skripte insgesamt',
helpTooltip: 'Hilfe zu Kategorien',
actions: {
collapse: 'Kategorien einklappen',
expand: 'Kategorien ausklappen',
},
all: {
label: 'Alle Kategorien',
tooltip: 'Alle Kategorien ({count})',
},
tooltips: {
category: '{category} ({count})',
},
categories: {
'Proxmox & Virtualization': 'Proxmox & Virtualisierung',
'Operating Systems': 'Betriebssysteme',
'Containers & Docker': 'Container & Docker',
'Network & Firewall': 'Netzwerk & Firewall',
'Adblock & DNS': 'Adblock & DNS',
'Authentication & Security': 'Authentifizierung & Sicherheit',
'Backup & Recovery': 'Backup & Wiederherstellung',
'Databases': 'Datenbanken',
'Monitoring & Analytics': 'Monitoring & Analysen',
'Dashboards & Frontends': 'Dashboards & Frontends',
'Files & Downloads': 'Dateien & Downloads',
'Documents & Notes': 'Dokumente & Notizen',
'Media & Streaming': 'Medien & Streaming',
'*Arr Suite': '*Arr Suite',
'NVR & Cameras': 'NVR & Kameras',
'IoT & Smart Home': 'IoT & Smart Home',
'ZigBee, Z-Wave & Matter': 'ZigBee, Z-Wave & Matter',
'MQTT & Messaging': 'MQTT & Messaging',
'Automation & Scheduling': 'Automatisierung & Planung',
'AI / Coding & Dev-Tools': 'KI / Coding & Dev-Tools',
'Webservers & Proxies': 'Webserver & Proxys',
'Bots & ChatOps': 'Bots & ChatOps',
'Finance & Budgeting': 'Finanzen & Budgetierung',
'Gaming & Leisure': 'Gaming & Freizeit',
'Business & ERP': 'Business & ERP',
'Miscellaneous': 'Verschiedenes',
},
},
settings: {
title: 'Einstellungen',
close: 'Schließen',
help: 'Hilfe zu den Einstellungen',
tabs: {
general: 'Allgemein',
github: 'GitHub',
auth: 'Authentifizierung',
},
general: {
title: 'Allgemeine Einstellungen',
description: 'Konfiguriere allgemeine Anwendungspräferenzen und Verhalten.',
sections: {
theme: {
title: 'Design',
description: 'Wähle dein bevorzugtes Farbdesign für die Anwendung.',
current: 'Aktuelles Design',
lightLabel: 'Hell',
darkLabel: 'Dunkel',
actions: {
light: 'Hell',
dark: 'Dunkel',
},
},
language: {
title: 'Sprache',
description: 'Wähle deine bevorzugte Anzeigesprache.',
current: 'Aktuelle Sprache',
actions: {
english: 'Englisch',
german: 'Deutsch',
},
},
filters: {
title: 'Filter speichern',
description: 'Speichere deine konfigurierten Skriptfilter.',
toggleLabel: 'Filterspeicherung aktivieren',
savedTitle: 'Gespeicherte Filter',
savedActive: 'Filter sind derzeit gespeichert',
savedEmpty: 'Noch keine Filter gespeichert',
details: {
search: 'Suche: {value}',
types: 'Typen: {count} ausgewählt',
sort: 'Sortierung: {field} ({order})',
none: 'Keine',
},
actions: {
clear: 'Löschen',
},
},
colorCoding: {
title: 'Server-Farbcodierung',
description: 'Aktiviere die Farbcodierung für Server, um sie in der Anwendung visuell zu unterscheiden.',
toggleLabel: 'Server-Farbcodierung aktivieren',
},
},
},
github: {
title: 'GitHub-Integration',
description: 'Konfiguriere die GitHub-Integration für Skriptverwaltung und Updates.',
sections: {
token: {
title: 'Persönliches GitHub-Zugriffstoken',
description: 'Speichere ein GitHub Personal Access Token, um GitHub API-Ratenbeschränkungen zu umgehen.',
tokenLabel: 'Token',
placeholder: 'Gib dein GitHub Personal Access Token ein',
actions: {
save: 'Token speichern',
saving: 'Speichern...',
refresh: 'Aktualisieren',
loading: 'Lädt...',
},
},
},
},
auth: {
title: 'Authentifizierungseinstellungen',
description: 'Konfiguriere die Authentifizierung, um den Zugriff auf deine Anwendung zu sichern.',
sections: {
status: {
title: 'Authentifizierungsstatus',
enabledWithCredentials: 'Authentifizierung ist {status}. Aktueller Benutzername: {username}',
enabledWithoutCredentials: 'Authentifizierung ist {status}. Keine Anmeldedaten konfiguriert.',
notSetup: 'Authentifizierung wurde noch nicht eingerichtet.',
enabled: 'aktiviert',
disabled: 'deaktiviert',
toggleLabel: 'Authentifizierung aktivieren',
toggleEnabled: 'Authentifizierung ist bei jedem Seitenladen erforderlich',
toggleDisabled: 'Authentifizierung ist optional',
},
credentials: {
title: 'Anmeldedaten aktualisieren',
description: 'Ändere deinen Benutzernamen und dein Passwort für die Authentifizierung.',
usernameLabel: 'Benutzername',
usernamePlaceholder: 'Benutzername eingeben',
passwordLabel: 'Neues Passwort',
passwordPlaceholder: 'Neues Passwort eingeben',
confirmPasswordLabel: 'Passwort bestätigen',
confirmPasswordPlaceholder: 'Neues Passwort bestätigen',
actions: {
update: 'Anmeldedaten aktualisieren',
updating: 'Speichern...',
refresh: 'Aktualisieren',
loading: 'Lädt...',
},
},
},
},
messages: {
filterSettingSaved: 'Filterspeicherungseinstellung aktualisiert!',
filterSettingError: 'Fehler beim Speichern der Einstellung',
clearFiltersSuccess: 'Gespeicherte Filter gelöscht!',
clearFiltersError: 'Fehler beim Löschen der Filter',
colorCodingSuccess: 'Farbcodierungseinstellung erfolgreich gespeichert',
colorCodingError: 'Fehler beim Speichern der Farbcodierungseinstellung',
githubTokenSuccess: 'GitHub-Token erfolgreich gespeichert!',
githubTokenError: 'Fehler beim Speichern des Tokens',
authCredentialsSuccess: 'Authentifizierungsanmeldedaten erfolgreich aktualisiert!',
authCredentialsError: 'Fehler beim Speichern der Anmeldedaten',
authStatusSuccess: 'Authentifizierung erfolgreich {status}!',
authStatusError: 'Fehler beim Aktualisieren des Authentifizierungsstatus',
passwordMismatch: 'Passwörter stimmen nicht überein',
},
},
};

View File

@@ -1,443 +0,0 @@
import type { NestedMessages } from './types';
export const enMessages: NestedMessages = {
common: {
language: {
english: 'English',
german: 'German',
switch: 'Switch language',
},
actions: {
cancel: 'Cancel',
close: 'Close',
confirm: 'Confirm',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
reset: 'Reset',
search: 'Search',
retry: 'Retry',
install: 'Install',
update: 'Update',
download: 'Download',
details: 'Details',
},
status: {
loading: 'Loading...',
success: 'Success',
error: 'An error occurred',
empty: 'No data available',
},
},
confirmationModal: {
typeToConfirm: 'Type {text} to confirm:',
placeholder: 'Type "{text}" here',
},
errorModal: {
detailsLabel: 'Details:',
errorDetailsLabel: 'Error Details:',
},
versionDisplay: {
loading: 'Loading...',
unknownVersion: 'Unknown',
unableToCheck: '(Unable to check for updates)',
upToDate: '✓ Up to date',
releaseNotes: 'Release Notes:',
helpTooltip: 'Help with updates',
update: {
updateNow: 'Update Now',
updateNowShort: 'Update',
updating: 'Updating...',
updatingShort: '...',
},
loadingOverlay: {
serverRestarting: 'Server Restarting',
updatingApplication: 'Updating Application',
serverRestartingMessage: 'The server is restarting after the update...',
updatingMessage: 'Please stand by while we update your application...',
serverRestartingNote: 'This may take a few moments. The page will reload automatically.',
updatingNote: 'The server will restart automatically when complete.',
updateStarted: 'Update started...',
updateComplete: 'Update complete! Server restarting...',
serverRestarting2: 'Server restarting... waiting for reconnection...',
reconnecting: 'Attempting to reconnect...',
serverBackOnline: 'Server is back online! Reloading...',
},
},
layout: {
title: 'PVE Scripts Management',
tagline: 'Manage and execute Proxmox helper scripts locally with live output streaming',
releaseNotes: 'Release Notes',
tabs: {
available: 'Available Scripts',
availableShort: 'Available',
downloaded: 'Downloaded Scripts',
downloadedShort: 'Downloaded',
installed: 'Installed Scripts',
installedShort: 'Installed',
},
help: {
availableTooltip: 'Help with Available Scripts',
downloadedTooltip: 'Help with Downloaded Scripts',
installedTooltip: 'Help with Installed Scripts',
},
},
footer: {
copyright: '© {year} PVE Scripts Local',
github: 'GitHub',
releaseNotes: 'Release Notes',
},
filterBar: {
loading: 'Loading saved filters...',
header: 'Filter Scripts',
helpTooltip: 'Help with filtering and searching',
search: {
placeholder: 'Search scripts...'
},
updatable: {
all: 'Updatable: All',
yes: 'Updatable: Yes ({count})',
no: 'Updatable: No'
},
types: {
all: 'All Types',
multiple: '{count} Types',
options: {
ct: 'LXC Container',
vm: 'Virtual Machine',
addon: 'Add-on',
pve: 'PVE Host'
}
},
actions: {
clearAllTypes: 'Clear all',
clearFilters: 'Clear all filters'
},
sort: {
byName: 'By Name',
byCreated: 'By Created Date',
oldestFirst: 'Oldest First',
newestFirst: 'Newest First',
aToZ: 'A-Z',
zToA: 'Z-A'
},
summary: {
showingAll: 'Showing all {count} scripts',
showingFiltered: '{filtered} of {total} scripts',
filteredSuffix: '(filtered)'
},
persistence: {
enabled: 'Filters are being saved automatically'
}
},
categorySidebar: {
headerTitle: 'Categories',
totalScripts: '{count} total scripts',
helpTooltip: 'Help with categories',
actions: {
collapse: 'Collapse categories',
expand: 'Expand categories',
},
all: {
label: 'All Categories',
tooltip: 'All Categories ({count})',
},
tooltips: {
category: '{category} ({count})',
},
categories: {
'Proxmox & Virtualization': 'Proxmox & Virtualization',
'Operating Systems': 'Operating Systems',
'Containers & Docker': 'Containers & Docker',
'Network & Firewall': 'Network & Firewall',
'Adblock & DNS': 'Adblock & DNS',
'Authentication & Security': 'Authentication & Security',
'Backup & Recovery': 'Backup & Recovery',
'Databases': 'Databases',
'Monitoring & Analytics': 'Monitoring & Analytics',
'Dashboards & Frontends': 'Dashboards & Frontends',
'Files & Downloads': 'Files & Downloads',
'Documents & Notes': 'Documents & Notes',
'Media & Streaming': 'Media & Streaming',
'*Arr Suite': '*Arr Suite',
'NVR & Cameras': 'NVR & Cameras',
'IoT & Smart Home': 'IoT & Smart Home',
'ZigBee, Z-Wave & Matter': 'ZigBee, Z-Wave & Matter',
'MQTT & Messaging': 'MQTT & Messaging',
'Automation & Scheduling': 'Automation & Scheduling',
'AI / Coding & Dev-Tools': 'AI / Coding & Dev-Tools',
'Webservers & Proxies': 'Webservers & Proxies',
'Bots & ChatOps': 'Bots & ChatOps',
'Finance & Budgeting': 'Finance & Budgeting',
'Gaming & Leisure': 'Gaming & Leisure',
'Business & ERP': 'Business & ERP',
'Miscellaneous': 'Miscellaneous',
},
},
settings: {
title: 'Settings',
close: 'Close',
help: 'Help with General Settings',
tabs: {
general: 'General',
github: 'GitHub',
auth: 'Authentication',
},
general: {
title: 'General Settings',
description: 'Configure general application preferences and behavior.',
sections: {
theme: {
title: 'Theme',
description: 'Choose your preferred color theme for the application.',
current: 'Current Theme',
lightLabel: 'Light mode',
darkLabel: 'Dark mode',
actions: {
light: 'Light',
dark: 'Dark',
},
},
language: {
title: 'Language',
description: 'Choose your preferred display language.',
current: 'Current Language',
actions: {
english: 'English',
german: 'German',
},
},
filters: {
title: 'Save Filters',
description: 'Save your configured script filters.',
toggleLabel: 'Enable filter saving',
savedTitle: 'Saved Filters',
savedActive: 'Filters are currently saved',
savedEmpty: 'No filters saved yet',
details: {
search: 'Search: {value}',
types: 'Types: {count} selected',
sort: 'Sort: {field} ({order})',
none: 'None',
},
actions: {
clear: 'Clear',
},
},
colorCoding: {
title: 'Server Color Coding',
description: 'Enable color coding for servers to visually distinguish them throughout the application.',
toggleLabel: 'Enable server color coding',
},
},
},
github: {
title: 'GitHub Integration',
description: 'Configure GitHub integration for script management and updates.',
sections: {
token: {
title: 'GitHub Personal Access Token',
description: 'Save a GitHub Personal Access Token to circumvent GitHub API rate limits.',
tokenLabel: 'Token',
placeholder: 'Enter your GitHub Personal Access Token',
actions: {
save: 'Save Token',
saving: 'Saving...',
refresh: 'Refresh',
loading: 'Loading...',
},
},
},
},
auth: {
title: 'Authentication Settings',
description: 'Configure authentication to secure access to your application.',
sections: {
status: {
title: 'Authentication Status',
enabledWithCredentials: 'Authentication is {status}. Current username: {username}',
enabledWithoutCredentials: 'Authentication is {status}. No credentials configured.',
notSetup: 'Authentication setup has not been completed yet.',
enabled: 'enabled',
disabled: 'disabled',
toggleLabel: 'Enable Authentication',
toggleEnabled: 'Authentication is required on every page load',
toggleDisabled: 'Authentication is optional',
},
credentials: {
title: 'Update Credentials',
description: 'Change your username and password for authentication.',
usernameLabel: 'Username',
usernamePlaceholder: 'Enter username',
passwordLabel: 'New Password',
passwordPlaceholder: 'Enter new password',
confirmPasswordLabel: 'Confirm Password',
confirmPasswordPlaceholder: 'Confirm new password',
actions: {
update: 'Update Credentials',
updating: 'Saving...',
refresh: 'Refresh',
loading: 'Loading...',
},
},
},
},
messages: {
filterSettingSaved: 'Save filter setting updated!',
filterSettingError: 'Failed to save setting',
clearFiltersSuccess: 'Saved filters cleared!',
clearFiltersError: 'Failed to clear filters',
colorCodingSuccess: 'Color coding setting saved successfully',
colorCodingError: 'Failed to save color coding setting',
githubTokenSuccess: 'GitHub token saved successfully!',
githubTokenError: 'Failed to save token',
authCredentialsSuccess: 'Authentication credentials updated successfully!',
authCredentialsError: 'Failed to save credentials',
authStatusSuccess: 'Authentication {status} successfully!',
authStatusError: 'Failed to update auth status',
passwordMismatch: 'Passwords do not match',
},
},
loadingModal: {
processing: 'Processing',
pleaseWait: 'Please wait...',
},
authModal: {
title: 'Authentication Required',
description: 'Please enter your credentials to access the application.',
username: {
label: 'Username',
placeholder: 'Enter your username',
},
password: {
label: 'Password',
placeholder: 'Enter your password',
},
error: 'Invalid username or password',
actions: {
signIn: 'Sign In',
signingIn: 'Signing In...',
},
},
setupModal: {
title: 'Setup Authentication',
description: 'Set up authentication to secure your application. This will be required for future access.',
username: {
label: 'Username',
placeholder: 'Choose a username',
},
password: {
label: 'Password',
placeholder: 'Choose a password',
},
confirmPassword: {
label: 'Confirm Password',
placeholder: 'Confirm your password',
},
enableAuth: {
title: 'Enable Authentication',
descriptionEnabled: 'Authentication will be required on every page load',
descriptionDisabled: 'Authentication will be optional (can be enabled later in settings)',
label: 'Enable authentication',
},
errors: {
passwordMismatch: 'Passwords do not match',
setupFailed: 'Failed to setup authentication',
},
actions: {
completeSetup: 'Complete Setup',
settingUp: 'Setting Up...',
},
},
helpButton: {
needHelp: 'Need help?',
openHelp: 'Open Help',
help: 'Help',
},
resyncButton: {
syncDescription: 'Sync scripts with ProxmoxVE repo',
syncing: 'Syncing...',
syncJsonFiles: 'Sync Json Files',
helpTooltip: 'Help with Sync Button',
lastSync: 'Last sync: {time}',
messages: {
success: 'Scripts synced successfully',
failed: 'Failed to sync scripts',
error: 'Error: {message}',
},
},
viewToggle: {
cardView: 'Card View',
listView: 'List View',
},
scriptCard: {
unnamedScript: 'Unnamed Script',
downloaded: 'Downloaded',
notDownloaded: 'Not Downloaded',
noDescription: 'No description available',
website: 'Website',
},
badge: {
updateable: 'Updateable',
privileged: 'Privileged',
},
serverSettingsButton: {
description: 'Add and manage PVE Servers:',
buttonTitle: 'Add PVE Server',
buttonLabel: 'Manage PVE Servers',
},
settingsButton: {
description: 'Application Settings:',
buttonTitle: 'Open Settings',
buttonLabel: 'Settings',
},
executionModeModal: {
title: 'Select Server',
loadingServers: 'Loading servers...',
noServersConfigured: 'No servers configured',
addServersHint: 'Add servers in Settings to execute scripts',
openServerSettings: 'Open Server Settings',
installConfirmation: {
title: 'Install Script Confirmation',
description: 'Do you want to install "{scriptName}" on the following server?',
},
unnamedServer: 'Unnamed Server',
multipleServers: {
title: 'Select server to execute "{scriptName}"',
selectServerLabel: 'Select Server',
placeholder: 'Select a server...',
},
actions: {
cancel: 'Cancel',
install: 'Install',
runOnServer: 'Run on Server',
},
errors: {
noServerSelected: 'Please select a server for SSH execution',
fetchFailed: 'Failed to fetch servers',
},
},
publicKeyModal: {
title: 'SSH Public Key',
subtitle: 'Add this key to your server\'s authorized_keys',
instructions: {
title: 'Instructions:',
step1: 'Copy the public key below',
step2: 'SSH into your server:',
step3: 'Add the key to authorized_keys:',
step4: 'Set proper permissions:',
},
publicKeyLabel: 'Public Key:',
quickCommandLabel: 'Quick Add Command:',
quickCommandHint: 'Copy and paste this command directly into your server terminal to add the key to authorized_keys',
placeholder: 'Public key will appear here...',
actions: {
copy: 'Copy',
copied: 'Copied!',
copyCommand: 'Copy Command',
close: 'Close',
},
copyFallback: 'Please manually copy this key:\n\n',
copyCommandFallback: 'Please manually copy this command:\n\n',
},
};

View File

@@ -1,9 +0,0 @@
import type { Locale } from '../config';
import type { NestedMessages } from './types';
import { enMessages } from './en';
import { deMessages } from './de';
export const messages: Record<Locale, NestedMessages> = {
en: enMessages,
de: deMessages,
};

View File

@@ -1,3 +0,0 @@
export type NestedMessages = {
[key: string]: string | NestedMessages;
};

View File

@@ -1,76 +0,0 @@
import { defaultLocale, type Locale, isLocale } from './config';
import { messages } from './messages';
import type { NestedMessages } from './messages/types';
export type TranslateValues = Record<string, string | number>;
export interface TranslateOptions {
fallback?: string;
values?: TranslateValues;
}
function getNestedMessage(tree: NestedMessages | string | undefined, segments: string[]): string | undefined {
if (segments.length === 0) {
return typeof tree === 'string' ? tree : undefined;
}
if (!tree || typeof tree === 'string') {
return undefined;
}
const [current, ...rest] = segments;
if (!current) {
return undefined;
}
const next: NestedMessages | string | undefined = tree[current];
return getNestedMessage(next, rest);
}
function formatMessage(template: string, values?: TranslateValues): string {
if (!values) {
return template;
}
return template.replace(/\{(.*?)\}/g, (match, token: string) => {
const value = values[token];
if (value === undefined || value === null) {
return match;
}
return String(value);
});
}
function resolveMessage(locale: Locale, key: string): string | undefined {
const dictionary = messages[locale];
if (!dictionary) {
return undefined;
}
const segments = key.split('.').filter(Boolean);
return getNestedMessage(dictionary, segments);
}
export function createTranslator(locale: Locale) {
const normalizedLocale: Locale = isLocale(locale) ? locale : defaultLocale;
return (key: string, options?: TranslateOptions): string => {
const fallbackLocales: Locale[] = [normalizedLocale];
if (normalizedLocale !== defaultLocale) {
fallbackLocales.push(defaultLocale);
}
for (const currentLocale of fallbackLocales) {
const message = resolveMessage(currentLocale, key);
if (typeof message === 'string') {
return formatMessage(message, options?.values);
}
}
if (options?.fallback) {
return formatMessage(options.fallback, options.values);
}
return key;
};
}

View File

@@ -1,31 +0,0 @@
'use client';
import { useCallback } from 'react';
import { type LanguageContextValue, useLanguageContext } from './LanguageProvider';
import type { TranslateOptions } from './translator';
export interface UseTranslationResult {
locale: LanguageContextValue['locale'];
availableLocales: LanguageContextValue['availableLocales'];
setLocale: LanguageContextValue['setLocale'];
t: (key: string, options?: TranslateOptions) => string;
}
export function useTranslation(namespace?: string): UseTranslationResult {
const { t: translate, locale, setLocale, availableLocales } = useLanguageContext();
const scopedTranslate = useCallback(
(key: string, options?: TranslateOptions) => {
const namespacedKey = namespace ? `${namespace}.${key}` : key;
return translate(namespacedKey, options);
},
[namespace, translate],
);
return {
locale,
availableLocales,
setLocale,
t: scopedTranslate,
};
}

View File

@@ -4,6 +4,7 @@ import { scriptManager } from "~/server/lib/scripts";
import { githubJsonService } from "~/server/services/githubJsonService";
import { localScriptsService } from "~/server/services/localScripts";
import { scriptDownloaderService } from "~/server/services/scriptDownloader";
import { AutoSyncService } from "~/server/services/autoSyncService";
import type { ScriptCard } from "~/types/script";
export const scriptsRouter = createTRPCRouter({
@@ -114,6 +115,18 @@ export const scriptsRouter = createTRPCRouter({
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
try {
console.log('getScriptBySlug called with slug:', input.slug);
console.log('githubJsonService methods:', Object.getOwnPropertyNames(Object.getPrototypeOf(githubJsonService)));
console.log('githubJsonService.getScriptBySlug type:', typeof githubJsonService.getScriptBySlug);
if (typeof githubJsonService.getScriptBySlug !== 'function') {
return {
success: false,
error: 'getScriptBySlug method is not available on githubJsonService',
script: null
};
}
const script = await githubJsonService.getScriptBySlug(input.slug);
if (!script) {
return {
@@ -124,6 +137,7 @@ export const scriptsRouter = createTRPCRouter({
}
return { success: true, script };
} catch (error) {
console.error('Error in getScriptBySlug:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch script',
@@ -457,5 +471,121 @@ export const scriptsRouter = createTRPCRouter({
message: 'Failed to check Proxmox VE status'
};
}
}),
// Auto-sync settings and operations
getAutoSyncSettings: publicProcedure
.query(async () => {
try {
const autoSyncService = new AutoSyncService();
const settings = autoSyncService.loadSettings();
return { success: true, settings };
} catch (error) {
console.error('Error getting auto-sync settings:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get auto-sync settings',
settings: null
};
}
}),
saveAutoSyncSettings: publicProcedure
.input(z.object({
autoSyncEnabled: z.boolean(),
syncIntervalType: z.enum(['predefined', 'custom']),
syncIntervalPredefined: z.string().optional(),
syncIntervalCron: z.string().optional(),
autoDownloadNew: z.boolean(),
autoUpdateExisting: z.boolean(),
notificationEnabled: z.boolean(),
appriseUrls: z.array(z.string()).optional()
}))
.mutation(async ({ input }) => {
try {
// Use the global auto-sync service instance
const { getAutoSyncService, setAutoSyncService } = await import('~/server/lib/autoSyncInit');
let autoSyncService = getAutoSyncService();
// If no global instance exists, create one
if (!autoSyncService) {
const { AutoSyncService } = await import('~/server/services/autoSyncService');
autoSyncService = new AutoSyncService();
setAutoSyncService(autoSyncService);
}
// Save settings to both .env file and service instance
autoSyncService.saveSettings(input);
// Reschedule auto-sync if enabled
if (input.autoSyncEnabled) {
autoSyncService.scheduleAutoSync();
console.log('Auto-sync rescheduled with new settings');
} else {
autoSyncService.stopAutoSync();
// Ensure the service is completely stopped and won't restart
autoSyncService.isRunning = false;
console.log('Auto-sync stopped');
}
return { success: true, message: 'Auto-sync settings saved successfully' };
} catch (error) {
console.error('Error saving auto-sync settings:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save auto-sync settings'
};
}
}),
testNotification: publicProcedure
.mutation(async () => {
try {
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.testNotification();
return result;
} catch (error) {
console.error('Error testing notification:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to test notification'
};
}
}),
triggerManualAutoSync: publicProcedure
.mutation(async () => {
try {
const autoSyncService = new AutoSyncService();
const result = await autoSyncService.executeAutoSync();
return {
success: true,
message: 'Manual auto-sync completed successfully',
result
};
} catch (error) {
console.error('Error in manual auto-sync:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to execute manual auto-sync',
result: null
};
}
}),
getAutoSyncStatus: publicProcedure
.query(async () => {
try {
const autoSyncService = new AutoSyncService();
const status = autoSyncService.getStatus();
return { success: true, status };
} catch (error) {
console.error('Error getting auto-sync status:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get auto-sync status',
status: null
};
}
})
});

View File

@@ -0,0 +1,84 @@
import { AutoSyncService } from '../services/autoSyncService.js';
let autoSyncService = null;
let isInitialized = false;
/**
* Initialize auto-sync service and schedule cron job if enabled
*/
export function initializeAutoSync() {
if (isInitialized) {
console.log('Auto-sync service already initialized, skipping...');
return;
}
try {
console.log('Initializing auto-sync service...');
autoSyncService = new AutoSyncService();
isInitialized = true;
console.log('AutoSyncService instance created');
// Load settings and schedule if enabled
const settings = autoSyncService.loadSettings();
console.log('Settings loaded:', settings);
if (settings.autoSyncEnabled) {
console.log('Auto-sync is enabled, scheduling cron job...');
autoSyncService.scheduleAutoSync();
console.log('Cron job scheduled');
} else {
console.log('Auto-sync is disabled');
}
console.log('Auto-sync service initialized successfully');
} catch (error) {
console.error('Failed to initialize auto-sync service:', error);
console.error('Error stack:', error.stack);
}
}
/**
* Stop auto-sync service and clean up cron jobs
*/
export function stopAutoSync() {
try {
if (autoSyncService) {
console.log('Stopping auto-sync service...');
autoSyncService.stopAutoSync();
autoSyncService = null;
isInitialized = false;
console.log('Auto-sync service stopped');
}
} catch (error) {
console.error('Error stopping auto-sync service:', error);
}
}
/**
* Get the auto-sync service instance
*/
export function getAutoSyncService() {
return autoSyncService;
}
/**
* Set the auto-sync service instance (for external management)
*/
export function setAutoSyncService(service) {
autoSyncService = service;
}
/**
* Graceful shutdown handler
*/
export function setupGracefulShutdown() {
const shutdown = (signal) => {
console.log(`Received ${signal}, shutting down gracefully...`);
stopAutoSync();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGUSR2', () => shutdown('SIGUSR2')); // For nodemon
}

View File

@@ -0,0 +1,72 @@
import { AutoSyncService } from '~/server/services/autoSyncService';
let autoSyncService: AutoSyncService | null = null;
/**
* Initialize auto-sync service and schedule cron job if enabled
*/
export function initializeAutoSync(): void {
try {
console.log('Initializing auto-sync service...');
autoSyncService = new AutoSyncService();
// Load settings and schedule if enabled
const settings = autoSyncService.loadSettings();
if (settings.autoSyncEnabled) {
console.log('Auto-sync is enabled, scheduling cron job...');
autoSyncService.scheduleAutoSync();
} else {
console.log('Auto-sync is disabled');
}
console.log('Auto-sync service initialized successfully');
} catch (error) {
console.error('Failed to initialize auto-sync service:', error);
}
}
/**
* Stop auto-sync service and clean up cron jobs
*/
export function stopAutoSync(): void {
try {
if (autoSyncService) {
console.log('Stopping auto-sync service...');
autoSyncService.stopAutoSync();
autoSyncService = null;
console.log('Auto-sync service stopped');
}
} catch (error) {
console.error('Error stopping auto-sync service:', error);
}
}
/**
* Get the auto-sync service instance
*/
export function getAutoSyncService(): AutoSyncService | null {
return autoSyncService;
}
/**
* Set the auto-sync service instance (for external management)
*/
export function setAutoSyncService(service: AutoSyncService | null): void {
autoSyncService = service;
}
/**
* Graceful shutdown handler
*/
export function setupGracefulShutdown(): void {
const shutdown = (signal: string) => {
console.log(`Received ${signal}, shutting down gracefully...`);
stopAutoSync();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGUSR2', () => shutdown('SIGUSR2')); // For nodemon
}

View File

@@ -25,6 +25,25 @@ export class ScriptManager {
// Initialize lazily to avoid accessing env vars during module load
}
/**
* Safely handle file modification time, providing fallback for invalid dates
* @param mtime - The file modification time from fs.stat
* @returns Date - Valid date or current date as fallback
*/
private safeMtime(mtime: Date): Date {
try {
// Check if the date is valid
if (!mtime || isNaN(mtime.getTime())) {
console.warn('Invalid mtime detected, using current time as fallback');
return new Date();
}
return mtime;
} catch (error) {
console.warn('Error processing mtime:', error);
return new Date();
}
}
private initializeConfig() {
if (this.scriptsDir === null) {
// Handle both absolute and relative paths for testing
@@ -63,7 +82,7 @@ export class ScriptManager {
path: filePath,
extension,
size: stats.size,
lastModified: stats.mtime,
lastModified: this.safeMtime(stats.mtime),
executable
});
}
@@ -125,7 +144,7 @@ export class ScriptManager {
path: filePath,
extension,
size: stats.size,
lastModified: stats.mtime,
lastModified: this.safeMtime(stats.mtime),
executable,
logo,
slug
@@ -212,7 +231,7 @@ export class ScriptManager {
path: filePath,
extension,
size: stats.size,
lastModified: stats.mtime,
lastModified: this.safeMtime(stats.mtime),
executable,
logo,
slug

View File

@@ -0,0 +1,123 @@
import axios from 'axios';
export class AppriseService {
constructor() {
this.baseUrl = 'http://localhost:8080'; // Default Apprise API URL
}
/**
* Send notification via Apprise
* @param {string} title - Notification title
* @param {string} body - Notification body
* @param {string[]} urls - Array of Apprise URLs
*/
async sendNotification(title, body, urls) {
if (!urls || urls.length === 0) {
throw new Error('No Apprise URLs provided');
}
try {
// Format the notification as form data (Apprise API expects form data)
const formData = new URLSearchParams();
formData.append('body', body || '');
formData.append('title', title || 'PVE Scripts Local');
formData.append('tags', 'all');
// Send to each URL
const results = [];
for (const url of urls) {
try {
const response = await axios.post(url, formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
timeout: 10000 // 10 second timeout
});
results.push({
url,
success: true,
status: response.status
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Failed to send notification to ${url}:`, errorMessage);
results.push({
url,
success: false,
error: errorMessage
});
}
}
// Check if any notifications succeeded
const successCount = results.filter(r => r.success).length;
if (successCount === 0) {
throw new Error('All notification attempts failed');
}
return {
success: true,
message: `Notification sent to ${successCount}/${urls.length} services`,
results
};
} catch (error) {
console.error('Apprise notification failed:', error);
throw error;
}
}
/**
* Test notification to a single URL
* @param {string} url - Apprise URL to test
*/
async testUrl(url) {
try {
await this.sendNotification('Test', 'This is a test notification', [url]);
return { success: true, message: 'Test notification sent successfully' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, message: errorMessage };
}
}
/**
* Validate Apprise URL format
* @param {string} url - URL to validate
*/
validateUrl(url) {
if (!url || typeof url !== 'string') {
return { valid: false, error: 'URL is required' };
}
// Basic URL validation
try {
new URL(url);
} catch {
return { valid: false, error: 'Invalid URL format' };
}
// Check for common Apprise URL patterns
const apprisePatterns = [
/^discord:\/\//,
/^tgram:\/\//,
/^mailto:\/\//,
/^slack:\/\//,
/^https?:\/\//
];
const isValidAppriseUrl = apprisePatterns.some(pattern => pattern.test(url));
if (!isValidAppriseUrl) {
return {
valid: false,
error: 'URL does not match known Apprise service patterns'
};
}
return { valid: true };
}
}
export const appriseService = new AppriseService();

View File

@@ -0,0 +1,758 @@
import cron from 'node-cron';
import { githubJsonService } from './githubJsonService.js';
import { scriptDownloaderService } from './scriptDownloader.js';
import { appriseService } from './appriseService.js';
import { readFile, writeFile, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import cronValidator from 'cron-validator';
// Global lock to prevent multiple autosync instances from running simultaneously
let globalAutoSyncLock = false;
export class AutoSyncService {
constructor() {
this.cronJob = null;
this.isRunning = false;
}
/**
* Safely convert a date to ISO string, handling invalid dates
* @param {Date} date - The date to convert
* @returns {string} - ISO string or fallback timestamp
*/
safeToISOString(date) {
try {
// Check if the date is valid
if (!date || isNaN(date.getTime())) {
console.warn('Invalid date provided to safeToISOString, using current time as fallback');
return new Date().toISOString();
}
return date.toISOString();
} catch (error) {
console.warn('Error converting date to ISO string:', error instanceof Error ? error.message : String(error));
return new Date().toISOString();
}
}
/**
* Load auto-sync settings from .env file
*/
loadSettings() {
try {
const envPath = join(process.cwd(), '.env');
const envContent = readFileSync(envPath, 'utf8');
/** @type {{
* autoSyncEnabled: boolean;
* syncIntervalType: string;
* syncIntervalPredefined?: string;
* syncIntervalCron?: string;
* autoDownloadNew: boolean;
* autoUpdateExisting: boolean;
* notificationEnabled: boolean;
* appriseUrls?: string[];
* lastAutoSync?: string;
* lastAutoSyncError?: string;
* lastAutoSyncErrorTime?: string;
* }} */
const settings = {
autoSyncEnabled: false,
syncIntervalType: 'predefined',
syncIntervalPredefined: '1hour',
syncIntervalCron: '',
autoDownloadNew: false,
autoUpdateExisting: false,
notificationEnabled: false,
appriseUrls: [],
lastAutoSync: '',
lastAutoSyncError: '',
lastAutoSyncErrorTime: ''
};
const lines = envContent.split('\n');
for (const line of lines) {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
let value = valueParts.join('=').trim();
// Remove surrounding quotes if present
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
switch (key.trim()) {
case 'AUTO_SYNC_ENABLED':
settings.autoSyncEnabled = value === 'true';
break;
case 'SYNC_INTERVAL_TYPE':
settings.syncIntervalType = value;
break;
case 'SYNC_INTERVAL_PREDEFINED':
settings.syncIntervalPredefined = value;
break;
case 'SYNC_INTERVAL_CRON':
settings.syncIntervalCron = value;
break;
case 'AUTO_DOWNLOAD_NEW':
settings.autoDownloadNew = value === 'true';
break;
case 'AUTO_UPDATE_EXISTING':
settings.autoUpdateExisting = value === 'true';
break;
case 'NOTIFICATION_ENABLED':
settings.notificationEnabled = value === 'true';
break;
case 'APPRISE_URLS':
try {
settings.appriseUrls = JSON.parse(value || '[]');
} catch {
settings.appriseUrls = [];
}
break;
case 'LAST_AUTO_SYNC':
settings.lastAutoSync = value;
break;
case 'LAST_AUTO_SYNC_ERROR':
settings.lastAutoSyncError = value;
break;
case 'LAST_AUTO_SYNC_ERROR_TIME':
settings.lastAutoSyncErrorTime = value;
break;
}
}
}
return settings;
} catch (error) {
console.error('Error loading auto-sync settings:', error);
return {
autoSyncEnabled: false,
syncIntervalType: 'predefined',
syncIntervalPredefined: '1hour',
syncIntervalCron: '',
autoDownloadNew: false,
autoUpdateExisting: false,
notificationEnabled: false,
appriseUrls: [],
lastAutoSync: '',
lastAutoSyncError: '',
lastAutoSyncErrorTime: ''
};
}
}
/**
* Save auto-sync settings to .env file
* @param {Object} settings - Settings object
* @param {boolean} settings.autoSyncEnabled
* @param {string} settings.syncIntervalType
* @param {string} [settings.syncIntervalPredefined]
* @param {string} [settings.syncIntervalCron]
* @param {boolean} settings.autoDownloadNew
* @param {boolean} settings.autoUpdateExisting
* @param {boolean} settings.notificationEnabled
* @param {Array<string>} [settings.appriseUrls]
* @param {string} [settings.lastAutoSync]
* @param {string} [settings.lastAutoSyncError]
* @param {string} [settings.lastAutoSyncErrorTime]
*/
saveSettings(settings) {
try {
const envPath = join(process.cwd(), '.env');
let envContent = '';
try {
envContent = readFileSync(envPath, 'utf8');
} catch {
// .env file doesn't exist, create it
}
const lines = envContent.split('\n');
const newLines = [];
const settingsMap = {
'AUTO_SYNC_ENABLED': settings.autoSyncEnabled.toString(),
'SYNC_INTERVAL_TYPE': settings.syncIntervalType,
'SYNC_INTERVAL_PREDEFINED': settings.syncIntervalPredefined || '',
'SYNC_INTERVAL_CRON': settings.syncIntervalCron || '',
'AUTO_DOWNLOAD_NEW': settings.autoDownloadNew.toString(),
'AUTO_UPDATE_EXISTING': settings.autoUpdateExisting.toString(),
'NOTIFICATION_ENABLED': settings.notificationEnabled.toString(),
'APPRISE_URLS': JSON.stringify(settings.appriseUrls || []),
'LAST_AUTO_SYNC': settings.lastAutoSync || '',
'LAST_AUTO_SYNC_ERROR': settings.lastAutoSyncError || '',
'LAST_AUTO_SYNC_ERROR_TIME': settings.lastAutoSyncErrorTime || ''
};
const existingKeys = new Set();
for (const line of lines) {
const trimmedLine = line.trim();
// Skip empty lines and comments
if (!trimmedLine || trimmedLine.startsWith('#')) {
newLines.push(line);
continue;
}
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex === -1) {
// Line doesn't contain '=', keep as is
newLines.push(line);
continue;
}
const key = trimmedLine.substring(0, equalIndex).trim();
if (key && key in settingsMap) {
// Replace existing setting
// @ts-ignore - Dynamic property access is safe here
newLines.push(`${key}=${settingsMap[key]}`);
existingKeys.add(key);
} else {
// Keep other settings as is
newLines.push(line);
}
}
// Add any missing settings
for (const [key, value] of Object.entries(settingsMap)) {
if (!existingKeys.has(key)) {
newLines.push(`${key}=${value}`);
}
}
writeFileSync(envPath, newLines.join('\n'));
console.log('Auto-sync settings saved successfully');
} catch (error) {
console.error('Error saving auto-sync settings:', error);
throw error;
}
}
/**
* Schedule auto-sync cron job
*/
scheduleAutoSync() {
this.stopAutoSync(); // Stop any existing job
const settings = this.loadSettings();
if (!settings.autoSyncEnabled) {
console.log('Auto-sync is disabled, not scheduling cron job');
this.isRunning = false; // Ensure we're completely stopped
return;
}
// Check if there's already a global autosync running
if (globalAutoSyncLock) {
console.log('Auto-sync is already running globally, not scheduling new cron job');
return;
}
let cronExpression;
if (settings.syncIntervalType === 'custom') {
cronExpression = settings.syncIntervalCron;
} else {
// Convert predefined intervals to cron expressions
const intervalMap = {
'15min': '*/15 * * * *',
'30min': '*/30 * * * *',
'1hour': '0 * * * *',
'6hours': '0 */6 * * *',
'12hours': '0 */12 * * *',
'24hours': '0 0 * * *'
};
// @ts-ignore - Dynamic key access is safe here
cronExpression = intervalMap[settings.syncIntervalPredefined] || '0 * * * *';
}
// Validate cron expression (5-field format for node-cron)
if (!cronValidator.isValidCron(cronExpression, { seconds: false })) {
console.error('Invalid cron expression:', cronExpression);
return;
}
console.log(`Scheduling auto-sync with cron expression: ${cronExpression}`);
this.cronJob = cron.schedule(cronExpression, async () => {
// Check global lock first
if (globalAutoSyncLock) {
console.log('Auto-sync already running globally, skipping cron execution...');
return;
}
if (this.isRunning) {
console.log('Auto-sync already running locally, skipping...');
return;
}
// Double-check that autosync is still enabled before executing
const currentSettings = this.loadSettings();
if (!currentSettings.autoSyncEnabled) {
console.log('Auto-sync has been disabled, stopping and destroying cron job');
this.stopAutoSync();
return;
}
// Additional check: if cronJob is null, it means it was stopped
if (!this.cronJob) {
console.log('Cron job was stopped, skipping execution');
return;
}
console.log('Starting scheduled auto-sync...');
await this.executeAutoSync();
}, {
scheduled: true,
timezone: 'UTC'
});
console.log('Auto-sync cron job scheduled successfully');
}
/**
* Stop auto-sync cron job
*/
stopAutoSync() {
if (this.cronJob) {
this.cronJob.stop();
this.cronJob.destroy();
this.cronJob = null;
this.isRunning = false;
console.log('Auto-sync cron job stopped and destroyed');
} else {
console.log('No active cron job to stop');
this.isRunning = false; // Ensure isRunning is false even if no cron job
}
}
/**
* Execute auto-sync process
*/
async executeAutoSync() {
// Check global lock first
if (globalAutoSyncLock) {
console.log('Auto-sync already running globally, skipping...');
return { success: false, message: 'Auto-sync already running globally' };
}
if (this.isRunning) {
console.log('Auto-sync already running locally, skipping...');
return { success: false, message: 'Auto-sync already running locally' };
}
// Set global lock
globalAutoSyncLock = true;
this.isRunning = true;
const startTime = new Date();
try {
console.log('Starting auto-sync execution...');
// Step 1: Sync JSON files
console.log('Syncing JSON files...');
const syncResult = await githubJsonService.syncJsonFiles();
if (!syncResult.success) {
throw new Error(`JSON sync failed: ${syncResult.message}`);
}
const results = {
jsonSync: syncResult,
newScripts: /** @type {any[]} */ ([]),
updatedScripts: /** @type {any[]} */ ([]),
errors: /** @type {string[]} */ ([])
};
// Step 2: Auto-download/update scripts if enabled
const settings = this.loadSettings();
if (settings.autoDownloadNew || settings.autoUpdateExisting) {
console.log('Processing synced JSON files for script downloads...');
// Only process scripts for files that were actually synced
if (syncResult.syncedFiles && syncResult.syncedFiles.length > 0) {
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for script downloads...`);
// Get scripts only for the synced files
const localScriptsService = await import('./localScripts.js');
const syncedScripts = [];
for (const filename of syncResult.syncedFiles) {
try {
// Extract slug from filename (remove .json extension)
const slug = filename.replace('.json', '');
const script = await localScriptsService.localScriptsService.getScriptBySlug(slug);
if (script) {
syncedScripts.push(script);
}
} catch (error) {
console.warn(`Error loading script from ${filename}:`, error);
}
}
console.log(`Found ${syncedScripts.length} scripts from synced JSON files`);
// Filter to only truly NEW scripts (not previously downloaded)
const newScripts = [];
const existingScripts = [];
for (const script of syncedScripts) {
try {
// Validate script object
if (!script || !script.slug) {
console.warn('Invalid script object found, skipping:', script);
continue;
}
const isDownloaded = await scriptDownloaderService.isScriptDownloaded(script);
if (!isDownloaded) {
newScripts.push(script);
} else {
existingScripts.push(script);
}
} catch (error) {
console.warn(`Error checking script ${script?.slug || 'unknown'}:`, error);
// Treat as new script if we can't check
if (script && script.slug) {
newScripts.push(script);
}
}
}
console.log(`Found ${newScripts.length} new scripts and ${existingScripts.length} existing scripts from synced files`);
// Download new scripts
if (settings.autoDownloadNew && newScripts.length > 0) {
console.log(`Auto-downloading ${newScripts.length} new scripts...`);
const downloaded = [];
const errors = [];
for (const script of newScripts) {
try {
const result = await scriptDownloaderService.loadScript(script);
if (result.success) {
downloaded.push(script); // Store full script object for category grouping
console.log(`Downloaded script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`${script.name || script.slug}: ${errorMsg}`);
console.error(`Failed to download script ${script.slug}:`, error);
}
}
results.newScripts = downloaded;
results.errors.push(...errors);
}
// Update existing scripts
if (settings.autoUpdateExisting && existingScripts.length > 0) {
console.log(`Auto-updating ${existingScripts.length} existing scripts...`);
const updated = [];
const errors = [];
for (const script of existingScripts) {
try {
// Always update existing scripts when auto-update is enabled
const result = await scriptDownloaderService.loadScript(script);
if (result.success) {
updated.push(script); // Store full script object for category grouping
console.log(`Updated script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`${script.name || script.slug}: ${errorMsg}`);
console.error(`Failed to update script ${script.slug}:`, error);
}
}
results.updatedScripts = updated;
results.errors.push(...errors);
}
} else {
console.log('No JSON files were synced, skipping script download/update');
}
} else {
console.log('Auto-download/update disabled, skipping script processing');
}
// Step 3: Send notifications if enabled
if (settings.notificationEnabled && settings.appriseUrls && settings.appriseUrls.length > 0) {
console.log('Sending success notifications...');
await this.sendSyncNotification(results);
console.log('Success notifications sent');
}
// Step 4: Update last sync time and clear any previous errors
const lastSyncTime = this.safeToISOString(new Date());
const updatedSettings = {
...settings,
lastAutoSync: lastSyncTime,
lastAutoSyncError: '' // Clear any previous errors on successful sync
};
this.saveSettings(updatedSettings);
const duration = new Date().getTime() - startTime.getTime();
console.log(`Auto-sync completed successfully in ${duration}ms`);
return {
success: true,
message: 'Auto-sync completed successfully',
results,
duration
};
} catch (error) {
console.error('Auto-sync execution failed:', error);
// Check if it's a rate limit error
const isRateLimitError = error instanceof Error && error.name === 'RateLimitError';
const errorMessage = error instanceof Error ? error.message : String(error);
// Send error notification if enabled
const settings = this.loadSettings();
if (settings.notificationEnabled && settings.appriseUrls && settings.appriseUrls.length > 0) {
try {
const notificationTitle = isRateLimitError ? 'Auto-Sync Rate Limited' : 'Auto-Sync Failed';
const notificationMessage = isRateLimitError
? `GitHub API rate limit exceeded. Please set a GITHUB_TOKEN in your .env file for higher rate limits. Error: ${errorMessage}`
: `Auto-sync failed with error: ${errorMessage}`;
await appriseService.sendNotification(
notificationTitle,
notificationMessage,
settings.appriseUrls || []
);
} catch (notifError) {
console.error('Failed to send error notification:', notifError);
}
}
// Store the error in settings for UI display
const errorSettings = this.loadSettings();
const errorToStore = isRateLimitError
? `GitHub API rate limit exceeded. Please set a GITHUB_TOKEN in your .env file for higher rate limits.`
: errorMessage;
const updatedErrorSettings = {
...errorSettings,
lastAutoSyncError: errorToStore,
lastAutoSyncErrorTime: this.safeToISOString(new Date())
};
this.saveSettings(updatedErrorSettings);
return {
success: false,
message: errorToStore,
error: errorMessage,
isRateLimitError
};
} finally {
this.isRunning = false;
globalAutoSyncLock = false; // Release global lock
}
}
/**
* Load categories from metadata.json
*/
loadCategories() {
try {
const metadataPath = join(process.cwd(), 'scripts', 'json', 'metadata.json');
const metadataContent = readFileSync(metadataPath, 'utf8');
const metadata = JSON.parse(metadataContent);
return metadata.categories || [];
} catch (error) {
console.error('Error loading categories:', error);
return [];
}
}
/**
* Group scripts by category
* @param {Array<any>} scripts - Array of script objects
* @param {Array<any>} categories - Array of category objects
*/
groupScriptsByCategory(scripts, categories) {
const categoryMap = new Map();
categories.forEach(cat => categoryMap.set(cat.id, cat.name));
const grouped = new Map();
scripts.forEach(script => {
// Validate script object
if (!script || !script.name) {
console.warn('Invalid script object in groupScriptsByCategory, skipping:', script);
return;
}
const scriptCategories = script.categories || [0]; // Default to Miscellaneous (id: 0)
scriptCategories.forEach((/** @type {number} */ catId) => {
const categoryName = categoryMap.get(catId) || 'Miscellaneous';
if (!grouped.has(categoryName)) {
grouped.set(categoryName, []);
}
grouped.get(categoryName).push(script.name);
});
});
return grouped;
}
/**
* Send notification about sync results
* @param {Object} results - Sync results object
*/
async sendSyncNotification(results) {
const settings = this.loadSettings();
if (!settings.notificationEnabled || !settings.appriseUrls?.length) {
return;
}
const title = 'ProxmoxVE-Local - Auto-Sync Completed';
let body = `Auto-sync completed successfully.\n\n`;
// Add JSON sync info
// @ts-ignore - Dynamic property access
if (results.jsonSync) {
// @ts-ignore - Dynamic property access
const syncedCount = results.jsonSync.count || 0;
// @ts-ignore - Dynamic property access
const syncedFiles = results.jsonSync.syncedFiles || [];
// Calculate up-to-date count (total files - synced files)
// We can't easily get total file count from the sync result, so just show synced count
if (syncedCount > 0) {
body += `JSON Files: ${syncedCount} synced\n`;
} else {
body += `JSON Files: All up-to-date\n`;
}
// @ts-ignore - Dynamic property access
if (results.jsonSync.errors?.length > 0) {
// @ts-ignore - Dynamic property access
body += `JSON Errors: ${results.jsonSync.errors.length}\n`;
}
body += '\n';
}
// Load categories for grouping
const categories = this.loadCategories();
// @ts-ignore - Dynamic property access
if (results.newScripts?.length > 0) {
// @ts-ignore - Dynamic property access
body += `New scripts downloaded: ${results.newScripts.length}\n`;
// Group new scripts by category
// @ts-ignore - Dynamic property access
const newScriptsGrouped = this.groupScriptsByCategory(results.newScripts, categories);
// Sort categories by name for consistent ordering
const sortedCategories = Array.from(newScriptsGrouped.keys()).sort();
sortedCategories.forEach(categoryName => {
const scripts = newScriptsGrouped.get(categoryName);
body += `\n**${categoryName}:**\n`;
scripts.forEach((/** @type {string} */ scriptName) => {
body += `${scriptName}\n`;
});
});
body += '\n';
}
// @ts-ignore - Dynamic property access
if (results.updatedScripts?.length > 0) {
// @ts-ignore - Dynamic property access
body += `Scripts updated: ${results.updatedScripts.length}\n`;
// Group updated scripts by category
// @ts-ignore - Dynamic property access
const updatedScriptsGrouped = this.groupScriptsByCategory(results.updatedScripts, categories);
// Sort categories by name for consistent ordering
const sortedCategories = Array.from(updatedScriptsGrouped.keys()).sort();
sortedCategories.forEach(categoryName => {
const scripts = updatedScriptsGrouped.get(categoryName);
body += `\n**${categoryName}:**\n`;
scripts.forEach((/** @type {string} */ scriptName) => {
body += `${scriptName}\n`;
});
});
body += '\n';
}
// @ts-ignore - Dynamic property access
if (results.errors?.length > 0) {
// @ts-ignore - Dynamic property access
body += `Script errors encountered: ${results.errors.length}\n`;
// @ts-ignore - Dynamic property access
body += `${results.errors.slice(0, 5).join('\n• ')}\n`;
// @ts-ignore - Dynamic property access
if (results.errors.length > 5) {
// @ts-ignore - Dynamic property access
body += `• ... and ${results.errors.length - 5} more errors\n`;
}
}
// @ts-ignore - Dynamic property access
if (results.newScripts?.length === 0 && results.updatedScripts?.length === 0 && results.errors?.length === 0) {
body += 'No script changes detected.';
}
try {
await appriseService.sendNotification(title, body, settings.appriseUrls);
console.log('Sync notification sent successfully');
} catch (error) {
console.error('Failed to send sync notification:', error);
}
}
/**
* Test notification
*/
async testNotification() {
const settings = this.loadSettings();
if (!settings.notificationEnabled || !settings.appriseUrls?.length) {
return {
success: false,
message: 'Notifications not enabled or no Apprise URLs configured'
};
}
try {
await appriseService.sendNotification(
'ProxmoxVE-Local - Test Notification',
'This is a test notification from PVE Scripts Local auto-sync feature.',
settings.appriseUrls
);
return {
success: true,
message: 'Test notification sent successfully'
};
} catch (error) {
return {
success: false,
message: `Failed to send test notification: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Get auto-sync status
*/
getStatus() {
return {
isRunning: this.isRunning,
hasCronJob: !!this.cronJob,
lastSync: this.loadSettings().lastAutoSync
};
}
}

View File

@@ -29,14 +29,24 @@ export class GitHubService {
}
private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
},
});
const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(`${this.baseUrl}${endpoint}`, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}

View File

@@ -0,0 +1,6 @@
// JavaScript wrapper for githubJsonService.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
import { githubJsonService } from './githubJsonService.ts';
export { githubJsonService };

View File

@@ -1,7 +1,7 @@
import { writeFile, mkdir } from 'fs/promises';
import { writeFile, mkdir, readdir } from 'fs/promises';
import { join } from 'path';
import { env } from '~/env.js';
import type { Script, ScriptCard, GitHubFile } from '~/types/script';
import { env } from '../../env.js';
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
export class GitHubJsonService {
private baseUrl: string | null = null;
@@ -41,14 +41,25 @@ export class GitHubJsonService {
private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
this.initializeConfig();
const response = await fetch(`${this.baseUrl!}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
},
});
const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(`${this.baseUrl!}${endpoint}`, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
@@ -59,8 +70,22 @@ export class GitHubJsonService {
this.initializeConfig();
const rawUrl = `https://raw.githubusercontent.com/${this.extractRepoPath()}/${this.branch!}/${filePath}`;
const response = await fetch(rawUrl);
const headers: HeadersInit = {
'User-Agent': 'PVEScripts-Local/1.0',
};
// Add GitHub token authentication if available (for raw files, use token in URL or header)
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(rawUrl, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
@@ -185,48 +210,90 @@ export class GitHubJsonService {
}
}
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number }> {
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> {
try {
// Get all scripts from GitHub (1 API call + raw downloads)
const scripts = await this.getAllScripts();
console.log('Starting fast incremental JSON sync...');
// Save scripts to local directory
await this.saveScriptsLocally(scripts);
// Get file list from GitHub
console.log('Fetching file list from GitHub...');
const githubFiles = await this.getJsonFiles();
console.log(`Found ${githubFiles.length} JSON files in repository`);
// Get local files
const localFiles = await this.getLocalJsonFiles();
console.log(`Found ${localFiles.length} files in local directory`);
console.log(`Found ${localFiles.filter(f => f.endsWith('.json')).length} local JSON files`);
// Compare and find files that need syncing
const filesToSync = this.findFilesToSync(githubFiles, localFiles);
console.log(`Found ${filesToSync.length} files that need syncing`);
if (filesToSync.length === 0) {
return {
success: true,
message: 'All JSON files are up to date',
count: 0,
syncedFiles: []
};
}
// Download and save only the files that need syncing
const syncedFiles = await this.syncSpecificFiles(filesToSync);
return {
success: true,
message: `Successfully synced ${scripts.length} scripts from GitHub using 1 API call + raw downloads`,
count: scripts.length
message: `Successfully synced ${syncedFiles.length} JSON files from GitHub`,
count: syncedFiles.length,
syncedFiles
};
} catch (error) {
console.error('Error syncing JSON files:', error);
console.error('JSON sync failed:', error);
return {
success: false,
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
count: 0
count: 0,
syncedFiles: []
};
}
}
private async saveScriptsLocally(scripts: Script[]): Promise<void> {
private async getLocalJsonFiles(): Promise<string[]> {
this.initializeConfig();
try {
// Ensure the directory exists
await mkdir(this.localJsonDirectory!, { recursive: true });
// Save each script as a JSON file
for (const script of scripts) {
const filename = `${script.slug}.json`;
const filePath = join(this.localJsonDirectory!, filename);
const content = JSON.stringify(script, null, 2);
await writeFile(filePath, content, 'utf-8');
}
} catch (error) {
console.error('Error saving scripts locally:', error);
throw new Error('Failed to save scripts locally');
const files = await readdir(this.localJsonDirectory!);
return files.filter(f => f.endsWith('.json'));
} catch {
return [];
}
}
private findFilesToSync(githubFiles: GitHubFile[], localFiles: string[]): GitHubFile[] {
const localFileSet = new Set(localFiles);
// Return only files that don't exist locally
return githubFiles.filter(ghFile => !localFileSet.has(ghFile.name));
}
private async syncSpecificFiles(filesToSync: GitHubFile[]): Promise<string[]> {
this.initializeConfig();
const syncedFiles: string[] = [];
await mkdir(this.localJsonDirectory!, { recursive: true });
for (const file of filesToSync) {
try {
const script = await this.downloadJsonFile(file.path);
const filename = `${script.slug}.json`;
const filePath = join(this.localJsonDirectory!, filename);
await writeFile(filePath, JSON.stringify(script, null, 2), 'utf-8');
syncedFiles.push(filename);
} catch (error) {
console.error(`Failed to sync ${file.name}:`, error);
}
}
return syncedFiles;
}
}
// Singleton instance

View File

@@ -0,0 +1,6 @@
// JavaScript wrapper for localScripts.ts
// This allows the JavaScript autoSyncService.js to import the TypeScript service
import { localScriptsService } from './localScripts.ts';
export { localScriptsService };

View File

@@ -0,0 +1,297 @@
// Real JavaScript implementation for script downloading
import { join } from 'path';
import { writeFile, mkdir, access } from 'fs/promises';
export class ScriptDownloaderService {
constructor() {
this.scriptsDirectory = null;
this.repoUrl = null;
}
initializeConfig() {
if (this.scriptsDirectory === null) {
this.scriptsDirectory = join(process.cwd(), 'scripts');
// Get REPO_URL from environment or use default
this.repoUrl = process.env.REPO_URL || 'https://github.com/community-scripts/ProxmoxVE';
}
}
async ensureDirectoryExists(dirPath) {
try {
await mkdir(dirPath, { recursive: true });
} catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
}
}
async downloadFileFromGitHub(filePath) {
this.initializeConfig();
if (!this.repoUrl) {
throw new Error('REPO_URL environment variable is not set');
}
// Extract repo path from URL
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
if (!match) {
throw new Error('Invalid GitHub repository URL');
}
const [, owner, repo] = match;
const url = `https://raw.githubusercontent.com/${owner}/${repo}/main/${filePath}`;
console.log(`Downloading from GitHub: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
return response.text();
}
modifyScriptContent(content) {
// Replace the build.func source line
const oldPattern = /source <\(curl -fsSL https:\/\/raw\.githubusercontent\.com\/community-scripts\/ProxmoxVE\/main\/misc\/build\.func\)/g;
const newPattern = 'SCRIPT_DIR="$(dirname "$0")" \nsource "$SCRIPT_DIR/../core/build.func"';
return content.replace(oldPattern, newPattern);
}
async loadScript(script) {
this.initializeConfig();
try {
const files = [];
// Ensure directories exist
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'ct'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'install'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'tools'));
await this.ensureDirectoryExists(join(this.scriptsDirectory, 'vm'));
if (script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Download from GitHub
console.log(`Downloading script file: ${scriptPath}`);
const content = await this.downloadFileFromGitHub(scriptPath);
// Determine target directory based on script path
let targetDir;
let finalTargetDir;
let filePath;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
// Modify the content for CT scripts
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
// Preserve subdirectory structure for tools scripts
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
// Preserve subdirectory structure for VM scripts
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
// Preserve subdirectory structure for VW scripts
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
// Ensure the subdirectory exists
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
await writeFile(filePath, content, 'utf-8');
} else {
// Handle other script types (fallback to ct directory)
targetDir = 'ct';
finalTargetDir = targetDir;
const modifiedContent = this.modifyScriptContent(content);
filePath = join(this.scriptsDirectory, targetDir, fileName);
await writeFile(filePath, modifiedContent, 'utf-8');
}
files.push(`${finalTargetDir}/${fileName}`);
console.log(`Successfully downloaded: ${finalTargetDir}/${fileName}`);
}
}
}
}
// Only download install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
try {
console.log(`Downloading install script: install/${installScriptName}`);
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
await writeFile(localInstallPath, installContent, 'utf-8');
files.push(`install/${installScriptName}`);
console.log(`Successfully downloaded: install/${installScriptName}`);
} catch (error) {
// Install script might not exist, that's okay
console.log(`Install script not found: install/${installScriptName}`);
}
}
return {
success: true,
message: `Successfully loaded ${files.length} script(s) for ${script.name}`,
files
};
} catch (error) {
console.error('Error loading script:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to load script',
files: []
};
}
}
async isScriptDownloaded(script) {
if (!script.install_methods?.length) return false;
// Check if ALL script files are downloaded
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Determine target directory based on script path
let targetDir;
let finalTargetDir;
let filePath;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory, targetDir, fileName);
}
try {
await import('fs/promises').then(fs => fs.readFile(filePath, 'utf8'));
// File exists, continue checking other methods
} catch {
// File doesn't exist, script is not fully downloaded
return false;
}
}
}
}
// All files exist, script is downloaded
return true;
}
async checkScriptExists(script) {
this.initializeConfig();
const files = [];
let ctExists = false;
let installExists = false;
try {
// Check scripts based on their install methods
if (script.install_methods?.length) {
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
let targetDir;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
} else {
targetDir = 'ct'; // Default fallback
}
const filePath = join(this.scriptsDirectory, targetDir, fileName);
try {
await access(filePath);
files.push(`${targetDir}/${fileName}`);
if (scriptPath.startsWith('ct/')) {
ctExists = true;
}
} catch {
// File doesn't exist
}
}
}
}
}
// Check for install script for CT scripts
const hasCtScript = script.install_methods?.some(method => method.script?.startsWith('ct/'));
if (hasCtScript) {
const installScriptName = `${script.slug}-install.sh`;
const installPath = join(this.scriptsDirectory, 'install', installScriptName);
try {
await access(installPath);
files.push(`install/${installScriptName}`);
installExists = true;
} catch {
// Install script doesn't exist
}
}
return { ctExists, installExists, files };
} catch (error) {
console.error('Error checking script existence:', error);
return { ctExists: false, installExists: false, files: [] };
}
}
}
export const scriptDownloaderService = new ScriptDownloaderService();

View File

@@ -167,6 +167,203 @@ export class ScriptDownloaderService {
}
}
/**
* Auto-download new scripts that haven't been downloaded yet
*/
async autoDownloadNewScripts(allScripts: Script[]): Promise<{ downloaded: string[]; errors: string[] }> {
this.initializeConfig();
const downloaded: string[] = [];
const errors: string[] = [];
for (const script of allScripts) {
try {
// Check if script is already downloaded
const isDownloaded = await this.isScriptDownloaded(script);
if (!isDownloaded) {
const result = await this.loadScript(script);
if (result.success) {
downloaded.push(script.name || script.slug);
console.log(`Auto-downloaded new script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
}
} catch (error) {
const errorMsg = `${script.name || script.slug}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
console.error(`Failed to auto-download script ${script.slug}:`, error);
}
}
return { downloaded, errors };
}
/**
* Auto-update existing scripts to newer versions
*/
async autoUpdateExistingScripts(allScripts: Script[]): Promise<{ updated: string[]; errors: string[] }> {
this.initializeConfig();
const updated: string[] = [];
const errors: string[] = [];
for (const script of allScripts) {
try {
// Check if script is downloaded
const isDownloaded = await this.isScriptDownloaded(script);
if (isDownloaded) {
// Check if update is needed by comparing content
const needsUpdate = await this.scriptNeedsUpdate(script);
if (needsUpdate) {
const result = await this.loadScript(script);
if (result.success) {
updated.push(script.name || script.slug);
console.log(`Auto-updated script: ${script.name || script.slug}`);
} else {
errors.push(`${script.name || script.slug}: ${result.message}`);
}
}
}
} catch (error) {
const errorMsg = `${script.name || script.slug}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
console.error(`Failed to auto-update script ${script.slug}:`, error);
}
}
return { updated, errors };
}
/**
* Check if a script is already downloaded
*/
async isScriptDownloaded(script: Script): Promise<boolean> {
if (!script.install_methods?.length) return false;
// Check if ALL script files are downloaded
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Determine target directory based on script path
let targetDir: string;
let finalTargetDir: string;
let filePath: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
}
try {
await readFile(filePath, 'utf8');
// File exists, continue checking other methods
} catch {
// File doesn't exist, script is not fully downloaded
return false;
}
}
}
}
// All files exist, script is downloaded
return true;
}
/**
* Check if a script needs updating by comparing local and remote content
*/
private async scriptNeedsUpdate(script: Script): Promise<boolean> {
if (!script.install_methods?.length) return false;
for (const method of script.install_methods) {
if (method.script) {
const scriptPath = method.script;
const fileName = scriptPath.split('/').pop();
if (fileName) {
// Determine target directory based on script path
let targetDir: string;
let finalTargetDir: string;
let filePath: string;
if (scriptPath.startsWith('ct/')) {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
} else if (scriptPath.startsWith('tools/')) {
targetDir = 'tools';
const subPath = scriptPath.replace('tools/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vm/')) {
targetDir = 'vm';
const subPath = scriptPath.replace('vm/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else if (scriptPath.startsWith('vw/')) {
targetDir = 'vw';
const subPath = scriptPath.replace('vw/', '');
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
filePath = join(this.scriptsDirectory!, finalTargetDir, fileName);
} else {
targetDir = 'ct';
finalTargetDir = targetDir;
filePath = join(this.scriptsDirectory!, targetDir, fileName);
}
try {
// Read local content
const localContent = await readFile(filePath, 'utf8');
// Download remote content
const remoteContent = await this.downloadFileFromGitHub(scriptPath);
// Compare content (simple string comparison for now)
// In a more sophisticated implementation, you might want to compare
// file modification times or use content hashing
return localContent !== remoteContent;
} catch {
// If we can't read local or download remote, assume update needed
return true;
}
}
}
}
return false;
}
async checkScriptExists(script: Script): Promise<{ ctExists: boolean; installExists: boolean; files: string[] }> {
this.initializeConfig();
const files: string[] = [];

View File

@@ -1,5 +1,6 @@
#!/bin/bash
#21.10.2025 - @michelroegl-brunner
# Enhanced update script for ProxmoxVE-Local
# Fetches latest release from GitHub and backs up data directory
@@ -298,6 +299,7 @@ clear_original_directory() {
# List of files/directories to preserve (already backed up)
local preserve_patterns=(
"data"
"data/*"
".env"
"*.log"
"update.log"
@@ -354,7 +356,7 @@ restore_backup_files() {
if [ -f ".env" ]; then
rm -f ".env"
fi
if mv "$BACKUP_DIR/.env" ".env"; then
if cp "$BACKUP_DIR/.env" ".env"; then
log_success ".env file restored from backup"
else
log_error "Failed to restore .env file"
@@ -369,7 +371,7 @@ restore_backup_files() {
if [ -d "data" ]; then
rm -rf "data"
fi
if mv "$BACKUP_DIR/data" "data"; then
if cp -r "$BACKUP_DIR/data" "data"; then
log_success "Data directory restored from backup"
else
log_error "Failed to restore data directory"
@@ -396,7 +398,7 @@ restore_backup_files() {
rm -rf "$target_dir"
fi
if mv "$BACKUP_DIR/$backup_name" "$target_dir"; then
if cp -r "$BACKUP_DIR/$backup_name" "$target_dir"; then
log_success "$target_dir directory restored from backup"
else
log_error "Failed to restore $target_dir directory"
@@ -412,6 +414,30 @@ restore_backup_files() {
fi
}
# Verify database was restored correctly
verify_database_restored() {
log "Verifying database was restored correctly..."
# Check for both possible database filenames
local db_file=""
if [ -f "data/database.sqlite" ]; then
db_file="data/database.sqlite"
elif [ -f "data/settings.db" ]; then
db_file="data/settings.db"
else
log_error "Database file not found after restore! (checked database.sqlite and settings.db)"
return 1
fi
local db_size=$(stat -f%z "$db_file" 2>/dev/null || stat -c%s "$db_file" 2>/dev/null)
if [ "$db_size" -eq 0 ]; then
log_warning "Database file is empty - will be recreated by Prisma migrations"
return 0 # Don't fail the update, let Prisma recreate the database
fi
log_success "Database verified (file: $db_file, size: $db_size bytes)"
}
# Ensure DATABASE_URL is set in .env file for Prisma
ensure_database_url() {
log "Ensuring DATABASE_URL is set in .env file..."
@@ -437,7 +463,7 @@ ensure_database_url() {
log "Adding DATABASE_URL to .env file..."
echo "" >> .env
echo "# Database" >> .env
echo "DATABASE_URL=\"file:./data/database.sqlite\"" >> .env
echo "DATABASE_URL=\"file:./data/settings.db\"" >> .env
log_success "DATABASE_URL added to .env file"
}
@@ -465,15 +491,15 @@ stop_application() {
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
else
# Try to find the application directory
app_dir=$(find /root -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
# Change to production application directory
app_dir="/opt/ProxmoxVE-Local"
if [ -d "$app_dir" ] && [ -f "$app_dir/server.js" ]; then
cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir"
return 1
}
else
log_error "Could not find application directory"
log_error "Production application directory not found: $app_dir"
return 1
fi
fi
@@ -555,7 +581,7 @@ update_files() {
local should_exclude=false
for pattern in "${exclude_patterns[@]}"; do
if [[ "$rel_path" == $pattern ]]; then
if [[ "$rel_path" == $pattern ]] || [[ "$rel_path" == $pattern/* ]]; then
should_exclude=true
break
fi
@@ -595,6 +621,7 @@ update_files() {
log_success "Application files updated successfully ($files_copied files)"
}
# Install dependencies and build
install_and_build() {
log "Installing dependencies..."
@@ -650,6 +677,15 @@ install_and_build() {
fi
log_success "Prisma client generated successfully"
# Check if Prisma migrations exist and are compatible
if [ -d "prisma/migrations" ]; then
log "Existing migration history detected"
local migration_count=$(find prisma/migrations -type d -mindepth 1 | wc -l)
log "Found $migration_count existing migrations"
else
log_warning "No existing migration history found - this may be a fresh install"
fi
# Run Prisma migrations
log "Running Prisma migrations..."
if ! npx prisma migrate deploy > "$npm_log" 2>&1; then
@@ -706,11 +742,16 @@ start_application() {
fi
else
log_error "Failed to enable/start service, falling back to npm start"
start_with_npm
if ! start_with_npm; then
log_error "Failed to start application with npm"
return 1
fi
fi
else
log "Service was not running before update or no service exists, starting with npm..."
start_with_npm
if ! start_with_npm; then
return 1
fi
fi
}
@@ -834,23 +875,15 @@ main() {
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
else
# Try multiple common locations:
for search_path in /opt /root /home /usr/local; do
if [ -d "$search_path" ]; then
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
break
fi
fi
done
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then
# Use production application directory
app_dir="/opt/ProxmoxVE-Local"
if [ -d "$app_dir" ] && [ -f "$app_dir/server.js" ]; then
cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir"
exit 1
}
else
log_error "Could not find application directory"
log_error "Production application directory not found: $app_dir"
exit 1
fi
fi
@@ -894,6 +927,12 @@ main() {
# Restore .env and data directory before building
restore_backup_files
# Verify database was restored correctly
if ! verify_database_restored; then
log_error "Database verification failed, rolling back..."
rollback
fi
# Ensure DATABASE_URL is set for Prisma
ensure_database_url
@@ -903,12 +942,17 @@ main() {
rollback
fi
# Cleanup
# Start the application
if ! start_application; then
log_error "Failed to start application after update"
rollback
fi
# Cleanup only after successful start
rm -rf "$source_dir"
rm -rf "/tmp/pve-update-$$"
# Start the application
start_application
rm -rf "$BACKUP_DIR"
log "Backup directory cleaned up"
log_success "Update completed successfully!"
}