Compare commits

..

14 Commits

Author SHA1 Message Date
github-actions[bot]
5c6f1f129f chore: add VERSION v0.4.6 2025-10-21 12:38:20 +00:00
Michel Roegl-Brunner
6b534474c4 Fix update.sh 2025-10-21 14:34:51 +02:00
Michel Roegl-Brunner
5bfbaca732 Fix update.sh 2025-10-21 14:33:24 +02:00
github-actions[bot]
a3d0141950 chore: add VERSION v0.4.6 (#220)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-21 11:21:33 +00:00
Michel Roegl-Brunner
529cb92e3c Fix Update 2025-10-21 13:16:14 +02:00
Michel Roegl-Brunner
b175c709f0 Fix update 2025-10-21 11:00:12 +02:00
Michel Roegl-Brunner
bf908eef66 Merge pull request #215 from community-scripts/dependabot/npm_and_yarn/npm_and_yarn-fd296dbd23 2025-10-21 07:09:55 +02:00
dependabot[bot]
4adf052db4 build(deps-dev): Bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


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

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

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

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

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

View File

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

View File

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

View File

@@ -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",

526
package-lock.json generated
View File

@@ -44,14 +44,14 @@
},
"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.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2",
@@ -59,14 +59,14 @@
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
"eslint-config-next": "^15.5.6",
"jsdom": "^27.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",
"tailwindcss": "^4.1.15",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.1",
"typescript-eslint": "^8.46.2",
"vitest": "^3.2.4"
}
},
@@ -129,9 +129,9 @@
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.6.1.tgz",
"integrity": "sha512-8QT9pokVe1fUt1C8IrJketaeFOdRfTOS96DL3EBjE8CRZm3eHnwMlQe2NPoOSEYPwJ5Q25uYoX1+m9044l3ysQ==",
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.2.tgz",
"integrity": "sha512-ccKogJI+0aiDhOahdjANIc9SDixSud1gbwdVrhn7kMopAtLXqsz9MKmQQtIl6Y5aC2IYq+j4dz/oedL2AVMmVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1753,19 +1753,6 @@
"node": ">=12"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
@@ -3068,54 +3055,49 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz",
"integrity": "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz",
"integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.0",
"lightningcss": "1.30.1",
"lightningcss": "1.30.2",
"magic-string": "^0.30.19",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.14"
"tailwindcss": "4.1.15"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.14.tgz",
"integrity": "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.15.tgz",
"integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.4",
"tar": "^7.5.1"
},
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.14",
"@tailwindcss/oxide-darwin-arm64": "4.1.14",
"@tailwindcss/oxide-darwin-x64": "4.1.14",
"@tailwindcss/oxide-freebsd-x64": "4.1.14",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.14",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.14",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.14",
"@tailwindcss/oxide-linux-x64-musl": "4.1.14",
"@tailwindcss/oxide-wasm32-wasi": "4.1.14",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.14",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.14"
"@tailwindcss/oxide-android-arm64": "4.1.15",
"@tailwindcss/oxide-darwin-arm64": "4.1.15",
"@tailwindcss/oxide-darwin-x64": "4.1.15",
"@tailwindcss/oxide-freebsd-x64": "4.1.15",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.15",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.15",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.15",
"@tailwindcss/oxide-linux-x64-musl": "4.1.15",
"@tailwindcss/oxide-wasm32-wasi": "4.1.15",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.15",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.15"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.14.tgz",
"integrity": "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.15.tgz",
"integrity": "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA==",
"cpu": [
"arm64"
],
@@ -3130,9 +3112,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.14.tgz",
"integrity": "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.15.tgz",
"integrity": "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w==",
"cpu": [
"arm64"
],
@@ -3147,9 +3129,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.14.tgz",
"integrity": "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.15.tgz",
"integrity": "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg==",
"cpu": [
"x64"
],
@@ -3164,9 +3146,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.14.tgz",
"integrity": "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.15.tgz",
"integrity": "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg==",
"cpu": [
"x64"
],
@@ -3181,9 +3163,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.14.tgz",
"integrity": "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.15.tgz",
"integrity": "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg==",
"cpu": [
"arm"
],
@@ -3198,9 +3180,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.14.tgz",
"integrity": "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.15.tgz",
"integrity": "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q==",
"cpu": [
"arm64"
],
@@ -3215,9 +3197,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.14.tgz",
"integrity": "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.15.tgz",
"integrity": "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg==",
"cpu": [
"arm64"
],
@@ -3232,9 +3214,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.14.tgz",
"integrity": "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.15.tgz",
"integrity": "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg==",
"cpu": [
"x64"
],
@@ -3249,9 +3231,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.14.tgz",
"integrity": "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.15.tgz",
"integrity": "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg==",
"cpu": [
"x64"
],
@@ -3266,9 +3248,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.14.tgz",
"integrity": "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.15.tgz",
"integrity": "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -3287,7 +3269,7 @@
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.0.5",
"@napi-rs/wasm-runtime": "^1.0.7",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.4.0"
},
@@ -3327,7 +3309,7 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.5",
"version": "1.0.7",
"dev": true,
"inBundle": true,
"license": "MIT",
@@ -3356,9 +3338,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
"integrity": "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.15.tgz",
"integrity": "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg==",
"cpu": [
"arm64"
],
@@ -3373,9 +3355,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.14.tgz",
"integrity": "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.15.tgz",
"integrity": "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w==",
"cpu": [
"x64"
],
@@ -3390,17 +3372,17 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.14.tgz",
"integrity": "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.15.tgz",
"integrity": "sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.14",
"@tailwindcss/oxide": "4.1.14",
"@tailwindcss/node": "4.1.15",
"@tailwindcss/oxide": "4.1.15",
"postcss": "^8.4.41",
"tailwindcss": "4.1.14"
"tailwindcss": "4.1.15"
}
},
"node_modules/@tailwindcss/typography": {
@@ -3749,12 +3731,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"version": "24.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.0.tgz",
"integrity": "sha512-MKNwXh3seSK8WurXF7erHPJ2AONmMwkI7zAMrXZDPIru8jRqkk6rGDBVbw4mLwfqA+ZZliiDPg05JQ3uW66tKQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.14.0"
"undici-types": "~7.16.0"
}
},
"node_modules/@types/prismjs": {
@@ -3807,17 +3789,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz",
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
"integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/type-utils": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/type-utils": "8.46.2",
"@typescript-eslint/utils": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -3831,7 +3813,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.46.1",
"@typescript-eslint/parser": "^8.46.2",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
@@ -3847,16 +3829,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz",
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"debug": "^4.3.4"
},
"engines": {
@@ -3872,14 +3854,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz",
"integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz",
"integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.46.1",
"@typescript-eslint/types": "^8.46.1",
"@typescript-eslint/tsconfig-utils": "^8.46.2",
"@typescript-eslint/types": "^8.46.2",
"debug": "^4.3.4"
},
"engines": {
@@ -3894,14 +3876,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
"integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz",
"integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1"
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3912,9 +3894,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz",
"integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz",
"integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3929,15 +3911,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
"integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz",
"integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2",
"@typescript-eslint/utils": "8.46.2",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@@ -3954,9 +3936,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
"integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
"integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3968,16 +3950,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
"integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz",
"integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.46.1",
"@typescript-eslint/tsconfig-utils": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"@typescript-eslint/project-service": "8.46.2",
"@typescript-eslint/tsconfig-utils": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/visitor-keys": "8.46.2",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -4066,16 +4048,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
"integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz",
"integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1"
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4090,13 +4072,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
"integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz",
"integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/types": "8.46.2",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -5015,12 +4997,6 @@
"node": ">=8"
}
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
@@ -5055,16 +5031,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"nan": "^2.17.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -5300,16 +5266,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
@@ -8003,21 +7959,21 @@
}
},
"node_modules/jsdom": {
"version": "27.0.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz",
"integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==",
"version": "27.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz",
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/dom-selector": "^6.5.4",
"cssstyle": "^5.3.0",
"@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.1",
"data-urls": "^6.0.0",
"decimal.js": "^10.5.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"parse5": "^7.3.0",
"parse5": "^8.0.0",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
@@ -8026,8 +7982,8 @@
"webidl-conversions": "^8.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^15.0.0",
"ws": "^8.18.2",
"whatwg-url": "^15.1.0",
"ws": "^8.18.3",
"xml-name-validator": "^5.0.0"
},
"engines": {
@@ -8205,9 +8161,9 @@
}
},
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
@@ -8221,22 +8177,44 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.30.1",
"lightningcss-darwin-x64": "1.30.1",
"lightningcss-freebsd-x64": "1.30.1",
"lightningcss-linux-arm-gnueabihf": "1.30.1",
"lightningcss-linux-arm64-gnu": "1.30.1",
"lightningcss-linux-arm64-musl": "1.30.1",
"lightningcss-linux-x64-gnu": "1.30.1",
"lightningcss-linux-x64-musl": "1.30.1",
"lightningcss-win32-arm64-msvc": "1.30.1",
"lightningcss-win32-x64-msvc": "1.30.1"
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [
"arm64"
],
@@ -8255,9 +8233,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [
"x64"
],
@@ -8276,9 +8254,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [
"x64"
],
@@ -8297,9 +8275,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [
"arm"
],
@@ -8318,9 +8296,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [
"arm64"
],
@@ -8339,9 +8317,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [
"arm64"
],
@@ -8360,9 +8338,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [
"x64"
],
@@ -8381,9 +8359,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [
"x64"
],
@@ -8402,9 +8380,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [
"arm64"
],
@@ -8423,9 +8401,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [
"x64"
],
@@ -9572,19 +9550,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -9601,6 +9566,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -9729,6 +9700,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"nan": "^2.17.0"
}
},
"node_modules/node-releases": {
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
@@ -10007,9 +9988,9 @@
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11843,9 +11824,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
"integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==",
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
"license": "MIT"
},
"node_modules/tapable": {
@@ -11862,33 +11843,6 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",
"integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
"dev": true,
"license": "ISC",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tar/node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/test-exclude": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
@@ -12261,16 +12215,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
"integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
"version": "8.46.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz",
"integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1"
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"@typescript-eslint/typescript-estree": "8.46.2",
"@typescript-eslint/utils": "8.46.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -12304,9 +12258,9 @@
}
},
"node_modules/undici-types": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/unified": {
@@ -12550,9 +12504,9 @@
}
},
"node_modules/vite": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"version": "7.1.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -13128,4 +13082,4 @@
}
}
}
}
}

View File

@@ -58,14 +58,14 @@
},
"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.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2",
@@ -73,14 +73,14 @@
"@vitest/ui": "^3.2.4",
"eslint": "^9.38.0",
"eslint-config-next": "^15.5.6",
"jsdom": "^27.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",
"tailwindcss": "^4.1.15",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.1",
"typescript-eslint": "^8.46.2",
"vitest": "^3.2.4"
},
"ct3aMetadata": {
@@ -90,4 +90,4 @@
"overrides": {
"prismjs": "^1.30.0"
}
}
}

6
server.log Normal file
View File

@@ -0,0 +1,6 @@
> pve-scripts-local@0.1.0 start
> node server.js
> Ready on http://0.0.0.0:3000
> WebSocket server running on ws://0.0.0.0:3000/ws/script-execution

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
#!/bin/bash
#21.10.2025
# 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
@@ -525,6 +551,7 @@ update_files() {
"*.backup"
"*.bak"
"scripts"
"prisma/migrations"
)
# Find the actual source directory (strip the top-level directory)
@@ -555,7 +582,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
@@ -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,13 @@ 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 +943,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!"
}