Compare commits

..

18 Commits

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


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

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

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

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

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

View File

@@ -26,4 +26,4 @@ AUTH_PASSWORD_HASH=
AUTH_ENABLED=false AUTH_ENABLED=false
AUTH_SETUP_COMPLETED=false AUTH_SETUP_COMPLETED=false
JWT_SECRET= JWT_SECRET=
DATABASE_URL="file:./data/database.sqlite" DATABASE_URL="file:/opt/ProxmoxVE-Local/data/settings.db"

View File

@@ -1 +1 @@
0.4.6 0.4.7

View File

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

View File

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

View File

@@ -19,8 +19,8 @@
"type": "default", "type": "default",
"script": "ct/paperless-ai.sh", "script": "ct/paperless-ai.sh",
"resources": { "resources": {
"cpu": 2, "cpu": 4,
"ram": 2048, "ram": 4096,
"hdd": 20, "hdd": 20,
"os": "debian", "os": "debian",
"version": "13" "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", "name": "crowdsecurity/crowdsec",
"version": "v1.7.1", "version": "v1.7.1",
"date": "2025-10-15T10:44:03Z" "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", "name": "wizarrrr/wizarr",
"version": "v2025.10.4", "version": "v2025.10.4",
"date": "2025-10-20T10:45:54Z" "date": "2025-10-20T10:45:54Z"
}, },
{
"name": "apache/cassandra",
"version": "cassandra-4.0.19",
"date": "2025-10-20T09:08:49Z"
},
{ {
"name": "jupyter/notebook", "name": "jupyter/notebook",
"version": "@jupyter-notebook/ui-components@7.5.0-beta.1", "version": "@jupyter-notebook/ui-components@7.5.0-beta.1",
"date": "2025-10-20T07:01:38Z" "date": "2025-10-20T07:01:38Z"
}, },
{
"name": "Jackett/Jackett",
"version": "v0.24.159",
"date": "2025-10-20T05:53:23Z"
},
{ {
"name": "inventree/InvenTree", "name": "inventree/InvenTree",
"version": "1.0.7", "version": "1.0.7",
@@ -49,16 +184,6 @@
"version": "v1.0.0-beta27", "version": "v1.0.0-beta27",
"date": "2025-10-20T00:38:13Z" "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", "name": "seriousm4x/UpSnap",
"version": "5.2.3", "version": "5.2.3",
@@ -99,11 +224,6 @@
"version": "v2.14.5.4836", "version": "v2.14.5.4836",
"date": "2025-10-08T15:30:50Z" "date": "2025-10-08T15:30:50Z"
}, },
{
"name": "henrygd/beszel",
"version": "v0.14.0",
"date": "2025-10-18T23:54:15Z"
},
{ {
"name": "BerriAI/litellm", "name": "BerriAI/litellm",
"version": "v1.78.5.rc.1", "version": "v1.78.5.rc.1",
@@ -124,11 +244,6 @@
"version": "v2025-10-18", "version": "v2025-10-18",
"date": "2025-10-18T20:35:54Z" "date": "2025-10-18T20:35:54Z"
}, },
{
"name": "chrisvel/tududi",
"version": "v0.84",
"date": "2025-10-18T19:39:04Z"
},
{ {
"name": "moghtech/komodo", "name": "moghtech/komodo",
"version": "v1.19.5", "version": "v1.19.5",
@@ -154,21 +269,6 @@
"version": "v0.9.0", "version": "v0.9.0",
"date": "2025-10-18T17:03:56Z" "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", "name": "TasmoAdmin/TasmoAdmin",
"version": "v4.3.2", "version": "v4.3.2",
@@ -199,11 +299,6 @@
"version": "v25.4", "version": "v25.4",
"date": "2025-10-09T10:27:01Z" "date": "2025-10-09T10:27:01Z"
}, },
{
"name": "TwiN/gatus",
"version": "v5.27.0",
"date": "2025-10-18T02:44:26Z"
},
{ {
"name": "9001/copyparty", "name": "9001/copyparty",
"version": "v1.19.17", "version": "v1.19.17",
@@ -219,26 +314,11 @@
"version": "2025.10.3", "version": "2025.10.3",
"date": "2025-10-17T21:15:07Z" "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", "name": "forgejo/forgejo",
"version": "v13.0.1", "version": "v13.0.1",
"date": "2025-10-17T18:54:16Z" "date": "2025-10-17T18:54:16Z"
}, },
{
"name": "keycloak/keycloak",
"version": "26.4.1",
"date": "2025-10-16T07:21:53Z"
},
{ {
"name": "grokability/snipe-it", "name": "grokability/snipe-it",
"version": "v8.3.4", "version": "v8.3.4",
@@ -254,6 +334,11 @@
"version": "v2.40.1", "version": "v2.40.1",
"date": "2025-10-17T13:42:04Z" "date": "2025-10-17T13:42:04Z"
}, },
{
"name": "neo4j/neo4j",
"version": "5.26.14",
"date": "2025-10-17T12:38:22Z"
},
{ {
"name": "mattermost/mattermost", "name": "mattermost/mattermost",
"version": "server/public/v0.1.21", "version": "server/public/v0.1.21",
@@ -369,11 +454,6 @@
"version": "v2.13.1", "version": "v2.13.1",
"date": "2025-10-15T13:29:37Z" "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", "name": "TandoorRecipes/recipes",
"version": "2.3.3", "version": "2.3.3",
@@ -384,11 +464,6 @@
"version": "jenkins-2.528.1", "version": "jenkins-2.528.1",
"date": "2025-10-15T12:51:20Z" "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", "name": "cockpit-project/cockpit",
"version": "349", "version": "349",
@@ -399,11 +474,6 @@
"version": "v0.14.1", "version": "v0.14.1",
"date": "2024-08-29T22:32:51Z" "date": "2024-08-29T22:32:51Z"
}, },
{
"name": "openobserve/openobserve",
"version": "v0.15.2",
"date": "2025-10-15T07:42:29Z"
},
{ {
"name": "wavelog/wavelog", "name": "wavelog/wavelog",
"version": "2.1.2", "version": "2.1.2",
@@ -419,21 +489,11 @@
"version": "2025.10.0", "version": "2025.10.0",
"date": "2025-10-14T19:07:37Z" "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", "name": "crafty-controller/crafty-4",
"version": "v4.5.5", "version": "v4.5.5",
"date": "2025-10-14T18:48:36Z" "date": "2025-10-14T18:48:36Z"
}, },
{
"name": "tailscale/tailscale",
"version": "v1.88.4",
"date": "2025-10-14T17:57:52Z"
},
{ {
"name": "plankanban/planka", "name": "plankanban/planka",
"version": "planka-1.1.0", "version": "planka-1.1.0",
@@ -449,11 +509,6 @@
"version": "v1.140.0", "version": "v1.140.0",
"date": "2025-10-14T15:57:12Z" "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", "name": "rogerfar/rdt-client",
"version": "v2.0.119", "version": "v2.0.119",
@@ -584,11 +639,6 @@
"version": "11.0.1", "version": "11.0.1",
"date": "2025-10-09T12:34:15Z" "date": "2025-10-09T12:34:15Z"
}, },
{
"name": "documenso/documenso",
"version": "v1.12.10",
"date": "2025-10-09T04:32:35Z"
},
{ {
"name": "rabbitmq/rabbitmq-server", "name": "rabbitmq/rabbitmq-server",
"version": "v4.1.4", "version": "v4.1.4",
@@ -639,11 +689,6 @@
"version": "v0.15.1", "version": "v0.15.1",
"date": "2025-10-07T20:30:56Z" "date": "2025-10-07T20:30:56Z"
}, },
{
"name": "VictoriaMetrics/VictoriaMetrics",
"version": "pmm-6401-v1.127.0",
"date": "2025-10-07T14:31:32Z"
},
{ {
"name": "thecfu/scraparr", "name": "thecfu/scraparr",
"version": "v2.2.5", "version": "v2.2.5",
@@ -714,11 +759,6 @@
"version": "8.2.2", "version": "8.2.2",
"date": "2025-10-03T06:22:38Z" "date": "2025-10-03T06:22:38Z"
}, },
{
"name": "kyantech/Palmr",
"version": "v3.2.3-beta",
"date": "2025-10-02T13:48:14Z"
},
{ {
"name": "actualbudget/actual", "name": "actualbudget/actual",
"version": "v25.10.0", "version": "v25.10.0",
@@ -739,11 +779,6 @@
"version": "v5.41.4", "version": "v5.41.4",
"date": "2025-09-30T22:26:11Z" "date": "2025-09-30T22:26:11Z"
}, },
{
"name": "zabbix/zabbix",
"version": "7.4.3",
"date": "2025-09-30T21:49:53Z"
},
{ {
"name": "mongodb/mongo", "name": "mongodb/mongo",
"version": "r8.2.1", "version": "r8.2.1",
@@ -764,21 +799,11 @@
"version": "v1.7.4", "version": "v1.7.4",
"date": "2025-09-30T13:34:30Z" "date": "2025-09-30T13:34:30Z"
}, },
{
"name": "neo4j/neo4j",
"version": "4.4.46",
"date": "2025-09-30T13:21:24Z"
},
{ {
"name": "thomiceli/opengist", "name": "thomiceli/opengist",
"version": "v1.11.1", "version": "v1.11.1",
"date": "2025-09-30T00:24:16Z" "date": "2025-09-30T00:24:16Z"
}, },
{
"name": "goauthentik/authentik",
"version": "version/2025.8.4",
"date": "2025-09-30T00:03:11Z"
},
{ {
"name": "influxdata/telegraf", "name": "influxdata/telegraf",
"version": "v1.36.2", "version": "v1.36.2",
@@ -849,11 +874,6 @@
"version": "1.2.39", "version": "1.2.39",
"date": "2025-09-25T15:57:02Z" "date": "2025-09-25T15:57:02Z"
}, },
{
"name": "rclone/rclone",
"version": "v1.71.1",
"date": "2025-09-24T16:32:16Z"
},
{ {
"name": "alexta69/metube", "name": "alexta69/metube",
"version": "2025.09.24", "version": "2025.09.24",
@@ -869,11 +889,6 @@
"version": "v2.0.10", "version": "v2.0.10",
"date": "2025-09-24T08:33:37Z" "date": "2025-09-24T08:33:37Z"
}, },
{
"name": "grafana/grafana",
"version": "v12.2.0",
"date": "2025-09-23T23:47:02Z"
},
{ {
"name": "postgres/postgres", "name": "postgres/postgres",
"version": "REL_18_0", "version": "REL_18_0",
@@ -1014,11 +1029,6 @@
"version": "0.6", "version": "0.6",
"date": "2025-09-05T06:05:04Z" "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", "name": "healthchecks/healthchecks",
"version": "v3.11.2", "version": "v3.11.2",
@@ -1224,11 +1234,6 @@
"version": "250707-d28b3101e", "version": "250707-d28b3101e",
"date": "2025-07-07T15:15:21Z" "date": "2025-07-07T15:15:21Z"
}, },
{
"name": "Kareadita/Kavita",
"version": "v0.8.7",
"date": "2025-07-05T20:08:58Z"
},
{ {
"name": "qbittorrent/qBittorrent", "name": "qbittorrent/qBittorrent",
"version": "release-5.1.2", "version": "release-5.1.2",
@@ -1479,11 +1484,6 @@
"version": "v0.7.3", "version": "v0.7.3",
"date": "2024-12-15T10:18:06Z" "date": "2024-12-15T10:18:06Z"
}, },
{
"name": "pymedusa/Medusa",
"version": "v1.0.22",
"date": "2024-12-13T12:22:19Z"
},
{ {
"name": "phpipam/phpipam", "name": "phpipam/phpipam",
"version": "v1.7.3", "version": "v1.7.3",

526
package-lock.json generated
View File

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

View File

@@ -58,14 +58,14 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15", "@tailwindcss/postcss": "^4.1.15",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.8.1", "@types/node": "^24.9.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
@@ -73,14 +73,14 @@
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"eslint": "^9.38.0", "eslint": "^9.38.0",
"eslint-config-next": "^15.5.6", "eslint-config-next": "^15.5.6",
"jsdom": "^27.0.0", "jsdom": "^27.0.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
"prisma": "^6.17.1", "prisma": "^6.17.1",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.15",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.46.1", "typescript-eslint": "^8.46.2",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"ct3aMetadata": { "ct3aMetadata": {
@@ -90,4 +90,4 @@
"overrides": { "overrides": {
"prismjs": "^1.30.0" "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 { useState } from 'react';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { Input } from "./ui/input"; import { Input } from './ui/input';
import { useAuth } from "./AuthProvider"; import { useAuth } from './AuthProvider';
import { Lock, User, AlertCircle } from "lucide-react"; import { Lock, User, AlertCircle } from 'lucide-react';
import { useRegisterModal } from "./modal/ModalStackProvider"; import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from "@/lib/i18n/useTranslation";
interface AuthModalProps { interface AuthModalProps {
isOpen: boolean; isOpen: boolean;
} }
export function AuthModal({ isOpen }: AuthModalProps) { 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 { login } = useAuth();
const [username, setUsername] = useState(""); const [username, setUsername] = useState('');
const [password, setPassword] = useState(""); const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -31,49 +25,44 @@ export function AuthModal({ isOpen }: AuthModalProps) {
setError(null); setError(null);
const success = await login(username, password); const success = await login(username, password);
if (!success) { if (!success) {
setError(t("error")); setError('Invalid username or password');
} }
setIsLoading(false); setIsLoading(false);
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */} {/* 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"> <div className="flex items-center gap-3">
<Lock className="text-primary h-8 w-8" /> <Lock className="h-8 w-8 text-primary" />
<h2 className="text-card-foreground text-2xl font-bold"> <h2 className="text-2xl font-bold text-card-foreground">Authentication Required</h2>
{t("title")}
</h2>
</div> </div>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
<p className="text-muted-foreground mb-6 text-center"> <p className="text-muted-foreground text-center mb-6">
{t("description")} Please enter your credentials to access the application.
</p> </p>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label <label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
htmlFor="username" Username
className="text-foreground mb-2 block text-sm font-medium"
>
{t("username.label")}
</label> </label>
<div className="relative"> <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 <Input
id="username" id="username"
type="text" type="text"
placeholder={t("username.placeholder")} placeholder="Enter your username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
disabled={isLoading} disabled={isLoading}
@@ -84,18 +73,15 @@ export function AuthModal({ isOpen }: AuthModalProps) {
</div> </div>
<div> <div>
<label <label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
htmlFor="password" Password
className="text-foreground mb-2 block text-sm font-medium"
>
{t("password.label")}
</label> </label>
<div className="relative"> <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 <Input
id="password" id="password"
type="password" type="password"
placeholder={t("password.placeholder")} placeholder="Enter your password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
disabled={isLoading} disabled={isLoading}
@@ -106,7 +92,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
</div> </div>
{error && ( {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" /> <AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span> <span className="text-sm">{error}</span>
</div> </div>
@@ -117,7 +103,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
disabled={isLoading || !username.trim() || !password.trim()} disabled={isLoading || !username.trim() || !password.trim()}
className="w-full" className="w-full"
> >
{isLoading ? t("actions.signingIn") : t("actions.signIn")} {isLoading ? 'Signing In...' : 'Sign In'}
</Button> </Button>
</form> </form>
</div> </div>

View File

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

View File

@@ -1,8 +1,7 @@
"use client"; 'use client';
import { useState } from "react"; import { useState } from 'react';
import { useTranslation } from "@/lib/i18n/useTranslation"; import { ContextualHelpIcon } from './ContextualHelpIcon';
import { ContextualHelpIcon } from "./ContextualHelpIcon";
interface CategorySidebarProps { interface CategorySidebarProps {
categories: string[]; categories: string[];
@@ -13,509 +12,218 @@ interface CategorySidebarProps {
} }
// Icon mapping for categories // Icon mapping for categories
const CategoryIcon = ({ const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; className?: string }) => {
iconName,
className = "w-5 h-5",
}: {
iconName: string;
className?: string;
}) => {
const iconMap: Record<string, React.ReactElement> = { const iconMap: Record<string, React.ReactElement> = {
server: ( server: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
monitor: ( monitor: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
box: ( box: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
shield: ( shield: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
"shield-check": ( "shield-check": (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
key: ( key: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
archive: ( archive: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
database: ( database: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
"chart-bar": ( "chart-bar": (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
template: ( template: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
"folder-open": ( "folder-open": (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
"document-text": ( "document-text": (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
film: ( film: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
download: ( download: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
"video-camera": ( "video-camera": (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
home: ( home: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
wifi: ( wifi: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
"chat-alt": ( "chat-alt": (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
clock: ( clock: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
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> </svg>
), ),
code: ( code: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
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> </svg>
), ),
"external-link": ( "external-link": (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
sparkles: ( sparkles: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
"currency-dollar": ( "currency-dollar": (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
puzzle: ( puzzle: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
office: ( office: (
<svg <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
className={className} <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" />
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> </svg>
), ),
}; };
return ( return iconMap[iconName] ?? (
iconMap[iconName] ?? ( <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg <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" />
className={className} </svg>
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({ export function CategorySidebar({
categories, categories,
categoryCounts, categoryCounts,
totalScripts, totalScripts,
selectedCategory, selectedCategory,
onCategorySelect, onCategorySelect
}: CategorySidebarProps) { }: CategorySidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false); 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) // Category to icon mapping (based on metadata.json)
const categoryIconMapping: Record<string, string> = { const categoryIconMapping: Record<string, string> = {
"Proxmox & Virtualization": "server", 'Proxmox & Virtualization': 'server',
"Operating Systems": "monitor", 'Operating Systems': 'monitor',
"Containers & Docker": "box", 'Containers & Docker': 'box',
"Network & Firewall": "shield", 'Network & Firewall': 'shield',
"Adblock & DNS": "shield-check", 'Adblock & DNS': 'shield-check',
"Authentication & Security": "key", 'Authentication & Security': 'key',
"Backup & Recovery": "archive", 'Backup & Recovery': 'archive',
Databases: "database", 'Databases': 'database',
"Monitoring & Analytics": "chart-bar", 'Monitoring & Analytics': 'chart-bar',
"Dashboards & Frontends": "template", 'Dashboards & Frontends': 'template',
"Files & Downloads": "folder-open", 'Files & Downloads': 'folder-open',
"Documents & Notes": "document-text", 'Documents & Notes': 'document-text',
"Media & Streaming": "film", 'Media & Streaming': 'film',
"*Arr Suite": "download", '*Arr Suite': 'download',
"NVR & Cameras": "video-camera", 'NVR & Cameras': 'video-camera',
"IoT & Smart Home": "home", 'IoT & Smart Home': 'home',
"ZigBee, Z-Wave & Matter": "wifi", 'ZigBee, Z-Wave & Matter': 'wifi',
"MQTT & Messaging": "chat-alt", 'MQTT & Messaging': 'chat-alt',
"Automation & Scheduling": "clock", 'Automation & Scheduling': 'clock',
"AI / Coding & Dev-Tools": "code", 'AI / Coding & Dev-Tools': 'code',
"Webservers & Proxies": "external-link", 'Webservers & Proxies': 'external-link',
"Bots & ChatOps": "sparkles", 'Bots & ChatOps': 'sparkles',
"Finance & Budgeting": "currency-dollar", 'Finance & Budgeting': 'currency-dollar',
"Gaming & Leisure": "puzzle", 'Gaming & Leisure': 'puzzle',
"Business & ERP": "office", 'Business & ERP': 'office',
Miscellaneous: "box", 'Miscellaneous': 'box'
}; };
// Sort categories by count (descending) and then alphabetically // Sort categories by count (descending) and then alphabetically
const sortedCategories = categories const sortedCategories = categories
.map((category) => [category, categoryCounts[category] ?? 0] as const) .map(category => [category, categoryCounts[category] ?? 0] as const)
.sort(([a, countA], [b, countB]) => { .sort(([a, countA], [b, countB]) => {
if (countB !== countA) return countB - countA; if (countB !== countA) return countB - countA;
return a.localeCompare(b); return a.localeCompare(b);
}); });
return ( return (
<div <div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
className={`bg-card border-border rounded-lg border shadow-md transition-all duration-300 ${ isCollapsed ? 'w-16' : 'w-full lg:w-80'
isCollapsed ? "w-16" : "w-full lg:w-80" }`}>
}`}
>
{/* Header */} {/* 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 && ( {!isCollapsed && (
<div className="flex w-full items-center justify-between"> <div className="flex items-center justify-between w-full">
<div> <div>
<h3 className="text-foreground text-lg font-semibold"> <h3 className="text-lg font-semibold text-foreground">Categories</h3>
{t("headerTitle")} <p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
</h3>
<p className="text-muted-foreground text-sm">
{t("totalScripts", { values: { count: totalScripts } })}
</p>
</div> </div>
<ContextualHelpIcon <ContextualHelpIcon section="available-scripts" tooltip="Help with categories" />
section="available-scripts"
tooltip={t("helpTooltip")}
/>
</div> </div>
)} )}
<button <button
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setIsCollapsed(!isCollapsed)}
className="hover:bg-muted rounded-lg p-2 transition-colors" className="p-2 rounded-lg hover:bg-muted transition-colors"
title={isCollapsed ? t("actions.expand") : t("actions.collapse")} title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
> >
<svg <svg
className={`text-muted-foreground h-5 w-5 transition-transform ${ className={`w-5 h-5 text-muted-foreground transition-transform ${
isCollapsed ? "rotate-180" : "" isCollapsed ? 'rotate-180' : ''
}`} }`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
</button> </button>
</div> </div>
@@ -527,26 +235,24 @@ export function CategorySidebar({
{/* "All Categories" option */} {/* "All Categories" option */}
<button <button
onClick={() => onCategorySelect(null)} onClick={() => onCategorySelect(null)}
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 ${
selectedCategory === null selectedCategory === null
? "bg-primary/10 text-primary border-primary/20 border" ? 'bg-primary/10 text-primary border border-primary/20'
: "hover:bg-accent text-muted-foreground" : 'hover:bg-accent text-muted-foreground'
}`} }`}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<CategoryIcon <CategoryIcon
iconName="template" iconName="template"
className={`h-5 w-5 ${selectedCategory === null ? "text-primary" : "text-muted-foreground"}`} 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> </div>
<span <span className={`text-sm px-2 py-1 rounded-full ${
className={`rounded-full px-2 py-1 text-sm ${ selectedCategory === null
selectedCategory === null ? 'bg-primary/20 text-primary'
? "bg-primary/20 text-primary" : 'bg-muted text-muted-foreground'
: "bg-muted text-muted-foreground" }`}>
}`}
>
{totalScripts} {totalScripts}
</span> </span>
</button> </button>
@@ -554,32 +260,31 @@ export function CategorySidebar({
{/* Individual Categories */} {/* Individual Categories */}
{sortedCategories.map(([category, count]) => { {sortedCategories.map(([category, count]) => {
const isSelected = selectedCategory === category; const isSelected = selectedCategory === category;
const categoryLabel = formatCategoryLabel(category);
return ( return (
<button <button
key={category} key={category}
onClick={() => onCategorySelect(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 isSelected
? "bg-primary/10 text-primary border-primary/20 border" ? 'bg-primary/10 text-primary border border-primary/20'
: "hover:bg-accent text-muted-foreground" : 'hover:bg-accent text-muted-foreground'
}`} }`}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<CategoryIcon <CategoryIcon
iconName={categoryIconMapping[category] ?? "box"} iconName={categoryIconMapping[category] ?? 'box'}
className={`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground"}`} 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> </div>
<span <span className={`text-sm px-2 py-1 rounded-full ${
className={`rounded-full px-2 py-1 text-sm ${ isSelected
isSelected ? 'bg-primary/20 text-primary'
? "bg-primary/20 text-primary" : 'bg-muted text-muted-foreground'
: "bg-muted text-muted-foreground" }`}>
}`}
>
{count} {count}
</span> </span>
</button> </button>
@@ -591,71 +296,66 @@ export function CategorySidebar({
{/* Collapsed state - show only icons with counters and tooltips */} {/* Collapsed state - show only icons with counters and tooltips */}
{isCollapsed && ( {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 */} {/* "All Categories" option */}
<div className="group relative"> <div className="group relative">
<button <button
onClick={() => onCategorySelect(null)} onClick={() => onCategorySelect(null)}
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 ${
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 ${
selectedCategory === null selectedCategory === null
? "bg-primary/20 text-primary" ? 'bg-primary/10 text-primary border border-primary/20'
: "bg-muted text-muted-foreground" : '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} {totalScripts}
</span> </span>
</button> </button>
{/* Tooltip */} {/* 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"> <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">
{t("all.tooltip", { values: { count: totalScripts } })} All Categories ({totalScripts})
</div> </div>
</div> </div>
{/* Individual Categories */} {/* Individual Categories */}
{sortedCategories.map(([category, count]) => { {sortedCategories.map(([category, count]) => {
const isSelected = selectedCategory === category; const isSelected = selectedCategory === category;
const categoryLabel = formatCategoryLabel(category);
return ( return (
<div key={category} className="group relative"> <div key={category} className="group relative">
<button <button
onClick={() => onCategorySelect(category)} 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 isSelected
? "bg-primary/10 text-primary border-primary/20 border" ? 'bg-primary/10 text-primary border border-primary/20'
: "hover:bg-accent text-muted-foreground" : 'hover:bg-accent text-muted-foreground'
}`} }`}
> >
<CategoryIcon <CategoryIcon
iconName={categoryIconMapping[category] ?? "box"} iconName={categoryIconMapping[category] ?? 'box'}
className={`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`} className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/> />
<span <span className={`text-xs mt-1 px-1 rounded ${
className={`mt-1 rounded px-1 text-xs ${ isSelected
isSelected ? 'bg-primary/20 text-primary'
? "bg-primary/20 text-primary" : 'bg-muted text-muted-foreground'
: "bg-muted text-muted-foreground" }`}>
}`}
>
{count} {count}
</span> </span>
</button> </button>
{/* Tooltip */} {/* 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"> <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">
{formatCategoryTooltip(categoryLabel, count)} {category} ({count})
</div> </div>
</div> </div>
); );
@@ -664,4 +364,4 @@ export function CategorySidebar({
)} )}
</div> </div>
); );
} }

View File

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

View File

@@ -1,10 +1,9 @@
"use client"; 'use client';
import { useEffect } from "react"; import { useEffect } from 'react';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { AlertCircle, CheckCircle } from "lucide-react"; import { AlertCircle, CheckCircle } from 'lucide-react';
import { useRegisterModal } from "./modal/ModalStackProvider"; import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from "~/lib/i18n/useTranslation";
interface ErrorModalProps { interface ErrorModalProps {
isOpen: boolean; isOpen: boolean;
@@ -12,7 +11,7 @@ interface ErrorModalProps {
title: string; title: string;
message: string; message: string;
details?: string; details?: string;
type?: "error" | "success"; type?: 'error' | 'success';
} }
export function ErrorModal({ export function ErrorModal({
@@ -21,11 +20,9 @@ export function ErrorModal({
title, title,
message, message,
details, details,
type = "error", type = 'error'
}: ErrorModalProps) { }: ErrorModalProps) {
const { t } = useTranslation("errorModal"); useRegisterModal(isOpen, { id: 'error-modal', allowEscape: true, onClose });
const { t: tc } = useTranslation("common.actions");
useRegisterModal(isOpen, { id: "error-modal", allowEscape: true, onClose });
// Auto-close after 10 seconds // Auto-close after 10 seconds
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -39,47 +36,41 @@ export function ErrorModal({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border w-full max-w-lg rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-lg w-full border border-border">
{/* Header */} {/* 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"> <div className="flex items-center gap-3">
{type === "success" ? ( {type === 'success' ? (
<CheckCircle className="text-success h-8 w-8" /> <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>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <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 && ( {details && (
<div <div className={`rounded-lg p-3 ${
className={`rounded-lg p-3 ${ type === 'success'
type === "success" ? 'bg-success/10 border border-success/20'
? "bg-success/10 border-success/20 border" : 'bg-error/10 border border-error/20'
: "bg-error/10 border-error/20 border" }`}>
}`} <p className={`text-xs font-medium mb-1 ${
> type === 'success'
<p ? 'text-success-foreground'
className={`mb-1 text-xs font-medium ${ : 'text-error-foreground'
type === "success" }`}>
? "text-success-foreground" {type === 'success' ? 'Details:' : 'Error Details:'}
: "text-error-foreground"
}`}
>
{type === "success"
? t("detailsLabel")
: t("errorDetailsLabel")}
</p> </p>
<pre <pre className={`text-xs whitespace-pre-wrap break-words ${
className={`text-xs break-words whitespace-pre-wrap ${ type === 'success'
type === "success" ? "text-success/80" : "text-error/80" ? 'text-success/80'
}`} : 'text-error/80'
> }`}>
{details} {details}
</pre> </pre>
</div> </div>
@@ -87,9 +78,9 @@ export function ErrorModal({
</div> </div>
{/* Footer */} {/* 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}> <Button variant="outline" onClick={onClose}>
{tc("close")} Close
</Button> </Button>
</div> </div>
</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 { interface ExecutionModeModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onExecute: (mode: "local" | "ssh", server?: Server) => void; onExecute: (mode: 'local' | 'ssh', server?: Server) => void;
scriptName: string; scriptName: string;
} }
export function ExecutionModeModal({ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) {
isOpen, useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
onClose,
onExecute,
scriptName,
}: ExecutionModeModalProps) {
const { t } = useTranslation("executionModeModal");
useRegisterModal(isOpen, {
id: "execution-mode-modal",
allowEscape: true,
onClose,
});
const [servers, setServers] = useState<Server[]>([]); const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<Server | null>(null); const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [settingsModalOpen, setSettingsModalOpen] = useState(false); 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 // Refresh servers when settings modal closes
const handleSettingsModalClose = () => { const handleSettingsModalClose = () => {
setSettingsModalOpen(false); setSettingsModalOpen(false);
@@ -44,78 +47,56 @@ export function ExecutionModeModal({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch("/api/servers"); const response = await fetch('/api/servers');
if (!response.ok) { if (!response.ok) {
throw new Error(t("errors.fetchFailed")); throw new Error('Failed to fetch servers');
} }
const data = await response.json(); const data = await response.json();
// Sort servers by name alphabetically // Sort servers by name alphabetically
const sortedServers = (data as Server[]).sort((a, b) => const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? "").localeCompare(b.name ?? ""), (a.name ?? '').localeCompare(b.name ?? '')
); );
setServers(sortedServers); setServers(sortedServers);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("errors.fetchFailed")); setError(err instanceof Error ? err.message : 'An error occurred');
} finally { } finally {
setLoading(false); 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 = () => { const handleExecute = () => {
if (!selectedServer) { if (!selectedServer) {
setError(t("errors.noServerSelected")); setError('Please select a server for SSH execution');
return; return;
} }
onExecute("ssh", selectedServer); onExecute('ssh', selectedServer);
onClose(); onClose();
}; };
const handleServerSelect = (server: Server | null) => { const handleServerSelect = (server: Server | null) => {
setSelectedServer(server); setSelectedServer(server);
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<> <>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl"> <div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
{/* Header */} {/* 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">
<h2 className="text-foreground text-xl font-bold">{t("title")}</h2> <h2 className="text-xl font-bold text-foreground">Select Server</h2>
<Button <Button
onClick={onClose} onClick={onClose}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-6 w-6" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@@ -123,72 +104,60 @@ export function ExecutionModeModal({
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
{error && ( {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">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg <svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
className="text-destructive h-5 w-5" <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" />
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> </svg>
</div> </div>
<div className="ml-3"> <div className="ml-3">
<p className="text-destructive text-sm">{error}</p> <p className="text-sm text-destructive">{error}</p>
</div> </div>
</div> </div>
</div> </div>
)} )}
{loading ? ( {loading ? (
<div className="py-8 text-center"> <div className="text-center py-8">
<div className="border-primary inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div> <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="text-muted-foreground mt-2 text-sm"> <p className="mt-2 text-sm text-muted-foreground">Loading servers...</p>
{t("loadingServers")}
</p>
</div> </div>
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<div className="text-muted-foreground py-8 text-center"> <div className="text-center py-8 text-muted-foreground">
<p className="text-sm">{t("noServersConfigured")}</p> <p className="text-sm">No servers configured</p>
<p className="mt-1 text-xs">{t("addServersHint")}</p> <p className="text-xs mt-1">Add servers in Settings to execute scripts</p>
<Button <Button
onClick={() => setSettingsModalOpen(true)} onClick={() => setSettingsModalOpen(true)}
variant="outline" variant="outline"
size="sm" size="sm"
className="mt-3" className="mt-3"
> >
{t("openServerSettings")} Open Server Settings
</Button> </Button>
</div> </div>
) : servers.length === 1 ? ( ) : servers.length === 1 ? (
/* Single Server Confirmation View */ /* Single Server Confirmation View */
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center"> <div className="text-center">
<h3 className="text-foreground mb-2 text-lg font-medium"> <h3 className="text-lg font-medium text-foreground mb-2">
{t("installConfirmation.title")} Install Script Confirmation
</h3> </h3>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
{t("installConfirmation.description", { Do you want to install &quot;{scriptName}&quot; on the following server?
values: { scriptName },
})}
</p> </p>
</div> </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 items-center space-x-3">
<div className="flex-shrink-0"> <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>
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<p className="text-foreground truncate text-sm font-medium"> <p className="text-sm font-medium text-foreground truncate">
{selectedServer?.name ?? t("unnamedServer")} {selectedServer?.name ?? 'Unnamed Server'}
</p> </p>
<p className="text-muted-foreground text-sm"> <p className="text-sm text-muted-foreground">
{selectedServer?.ip} {selectedServer?.ip}
</p> </p>
</div> </div>
@@ -197,15 +166,19 @@ export function ExecutionModeModal({
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<Button onClick={onClose} variant="outline" size="default"> <Button
{t("actions.cancel")} onClick={onClose}
variant="outline"
size="default"
>
Cancel
</Button> </Button>
<Button <Button
onClick={handleExecute} onClick={handleExecute}
variant="default" variant="default"
size="default" size="default"
> >
{t("actions.install")} Install
</Button> </Button>
</div> </div>
</div> </div>
@@ -213,44 +186,41 @@ export function ExecutionModeModal({
/* Multiple Servers Selection View */ /* Multiple Servers Selection View */
<div className="space-y-6"> <div className="space-y-6">
<div className="mb-6"> <div className="mb-6">
<h3 className="text-foreground mb-2 text-lg font-medium"> <h3 className="text-lg font-medium text-foreground mb-2">
{t("multipleServers.title", { values: { scriptName } })} Select server to execute &quot;{scriptName}&quot;
</h3> </h3>
</div> </div>
{/* Server Selection */} {/* Server Selection */}
<div className="mb-6"> <div className="mb-6">
<label <label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
htmlFor="server" Select Server
className="text-foreground mb-2 block text-sm font-medium"
>
{t("multipleServers.selectServerLabel")}
</label> </label>
<ColorCodedDropdown <ColorCodedDropdown
servers={servers} servers={servers}
selectedServer={selectedServer} selectedServer={selectedServer}
onServerSelect={handleServerSelect} onServerSelect={handleServerSelect}
placeholder={t("multipleServers.placeholder")} placeholder="Select a server..."
/> />
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<Button onClick={onClose} variant="outline" size="default"> <Button
{t("actions.cancel")} onClick={onClose}
variant="outline"
size="default"
>
Cancel
</Button> </Button>
<Button <Button
onClick={handleExecute} onClick={handleExecute}
disabled={!selectedServer} disabled={!selectedServer}
variant="default" variant="default"
size="default" size="default"
className={ className={!selectedServer ? 'bg-muted-foreground cursor-not-allowed' : ''}
!selectedServer
? "bg-muted-foreground cursor-not-allowed"
: ""
}
> >
{t("actions.runOnServer")} Run on Server
</Button> </Button>
</div> </div>
</div> </div>
@@ -260,9 +230,9 @@ export function ExecutionModeModal({
</div> </div>
{/* Server Settings Modal */} {/* Server Settings Modal */}
<SettingsModal <SettingsModal
isOpen={settingsModalOpen} isOpen={settingsModalOpen}
onClose={handleSettingsModalClose} onClose={handleSettingsModalClose}
/> />
</> </>
); );

View File

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

View File

@@ -1,9 +1,8 @@
"use client"; 'use client';
import { api } from "~/trpc/react"; import { api } from '~/trpc/react';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { ExternalLink, FileText } from "lucide-react"; import { ExternalLink, FileText } from 'lucide-react';
import { useTranslation } from "~/lib/i18n/useTranslation";
interface FooterProps { interface FooterProps {
onOpenReleaseNotes: () => void; onOpenReleaseNotes: () => void;
@@ -11,43 +10,41 @@ interface FooterProps {
export function Footer({ onOpenReleaseNotes }: FooterProps) { export function Footer({ onOpenReleaseNotes }: FooterProps) {
const { data: versionData } = api.version.getCurrentVersion.useQuery(); const { data: versionData } = api.version.getCurrentVersion.useQuery();
const { t } = useTranslation("footer");
const currentYear = new Date().getFullYear();
return ( 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="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"> <div className="flex items-center gap-2">
<span>{t("copyright", { values: { year: currentYear } })}</span> <span>© 2024 PVE Scripts Local</span>
{versionData?.success && versionData.version && ( {versionData?.success && versionData.version && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onOpenReleaseNotes} 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} v{versionData.version}
</Button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onOpenReleaseNotes} 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" /> <FileText className="h-3 w-3 mr-1" />
{t("releaseNotes")} Release Notes
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
asChild asChild
className="hover:text-foreground h-auto p-2 text-xs" className="h-auto p-2 text-xs hover:text-foreground"
> >
<a <a
href="https://github.com/community-scripts/ProxmoxVE-Local" href="https://github.com/community-scripts/ProxmoxVE-Local"
@@ -56,7 +53,7 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
{t("github")} GitHub
</a> </a>
</Button> </Button>
</div> </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 { useState } from 'react';
import { HelpModal } from "./HelpModal"; import { HelpModal } from './HelpModal';
import { Button } from "./ui/button"; import { Button } from './ui/button';
import { HelpCircle } from "lucide-react"; import { HelpCircle } from 'lucide-react';
import { useTranslation } from "@/lib/i18n/useTranslation";
interface HelpButtonProps { interface HelpButtonProps {
initialSection?: string; initialSection?: string;
} }
export function HelpButton({ initialSection }: HelpButtonProps) { export function HelpButton({ initialSection }: HelpButtonProps) {
const { t } = useTranslation("helpButton");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<> <>
<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="text-muted-foreground text-sm font-medium"> <div className="text-sm text-muted-foreground font-medium">
{t("needHelp")} Need help?
</div> </div>
<Button <Button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
variant="outline" variant="outline"
size="default" size="default"
className="inline-flex items-center" className="inline-flex items-center"
title={t("openHelp")} title="Open Help"
> >
<HelpCircle className="mr-2 h-5 w-5" /> <HelpCircle className="w-5 h-5 mr-2" />
{t("help")} Help
</Button> </Button>
</div> </div>
<HelpModal <HelpModal
isOpen={isOpen} isOpen={isOpen}
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
initialSection={initialSection} 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 { Loader2 } from 'lucide-react';
import { useRegisterModal } from "./modal/ModalStackProvider"; import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from "@/lib/i18n/useTranslation";
interface LoadingModalProps { interface LoadingModalProps {
isOpen: boolean; isOpen: boolean;
@@ -10,29 +9,26 @@ interface LoadingModalProps {
} }
export function LoadingModal({ isOpen, action }: 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; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"> <div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card border-border w-full max-w-md rounded-lg border p-8 shadow-xl"> <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="flex flex-col items-center space-y-4">
<div className="relative"> <div className="relative">
<Loader2 className="text-primary h-12 w-12 animate-spin" /> <Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div> <div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-card-foreground mb-2 text-lg font-semibold"> <h3 className="text-lg font-semibold text-card-foreground mb-2">
{t("processing")} Processing
</h3> </h3>
<p className="text-muted-foreground text-sm">{action}</p> <p className="text-sm text-muted-foreground">
<p className="text-muted-foreground mt-2 text-xs"> {action}
{t("pleaseWait")} </p>
<p className="text-xs text-muted-foreground mt-2">
Please wait...
</p> </p>
</div> </div>
</div> </div>
@@ -40,3 +36,4 @@ export function LoadingModal({ isOpen, action }: LoadingModalProps) {
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,7 @@ import "~/styles/globals.css";
import { type Metadata, type Viewport } from "next"; import { type Metadata, type Viewport } from "next";
import { Geist } from "next/font/google"; 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 { TRPCReactProvider } from "~/trpc/react";
import { AuthProvider } from "./_components/AuthProvider"; import { AuthProvider } from "./_components/AuthProvider";
import { AuthGuard } from "./_components/AuthGuard"; import { AuthGuard } from "./_components/AuthGuard";
@@ -14,8 +11,7 @@ import { ModalStackProvider } from "./_components/modal/ModalStackProvider";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PVE Scripts local", title: "PVE Scripts local",
description: description: "Manage and execute Proxmox helper scripts locally with live output streaming",
"Manage and execute Proxmox helper scripts locally with live output streaming",
icons: [ icons: [
{ rel: "icon", url: "/favicon.png", type: "image/png" }, { rel: "icon", url: "/favicon.png", type: "image/png" },
{ rel: "icon", url: "/favicon.ico", sizes: "any" }, { rel: "icon", url: "/favicon.ico", sizes: "any" },
@@ -34,44 +30,26 @@ const geist = Geist({
variable: "--font-jetbrains-mono", variable: "--font-jetbrains-mono",
}); });
export default async function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ children: React.ReactNode }>) { }: 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 ( return (
<html lang={initialLocale} className={geist.variable}> <html lang="en" className={geist.variable}>
<body <body
className="bg-background text-foreground transition-colors" className="bg-background text-foreground transition-colors"
suppressHydrationWarning={true} suppressHydrationWarning={true}
> >
<LanguageProvider initialLocale={initialLocale}> <ThemeProvider>
<ThemeProvider> <TRPCReactProvider>
<TRPCReactProvider> <AuthProvider>
<AuthProvider> <ModalStackProvider>
<ModalStackProvider> <AuthGuard>
<AuthGuard>{children}</AuthGuard> {children}
</ModalStackProvider> </AuthGuard>
</AuthProvider> </ModalStackProvider>
</TRPCReactProvider> </AuthProvider>
</ThemeProvider> </TRPCReactProvider>
</LanguageProvider> </ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,67 +1,47 @@
"use client";
import { useState, useRef, useEffect } from "react"; 'use client';
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab"; import { useState, useRef, useEffect } from 'react';
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab"; import { ScriptsGrid } from './_components/ScriptsGrid';
import { ResyncButton } from "./_components/ResyncButton"; import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { Terminal } from "./_components/Terminal"; import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { ServerSettingsButton } from "./_components/ServerSettingsButton"; import { ResyncButton } from './_components/ResyncButton';
import { SettingsButton } from "./_components/SettingsButton"; import { Terminal } from './_components/Terminal';
import { HelpButton } from "./_components/HelpButton"; import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { VersionDisplay } from "./_components/VersionDisplay"; import { SettingsButton } from './_components/SettingsButton';
import { ThemeToggle } from "./_components/ThemeToggle"; import { HelpButton } from './_components/HelpButton';
import { LanguageToggle } from "./_components/LanguageToggle"; import { VersionDisplay } from './_components/VersionDisplay';
import { Button } from "./_components/ui/button"; import { ThemeToggle } from './_components/ThemeToggle';
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon"; import { Button } from './_components/ui/button';
import { import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
ReleaseNotesModal, import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
getLastSeenVersion, import { Footer } from './_components/Footer';
} from "./_components/ReleaseNotesModal"; import { Package, HardDrive, FolderOpen } from 'lucide-react';
import { Footer } from "./_components/Footer"; import { api } from '~/trpc/react';
import { Package, HardDrive, FolderOpen } from "lucide-react";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { api } from "~/trpc/react";
export default function Home() { export default function Home() {
const { t } = useTranslation("layout"); const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [runningScript, setRunningScript] = useState<{ const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
path: string; if (typeof window !== 'undefined') {
name: string; const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
mode?: "local" | "ssh"; return savedTab || 'scripts';
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 [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>( const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
undefined,
);
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts // Fetch data for script counts
const { data: scriptCardsData } = const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
api.scripts.getScriptCardsWithCategories.useQuery(); const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: localScriptsData } = const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } =
api.installedScripts.getAllInstalledScripts.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery(); const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Save active tab to localStorage whenever it changes // Save active tab to localStorage whenever it changes
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== 'undefined') {
localStorage.setItem("activeTab", activeTab); localStorage.setItem('activeTab', activeTab);
} }
}, [activeTab]); }, [activeTab]);
@@ -70,12 +50,9 @@ export default function Home() {
if (versionData?.success && versionData.version) { if (versionData?.success && versionData.version) {
const currentVersion = versionData.version; const currentVersion = versionData.version;
const lastSeenVersion = getLastSeenVersion(); const lastSeenVersion = getLastSeenVersion();
// If we have a current version and either no last seen version or versions don't match // If we have a current version and either no last seen version or versions don't match
if ( if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
currentVersion &&
(!lastSeenVersion || currentVersion !== lastSeenVersion)
) {
setHighlightVersion(currentVersion); setHighlightVersion(currentVersion);
setReleaseNotesOpen(true); setReleaseNotesOpen(true);
} }
@@ -96,11 +73,11 @@ export default function Home() {
const scriptCounts = { const scriptCounts = {
available: (() => { available: (() => {
if (!scriptCardsData?.success) return 0; if (!scriptCardsData?.success) return 0;
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx) // Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, any>(); const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach((script) => { scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) { if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence // Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) { if (!scriptMap.has(script.slug)) {
@@ -108,40 +85,38 @@ export default function Home() {
} }
} }
}); });
return scriptMap.size; return scriptMap.size;
})(), })(),
downloaded: (() => { downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0; if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// First deduplicate GitHub scripts using Map by slug // First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>(); const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach((script) => { scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) { if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) { if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script); scriptMap.set(script.slug, script);
} }
} }
}); });
const deduplicatedGithubScripts = Array.from(scriptMap.values()); const deduplicatedGithubScripts = Array.from(scriptMap.values());
const localScripts = localScriptsData.scripts ?? []; const localScripts = localScriptsData.scripts ?? [];
// Count scripts that are both in deduplicated GitHub data and have local versions // 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; if (!script?.name) return false;
return localScripts.some((local) => { return localScripts.some(local => {
if (!local?.name) return false; if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, ""); const localName = local.name.replace(/\.sh$/, '');
return ( return localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === script.name.toLowerCase() || localName.toLowerCase() === (script.slug ?? '').toLowerCase();
localName.toLowerCase() === (script.slug ?? "").toLowerCase()
);
}); });
}).length; }).length;
})(), })(),
installed: installedScriptsData?.scripts?.length ?? 0, installed: installedScriptsData?.scripts?.length ?? 0
}; };
const scrollToTerminal = () => { 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 // Get the element's position and scroll with a small offset for better mobile experience
const elementTop = terminalRef.current.offsetTop; const elementTop = terminalRef.current.offsetTop;
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
window.scrollTo({ window.scrollTo({
top: elementTop - offset, top: elementTop - offset,
behavior: "smooth", behavior: 'smooth'
}); });
} }
}; };
const handleRunScript = ( const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server }); setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered // Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100); setTimeout(scrollToTerminal, 100);
@@ -173,22 +143,21 @@ export default function Home() {
}; };
return ( return (
<main className="bg-background min-h-screen"> <main className="min-h-screen bg-background">
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8"> <div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
{/* Header */} {/* Header */}
<div className="mb-6 text-center sm:mb-8"> <div className="text-center mb-6 sm:mb-8">
<div className="mb-2 flex items-start justify-between"> <div className="flex justify-between items-start mb-2">
<div className="flex-1"></div> <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"> <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">{t("title")}</span> <span className="break-words">PVE Scripts Management</span>
</h1> </h1>
<div className="flex flex-1 justify-end gap-2"> <div className="flex-1 flex justify-end">
<LanguageToggle />
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base"> <p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
{t("tagline")} Manage and execute Proxmox helper scripts locally with live output streaming
</p> </p>
<div className="flex justify-center px-2"> <div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} /> <VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
@@ -197,7 +166,7 @@ export default function Home() {
{/* Controls */} {/* Controls */}
<div className="mb-6 sm:mb-8"> <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 /> <ServerSettingsButton />
<SettingsButton /> <SettingsButton />
<ResyncButton /> <ResyncButton />
@@ -207,75 +176,65 @@ export default function Home() {
{/* Tab Navigation */} {/* Tab Navigation */}
<div className="mb-6 sm:mb-8"> <div className="mb-6 sm:mb-8">
<div className="border-border border-b"> <div className="border-b border-border">
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1"> <nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab("scripts")} 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 ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "scripts" activeTab === 'scripts'
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none" ? '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" : 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`} }`}>
>
<Package className="h-4 w-4" /> <Package className="h-4 w-4" />
<span className="hidden sm:inline">{t("tabs.available")}</span> <span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">{t("tabs.availableShort")}</span> <span className="sm:hidden">Available</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available} {scriptCounts.available}
</span> </span>
<ContextualHelpIcon <ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
section="available-scripts"
tooltip={t("help.availableTooltip")}
/>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab("downloaded")} 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 ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "downloaded" activeTab === 'downloaded'
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none" ? '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" : 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`} }`}>
>
<HardDrive className="h-4 w-4" /> <HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">{t("tabs.downloaded")}</span> <span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">{t("tabs.downloadedShort")}</span> <span className="sm:hidden">Downloaded</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded} {scriptCounts.downloaded}
</span> </span>
<ContextualHelpIcon <ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
section="downloaded-scripts"
tooltip={t("help.downloadedTooltip")}
/>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="null" size="null"
onClick={() => setActiveTab("installed")} 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 ${ className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === "installed" activeTab === 'installed'
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none" ? '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" : 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`} }`}>
>
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">{t("tabs.installed")}</span> <span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">{t("tabs.installedShort")}</span> <span className="sm:hidden">Installed</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs"> <span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed} {scriptCounts.installed}
</span> </span>
<ContextualHelpIcon <ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
section="installed-scripts"
tooltip={t("help.installedTooltip")}
/>
</Button> </Button>
</nav> </nav>
</div> </div>
</div> </div>
{/* Running Script Terminal */} {/* Running Script Terminal */}
{runningScript && ( {runningScript && (
<div ref={terminalRef} className="mb-8"> <div ref={terminalRef} className="mb-8">
@@ -289,15 +248,17 @@ export default function Home() {
)} )}
{/* Tab Content */} {/* Tab Content */}
{activeTab === "scripts" && ( {activeTab === 'scripts' && (
<ScriptsGrid onInstallScript={handleRunScript} /> <ScriptsGrid onInstallScript={handleRunScript} />
)} )}
{activeTab === "downloaded" && ( {activeTab === 'downloaded' && (
<DownloadedScriptsTab onInstallScript={handleRunScript} /> <DownloadedScriptsTab onInstallScript={handleRunScript} />
)} )}
{activeTab === "installed" && <InstalledScriptsTab />} {activeTab === 'installed' && (
<InstalledScriptsTab />
)}
</div> </div>
{/* Footer */} {/* 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 #!/bin/bash
#21.10.2025 - @michelroegl-brunner
# Enhanced update script for ProxmoxVE-Local # Enhanced update script for ProxmoxVE-Local
# Fetches latest release from GitHub and backs up data directory # 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) # List of files/directories to preserve (already backed up)
local preserve_patterns=( local preserve_patterns=(
"data" "data"
"data/*"
".env" ".env"
"*.log" "*.log"
"update.log" "update.log"
@@ -354,7 +356,7 @@ restore_backup_files() {
if [ -f ".env" ]; then if [ -f ".env" ]; then
rm -f ".env" rm -f ".env"
fi fi
if mv "$BACKUP_DIR/.env" ".env"; then if cp "$BACKUP_DIR/.env" ".env"; then
log_success ".env file restored from backup" log_success ".env file restored from backup"
else else
log_error "Failed to restore .env file" log_error "Failed to restore .env file"
@@ -369,7 +371,7 @@ restore_backup_files() {
if [ -d "data" ]; then if [ -d "data" ]; then
rm -rf "data" rm -rf "data"
fi fi
if mv "$BACKUP_DIR/data" "data"; then if cp -r "$BACKUP_DIR/data" "data"; then
log_success "Data directory restored from backup" log_success "Data directory restored from backup"
else else
log_error "Failed to restore data directory" log_error "Failed to restore data directory"
@@ -396,7 +398,7 @@ restore_backup_files() {
rm -rf "$target_dir" rm -rf "$target_dir"
fi 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" log_success "$target_dir directory restored from backup"
else else
log_error "Failed to restore $target_dir directory" log_error "Failed to restore $target_dir directory"
@@ -412,6 +414,30 @@ restore_backup_files() {
fi 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 is set in .env file for Prisma
ensure_database_url() { ensure_database_url() {
log "Ensuring DATABASE_URL is set in .env file..." log "Ensuring DATABASE_URL is set in .env file..."
@@ -437,7 +463,7 @@ ensure_database_url() {
log "Adding DATABASE_URL to .env file..." log "Adding DATABASE_URL to .env file..."
echo "" >> .env echo "" >> .env
echo "# Database" >> .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" log_success "DATABASE_URL added to .env file"
} }
@@ -465,15 +491,15 @@ stop_application() {
if [ -f "package.json" ] && [ -f "server.js" ]; then if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)" app_dir="$(pwd)"
else else
# Try to find the application directory # Change to production application directory
app_dir=$(find /root -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1) app_dir="/opt/ProxmoxVE-Local"
if [ -n "$app_dir" ] && [ -d "$app_dir" ]; then if [ -d "$app_dir" ] && [ -f "$app_dir/server.js" ]; then
cd "$app_dir" || { cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir" log_error "Failed to change to application directory: $app_dir"
return 1 return 1
} }
else else
log_error "Could not find application directory" log_error "Production application directory not found: $app_dir"
return 1 return 1
fi fi
fi fi
@@ -555,7 +581,7 @@ update_files() {
local should_exclude=false local should_exclude=false
for pattern in "${exclude_patterns[@]}"; do for pattern in "${exclude_patterns[@]}"; do
if [[ "$rel_path" == $pattern ]]; then if [[ "$rel_path" == $pattern ]] || [[ "$rel_path" == $pattern/* ]]; then
should_exclude=true should_exclude=true
break break
fi fi
@@ -595,6 +621,7 @@ update_files() {
log_success "Application files updated successfully ($files_copied files)" log_success "Application files updated successfully ($files_copied files)"
} }
# Install dependencies and build # Install dependencies and build
install_and_build() { install_and_build() {
log "Installing dependencies..." log "Installing dependencies..."
@@ -650,6 +677,15 @@ install_and_build() {
fi fi
log_success "Prisma client generated successfully" 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 # Run Prisma migrations
log "Running Prisma migrations..." log "Running Prisma migrations..."
if ! npx prisma migrate deploy > "$npm_log" 2>&1; then if ! npx prisma migrate deploy > "$npm_log" 2>&1; then
@@ -706,11 +742,16 @@ start_application() {
fi fi
else else
log_error "Failed to enable/start service, falling back to npm start" 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 fi
else else
log "Service was not running before update or no service exists, starting with npm..." 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 fi
} }
@@ -834,23 +875,15 @@ main() {
if [ -f "package.json" ] && [ -f "server.js" ]; then if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)" app_dir="$(pwd)"
else else
# Try multiple common locations: # Use production application directory
for search_path in /opt /root /home /usr/local; do app_dir="/opt/ProxmoxVE-Local"
if [ -d "$search_path" ]; then if [ -d "$app_dir" ] && [ -f "$app_dir/server.js" ]; 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
cd "$app_dir" || { cd "$app_dir" || {
log_error "Failed to change to application directory: $app_dir" log_error "Failed to change to application directory: $app_dir"
exit 1 exit 1
} }
else else
log_error "Could not find application directory" log_error "Production application directory not found: $app_dir"
exit 1 exit 1
fi fi
fi fi
@@ -894,6 +927,12 @@ main() {
# Restore .env and data directory before building # Restore .env and data directory before building
restore_backup_files 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 is set for Prisma
ensure_database_url ensure_database_url
@@ -903,12 +942,17 @@ main() {
rollback rollback
fi 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 "$source_dir"
rm -rf "/tmp/pve-update-$$" rm -rf "/tmp/pve-update-$$"
rm -rf "$BACKUP_DIR"
# Start the application log "Backup directory cleaned up"
start_application
log_success "Update completed successfully!" log_success "Update completed successfully!"
} }