Compare commits
43 Commits
localizati
...
v0.4.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2912170f5e | ||
|
|
8a07fb781c | ||
|
|
79f17236db | ||
|
|
83ab60ec2d | ||
|
|
cd2b00b704 | ||
|
|
7817ce3d8e | ||
|
|
82b2012f50 | ||
|
|
bb4eb2964b | ||
|
|
a6a02a15fe | ||
|
|
bf5b602fd1 | ||
|
|
4d12a51b05 | ||
|
|
e0bea6c6e0 | ||
|
|
2f1b738164 | ||
|
|
3639dd231a | ||
|
|
86f55069e6 | ||
|
|
5faa7f3646 | ||
|
|
bc52256301 | ||
|
|
9e66cdd7ea | ||
|
|
931c9cedf1 | ||
|
|
83a51db265 | ||
|
|
1b09d1494e | ||
|
|
02fe842995 | ||
|
|
a464c845c2 | ||
|
|
5a69f58770 | ||
|
|
48c243f624 | ||
|
|
7af0717b1b | ||
|
|
9977d390ac | ||
|
|
ea5e801718 | ||
|
|
7d54481f75 | ||
|
|
fbc6a9362e | ||
|
|
6b534474c4 | ||
|
|
5bfbaca732 | ||
|
|
a3d0141950 | ||
|
|
529cb92e3c | ||
|
|
b175c709f0 | ||
|
|
bf908eef66 | ||
|
|
4adf052db4 | ||
|
|
2158ea42ba | ||
|
|
5fc1205ca5 | ||
|
|
d928058f97 | ||
|
|
11e1704116 | ||
|
|
26829ce355 | ||
|
|
deffba0969 |
@@ -26,4 +26,4 @@ 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"
|
||||
|
||||
29
.github/workflows/publish_release.yml
vendored
29
.github/workflows/publish_release.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"ram": 2048,
|
||||
"hdd": 8,
|
||||
"os": "debian",
|
||||
"version": "13"
|
||||
"version": "12"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
908
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
11
server.js
11
server.js
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "{scriptName}" 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 "{scriptName}"
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'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 "<paste-key>" >>
|
||||
~/.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 "<paste-key>" >> ~/.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 "{publicKey}" >> ~/.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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
379
src/app/api/settings/auto-sync/route.ts
Normal file
379
src/app/api/settings/auto-sync/route.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
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') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Custom cron expression is required when syncIntervalType is "custom"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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 || ''
|
||||
};
|
||||
|
||||
// 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 { AutoSyncService } = await import('../../../../server/services/autoSyncService.js');
|
||||
const autoSyncService = new AutoSyncService();
|
||||
|
||||
if (settings.autoSyncEnabled) {
|
||||
autoSyncService.scheduleAutoSync();
|
||||
console.log('Auto-sync rescheduled with new settings');
|
||||
} else {
|
||||
autoSyncService.stopAutoSync();
|
||||
console.log('Auto-sync stopped');
|
||||
}
|
||||
} 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: ''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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') || ''
|
||||
};
|
||||
|
||||
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 '';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
243
src/app/page.tsx
243
src/app/page.tsx
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export type NestedMessages = {
|
||||
[key: string]: string | NestedMessages;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
@@ -457,5 +458,106 @@ 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 {
|
||||
const autoSyncService = new AutoSyncService();
|
||||
autoSyncService.saveSettings(input);
|
||||
|
||||
// Reschedule auto-sync if enabled
|
||||
if (input.autoSyncEnabled) {
|
||||
autoSyncService.scheduleAutoSync();
|
||||
} else {
|
||||
autoSyncService.stopAutoSync();
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
65
src/server/lib/autoSyncInit.js
Normal file
65
src/server/lib/autoSyncInit.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { AutoSyncService } from '../services/autoSyncService.js';
|
||||
|
||||
let autoSyncService = null;
|
||||
|
||||
/**
|
||||
* Initialize auto-sync service and schedule cron job if enabled
|
||||
*/
|
||||
export function initializeAutoSync() {
|
||||
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() {
|
||||
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() {
|
||||
return autoSyncService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
65
src/server/lib/autoSyncInit.ts
Normal file
65
src/server/lib/autoSyncInit.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
123
src/server/services/appriseService.js
Normal file
123
src/server/services/appriseService.js
Normal 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();
|
||||
542
src/server/services/autoSyncService.js
Normal file
542
src/server/services/autoSyncService.js
Normal file
@@ -0,0 +1,542 @@
|
||||
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';
|
||||
|
||||
export class AutoSyncService {
|
||||
constructor() {
|
||||
this.cronJob = null;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load auto-sync settings from .env file
|
||||
*/
|
||||
loadSettings() {
|
||||
try {
|
||||
const envPath = join(process.cwd(), '.env');
|
||||
const envContent = readFileSync(envPath, 'utf8');
|
||||
|
||||
const settings = {
|
||||
autoSyncEnabled: false,
|
||||
syncIntervalType: 'predefined',
|
||||
syncIntervalPredefined: '1hour',
|
||||
syncIntervalCron: '',
|
||||
autoDownloadNew: false,
|
||||
autoUpdateExisting: false,
|
||||
notificationEnabled: false,
|
||||
appriseUrls: [],
|
||||
lastAutoSync: ''
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
*/
|
||||
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 || ''
|
||||
};
|
||||
|
||||
const existingKeys = new Set();
|
||||
|
||||
for (const line of lines) {
|
||||
const [key] = line.split('=');
|
||||
const trimmedKey = key?.trim();
|
||||
if (trimmedKey && trimmedKey in settingsMap) {
|
||||
// @ts-ignore - Dynamic key access is safe here
|
||||
newLines.push(`${trimmedKey}=${settingsMap[trimmedKey]}`);
|
||||
existingKeys.add(trimmedKey);
|
||||
} else if (trimmedKey && !(trimmedKey in settingsMap)) {
|
||||
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) {
|
||||
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 () => {
|
||||
if (this.isRunning) {
|
||||
console.log('Auto-sync already running, skipping...');
|
||||
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 = null;
|
||||
console.log('Auto-sync cron job stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute auto-sync process
|
||||
*/
|
||||
async executeAutoSync() {
|
||||
if (this.isRunning) {
|
||||
console.log('Auto-sync already running, skipping...');
|
||||
return { success: false, message: 'Auto-sync already running' };
|
||||
}
|
||||
|
||||
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: [],
|
||||
updatedScripts: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Step 2: Auto-download/update scripts if enabled
|
||||
const settings = this.loadSettings();
|
||||
|
||||
if (settings.autoDownloadNew || settings.autoUpdateExisting) {
|
||||
// Only process scripts for files that were actually synced
|
||||
// @ts-ignore - syncedFiles exists in the JavaScript version
|
||||
if (syncResult.syncedFiles && syncResult.syncedFiles.length > 0) {
|
||||
// @ts-ignore - syncedFiles exists in the JavaScript version
|
||||
console.log(`Processing ${syncResult.syncedFiles.length} synced JSON files for new scripts...`);
|
||||
|
||||
// Get all scripts from synced files
|
||||
// @ts-ignore - syncedFiles exists in the JavaScript version
|
||||
const allSyncedScripts = await githubJsonService.getScriptsForFiles(syncResult.syncedFiles);
|
||||
|
||||
// Initialize script downloader service
|
||||
// @ts-ignore - initializeConfig is public in the JS version
|
||||
scriptDownloaderService.initializeConfig();
|
||||
|
||||
// Filter to only truly NEW scripts (not previously downloaded)
|
||||
const newScripts = [];
|
||||
for (const script of allSyncedScripts) {
|
||||
const isDownloaded = await scriptDownloaderService.isScriptDownloaded(script);
|
||||
if (!isDownloaded) {
|
||||
newScripts.push(script);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${newScripts.length} new scripts out of ${allSyncedScripts.length} total scripts`);
|
||||
|
||||
if (settings.autoDownloadNew && newScripts.length > 0) {
|
||||
console.log(`Auto-downloading ${newScripts.length} new scripts...`);
|
||||
const downloadResult = await scriptDownloaderService.autoDownloadNewScripts(newScripts);
|
||||
// @ts-ignore - Type assertion needed for dynamic assignment
|
||||
results.newScripts = downloadResult.downloaded;
|
||||
// @ts-ignore - Type assertion needed for dynamic assignment
|
||||
results.errors.push(...downloadResult.errors);
|
||||
}
|
||||
|
||||
if (settings.autoUpdateExisting) {
|
||||
console.log('Auto-updating existing scripts from synced files...');
|
||||
const updateResult = await scriptDownloaderService.autoUpdateExistingScripts(allSyncedScripts);
|
||||
// @ts-ignore - Type assertion needed for dynamic assignment
|
||||
results.updatedScripts = updateResult.updated;
|
||||
// @ts-ignore - Type assertion needed for dynamic assignment
|
||||
results.errors.push(...updateResult.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?.length > 0) {
|
||||
console.log('Sending notifications...');
|
||||
await this.sendSyncNotification(results);
|
||||
}
|
||||
|
||||
// Step 4: Update last sync time
|
||||
const lastSyncTime = new Date().toISOString();
|
||||
const updatedSettings = { ...settings, lastAutoSync: lastSyncTime };
|
||||
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);
|
||||
|
||||
// Send error notification if enabled
|
||||
const settings = this.loadSettings();
|
||||
if (settings.notificationEnabled && settings.appriseUrls?.length > 0) {
|
||||
try {
|
||||
await appriseService.sendNotification(
|
||||
'Auto-Sync Failed',
|
||||
`Auto-sync failed with error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
settings.appriseUrls
|
||||
);
|
||||
} catch (notifError) {
|
||||
console.error('Failed to send error notification:', notifError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 => {
|
||||
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
|
||||
body += `JSON Files: ${results.jsonSync.syncedCount} synced, ${results.jsonSync.skippedCount} 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
|
||||
};
|
||||
}
|
||||
}
|
||||
276
src/server/services/githubJsonService.js
Normal file
276
src/server/services/githubJsonService.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { readFileSync, readdirSync, statSync, utimesSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
export class GitHubJsonService {
|
||||
constructor() {
|
||||
this.baseUrl = null;
|
||||
this.repoUrl = null;
|
||||
this.branch = null;
|
||||
this.jsonFolder = null;
|
||||
this.localJsonDirectory = null;
|
||||
this.scriptCache = new Map();
|
||||
}
|
||||
|
||||
initializeConfig() {
|
||||
if (this.repoUrl === null) {
|
||||
// Get environment variables
|
||||
this.repoUrl = process.env.REPO_URL || "";
|
||||
this.branch = process.env.REPO_BRANCH || "main";
|
||||
this.jsonFolder = process.env.JSON_FOLDER || "scripts";
|
||||
this.localJsonDirectory = join(process.cwd(), 'scripts', 'json');
|
||||
|
||||
// Only validate GitHub URL if it's provided
|
||||
if (this.repoUrl) {
|
||||
// Extract owner and repo from the URL
|
||||
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(this.repoUrl);
|
||||
if (!urlMatch) {
|
||||
throw new Error(`Invalid GitHub repository URL: ${this.repoUrl}`);
|
||||
}
|
||||
|
||||
const [, owner, repo] = urlMatch;
|
||||
this.baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
||||
} else {
|
||||
// Set a dummy base URL if no REPO_URL is provided
|
||||
this.baseUrl = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchFromGitHub(endpoint) {
|
||||
this.initializeConfig();
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'PVEScripts-Local/1.0',
|
||||
...(process.env.GITHUB_TOKEN && { 'Authorization': `token ${process.env.GITHUB_TOKEN}` })
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async syncJsonFiles() {
|
||||
try {
|
||||
this.initializeConfig();
|
||||
|
||||
if (!this.baseUrl) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No GitHub repository configured'
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Starting fast incremental JSON sync...');
|
||||
|
||||
// Ensure local directory exists
|
||||
await mkdir(this.localJsonDirectory, { recursive: true });
|
||||
|
||||
// Step 1: Get file list from GitHub (single API call)
|
||||
console.log('Fetching file list from GitHub...');
|
||||
const files = await this.fetchFromGitHub(`/contents/${this.jsonFolder}?ref=${this.branch}`);
|
||||
|
||||
if (!Array.isArray(files)) {
|
||||
throw new Error('Invalid response from GitHub API');
|
||||
}
|
||||
|
||||
const jsonFiles = files.filter(file => file.name.endsWith('.json'));
|
||||
console.log(`Found ${jsonFiles.length} JSON files in repository`);
|
||||
|
||||
// Step 2: Get local file list (fast local operation)
|
||||
const localFiles = new Map();
|
||||
try {
|
||||
console.log(`Looking for local files in: ${this.localJsonDirectory}`);
|
||||
const localFileList = readdirSync(this.localJsonDirectory);
|
||||
console.log(`Found ${localFileList.length} files in local directory`);
|
||||
for (const fileName of localFileList) {
|
||||
if (fileName.endsWith('.json')) {
|
||||
const filePath = join(this.localJsonDirectory, fileName);
|
||||
const stats = statSync(filePath);
|
||||
localFiles.set(fileName, {
|
||||
mtime: stats.mtime,
|
||||
size: stats.size
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error reading local directory:', error.message);
|
||||
console.log('Directory path:', this.localJsonDirectory);
|
||||
console.log('No local files found, will download all');
|
||||
}
|
||||
|
||||
console.log(`Found ${localFiles.size} local JSON files`);
|
||||
|
||||
// Step 3: Compare and identify files that need syncing
|
||||
const filesToSync = [];
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
const localFile = localFiles.get(file.name);
|
||||
|
||||
if (!localFile) {
|
||||
// File doesn't exist locally
|
||||
filesToSync.push(file);
|
||||
console.log(`Missing: ${file.name}`);
|
||||
} else {
|
||||
// Compare modification times and sizes
|
||||
const localMtime = new Date(localFile.mtime);
|
||||
const remoteMtime = new Date(file.updated_at);
|
||||
const localSize = localFile.size;
|
||||
const remoteSize = file.size;
|
||||
|
||||
// Sync if remote is newer OR sizes are different (content changed)
|
||||
if (localMtime < remoteMtime || localSize !== remoteSize) {
|
||||
filesToSync.push(file);
|
||||
console.log(`Changed: ${file.name} (${localMtime.toISOString()} -> ${remoteMtime.toISOString()})`);
|
||||
} else {
|
||||
skippedCount++;
|
||||
console.log(`Up-to-date: ${file.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Files to sync: ${filesToSync.length}, Up-to-date: ${skippedCount}`);
|
||||
|
||||
// Step 4: Download only the files that need syncing
|
||||
let syncedCount = 0;
|
||||
const errors = [];
|
||||
const syncedFiles = [];
|
||||
|
||||
// Process files in batches to avoid overwhelming the API
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < filesToSync.length; i += batchSize) {
|
||||
const batch = filesToSync.slice(i, i + batchSize);
|
||||
|
||||
// Process batch in parallel
|
||||
const promises = batch.map(async (file) => {
|
||||
try {
|
||||
const content = await this.fetchFromGitHub(`/contents/${file.path}?ref=${this.branch}`);
|
||||
|
||||
if (content.content) {
|
||||
// Decode base64 content
|
||||
const fileContent = Buffer.from(content.content, 'base64').toString('utf-8');
|
||||
|
||||
// Write to local file
|
||||
const localPath = join(this.localJsonDirectory, file.name);
|
||||
await writeFile(localPath, fileContent, 'utf-8');
|
||||
|
||||
// Update file modification time to match remote
|
||||
const remoteMtime = new Date(file.updated_at);
|
||||
utimesSync(localPath, remoteMtime, remoteMtime);
|
||||
|
||||
syncedCount++;
|
||||
syncedFiles.push(file.name);
|
||||
console.log(`Synced: ${file.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync ${file.name}:`, error.message);
|
||||
errors.push(`${file.name}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Small delay between batches to be nice to the API
|
||||
if (i + batchSize < filesToSync.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`JSON sync completed. Synced ${syncedCount} files, skipped ${skippedCount} files.`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully synced ${syncedCount} JSON files (${skippedCount} up-to-date)`,
|
||||
syncedCount,
|
||||
skippedCount,
|
||||
syncedFiles,
|
||||
errors
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('JSON sync failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getAllScripts() {
|
||||
try {
|
||||
this.initializeConfig();
|
||||
|
||||
if (!this.localJsonDirectory) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scripts = [];
|
||||
|
||||
// Read all JSON files from local directory
|
||||
const files = readdirSync(this.localJsonDirectory);
|
||||
const jsonFiles = files.filter(file => file.endsWith('.json'));
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const filePath = join(this.localJsonDirectory, file);
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const script = JSON.parse(content);
|
||||
|
||||
if (script && typeof script === 'object') {
|
||||
scripts.push(script);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse ${file}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return scripts;
|
||||
} catch (error) {
|
||||
console.error('Failed to get all scripts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scripts only for specific JSON files that were synced
|
||||
*/
|
||||
async getScriptsForFiles(syncedFiles) {
|
||||
try {
|
||||
this.initializeConfig();
|
||||
|
||||
if (!this.localJsonDirectory || !syncedFiles || syncedFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scripts = [];
|
||||
|
||||
for (const fileName of syncedFiles) {
|
||||
try {
|
||||
const filePath = join(this.localJsonDirectory, fileName);
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const script = JSON.parse(content);
|
||||
|
||||
if (script && typeof script === 'object') {
|
||||
scripts.push(script);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse ${fileName}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return scripts;
|
||||
} catch (error) {
|
||||
console.error('Failed to get scripts for synced files:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const githubJsonService = new GitHubJsonService();
|
||||
346
src/server/services/scriptDownloader.js
Normal file
346
src/server/services/scriptDownloader.js
Normal file
@@ -0,0 +1,346 @@
|
||||
import { writeFile, readFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export class ScriptDownloaderService {
|
||||
constructor() {
|
||||
this.scriptsDirectory = null;
|
||||
}
|
||||
|
||||
initializeConfig() {
|
||||
if (this.scriptsDirectory === null) {
|
||||
this.scriptsDirectory = join(process.cwd(), 'scripts');
|
||||
}
|
||||
}
|
||||
|
||||
async ensureDirectoryExists(dirPath) {
|
||||
try {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFileFromGitHub(filePath) {
|
||||
// This is a simplified version - in a real implementation,
|
||||
// you would fetch the file content from GitHub
|
||||
// For now, we'll return a placeholder
|
||||
return `#!/bin/bash
|
||||
# Downloaded script: ${filePath}
|
||||
# This is a placeholder - implement actual GitHub file download
|
||||
echo "Script downloaded: ${filePath}"
|
||||
`;
|
||||
}
|
||||
|
||||
modifyScriptContent(content) {
|
||||
// Modify script content for CT scripts if needed
|
||||
return content;
|
||||
}
|
||||
|
||||
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
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const installContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
|
||||
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
|
||||
await writeFile(localInstallPath, installContent, 'utf-8');
|
||||
files.push(`install/${installScriptName}`);
|
||||
} catch {
|
||||
// Install script might not exist, that's okay
|
||||
}
|
||||
}
|
||||
|
||||
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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-download new scripts that haven't been downloaded yet
|
||||
*/
|
||||
async autoDownloadNewScripts(allScripts) {
|
||||
this.initializeConfig();
|
||||
const downloaded = [];
|
||||
const errors = [];
|
||||
|
||||
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); // Return full script object instead of just name
|
||||
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) {
|
||||
this.initializeConfig();
|
||||
const updated = [];
|
||||
const errors = [];
|
||||
|
||||
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); // Return full script object instead of just name
|
||||
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) {
|
||||
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 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
|
||||
*/
|
||||
async scriptNeedsUpdate(script) {
|
||||
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;
|
||||
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 {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
export const scriptDownloaderService = new ScriptDownloaderService();
|
||||
@@ -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[] = [];
|
||||
|
||||
98
update.sh
98
update.sh
@@ -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!"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user