feat: search, presets, dependency warnings, port conflicts, URL state, drag-drop analyser, diff view, auth + community gallery API (PostgreSQL), professional UI overhaul
This commit is contained in:
3
api/.env
Normal file
3
api/.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DATABASE_URL=postgresql://fitcoach:localdev@192.168.4.20:5432/stackpilot
|
||||||
|
JWT_SECRET=stackpilot-jwt-secret-change-in-production
|
||||||
|
PORT=3052
|
||||||
283
api/package-lock.json
generated
283
api/package-lock.json
generated
@@ -8,8 +8,13 @@
|
|||||||
"name": "stackpilot-api",
|
"name": "stackpilot-api",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"express": "4.21.2"
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "4.21.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"sql.js": "^1.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@@ -31,6 +36,12 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
@@ -55,6 +66,12 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -170,6 +187,18 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||||
|
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -184,6 +213,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -451,6 +489,97 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^3.2.2",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^1.4.2",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -583,6 +712,131 @@
|
|||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz",
|
||||||
|
"integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.7.0",
|
||||||
|
"pg-pool": "^3.7.0",
|
||||||
|
"pg-protocol": "^1.7.0",
|
||||||
|
"pg-types": "^2.1.0",
|
||||||
|
"pgpass": "1.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-lqIfH7bdgsxHAY/ZnUOwm+aCFKrsHBDhSFuk9O0B9uCqJAIkrKTo/+LQqLPLUS4e04+jCmQVikxE3QipH5chPw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -661,6 +915,18 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "0.19.0",
|
"version": "0.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||||
@@ -793,6 +1059,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sql.js": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
@@ -850,6 +1122,15 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"express": "4.21.2",
|
"express": "4.21.2",
|
||||||
"cors": "2.8.5"
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"sql.js": "^1.11.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
288
api/server.js
288
api/server.js
@@ -1,90 +1,237 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
import pg from 'pg';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const DATA_FILE = join(__dirname, 'data', 'popularity.json');
|
const DATA_DIR = join(__dirname, 'data');
|
||||||
const DOWNLOADS_LOG = join(__dirname, 'data', 'downloads.json');
|
mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Ensure data dir exists
|
const { Pool } = pg;
|
||||||
import { mkdirSync } from 'fs';
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
mkdirSync(join(__dirname, 'data'), { recursive: true });
|
|
||||||
|
|
||||||
// Load or init popularity data
|
const JWT_SECRET = process.env.JWT_SECRET || 'stackpilot-dev-secret';
|
||||||
function loadPopularity() {
|
|
||||||
if (existsSync(DATA_FILE)) {
|
// ============ AUTH MIDDLEWARE ============
|
||||||
return JSON.parse(readFileSync(DATA_FILE, 'utf8'));
|
function authMiddleware(req, res, next) {
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, JWT_SECRET);
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePopularity(data) {
|
function optionalAuth(req, res, next) {
|
||||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
}
|
if (token) {
|
||||||
|
try { req.user = jwt.verify(token, JWT_SECRET); } catch (e) {}
|
||||||
function loadDownloads() {
|
|
||||||
if (existsSync(DOWNLOADS_LOG)) {
|
|
||||||
return JSON.parse(readFileSync(DOWNLOADS_LOG, 'utf8'));
|
|
||||||
}
|
}
|
||||||
return { total: 0, history: [] };
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDownloads(data) {
|
// ============ AUTH ROUTES ============
|
||||||
writeFileSync(DOWNLOADS_LOG, JSON.stringify(data, null, 2));
|
app.post('/api/auth/register', async (req, res) => {
|
||||||
}
|
const { username, email, password, display_name } = req.body;
|
||||||
|
if (!username || !email || !password) {
|
||||||
|
return res.status(400).json({ error: 'username, email, and password required' });
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO users (username, email, password_hash, display_name) VALUES ($1, $2, $3, $4) RETURNING id, username, email, display_name, created_at',
|
||||||
|
[username, email, hash, display_name || username]
|
||||||
|
);
|
||||||
|
const user = result.rows[0];
|
||||||
|
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
|
res.json({ user, token });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'Username or email already exists' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Registration failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'email and password required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
|
||||||
|
const user = result.rows[0];
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '30d' });
|
||||||
|
res.json({
|
||||||
|
user: { id: user.id, username: user.username, email: user.email, display_name: user.display_name },
|
||||||
|
token
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Login failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/me', authMiddleware, async (req, res) => {
|
||||||
|
const result = await pool.query('SELECT id, username, email, display_name, avatar_url, created_at FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ COMMUNITY STACKS (GALLERY) ============
|
||||||
|
app.get('/api/stacks', optionalAuth, async (req, res) => {
|
||||||
|
const { sort = 'stars', search, tag, limit = 20, offset = 0 } = req.query;
|
||||||
|
let query = 'SELECT s.*, u.username, u.display_name FROM stacks s JOIN users u ON s.user_id = u.id WHERE s.is_public = true';
|
||||||
|
const params = [];
|
||||||
|
let paramIdx = 1;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query += ` AND (s.name ILIKE $${paramIdx} OR s.description ILIKE $${paramIdx})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
if (tag) {
|
||||||
|
query += ` AND $${paramIdx} = ANY(s.tags)`;
|
||||||
|
params.push(tag);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderMap = { stars: 'stars DESC', downloads: 'downloads DESC', recent: 'created_at DESC' };
|
||||||
|
query += ` ORDER BY ${orderMap[sort] || 'stars DESC'}`;
|
||||||
|
query += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
// If user is logged in, mark which ones they've starred
|
||||||
|
if (req.user) {
|
||||||
|
const starResult = await pool.query('SELECT stack_id FROM stars WHERE user_id = $1', [req.user.id]);
|
||||||
|
const starred = new Set(starResult.rows.map(r => r.stack_id));
|
||||||
|
result.rows.forEach(s => { s.starred_by_me = starred.has(s.id); });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stacks/:id', optionalAuth, async (req, res) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT s.*, u.username, u.display_name FROM stacks s JOIN users u ON s.user_id = u.id WHERE s.id = $1',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Stack not found' });
|
||||||
|
const stack = result.rows[0];
|
||||||
|
if (!stack.is_public && (!req.user || req.user.id !== stack.user_id)) {
|
||||||
|
return res.status(404).json({ error: 'Stack not found' });
|
||||||
|
}
|
||||||
|
res.json(stack);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/stacks', authMiddleware, async (req, res) => {
|
||||||
|
const { name, description, apps, config: stackConfig, tags, is_public = true } = req.body;
|
||||||
|
if (!name || !apps || !Array.isArray(apps) || apps.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'name and apps array required' });
|
||||||
|
}
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO stacks (user_id, name, description, apps, config, tags, is_public) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *',
|
||||||
|
[req.user.id, name, description || '', apps, stackConfig || {}, tags || [], is_public]
|
||||||
|
);
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/stacks/:id', authMiddleware, async (req, res) => {
|
||||||
|
const { name, description, apps, config: stackConfig, tags, is_public } = req.body;
|
||||||
|
const existing = await pool.query('SELECT * FROM stacks WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
||||||
|
if (existing.rows.length === 0) return res.status(404).json({ error: 'Stack not found or not yours' });
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE stacks SET name = COALESCE($1, name), description = COALESCE($2, description), apps = COALESCE($3, apps), config = COALESCE($4, config), tags = COALESCE($5, tags), is_public = COALESCE($6, is_public), updated_at = NOW() WHERE id = $7 RETURNING *',
|
||||||
|
[name, description, apps, stackConfig, tags, is_public, req.params.id]
|
||||||
|
);
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/stacks/:id', authMiddleware, async (req, res) => {
|
||||||
|
await pool.query('DELETE FROM stars WHERE stack_id = $1', [req.params.id]);
|
||||||
|
const result = await pool.query('DELETE FROM stacks WHERE id = $1 AND user_id = $2 RETURNING id', [req.params.id, req.user.id]);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Stack not found or not yours' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Star/unstar
|
||||||
|
app.post('/api/stacks/:id/star', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query('INSERT INTO stars (user_id, stack_id) VALUES ($1, $2)', [req.user.id, req.params.id]);
|
||||||
|
await pool.query('UPDATE stacks SET stars = stars + 1 WHERE id = $1', [req.params.id]);
|
||||||
|
res.json({ starred: true });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23505') {
|
||||||
|
// Already starred — unstar
|
||||||
|
await pool.query('DELETE FROM stars WHERE user_id = $1 AND stack_id = $2', [req.user.id, req.params.id]);
|
||||||
|
await pool.query('UPDATE stacks SET stars = GREATEST(stars - 1, 0) WHERE id = $1', [req.params.id]);
|
||||||
|
res.json({ starred: false });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: 'Failed to star' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track stack download
|
||||||
|
app.post('/api/stacks/:id/download', async (req, res) => {
|
||||||
|
await pool.query('UPDATE stacks SET downloads = downloads + 1 WHERE id = $1', [req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// My stacks
|
||||||
|
app.get('/api/my/stacks', authMiddleware, async (req, res) => {
|
||||||
|
const result = await pool.query('SELECT * FROM stacks WHERE user_id = $1 ORDER BY updated_at DESC', [req.user.id]);
|
||||||
|
res.json(result.rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ ANALYTICS (from before) ============
|
||||||
|
const POPULARITY_FILE = join(DATA_DIR, 'popularity.json');
|
||||||
|
const DOWNLOADS_FILE = join(DATA_DIR, 'downloads.json');
|
||||||
|
|
||||||
|
function loadJson(path, fallback) { return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : fallback; }
|
||||||
|
function saveJson(path, data) { writeFileSync(path, JSON.stringify(data, null, 2)); }
|
||||||
|
|
||||||
// POST /api/analytics/download — record a bundle download
|
|
||||||
app.post('/api/analytics/download', (req, res) => {
|
app.post('/api/analytics/download', (req, res) => {
|
||||||
const { apps, timestamp } = req.body;
|
const { apps, timestamp } = req.body;
|
||||||
if (!apps || !Array.isArray(apps)) {
|
if (!apps || !Array.isArray(apps)) return res.status(400).json({ error: 'apps array required' });
|
||||||
return res.status(400).json({ error: 'apps array required' });
|
const popularity = loadJson(POPULARITY_FILE, {});
|
||||||
}
|
for (const appId of apps) { popularity[appId] = (popularity[appId] || 0) + 1; }
|
||||||
|
saveJson(POPULARITY_FILE, popularity);
|
||||||
// Update popularity counts
|
const downloads = loadJson(DOWNLOADS_FILE, { total: 0, history: [] });
|
||||||
const popularity = loadPopularity();
|
|
||||||
for (const appId of apps) {
|
|
||||||
popularity[appId] = (popularity[appId] || 0) + 1;
|
|
||||||
}
|
|
||||||
savePopularity(popularity);
|
|
||||||
|
|
||||||
// Log the download
|
|
||||||
const downloads = loadDownloads();
|
|
||||||
downloads.total += 1;
|
downloads.total += 1;
|
||||||
downloads.history.push({
|
downloads.history.push({ timestamp: timestamp || new Date().toISOString(), apps, count: apps.length });
|
||||||
timestamp: timestamp || new Date().toISOString(),
|
if (downloads.history.length > 10000) downloads.history = downloads.history.slice(-10000);
|
||||||
apps,
|
saveJson(DOWNLOADS_FILE, downloads);
|
||||||
count: apps.length,
|
|
||||||
});
|
|
||||||
// Keep last 10,000 entries to avoid unbounded growth
|
|
||||||
if (downloads.history.length > 10000) {
|
|
||||||
downloads.history = downloads.history.slice(-10000);
|
|
||||||
}
|
|
||||||
saveDownloads(downloads);
|
|
||||||
|
|
||||||
res.json({ ok: true, total_downloads: downloads.total });
|
res.json({ ok: true, total_downloads: downloads.total });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/analytics/popularity — get app download counts
|
app.get('/api/analytics/popularity', (req, res) => { res.json(loadJson(POPULARITY_FILE, {})); });
|
||||||
app.get('/api/analytics/popularity', (req, res) => {
|
|
||||||
const popularity = loadPopularity();
|
|
||||||
res.json(popularity);
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/analytics/stats — overview stats
|
|
||||||
app.get('/api/analytics/stats', (req, res) => {
|
app.get('/api/analytics/stats', (req, res) => {
|
||||||
const popularity = loadPopularity();
|
const popularity = loadJson(POPULARITY_FILE, {});
|
||||||
const downloads = loadDownloads();
|
const downloads = loadJson(DOWNLOADS_FILE, { total: 0, history: [] });
|
||||||
|
const sorted = Object.entries(popularity).sort((a, b) => b[1] - a[1]);
|
||||||
// Top apps
|
|
||||||
const sorted = Object.entries(popularity)
|
|
||||||
.sort((a, b) => b[1] - a[1]);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
total_downloads: downloads.total,
|
total_downloads: downloads.total,
|
||||||
unique_apps_selected: sorted.length,
|
unique_apps_selected: sorted.length,
|
||||||
@@ -93,23 +240,6 @@ app.get('/api/analytics/stats', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/analytics/app/:id — stats for a specific app
|
// ============ START ============
|
||||||
app.get('/api/analytics/app/:id', (req, res) => {
|
|
||||||
const popularity = loadPopularity();
|
|
||||||
const count = popularity[req.params.id] || 0;
|
|
||||||
const downloads = loadDownloads();
|
|
||||||
const total = downloads.total || 1;
|
|
||||||
const percentage = ((count / total) * 100).toFixed(1);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
id: req.params.id,
|
|
||||||
download_count: count,
|
|
||||||
percentage: `${percentage}%`,
|
|
||||||
total_bundles: total,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3052;
|
const PORT = process.env.PORT || 3052;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => { console.log(`StackPilot API running on port ${PORT}`); });
|
||||||
console.log(`StackPilot Analytics API running on port ${PORT}`);
|
|
||||||
});
|
|
||||||
|
|||||||
29
index.html
29
index.html
@@ -61,6 +61,20 @@
|
|||||||
<!-- Step 2: App Selection -->
|
<!-- Step 2: App Selection -->
|
||||||
<section class="step" id="step-apps">
|
<section class="step" id="step-apps">
|
||||||
<h2>2. Select Apps</h2>
|
<h2>2. Select Apps</h2>
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="app-search" placeholder="🔍 Search 186 apps..." />
|
||||||
|
</div>
|
||||||
|
<div class="presets-bar">
|
||||||
|
<span class="presets-label">Quick stacks:</span>
|
||||||
|
<button class="preset-btn" data-preset="media">🎬 Media Server</button>
|
||||||
|
<button class="preset-btn" data-preset="arr">📡 Full Arr Suite</button>
|
||||||
|
<button class="preset-btn" data-preset="ai">🤖 AI Lab</button>
|
||||||
|
<button class="preset-btn" data-preset="dev">💻 Dev Stack</button>
|
||||||
|
<button class="preset-btn" data-preset="homelab">🏠 Homelab Essentials</button>
|
||||||
|
<button class="preset-btn" data-preset="gaming">🎮 Game Servers</button>
|
||||||
|
</div>
|
||||||
|
<div id="dependency-warnings" class="dependency-warnings"></div>
|
||||||
|
<div id="port-conflicts" class="port-conflicts"></div>
|
||||||
<div id="app-grid"></div>
|
<div id="app-grid"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -120,16 +134,27 @@
|
|||||||
<!-- Compose Analyser Modal/Section -->
|
<!-- Compose Analyser Modal/Section -->
|
||||||
<section class="step" id="step-analyser" style="display:none;">
|
<section class="step" id="step-analyser" style="display:none;">
|
||||||
<h2>🔍 Analyse Existing Docker Compose</h2>
|
<h2>🔍 Analyse Existing Docker Compose</h2>
|
||||||
<p class="analyser-desc">Paste your existing docker-compose.yml below to get security, performance, and best-practice recommendations.</p>
|
<p class="analyser-desc">Paste or drop your docker-compose.yml below to get security, performance, and best-practice recommendations.</p>
|
||||||
<textarea id="analyser-input" class="analyser-textarea" placeholder="Paste your docker-compose.yml here..."></textarea>
|
<div class="drop-zone" id="drop-zone">
|
||||||
|
<textarea id="analyser-input" class="analyser-textarea" placeholder="Paste your docker-compose.yml here, or drag & drop a file..."></textarea>
|
||||||
|
<div class="drop-overlay" id="drop-overlay">📁 Drop docker-compose.yml here</div>
|
||||||
|
</div>
|
||||||
<button class="analyse-btn" id="analyse-btn">Analyse</button>
|
<button class="analyse-btn" id="analyse-btn">Analyse</button>
|
||||||
<div id="analyser-results" class="analyser-results"></div>
|
<div id="analyser-results" class="analyser-results"></div>
|
||||||
<button class="analyse-btn fixed-btn hidden" id="show-fixed-btn">✨ Show Fixed Compose</button>
|
<button class="analyse-btn fixed-btn hidden" id="show-fixed-btn">✨ Show Fixed Compose</button>
|
||||||
|
<div class="view-toggle hidden" id="view-toggle">
|
||||||
|
<button class="view-btn active" data-view="fixed">Fixed</button>
|
||||||
|
<button class="view-btn" data-view="diff">Diff</button>
|
||||||
|
</div>
|
||||||
<div id="fixed-preview" class="fixed-preview hidden">
|
<div id="fixed-preview" class="fixed-preview hidden">
|
||||||
<h3>Improved docker-compose.yml</h3>
|
<h3>Improved docker-compose.yml</h3>
|
||||||
<pre class="preview-code"><code id="fixed-content"></code></pre>
|
<pre class="preview-code"><code id="fixed-content"></code></pre>
|
||||||
<button class="download-btn" id="download-fixed-btn" style="margin-top:1rem;">📦 Download Fixed Compose</button>
|
<button class="download-btn" id="download-fixed-btn" style="margin-top:1rem;">📦 Download Fixed Compose</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="diff-preview" class="fixed-preview hidden">
|
||||||
|
<h3>Changes (Before → After)</h3>
|
||||||
|
<pre class="preview-code diff-code"><code id="diff-content"></code></pre>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
|||||||
222
src/main.js
222
src/main.js
@@ -34,6 +34,17 @@ const state = {
|
|||||||
activeTab: 'compose',
|
activeTab: 'compose',
|
||||||
os: 'linux',
|
os: 'linux',
|
||||||
popularity: null,
|
popularity: null,
|
||||||
|
searchQuery: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Presets
|
||||||
|
const presets = {
|
||||||
|
media: ['jellyfin', 'sonarr', 'radarr', 'prowlarr', 'qbittorrent', 'bazarr', 'jellyseerr', 'flaresolverr'],
|
||||||
|
arr: ['sonarr', 'radarr', 'lidarr', 'readarr', 'prowlarr', 'bazarr', 'qbittorrent', 'flaresolverr', 'recyclarr'],
|
||||||
|
ai: ['ollama', 'open-webui', 'comfyui', 'stable-diffusion', 'flowise', 'anything-llm', 'whisper'],
|
||||||
|
dev: ['gitea', 'code-server', 'drone', 'registry', 'portainer', 'postgres', 'redis'],
|
||||||
|
homelab: ['homepage', 'portainer', 'uptime-kuma', 'watchtower', 'tailscale', 'vaultwarden', 'ntfy', 'adguard-home'],
|
||||||
|
gaming: ['minecraft', 'valheim', 'palworld', 'satisfactory', 'crafty', 'pterodactyl'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get config from inputs
|
// Get config from inputs
|
||||||
@@ -64,8 +75,20 @@ function renderAppGrid() {
|
|||||||
badge.textContent = `${state.selected.size} app${state.selected.size !== 1 ? 's' : ''} selected`;
|
badge.textContent = `${state.selected.size} app${state.selected.size !== 1 ? 's' : ''} selected`;
|
||||||
grid.appendChild(badge);
|
grid.appendChild(badge);
|
||||||
|
|
||||||
|
const query = state.searchQuery.toLowerCase();
|
||||||
|
|
||||||
for (const cat of categories) {
|
for (const cat of categories) {
|
||||||
const catApps = apps.filter(a => a.category === cat.id);
|
let catApps = apps.filter(a => a.category === cat.id);
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if (query) {
|
||||||
|
catApps = catApps.filter(a =>
|
||||||
|
a.name.toLowerCase().includes(query) ||
|
||||||
|
a.description.toLowerCase().includes(query) ||
|
||||||
|
a.id.includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (catApps.length === 0) continue;
|
if (catApps.length === 0) continue;
|
||||||
|
|
||||||
const group = document.createElement('div');
|
const group = document.createElement('div');
|
||||||
@@ -89,6 +112,8 @@ function renderAppGrid() {
|
|||||||
catApps.forEach(a => state.selected.add(a.id));
|
catApps.forEach(a => state.selected.add(a.id));
|
||||||
}
|
}
|
||||||
renderAppGrid();
|
renderAppGrid();
|
||||||
|
checkDependencies();
|
||||||
|
checkPortConflicts();
|
||||||
updatePreview();
|
updatePreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,7 +152,79 @@ function toggleApp(id) {
|
|||||||
state.selected.add(id);
|
state.selected.add(id);
|
||||||
}
|
}
|
||||||
renderAppGrid();
|
renderAppGrid();
|
||||||
|
checkDependencies();
|
||||||
|
checkPortConflicts();
|
||||||
updatePreview();
|
updatePreview();
|
||||||
|
updateUrlState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency warnings
|
||||||
|
function checkDependencies() {
|
||||||
|
const warnings = [];
|
||||||
|
const ids = state.selected;
|
||||||
|
|
||||||
|
if (ids.has('jellyseerr') && !ids.has('jellyfin')) warnings.push('Jellyseerr requires Jellyfin to function');
|
||||||
|
if (ids.has('overseerr') && !ids.has('plex')) warnings.push('Overseerr requires Plex to function');
|
||||||
|
if (ids.has('bazarr') && !ids.has('sonarr') && !ids.has('radarr')) warnings.push('Bazarr needs Sonarr or Radarr to manage subtitles for');
|
||||||
|
if (ids.has('prowlarr') && !ids.has('sonarr') && !ids.has('radarr') && !ids.has('lidarr') && !ids.has('readarr')) warnings.push('Prowlarr has no Arr apps to sync indexers to');
|
||||||
|
if ((ids.has('sonarr') || ids.has('radarr')) && !ids.has('qbittorrent') && !ids.has('transmission') && !ids.has('deluge') && !ids.has('sabnzbd') && !ids.has('nzbget')) warnings.push('Sonarr/Radarr need a download client (qBittorrent, Transmission, etc.)');
|
||||||
|
if (ids.has('open-webui') && !ids.has('ollama')) warnings.push('Open WebUI needs Ollama for local LLM inference');
|
||||||
|
if (ids.has('immich') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Immich requires PostgreSQL and Redis');
|
||||||
|
if (ids.has('outline') && !ids.has('postgres') && !ids.has('redis')) warnings.push('Outline requires PostgreSQL and Redis');
|
||||||
|
if (ids.has('bookstack') && !ids.has('mariadb')) warnings.push('BookStack requires MariaDB');
|
||||||
|
if (ids.has('rocketchat') && !ids.has('mongodb')) warnings.push('Rocket.Chat requires MongoDB');
|
||||||
|
|
||||||
|
const el = document.getElementById('dependency-warnings');
|
||||||
|
el.innerHTML = warnings.map(w => `<div class="dep-warning">⚠️ ${w}</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port conflict detection
|
||||||
|
function checkPortConflicts() {
|
||||||
|
const selectedApps = apps.filter(a => state.selected.has(a.id));
|
||||||
|
const portMap = {};
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
for (const app of selectedApps) {
|
||||||
|
if (!app.ports) continue;
|
||||||
|
for (const port of app.ports) {
|
||||||
|
const hostPort = `${port.host}/${port.protocol}`;
|
||||||
|
if (portMap[hostPort]) {
|
||||||
|
conflicts.push(`Port ${port.host} conflict: ${portMap[hostPort]} and ${app.name}`);
|
||||||
|
} else {
|
||||||
|
portMap[hostPort] = app.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.getElementById('port-conflicts');
|
||||||
|
el.innerHTML = conflicts.map(c => `<div class="port-conflict">❌ ${c}</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL state (shareable selections)
|
||||||
|
function updateUrlState() {
|
||||||
|
const selected = [...state.selected];
|
||||||
|
if (selected.length > 0) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('apps', selected.join(','));
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
} else {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.delete('apps');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUrlState() {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
const appsParam = url.searchParams.get('apps');
|
||||||
|
if (appsParam) {
|
||||||
|
const appIds = appsParam.split(',');
|
||||||
|
for (const id of appIds) {
|
||||||
|
if (apps.some(a => a.id === id)) {
|
||||||
|
state.selected.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update preview
|
// Update preview
|
||||||
@@ -276,6 +373,7 @@ function initOsSelector() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
|
loadUrlState();
|
||||||
renderAppGrid();
|
renderAppGrid();
|
||||||
initTabs();
|
initTabs();
|
||||||
initConfigListeners();
|
initConfigListeners();
|
||||||
@@ -283,16 +381,45 @@ initTailscale();
|
|||||||
initOsSelector();
|
initOsSelector();
|
||||||
initModeNav();
|
initModeNav();
|
||||||
initAnalyser();
|
initAnalyser();
|
||||||
|
initSearch();
|
||||||
|
initPresets();
|
||||||
document.getElementById('download-btn').addEventListener('click', downloadBundle);
|
document.getElementById('download-btn').addEventListener('click', downloadBundle);
|
||||||
|
|
||||||
// Load popularity data (async, non-blocking)
|
// Load popularity data (async, non-blocking)
|
||||||
fetchPopularity().then(data => {
|
fetchPopularity().then(data => {
|
||||||
if (data) {
|
if (data) {
|
||||||
state.popularity = data;
|
state.popularity = data;
|
||||||
renderAppGrid(); // Re-render with badges
|
renderAppGrid();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search
|
||||||
|
function initSearch() {
|
||||||
|
const input = document.getElementById('app-search');
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
state.searchQuery = e.target.value;
|
||||||
|
renderAppGrid();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets
|
||||||
|
function initPresets() {
|
||||||
|
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const preset = presets[btn.dataset.preset];
|
||||||
|
if (!preset) return;
|
||||||
|
for (const id of preset) {
|
||||||
|
state.selected.add(id);
|
||||||
|
}
|
||||||
|
renderAppGrid();
|
||||||
|
checkDependencies();
|
||||||
|
checkPortConflicts();
|
||||||
|
updatePreview();
|
||||||
|
updateUrlState();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Mode switching (builder vs analyser)
|
// Mode switching (builder vs analyser)
|
||||||
function initModeNav() {
|
function initModeNav() {
|
||||||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
document.querySelectorAll('.mode-btn').forEach(btn => {
|
||||||
@@ -308,26 +435,70 @@ function initModeNav() {
|
|||||||
|
|
||||||
// Analyser
|
// Analyser
|
||||||
function initAnalyser() {
|
function initAnalyser() {
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
const textarea = document.getElementById('analyser-input');
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragging'); });
|
||||||
|
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('dragging'); });
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('dragging');
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => { textarea.value = ev.target.result; };
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Analyse button
|
||||||
document.getElementById('analyse-btn').addEventListener('click', () => {
|
document.getElementById('analyse-btn').addEventListener('click', () => {
|
||||||
const input = document.getElementById('analyser-input').value.trim();
|
const input = textarea.value.trim();
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
const results = analyseCompose(input);
|
const results = analyseCompose(input);
|
||||||
renderAnalyserResults(results);
|
renderAnalyserResults(results);
|
||||||
|
|
||||||
// Show "Show Fixed Compose" button if there are fixable issues
|
|
||||||
const fixBtn = document.getElementById('show-fixed-btn');
|
const fixBtn = document.getElementById('show-fixed-btn');
|
||||||
const hasFixable = results.warnings.length > 0 || results.suggestions.length > 0;
|
const hasFixable = results.warnings.length > 0 || results.suggestions.length > 0;
|
||||||
fixBtn.classList.toggle('hidden', !hasFixable);
|
fixBtn.classList.toggle('hidden', !hasFixable);
|
||||||
|
// Hide previous fixed/diff views
|
||||||
|
document.getElementById('fixed-preview').classList.add('hidden');
|
||||||
|
document.getElementById('diff-preview').classList.add('hidden');
|
||||||
|
document.getElementById('view-toggle').classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show fixed button
|
||||||
document.getElementById('show-fixed-btn').addEventListener('click', () => {
|
document.getElementById('show-fixed-btn').addEventListener('click', () => {
|
||||||
const input = document.getElementById('analyser-input').value.trim();
|
const input = textarea.value.trim();
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
const fixed = fixCompose(input);
|
const fixed = fixCompose(input);
|
||||||
document.getElementById('fixed-content').textContent = fixed;
|
document.getElementById('fixed-content').textContent = fixed;
|
||||||
document.getElementById('fixed-preview').classList.remove('hidden');
|
document.getElementById('fixed-preview').classList.remove('hidden');
|
||||||
|
document.getElementById('diff-preview').classList.add('hidden');
|
||||||
|
document.getElementById('view-toggle').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Generate diff
|
||||||
|
const diffHtml = generateDiff(input, fixed);
|
||||||
|
document.getElementById('diff-content').innerHTML = diffHtml;
|
||||||
|
|
||||||
|
// Set active view
|
||||||
|
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelector('.view-btn[data-view="fixed"]').classList.add('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// View toggle (fixed vs diff)
|
||||||
|
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const view = btn.dataset.view;
|
||||||
|
document.getElementById('fixed-preview').classList.toggle('hidden', view !== 'fixed');
|
||||||
|
document.getElementById('diff-preview').classList.toggle('hidden', view !== 'diff');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download fixed
|
||||||
document.getElementById('download-fixed-btn').addEventListener('click', () => {
|
document.getElementById('download-fixed-btn').addEventListener('click', () => {
|
||||||
const fixed = document.getElementById('fixed-content').textContent;
|
const fixed = document.getElementById('fixed-content').textContent;
|
||||||
const blob = new Blob([fixed], { type: 'text/yaml' });
|
const blob = new Blob([fixed], { type: 'text/yaml' });
|
||||||
@@ -340,6 +511,47 @@ function initAnalyser() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple line-by-line diff
|
||||||
|
function generateDiff(before, after) {
|
||||||
|
const beforeLines = before.split('\n');
|
||||||
|
const afterLines = after.split('\n');
|
||||||
|
const output = [];
|
||||||
|
|
||||||
|
// Simple LCS-based approach for readable diff
|
||||||
|
const beforeSet = new Set(beforeLines.map((l, i) => `${i}:${l}`));
|
||||||
|
const afterSet = new Set(afterLines);
|
||||||
|
|
||||||
|
let bi = 0, ai = 0;
|
||||||
|
while (bi < beforeLines.length || ai < afterLines.length) {
|
||||||
|
if (bi < beforeLines.length && ai < afterLines.length && beforeLines[bi] === afterLines[ai]) {
|
||||||
|
output.push(`<span class="diff-context"> ${escHtml(beforeLines[bi])}</span>`);
|
||||||
|
bi++; ai++;
|
||||||
|
} else if (ai < afterLines.length && !beforeLines.includes(afterLines[ai])) {
|
||||||
|
output.push(`<span class="diff-add">+ ${escHtml(afterLines[ai])}</span>`);
|
||||||
|
ai++;
|
||||||
|
} else if (bi < beforeLines.length && !afterLines.includes(beforeLines[bi])) {
|
||||||
|
output.push(`<span class="diff-remove">- ${escHtml(beforeLines[bi])}</span>`);
|
||||||
|
bi++;
|
||||||
|
} else {
|
||||||
|
// Changed line
|
||||||
|
if (bi < beforeLines.length) {
|
||||||
|
output.push(`<span class="diff-remove">- ${escHtml(beforeLines[bi])}</span>`);
|
||||||
|
bi++;
|
||||||
|
}
|
||||||
|
if (ai < afterLines.length) {
|
||||||
|
output.push(`<span class="diff-add">+ ${escHtml(afterLines[ai])}</span>`);
|
||||||
|
ai++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
function renderAnalyserResults(results) {
|
function renderAnalyserResults(results) {
|
||||||
const container = document.getElementById('analyser-results');
|
const container = document.getElementById('analyser-results');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|||||||
@@ -170,6 +170,56 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* App Grid */
|
/* App Grid */
|
||||||
|
.search-bar { margin-bottom: 1rem; }
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.search-bar input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||||
|
.presets-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.presets-label { font-size: 0.8rem; color: var(--text-muted); font-weight: 500; }
|
||||||
|
.preset-btn {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.preset-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-glow); }
|
||||||
|
.dependency-warnings, .port-conflicts { margin-bottom: 1rem; }
|
||||||
|
.dep-warning, .port-conflict {
|
||||||
|
background: rgba(245,158,11,0.06);
|
||||||
|
border: 1px solid rgba(245,158,11,0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--warning);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.port-conflict {
|
||||||
|
background: rgba(239,68,68,0.06);
|
||||||
|
border-color: rgba(239,68,68,0.2);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
.category-group { margin-bottom: 2.5rem; }
|
.category-group { margin-bottom: 2.5rem; }
|
||||||
.category-header {
|
.category-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -391,6 +441,49 @@ body {
|
|||||||
|
|
||||||
/* Analyser */
|
/* Analyser */
|
||||||
.analyser-desc { color: var(--text-dim); margin-bottom: 1.25rem; font-size: 0.9rem; }
|
.analyser-desc { color: var(--text-dim); margin-bottom: 1.25rem; font-size: 0.9rem; }
|
||||||
|
.drop-zone { position: relative; margin-bottom: 1.25rem; }
|
||||||
|
.drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(99,102,241,0.1);
|
||||||
|
border: 2px dashed var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.drop-zone.dragging .drop-overlay { opacity: 1; }
|
||||||
|
.drop-zone.dragging .analyser-textarea { border-color: var(--accent); }
|
||||||
|
.view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.view-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.view-btn.active { background: var(--accent); color: white; }
|
||||||
|
.diff-code .diff-add { color: var(--success); }
|
||||||
|
.diff-code .diff-remove { color: var(--danger); opacity: 0.7; }
|
||||||
|
.diff-code .diff-context { color: var(--text-muted); }
|
||||||
.analyser-textarea {
|
.analyser-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
|
|||||||
Reference in New Issue
Block a user