Compare commits
5 Commits
0714b442be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ad80afbfdd | |||
| 9b9c607fd1 | |||
| 166276d19b | |||
| a836645728 | |||
| 3da8608cf1 |
BIN
web-client/image/logo_PNKX.png
Normal file
BIN
web-client/image/logo_PNKX.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -4,13 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/png" href="/image/logo_PNKX.png" />
|
||||
<title>Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
323
web-client/package-lock.json
generated
323
web-client/package-lock.json
generated
@@ -8,21 +8,21 @@
|
||||
"name": "robot-installer-web-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fluentui/react-icons": "^2.0.333",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
"vite": "^7.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -31,29 +31,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
|
||||
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -70,13 +70,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -86,13 +86,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -102,36 +102,36 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -150,52 +150,52 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -234,32 +234,41 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -267,18 +276,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
@@ -695,6 +710,64 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fluentui/react-icons": {
|
||||
"version": "2.0.333",
|
||||
"resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.333.tgz",
|
||||
"integrity": "sha512-HvS5kKw9tGweI3Poy6OGAiFqrt6HGElO46DFhNBeoIQRK/q35mZ2ep0u762MFuvOfLX3bVGSB8frORFTjh1E7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@griffel/react": "^1.6.1",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/montserrat": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/montserrat/-/montserrat-5.3.0.tgz",
|
||||
"integrity": "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@griffel/core": {
|
||||
"version": "1.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@griffel/core/-/core-1.21.3.tgz",
|
||||
"integrity": "sha512-FMnlwhtmCRWvXEg2j/6W90wzvW+PFqdrsWFslfmxwS6l9X73gO0dnndKphht9ZOsJODvmLdqlnU1Lh8igg2mKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/hash": "^0.9.0",
|
||||
"@griffel/style-types": "^1.4.2",
|
||||
"csstype": "^3.2.3",
|
||||
"rtl-css-js": "^1.16.1",
|
||||
"stylis": "^4.4.0",
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@griffel/react": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@griffel/react/-/react-1.7.6.tgz",
|
||||
"integrity": "sha512-hqkbKRfSN/jKvPFzMoaA+gzoA9INfDyDqt7ypHOJP3KjZWp+n5f/QxLpmvqE3ssqDDS7YsFKN6qPmYBRC2SA7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@griffel/core": "^1.21.3",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.14.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@griffel/style-types": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@griffel/style-types/-/style-types-1.4.2.tgz",
|
||||
"integrity": "sha512-MsSghfpyxR2MpTrYdcCozISsSLkmFjNw94wNPi4bDBRLW8W43718W/ZjmUdVkoM0KXMtJPYuEkx8Mzibqb03qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1178,9 +1251,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.31",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
|
||||
"integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==",
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz",
|
||||
"integrity": "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
@@ -1190,9 +1263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
||||
"version": "4.28.7",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1209,10 +1282,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
"electron-to-chromium": "^1.5.328",
|
||||
"node-releases": "^2.0.36",
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"electron-to-chromium": "^1.5.393",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1223,9 +1296,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001793",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1248,6 +1321,12 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1266,9 +1345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.361",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
|
||||
"integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==",
|
||||
"version": "1.5.395",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
|
||||
"integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
@@ -1400,15 +1479,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.468.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
|
||||
"integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1434,9 +1504,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.46",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
|
||||
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1562,6 +1632,15 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rtl-css-js": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
|
||||
"integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -1586,6 +1665,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
|
||||
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -1602,6 +1687,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -1633,9 +1724,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
||||
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
||||
"version": "7.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
"type": "module",
|
||||
"description": "Public web client for installing Robot applications through the Local Installer Agent.",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/react-icons": "^2.0.333",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
"vite": "^7.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
Box,
|
||||
CheckCircle2,
|
||||
Clipboard,
|
||||
Cpu,
|
||||
Download,
|
||||
ExternalLink,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
PackageCheck,
|
||||
Play,
|
||||
PlugZap,
|
||||
RefreshCcw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
WifiOff,
|
||||
X,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
ArrowCounterclockwise20Regular as RotateCcw,
|
||||
ArrowDownload20Regular as Download,
|
||||
ArrowSync20Regular as RefreshCcw,
|
||||
Board20Regular as Cpu,
|
||||
Box20Regular as Box,
|
||||
CheckmarkCircle20Filled as CheckCircle2,
|
||||
Clipboard20Regular as Clipboard,
|
||||
Delete20Regular as Trash2,
|
||||
Dismiss20Regular as X,
|
||||
ErrorCircle20Filled as XCircle,
|
||||
HardDrive20Regular as HardDrive,
|
||||
Open20Regular as ExternalLink,
|
||||
Play20Filled as Play,
|
||||
PlugConnected20Regular as PlugZap,
|
||||
Pulse20Regular as Activity,
|
||||
Search20Regular as Search,
|
||||
Server20Regular as Server,
|
||||
Settings20Regular as Settings,
|
||||
ShieldCheckmark20Filled as ShieldCheck,
|
||||
SpinnerIos20Regular as Loader2,
|
||||
Warning20Filled as AlertCircle,
|
||||
WifiOff20Regular as WifiOff
|
||||
} from '@fluentui/react-icons';
|
||||
import '@fontsource-variable/montserrat';
|
||||
import {
|
||||
DEFAULT_AGENT_BASE_URL,
|
||||
DEFAULT_PACKAGE_BASE_URL,
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
queueRemove,
|
||||
queueUpdate
|
||||
} from './services/api.js';
|
||||
import logoPnkx from '../image/logo_PNKX.png';
|
||||
import './styles.css';
|
||||
|
||||
const SETTINGS_KEY = 'robot-installer-client-settings';
|
||||
@@ -755,7 +756,7 @@ function App() {
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<div className="brand-mark">
|
||||
<PackageCheck size={20} aria-hidden="true" />
|
||||
<img className="brand-logo" src={logoPnkx} alt="" />
|
||||
</div>
|
||||
<div className="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
@@ -790,7 +791,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary full" type="button" onClick={openEndpointDialog}>
|
||||
<Settings size={15} aria-hidden="true" />
|
||||
<Settings fontSize={15} aria-hidden="true" />
|
||||
Endpoint settings
|
||||
</button>
|
||||
</div>
|
||||
@@ -810,10 +811,10 @@ function App() {
|
||||
tone={agentStatusTone}
|
||||
/>
|
||||
<a className="icon-button" href={joinUrl(packageBaseUrl, '/api/apps')} target="_blank" rel="noreferrer" title="Open package API">
|
||||
<ExternalLink size={17} aria-hidden="true" />
|
||||
<ExternalLink fontSize={17} aria-hidden="true" />
|
||||
</a>
|
||||
<button className="btn btn-secondary" type="button" onClick={refreshAll}>
|
||||
<RefreshCcw size={15} aria-hidden="true" />
|
||||
<RefreshCcw fontSize={15} aria-hidden="true" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
@@ -832,13 +833,13 @@ function App() {
|
||||
|
||||
{canUseAgentEndpoint && !agentHealth && (
|
||||
<div className="offline-banner">
|
||||
<AlertCircle size={19} aria-hidden="true" />
|
||||
<AlertCircle fontSize={19} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{agentTargetLabel} is offline</strong>
|
||||
<code>{agentCommand}</code>
|
||||
</div>
|
||||
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
<Clipboard fontSize={15} aria-hidden="true" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
@@ -846,13 +847,13 @@ function App() {
|
||||
|
||||
{canUseAgentEndpoint && agentNeedsUpdate && (
|
||||
<div className="offline-banner agent-update-banner">
|
||||
<AlertCircle size={19} aria-hidden="true" />
|
||||
<AlertCircle fontSize={19} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Agent {latestAgentPackage.version} is ready</strong>
|
||||
<code>{agentCommand}</code>
|
||||
</div>
|
||||
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
<Clipboard fontSize={15} aria-hidden="true" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
@@ -864,7 +865,7 @@ function App() {
|
||||
<label className="filter-field wide">
|
||||
<span>Search</span>
|
||||
<div className="input-with-icon">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<Search fontSize={15} aria-hidden="true" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
@@ -952,7 +953,7 @@ function App() {
|
||||
runAppAction('install', app);
|
||||
}}
|
||||
>
|
||||
{installBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Play size={14} aria-hidden="true" />}
|
||||
{installBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <Play fontSize={14} aria-hidden="true" />}
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
@@ -966,7 +967,7 @@ function App() {
|
||||
runAppAction('update', app);
|
||||
}}
|
||||
>
|
||||
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
|
||||
{updateBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <RotateCcw fontSize={14} aria-hidden="true" />}
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
@@ -980,7 +981,7 @@ function App() {
|
||||
runAppAction('update', app);
|
||||
}}
|
||||
>
|
||||
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
|
||||
{updateBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <RotateCcw fontSize={14} aria-hidden="true" />}
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
@@ -993,7 +994,7 @@ function App() {
|
||||
title={`Open ${app.appName}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
<ExternalLink fontSize={14} aria-hidden="true" />
|
||||
Open App
|
||||
</a>
|
||||
)}
|
||||
@@ -1008,7 +1009,7 @@ function App() {
|
||||
runAppAction('remove', app);
|
||||
}}
|
||||
>
|
||||
{removeBusy ? <Loader2 className="spin" size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
|
||||
{removeBusy ? <Loader2 className="spin" fontSize={16} aria-hidden="true" /> : <Trash2 fontSize={16} aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1084,7 +1085,7 @@ function AgentStatusButton({ title, detail, tone, onClick }) {
|
||||
return (
|
||||
<button className={`agent-status-button tone-${tone || 'info'}`} type="button" onClick={onClick}>
|
||||
<span className="agent-status-icon">
|
||||
<Icon className={tone === 'info' ? 'spin' : ''} size={16} aria-hidden="true" />
|
||||
<Icon className={tone === 'info' ? 'spin' : ''} fontSize={16} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="agent-status-copy">
|
||||
<strong>{title}</strong>
|
||||
@@ -1130,7 +1131,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
|
||||
<p>Changes only apply after you press Apply.</p>
|
||||
</div>
|
||||
<button className="icon-button subtle" type="button" title="Close" onClick={onCancel}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
<X fontSize={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1163,7 +1164,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
<Settings size={15} aria-hidden="true" />
|
||||
<Settings fontSize={15} aria-hidden="true" />
|
||||
Apply endpoints
|
||||
</button>
|
||||
</div>
|
||||
@@ -1176,7 +1177,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
|
||||
function StatusBox({ icon: Icon, title, detail, tone }) {
|
||||
return (
|
||||
<div className={`status-box tone-${tone || 'muted'}`}>
|
||||
<span className="status-icon"><Icon size={16} aria-hidden="true" /></span>
|
||||
<span className="status-icon"><Icon fontSize={16} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<span>{detail}</span>
|
||||
@@ -1188,7 +1189,7 @@ function StatusBox({ icon: Icon, title, detail, tone }) {
|
||||
function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand, osLabel }) {
|
||||
return (
|
||||
<div className={`offline-banner client-os-banner ${canShowCommand ? 'command-visible' : ''}`}>
|
||||
<AlertCircle size={19} aria-hidden="true" />
|
||||
<AlertCircle fontSize={19} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{isWindows ? 'Remote Ubuntu endpoint needed' : 'Local Agent requires Linux'}</strong>
|
||||
<p>
|
||||
@@ -1200,7 +1201,7 @@ function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand
|
||||
</div>
|
||||
{canShowCommand && (
|
||||
<button className="btn btn-secondary" type="button" onClick={onCopyCommand}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
<Clipboard fontSize={15} aria-hidden="true" />
|
||||
Copy
|
||||
</button>
|
||||
)}
|
||||
@@ -1268,11 +1269,11 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
||||
<div className="panel-actions">
|
||||
<span className={statusBadgeClass(task.status)}>{task.status || 'queued'}</span>
|
||||
<button className="icon-button subtle" type="button" title="Refresh task" onClick={onRefresh} disabled={!canRefresh}>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
<RefreshCcw fontSize={16} aria-hidden="true" />
|
||||
</button>
|
||||
{canClear && (
|
||||
<button className="icon-button subtle" type="button" title="Clear task" onClick={onClear}>
|
||||
<XCircle size={16} aria-hidden="true" />
|
||||
<XCircle fontSize={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1305,7 +1306,7 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
||||
|
||||
<div className="component-list task-components">
|
||||
<div className="component-list-title">
|
||||
<Activity size={15} aria-hidden="true" />
|
||||
<Activity fontSize={15} aria-hidden="true" />
|
||||
Components
|
||||
</div>
|
||||
{components.map((component) => (
|
||||
@@ -1351,7 +1352,7 @@ function ServiceChecks({ checks = [], status = 'not-checked', showSource = false
|
||||
return (
|
||||
<div className="service-check-list">
|
||||
<div className="service-check-empty">
|
||||
<Activity size={14} aria-hidden="true" />
|
||||
<Activity fontSize={14} aria-hidden="true" />
|
||||
<span>{message || 'Service status unavailable'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1364,8 +1365,8 @@ function ServiceChecks({ checks = [], status = 'not-checked', showSource = false
|
||||
<div className="service-check-row" key={`${check.componentId || ''}:${check.serviceName}`}>
|
||||
<div className="service-check-name">
|
||||
{check.healthy
|
||||
? <CheckCircle2 className="service-check-icon healthy" size={14} aria-hidden="true" />
|
||||
: <AlertCircle className="service-check-icon unhealthy" size={14} aria-hidden="true" />}
|
||||
? <CheckCircle2 className="service-check-icon healthy" fontSize={14} aria-hidden="true" />
|
||||
: <AlertCircle className="service-check-icon unhealthy" fontSize={14} aria-hidden="true" />}
|
||||
<span>
|
||||
<strong>{check.serviceName}</strong>
|
||||
<small>
|
||||
@@ -1412,9 +1413,9 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
|
||||
<p>{status.message || endpoint || '127.0.0.1:5010'}</p>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
{needsUpdate ? <AlertCircle className="panel-state warning" size={20} aria-hidden="true" /> : health ? <CheckCircle2 className="panel-state success" size={20} aria-hidden="true" /> : <XCircle className="panel-state danger" size={20} aria-hidden="true" />}
|
||||
{needsUpdate ? <AlertCircle className="panel-state warning" fontSize={20} aria-hidden="true" /> : health ? <CheckCircle2 className="panel-state success" fontSize={20} aria-hidden="true" /> : <XCircle className="panel-state danger" fontSize={20} aria-hidden="true" />}
|
||||
<button className="icon-button subtle" type="button" title="Close" onClick={onClose}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
<X fontSize={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1454,18 +1455,18 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
|
||||
</div>
|
||||
</dl>
|
||||
<div className="agent-metrics">
|
||||
<span><Cpu size={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
|
||||
<span><HardDrive size={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
|
||||
<span><Cpu fontSize={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
|
||||
<span><HardDrive fontSize={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
|
||||
</div>
|
||||
{showAgentActions && (
|
||||
<div className="agent-update-action">
|
||||
<button className={`btn ${needsUpdate ? 'btn-warning' : 'btn-secondary'}`} type="button" onClick={onCopyUpdate}>
|
||||
<Clipboard size={15} aria-hidden="true" />
|
||||
<Clipboard fontSize={15} aria-hidden="true" />
|
||||
{needsUpdate ? 'Copy update command' : 'Copy Agent command'}
|
||||
</button>
|
||||
{needsUpdate && latestAgentPackage?.downloadUrl && (
|
||||
<a className="btn btn-secondary" href={latestAgentPackage.downloadUrl} target="_blank" rel="noreferrer">
|
||||
<Download size={15} aria-hidden="true" />
|
||||
<Download fontSize={15} aria-hidden="true" />
|
||||
Latest .deb
|
||||
</a>
|
||||
)}
|
||||
@@ -1488,7 +1489,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
<h2>{app?.appName || 'App detail'}</h2>
|
||||
<p>{app?.appCode || app?.appId || status.message || packageBaseUrl}</p>
|
||||
</div>
|
||||
{status.state === 'loading' ? <Loader2 className="spin panel-state" size={20} aria-hidden="true" /> : <Box className="panel-state" size={20} aria-hidden="true" />}
|
||||
{status.state === 'loading' ? <Loader2 className="spin panel-state" fontSize={20} aria-hidden="true" /> : <Box className="panel-state" fontSize={20} aria-hidden="true" />}
|
||||
</div>
|
||||
|
||||
{app ? (
|
||||
@@ -1510,7 +1511,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
|
||||
<div className="component-list">
|
||||
<div className="component-list-title">
|
||||
<ShieldCheck size={15} aria-hidden="true" />
|
||||
<ShieldCheck fontSize={15} aria-hidden="true" />
|
||||
Components
|
||||
</div>
|
||||
{status.state === 'danger' && (
|
||||
@@ -1533,7 +1534,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
{app.installed && installedServiceStatus !== 'not-checked' && (
|
||||
<div className="service-health-panel">
|
||||
<div className="component-list-title">
|
||||
<Activity size={15} aria-hidden="true" />
|
||||
<Activity fontSize={15} aria-hidden="true" />
|
||||
Service health
|
||||
<span className={`badge badge-${installedServiceStatus === 'healthy' ? 'success' : installedServiceStatus === 'unhealthy' ? 'danger' : 'muted'}`}>
|
||||
{serviceCheckSummary(installedServiceStatus, installedServiceChecks)}
|
||||
@@ -1559,7 +1560,7 @@ function Toast({ toast }) {
|
||||
const Icon = tone === 'danger' ? AlertCircle : tone === 'success' ? CheckCircle2 : Activity;
|
||||
return (
|
||||
<div className={`toast tone-${tone || 'info'}`}>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<Icon fontSize={17} aria-hidden="true" />
|
||||
<span>{toast.message}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,93 @@
|
||||
:root {
|
||||
--primary: #3755c3;
|
||||
--primary-dim: #2848b7;
|
||||
--primary-container: #dde1ff;
|
||||
--on-primary: #f8f7ff;
|
||||
--background: #f7f9fb;
|
||||
--surface-lowest: #ffffff;
|
||||
--surface-low: #f0f4f7;
|
||||
--surface: #e8eff3;
|
||||
--surface-high: #e1e9ee;
|
||||
--on-surface: #2a3439;
|
||||
--on-surface-variant: #566166;
|
||||
--outline-variant: #a9b4b9;
|
||||
--danger: #b42318;
|
||||
--danger-bg: #fee4e2;
|
||||
--success: #067647;
|
||||
--success-bg: #dcfae6;
|
||||
--warning: #b54708;
|
||||
--warning-bg: #fef0c7;
|
||||
--info: #175cd3;
|
||||
--info-bg: #d1e9ff;
|
||||
--muted-bg: #e2e8f0;
|
||||
--radius: 8px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.18);
|
||||
color-scheme: light;
|
||||
|
||||
/* Phenikaa-X universal tokens — design-language.md v0.4.4 */
|
||||
--blue-50: #eaedf5;
|
||||
--blue-100: #c5cce0;
|
||||
--blue-200: #8c9ac4;
|
||||
--blue-300: #6e7ca8;
|
||||
--blue-500: #223771;
|
||||
--blue-700: #1a2a57;
|
||||
--blue-900: #1e2243;
|
||||
--orange-100: #fcd9c4;
|
||||
--orange-300: #f58220;
|
||||
--orange-500: #f26522;
|
||||
--orange-700: #c44e14;
|
||||
--neutral-0: #ffffff;
|
||||
--neutral-50: #f7f8fb;
|
||||
--neutral-100: #eeedf6;
|
||||
--neutral-200: #dddfea;
|
||||
--neutral-300: #c5c8d8;
|
||||
--neutral-400: #9fa3b8;
|
||||
--neutral-450: #8c90a8;
|
||||
--neutral-500: #757a91;
|
||||
--neutral-600: #565b70;
|
||||
--neutral-700: #3c4054;
|
||||
--neutral-800: #272a3a;
|
||||
--neutral-900: #161824;
|
||||
|
||||
--surface-base: var(--neutral-0);
|
||||
--surface-raised: var(--neutral-50);
|
||||
--surface-brand-tint: var(--neutral-100);
|
||||
--text-primary: var(--neutral-900);
|
||||
--text-secondary: var(--neutral-600);
|
||||
--text-tertiary: var(--neutral-500);
|
||||
--border-subtle: var(--neutral-300);
|
||||
--border-strong: var(--neutral-450);
|
||||
--brand-primary: var(--blue-500);
|
||||
--brand-primary-strong: var(--blue-700);
|
||||
--brand-accent: var(--orange-500);
|
||||
--brand-accent-strong: var(--orange-700);
|
||||
|
||||
--semantic-success: #2e7d32;
|
||||
--semantic-success-bg: #e8f5e9;
|
||||
--semantic-warning: #9a5b00;
|
||||
--semantic-warning-icon: #cb6119;
|
||||
--semantic-warning-bg: #fff4db;
|
||||
--semantic-error: #c62828;
|
||||
--semantic-error-bg: #ffebee;
|
||||
--semantic-info: #1565c0;
|
||||
--semantic-info-bg: #e7f1fc;
|
||||
|
||||
--space-xxs: 4px;
|
||||
--space-xs: 8px;
|
||||
--space-sm: 12px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--space-2xl: 48px;
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 8px;
|
||||
--elevation-1: 0 1px 3px rgba(22, 24, 36, 0.12);
|
||||
--elevation-2: 0 4px 12px rgba(22, 24, 36, 0.18);
|
||||
|
||||
/* Compatibility aliases used by the existing components. */
|
||||
--primary: var(--brand-primary);
|
||||
--primary-dim: var(--brand-primary-strong);
|
||||
--primary-container: var(--blue-50);
|
||||
--on-primary: var(--neutral-0);
|
||||
--background: var(--surface-raised);
|
||||
--surface-lowest: var(--surface-base);
|
||||
--surface-low: var(--surface-raised);
|
||||
--surface: var(--surface-brand-tint);
|
||||
--surface-high: var(--neutral-200);
|
||||
--on-surface: var(--text-primary);
|
||||
--on-surface-variant: var(--text-secondary);
|
||||
--outline: var(--border-strong);
|
||||
--outline-variant: var(--border-subtle);
|
||||
--danger: var(--semantic-error);
|
||||
--danger-bg: var(--semantic-error-bg);
|
||||
--success: var(--semantic-success);
|
||||
--success-bg: var(--semantic-success-bg);
|
||||
--warning: var(--semantic-warning);
|
||||
--warning-bg: var(--semantic-warning-bg);
|
||||
--info: var(--semantic-info);
|
||||
--info-bg: var(--semantic-info-bg);
|
||||
--muted-bg: var(--neutral-200);
|
||||
--radius: var(--radius-md);
|
||||
--shadow-sm: var(--elevation-1);
|
||||
--shadow-lg: var(--elevation-2);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -40,8 +105,9 @@ body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--on-surface);
|
||||
font-family: "Inter", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -75,7 +141,7 @@ p {
|
||||
h1,
|
||||
h2,
|
||||
.brand-copy strong {
|
||||
font-family: "Manrope", Arial, sans-serif;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -123,11 +189,17 @@ code {
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius);
|
||||
color: var(--on-primary);
|
||||
background: transparent;
|
||||
flex: 0 0 auto;
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
width: 46px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
@@ -1161,7 +1233,12 @@ tbody tr.selected-row td.action-col {
|
||||
}
|
||||
|
||||
.task-log-list {
|
||||
border-top: 1px solid #eef2f7;
|
||||
--task-log-border: var(--neutral-700);
|
||||
--task-log-text-primary: var(--neutral-100);
|
||||
--task-log-text-secondary: var(--neutral-400);
|
||||
--task-log-text-accent: var(--blue-200);
|
||||
|
||||
border-top: 1px solid var(--task-log-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
@@ -1171,7 +1248,7 @@ tbody tr.selected-row td.action-col {
|
||||
}
|
||||
|
||||
.task-log-line {
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
border-bottom: 1px solid var(--task-log-border);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 64px 46px minmax(0, 1fr);
|
||||
@@ -1182,9 +1259,16 @@ tbody tr.selected-row td.action-col {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.task-log-line span,
|
||||
.task-log-line span {
|
||||
color: var(--task-log-text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-log-line strong {
|
||||
color: #64748b;
|
||||
color: var(--task-log-text-accent);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
@@ -1192,13 +1276,17 @@ tbody tr.selected-row td.action-col {
|
||||
}
|
||||
|
||||
.task-log-line p {
|
||||
color: #172033;
|
||||
color: var(--task-log-text-primary);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-log-list .table-empty {
|
||||
color: var(--task-log-text-secondary);
|
||||
}
|
||||
|
||||
.dialog-backdrop {
|
||||
align-items: center;
|
||||
background: rgba(15, 23, 42, 0.36);
|
||||
@@ -1446,3 +1534,288 @@ tbody tr.selected-row td.action-col {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Phenikaa-X application theme
|
||||
Keeps the existing component contract while applying the Layer-1 language.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
::selection {
|
||||
background: var(--orange-100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--blue-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--blue-900);
|
||||
border-color: var(--neutral-700);
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
border-bottom: 1px solid rgba(197, 204, 224, 0.18);
|
||||
min-height: 72px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.brand-copy strong,
|
||||
.sidebar .status-box strong,
|
||||
.sidebar .endpoint-summary-row strong {
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.brand-copy span,
|
||||
.sidebar .nav-label,
|
||||
.sidebar .endpoint-summary-row span,
|
||||
.sidebar .status-box div > span {
|
||||
color: var(--blue-100);
|
||||
}
|
||||
|
||||
.brand-copy span,
|
||||
.nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.sidebar .status-box,
|
||||
.sidebar .endpoint-summary {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
border-color: rgba(197, 204, 224, 0.2);
|
||||
}
|
||||
|
||||
.sidebar .status-box:hover,
|
||||
.sidebar .endpoint-summary:hover {
|
||||
background: rgba(255, 255, 255, 0.085);
|
||||
border-color: rgba(197, 204, 224, 0.35);
|
||||
}
|
||||
|
||||
.sidebar .btn-secondary {
|
||||
background: transparent;
|
||||
border-color: var(--blue-300);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.sidebar .btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--blue-100);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-color: var(--border-subtle);
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.topbar-title span {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.topbar-title strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.page h1 {
|
||||
color: var(--text-primary);
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.table-panel,
|
||||
.dialog-panel,
|
||||
.offline-banner,
|
||||
.agent-status-button {
|
||||
border-color: var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.panel,
|
||||
.table-panel,
|
||||
.dialog-panel {
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.dialog-header,
|
||||
.dialog-actions,
|
||||
.table-wrap thead,
|
||||
.page-filters {
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.btn,
|
||||
.icon-button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand-primary);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--brand-primary-strong);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--brand-accent);
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: var(--brand-accent-strong);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.icon-button {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.icon-button:hover,
|
||||
.icon-button.subtle:hover {
|
||||
background: var(--blue-50);
|
||||
border-color: var(--blue-200);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.settings-field input,
|
||||
.filter-field input,
|
||||
.filter-field select,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-field input:focus,
|
||||
.filter-field input:focus,
|
||||
.filter-field select:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(34, 55, 113, 0.16);
|
||||
}
|
||||
|
||||
table {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--surface-brand-tint);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--blue-700);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.045em;
|
||||
}
|
||||
|
||||
td {
|
||||
border-color: var(--neutral-200);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
tbody tr:hover,
|
||||
.selected-row {
|
||||
background: var(--blue-50);
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--semantic-success-bg);
|
||||
color: var(--semantic-success);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--semantic-error-bg);
|
||||
color: var(--semantic-error);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--semantic-warning-bg);
|
||||
color: var(--semantic-warning);
|
||||
}
|
||||
|
||||
.badge-info,
|
||||
.badge-primary {
|
||||
background: var(--semantic-info-bg);
|
||||
color: var(--semantic-info);
|
||||
}
|
||||
|
||||
code,
|
||||
.task-log-list {
|
||||
background: var(--blue-900);
|
||||
color: var(--neutral-100);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.sidebar {
|
||||
border-bottom-color: var(--neutral-700);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ WEB_SERVER_PORT=3005
|
||||
IMAGE_TAG=1.0.0
|
||||
DOCKER_NETWORK=robot-installer-net
|
||||
WEB_SERVER_UPLOADS_DIR=./uploads
|
||||
MAX_UPLOAD_BYTES=1073741824
|
||||
AGENT_MAX_UPLOAD_BYTES=1073741824
|
||||
DOCUMENT_MAX_UPLOAD_BYTES=52428800
|
||||
DOCUMENT_MAX_CONTENT_BYTES=2097152
|
||||
DOCUMENT_MAX_CONTENT_CHARS=500000
|
||||
SQLSERVER_HOST=172.20.235.176
|
||||
SQLSERVER_PORT=1433
|
||||
SQLSERVER_DATABASE=RobotInstaller
|
||||
|
||||
@@ -18,7 +18,7 @@ COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p uploads/packages/agent \
|
||||
RUN mkdir -p uploads/packages/agent uploads/documents \
|
||||
&& chown -R node:node uploads \
|
||||
&& chmod +x docker-entrypoint.sh
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ IF OBJECT_ID(N'dbo.ApplicationPackages', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.PackageVersions', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.Applications', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.Packages', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.Documents', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.EmailConfirmationTokens', N'U') IS NOT NULL
|
||||
OR OBJECT_ID(N'dbo.Users', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
@@ -54,6 +55,37 @@ CREATE TABLE dbo.EmailConfirmationTokens
|
||||
);
|
||||
GO
|
||||
|
||||
CREATE TABLE dbo.Documents
|
||||
(
|
||||
Id UNIQUEIDENTIFIER NOT NULL
|
||||
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
|
||||
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
|
||||
Title NVARCHAR(200) NOT NULL,
|
||||
Category NVARCHAR(50) NOT NULL
|
||||
CONSTRAINT DF_Documents_Category DEFAULT N'other',
|
||||
Summary NVARCHAR(1000) NULL,
|
||||
Content NVARCHAR(MAX) NULL,
|
||||
FilePath NVARCHAR(1000) NULL,
|
||||
OriginalFileName NVARCHAR(260) NULL,
|
||||
MimeType NVARCHAR(200) NULL,
|
||||
FileSizeBytes BIGINT NULL,
|
||||
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
|
||||
CreatedAt DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
|
||||
UpdatedAt DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Documents_CreatedByUser
|
||||
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
|
||||
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
|
||||
CONSTRAINT CK_Documents_Category CHECK (
|
||||
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
|
||||
),
|
||||
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
|
||||
CONSTRAINT CK_Documents_HasReadableContent CHECK (
|
||||
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
|
||||
)
|
||||
);
|
||||
GO
|
||||
|
||||
CREATE TABLE dbo.Packages
|
||||
(
|
||||
Id UNIQUEIDENTIFIER NOT NULL
|
||||
@@ -164,6 +196,13 @@ CREATE INDEX IX_EmailConfirmationTokens_UserId
|
||||
ON dbo.EmailConfirmationTokens(UserId);
|
||||
GO
|
||||
|
||||
CREATE INDEX IX_Documents_Category_UpdatedAt
|
||||
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
|
||||
|
||||
CREATE INDEX IX_Documents_CreatedByUserId
|
||||
ON dbo.Documents(CreatedByUserId);
|
||||
GO
|
||||
|
||||
CREATE UNIQUE INDEX UX_Packages_PackageCode ON dbo.Packages(PackageCode);
|
||||
CREATE INDEX IX_Packages_CreatedByUserId ON dbo.Packages(CreatedByUserId);
|
||||
CREATE INDEX IX_Packages_PackageType ON dbo.Packages(PackageType);
|
||||
|
||||
67
web-server/database/05_documents.sql
Normal file
67
web-server/database/05_documents.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
USE [RobotInstaller];
|
||||
GO
|
||||
|
||||
SET ANSI_NULLS ON;
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Documents', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Documents
|
||||
(
|
||||
Id UNIQUEIDENTIFIER NOT NULL
|
||||
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
|
||||
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
|
||||
Title NVARCHAR(200) NOT NULL,
|
||||
Category NVARCHAR(50) NOT NULL
|
||||
CONSTRAINT DF_Documents_Category DEFAULT N'other',
|
||||
Summary NVARCHAR(1000) NULL,
|
||||
Content NVARCHAR(MAX) NULL,
|
||||
FilePath NVARCHAR(1000) NULL,
|
||||
OriginalFileName NVARCHAR(260) NULL,
|
||||
MimeType NVARCHAR(200) NULL,
|
||||
FileSizeBytes BIGINT NULL,
|
||||
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
|
||||
CreatedAt DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
|
||||
UpdatedAt DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Documents_CreatedByUser
|
||||
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
|
||||
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
|
||||
CONSTRAINT CK_Documents_Category CHECK (
|
||||
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
|
||||
),
|
||||
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
|
||||
CONSTRAINT CK_Documents_HasReadableContent CHECK (
|
||||
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
|
||||
)
|
||||
);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_Category_UpdatedAt'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_Category_UpdatedAt
|
||||
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
|
||||
END;
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_CreatedByUserId'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_CreatedByUserId
|
||||
ON dbo.Documents(CreatedByUserId);
|
||||
END;
|
||||
GO
|
||||
|
||||
PRINT N'RobotInstaller documents schema is ready.';
|
||||
GO
|
||||
@@ -28,6 +28,7 @@ Không lưu mật khẩu thật vào file cấu hình. Khi chạy local, tạo f
|
||||
| `dbo.PackageVersions` | Các version của từng package |
|
||||
| `dbo.Applications` | App được đóng gói từ nhiều package |
|
||||
| `dbo.ApplicationPackages` | Liên kết app-package, có thể chọn version cụ thể |
|
||||
| `dbo.Documents` | Nội dung tài liệu và metadata file đính kèm |
|
||||
| `dbo.Notifications` | Thông báo riêng cho từng user và thông báo hệ thống do Admin đăng |
|
||||
|
||||
## Ràng buộc quan trọng
|
||||
@@ -65,13 +66,14 @@ sqlcmd -S 172.20.235.176 -U sa -b -i .\database\01_create_database.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\02_schema.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\03_views.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\04_notifications.sql
|
||||
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\05_documents.sql
|
||||
```
|
||||
|
||||
Chạy các lệnh trên từ thư mục `web-server`.
|
||||
|
||||
Khi dùng `sqlcmd` để seed/test dữ liệu, thêm `-I` hoặc bật `SET QUOTED_IDENTIFIER ON` vì schema có filtered index cho ràng buộc một latest version trên mỗi package.
|
||||
|
||||
`04_notifications.sql` là migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy file này; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại.
|
||||
`04_notifications.sql` và `05_documents.sql` là các migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy các migration còn thiếu; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại. Web server cũng tự bảo đảm bảng `Documents` tồn tại khi chức năng tài liệu được truy cập.
|
||||
|
||||
## Luồng dữ liệu đề xuất
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p /app/uploads/packages/agent
|
||||
mkdir -p /app/uploads/packages/agent /app/uploads/documents
|
||||
chown -R node:node /app/uploads
|
||||
|
||||
exec su-exec node "$@"
|
||||
|
||||
BIN
web-server/image/logo_PNKX.png
Normal file
BIN
web-server/image/logo_PNKX.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
45
web-server/package-lock.json
generated
45
web-server/package-lock.json
generated
@@ -8,12 +8,14 @@
|
||||
"name": "robot-installer-web-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fluentui/svg-icons": "^1.1.333",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.19.2",
|
||||
"mssql": "^12.5.4",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.7",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"notiflix": "^3.2.8"
|
||||
}
|
||||
},
|
||||
@@ -276,6 +278,21 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fluentui/svg-icons": {
|
||||
"version": "1.1.333",
|
||||
"resolved": "https://registry.npmjs.org/@fluentui/svg-icons/-/svg-icons-1.1.333.tgz",
|
||||
"integrity": "sha512-uSFMDQq25/sVgHvD9KhP+c1rZrHWqIA0OZkofu9Ii3Q5b72DqLgqOreABHbPD67U5XfsFc4iPlZzWEl1849Obg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/montserrat": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/montserrat/-/montserrat-5.3.0.tgz",
|
||||
"integrity": "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@js-joda/core": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz",
|
||||
@@ -427,9 +444,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -451,9 +468,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -1419,9 +1436,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -1453,9 +1470,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/svg-icons": "^1.1.333",
|
||||
"@fontsource-variable/montserrat": "^5.3.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.19.2",
|
||||
"mssql": "^12.5.4",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.7",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"notiflix": "^3.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,92 @@
|
||||
:root {
|
||||
--primary: #3755c3;
|
||||
--primary-dim: #2848b7;
|
||||
--primary-container: #dde1ff;
|
||||
--on-primary: #f8f7ff;
|
||||
--background: #f7f9fb;
|
||||
--surface-lowest: #ffffff;
|
||||
--surface-low: #f0f4f7;
|
||||
--surface: #e8eff3;
|
||||
--surface-high: #e1e9ee;
|
||||
--on-surface: #2a3439;
|
||||
--on-surface-variant: #566166;
|
||||
--outline: #717c82;
|
||||
--outline-variant: #a9b4b9;
|
||||
--danger: #b42318;
|
||||
--danger-bg: #fee4e2;
|
||||
--success: #067647;
|
||||
--success-bg: #dcfae6;
|
||||
--warning: #b54708;
|
||||
--warning-bg: #fef0c7;
|
||||
--info: #175cd3;
|
||||
--info-bg: #d1e9ff;
|
||||
--radius: 8px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.18);
|
||||
color-scheme: light;
|
||||
|
||||
/* Phenikaa-X universal tokens — design-language.md v0.4.4 */
|
||||
--blue-50: #eaedf5;
|
||||
--blue-100: #c5cce0;
|
||||
--blue-200: #8c9ac4;
|
||||
--blue-300: #6e7ca8;
|
||||
--blue-500: #223771;
|
||||
--blue-700: #1a2a57;
|
||||
--blue-900: #1e2243;
|
||||
--orange-100: #fcd9c4;
|
||||
--orange-300: #f58220;
|
||||
--orange-500: #f26522;
|
||||
--orange-700: #c44e14;
|
||||
--neutral-0: #ffffff;
|
||||
--neutral-50: #f7f8fb;
|
||||
--neutral-100: #eeedf6;
|
||||
--neutral-200: #dddfea;
|
||||
--neutral-300: #c5c8d8;
|
||||
--neutral-400: #9fa3b8;
|
||||
--neutral-450: #8c90a8;
|
||||
--neutral-500: #757a91;
|
||||
--neutral-600: #565b70;
|
||||
--neutral-700: #3c4054;
|
||||
--neutral-800: #272a3a;
|
||||
--neutral-900: #161824;
|
||||
|
||||
--surface-base: var(--neutral-0);
|
||||
--surface-raised: var(--neutral-50);
|
||||
--surface-brand-tint: var(--neutral-100);
|
||||
--text-primary: var(--neutral-900);
|
||||
--text-secondary: var(--neutral-600);
|
||||
--text-tertiary: var(--neutral-500);
|
||||
--border-subtle: var(--neutral-300);
|
||||
--border-strong: var(--neutral-450);
|
||||
--brand-primary: var(--blue-500);
|
||||
--brand-primary-strong: var(--blue-700);
|
||||
--brand-accent: var(--orange-500);
|
||||
--brand-accent-strong: var(--orange-700);
|
||||
|
||||
--semantic-success: #2e7d32;
|
||||
--semantic-success-bg: #e8f5e9;
|
||||
--semantic-warning: #9a5b00;
|
||||
--semantic-warning-icon: #cb6119;
|
||||
--semantic-warning-bg: #fff4db;
|
||||
--semantic-error: #c62828;
|
||||
--semantic-error-bg: #ffebee;
|
||||
--semantic-info: #1565c0;
|
||||
--semantic-info-bg: #e7f1fc;
|
||||
|
||||
--space-xxs: 4px;
|
||||
--space-xs: 8px;
|
||||
--space-sm: 12px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--space-2xl: 48px;
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 8px;
|
||||
--elevation-1: 0 1px 3px rgba(22, 24, 36, 0.12);
|
||||
--elevation-2: 0 4px 12px rgba(22, 24, 36, 0.18);
|
||||
|
||||
/* Compatibility aliases used by the existing components. */
|
||||
--primary: var(--brand-primary);
|
||||
--primary-dim: var(--brand-primary-strong);
|
||||
--primary-container: var(--blue-50);
|
||||
--on-primary: var(--neutral-0);
|
||||
--background: var(--surface-raised);
|
||||
--surface-lowest: var(--surface-base);
|
||||
--surface-low: var(--surface-raised);
|
||||
--surface: var(--surface-brand-tint);
|
||||
--surface-high: var(--neutral-200);
|
||||
--on-surface: var(--text-primary);
|
||||
--on-surface-variant: var(--text-secondary);
|
||||
--outline: var(--border-strong);
|
||||
--outline-variant: var(--border-subtle);
|
||||
--danger: var(--semantic-error);
|
||||
--danger-bg: var(--semantic-error-bg);
|
||||
--success: var(--semantic-success);
|
||||
--success-bg: var(--semantic-success-bg);
|
||||
--warning: var(--semantic-warning);
|
||||
--warning-bg: var(--semantic-warning-bg);
|
||||
--info: var(--semantic-info);
|
||||
--info-bg: var(--semantic-info-bg);
|
||||
--radius: var(--radius-md);
|
||||
--shadow-sm: var(--elevation-1);
|
||||
--shadow-lg: var(--elevation-2);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -39,8 +103,9 @@ body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--on-surface);
|
||||
font-family: "Inter", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -48,7 +113,7 @@ h1,
|
||||
h2,
|
||||
h3,
|
||||
.brand-copy strong {
|
||||
font-family: "Manrope", Arial, sans-serif;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -79,17 +144,13 @@ button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
direction: ltr;
|
||||
.fluent-icon {
|
||||
background: currentColor;
|
||||
display: inline-flex;
|
||||
font-family: "Material Symbols Outlined";
|
||||
font-size: 1.25rem;
|
||||
font-style: normal;
|
||||
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 20;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
height: 1.25rem;
|
||||
mask: var(--fluent-icon-url) center / contain no-repeat;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
@@ -118,13 +179,19 @@ button:disabled {
|
||||
|
||||
.brand-mark {
|
||||
align-items: center;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius);
|
||||
color: var(--on-primary);
|
||||
background: transparent;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
height: 38px;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
width: 46px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
@@ -304,7 +371,7 @@ button:disabled {
|
||||
|
||||
.notification-panel-header strong {
|
||||
color: #111827;
|
||||
font-family: "Manrope", Arial, sans-serif;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -394,8 +461,10 @@ button:disabled {
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.notification-item-icon .material-symbols-outlined {
|
||||
font-size: 19px;
|
||||
.notification-item-icon .material-symbols-outlined,
|
||||
.notification-item-icon .fluent-icon {
|
||||
height: 19px;
|
||||
width: 19px;
|
||||
}
|
||||
|
||||
.notification-item.tone-success .notification-item-icon {
|
||||
@@ -468,9 +537,11 @@ button:disabled {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-empty .material-symbols-outlined {
|
||||
.notification-empty .material-symbols-outlined,
|
||||
.notification-empty .fluent-icon {
|
||||
color: #94a3b8;
|
||||
font-size: 32px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.notification-empty strong {
|
||||
@@ -705,7 +776,7 @@ button:disabled {
|
||||
|
||||
.metric-card strong {
|
||||
color: #111827;
|
||||
font-family: "Manrope", Arial, sans-serif;
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -1208,9 +1279,11 @@ tbody tr:hover td.action-col {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-dropzone-content > .material-symbols-outlined {
|
||||
.file-dropzone-content > .material-symbols-outlined,
|
||||
.file-dropzone-content > .fluent-icon {
|
||||
color: var(--primary);
|
||||
font-size: 34px;
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.file-dropzone-content strong {
|
||||
@@ -1237,7 +1310,8 @@ tbody tr:hover td.action-col {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.file-preview > .material-symbols-outlined {
|
||||
.file-preview > .material-symbols-outlined,
|
||||
.file-preview > .fluent-icon {
|
||||
align-items: center;
|
||||
background: var(--primary-container);
|
||||
border-radius: var(--radius);
|
||||
@@ -1656,12 +1730,155 @@ tbody tr:hover td.action-col {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.documents-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.document-title-cell {
|
||||
max-width: 360px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.document-title-cell .table-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-file-name {
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-detail-grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.document-meta-panel,
|
||||
.document-reader-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-reader {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.document-copy {
|
||||
color: var(--text-primary);
|
||||
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.document-preview-block {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.document-section-heading {
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.document-section-heading .material-symbols-outlined {
|
||||
color: var(--brand-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.document-file-preview {
|
||||
background: var(--neutral-50);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
min-height: 540px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-image-preview {
|
||||
align-self: center;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.document-attachment-state {
|
||||
align-items: center;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.document-attachment-state > .material-symbols-outlined {
|
||||
color: var(--brand-primary);
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.document-attachment-state strong,
|
||||
.document-attachment-state p {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.document-attachment-state p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.document-remove-option {
|
||||
align-items: flex-start;
|
||||
background: var(--semantic-warning-bg);
|
||||
border: 1px solid #f3d28c;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--semantic-warning);
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.document-remove-option input {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-grid,
|
||||
.detail-grid,
|
||||
.builder-layout,
|
||||
.agent-layout,
|
||||
.users-layout {
|
||||
.users-layout,
|
||||
.document-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1672,9 +1889,14 @@ tbody tr:hover td.action-col {
|
||||
.detail-grid,
|
||||
.builder-layout,
|
||||
.agent-layout,
|
||||
.users-layout {
|
||||
.users-layout,
|
||||
.document-detail-grid {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.document-reader-panel {
|
||||
min-height: 620px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -1765,6 +1987,16 @@ tbody tr:hover td.action-col {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.document-attachment-state {
|
||||
align-items: flex-start;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.document-attachment-state .btn {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
align-items: flex-end;
|
||||
}
|
||||
@@ -1798,3 +2030,327 @@ tbody tr:hover td.action-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Phenikaa-X application theme
|
||||
Keeps the existing EJS/JavaScript contract while applying Layer-1 tokens.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
::selection {
|
||||
background: var(--orange-100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--blue-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--blue-900);
|
||||
border-color: var(--neutral-700);
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
border-bottom: 1px solid rgba(197, 204, 224, 0.18);
|
||||
min-height: 72px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
color: var(--neutral-0);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.brand-copy span,
|
||||
.sidebar .nav-label,
|
||||
.sidebar-copyright {
|
||||
color: var(--blue-100);
|
||||
}
|
||||
|
||||
.brand-copy span,
|
||||
.nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
gap: var(--space-xxs);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
border-left-width: 4px;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
color: var(--blue-100);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
gap: var(--space-sm);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-left-color: var(--brand-accent);
|
||||
color: var(--neutral-0);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-color: var(--border-subtle);
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.page-header h1,
|
||||
.auth-heading h1 {
|
||||
color: var(--text-primary);
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.table-panel,
|
||||
.metric-card,
|
||||
.modal-content,
|
||||
.notification-panel,
|
||||
.empty-state,
|
||||
.auth-panel {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--elevation-1);
|
||||
}
|
||||
|
||||
.modal-content,
|
||||
.notification-panel,
|
||||
.auth-panel {
|
||||
box-shadow: var(--elevation-2);
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.modal-header,
|
||||
.modal-actions,
|
||||
.page-filters,
|
||||
thead,
|
||||
td {
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.btn,
|
||||
.icon-button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand-primary);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--brand-primary-strong);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--brand-accent);
|
||||
color: var(--neutral-900);
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: var(--brand-accent-strong);
|
||||
color: var(--neutral-0);
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.icon-button {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.icon-button:hover,
|
||||
.icon-button.subtle:hover {
|
||||
background: var(--blue-50);
|
||||
border-color: var(--blue-200);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field select,
|
||||
.form-field textarea,
|
||||
.filter-field input,
|
||||
.filter-field select,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--surface-base);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-field input:focus,
|
||||
.form-field select:focus,
|
||||
.form-field textarea:focus,
|
||||
.filter-field input:focus,
|
||||
.filter-field select:focus,
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(34, 55, 113, 0.16);
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--surface-brand-tint);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--blue-700);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.045em;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--blue-50);
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--semantic-success-bg);
|
||||
color: var(--semantic-success);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--semantic-error-bg);
|
||||
color: var(--semantic-error);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--semantic-warning-bg);
|
||||
color: var(--semantic-warning);
|
||||
}
|
||||
|
||||
.badge-info,
|
||||
.badge-primary {
|
||||
background: var(--semantic-info-bg);
|
||||
color: var(--semantic-info);
|
||||
}
|
||||
|
||||
.empty-state > .fluent-icon {
|
||||
color: var(--brand-primary);
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.error-state > .fluent-icon {
|
||||
color: var(--semantic-error);
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(30, 34, 67, 0.98), rgba(34, 55, 113, 0.93) 42%, rgba(247, 248, 251, 0.98) 42.1%),
|
||||
var(--surface-raised);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
border-top: 4px solid var(--brand-accent);
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.auth-brand .brand-mark {
|
||||
background: var(--blue-900);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.auth-brand .brand-copy strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auth-brand .brand-copy span {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.auth-confirm-icon {
|
||||
background: var(--blue-50);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.auth-confirm-icon .fluent-icon {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--blue-900);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--elevation-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
box-shadow: var(--elevation-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,67 @@
|
||||
const body = document.body;
|
||||
const menuButton = document.getElementById('mobileMenuBtn');
|
||||
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
|
||||
const FLUENT_ICON_FILES = Object.freeze({
|
||||
add: 'add_20_regular.svg',
|
||||
add_box: 'box_24_regular.svg',
|
||||
apps: 'apps_24_regular.svg',
|
||||
archive: 'archive_24_regular.svg',
|
||||
attach_file: 'attach_20_regular.svg',
|
||||
campaign: 'megaphone_20_regular.svg',
|
||||
check_circle: 'checkmark_circle_20_filled.svg',
|
||||
close: 'dismiss_20_regular.svg',
|
||||
dashboard: 'grid_24_regular.svg',
|
||||
database_off: 'database_warning_20_regular.svg',
|
||||
delete: 'delete_20_regular.svg',
|
||||
deployed_code: 'cube_24_regular.svg',
|
||||
description: 'document_24_regular.svg',
|
||||
download: 'arrow_download_20_regular.svg',
|
||||
draft: 'document_24_regular.svg',
|
||||
edit: 'edit_20_regular.svg',
|
||||
error: 'error_circle_20_filled.svg',
|
||||
forward_to_inbox: 'mail_arrow_forward_20_regular.svg',
|
||||
group: 'people_24_regular.svg',
|
||||
inventory_2: 'box_24_regular.svg',
|
||||
library_books: 'library_24_regular.svg',
|
||||
link_off: 'link_dismiss_20_regular.svg',
|
||||
login: 'arrow_enter_20_regular.svg',
|
||||
logout: 'sign_out_20_regular.svg',
|
||||
mark_email_unread: 'mail_unread_24_regular.svg',
|
||||
memory: 'memory_16_regular.svg',
|
||||
menu: 'navigation_20_regular.svg',
|
||||
notifications: 'alert_20_regular.svg',
|
||||
notifications_none: 'alert_off_20_regular.svg',
|
||||
note_add: 'document_add_24_regular.svg',
|
||||
person_add: 'person_add_20_regular.svg',
|
||||
precision_manufacturing: 'bot_24_filled.svg',
|
||||
save: 'save_20_regular.svg',
|
||||
search_off: 'search_24_regular.svg',
|
||||
send: 'send_20_regular.svg',
|
||||
stars: 'star_20_filled.svg',
|
||||
swap_horiz: 'arrow_swap_20_regular.svg',
|
||||
sync: 'arrow_sync_20_regular.svg',
|
||||
upgrade: 'arrow_up_20_regular.svg',
|
||||
upload: 'arrow_upload_20_regular.svg',
|
||||
upload_file: 'document_arrow_up_24_regular.svg',
|
||||
visibility: 'eye_20_regular.svg',
|
||||
warning: 'warning_20_filled.svg'
|
||||
});
|
||||
|
||||
function applyFluentIcon(element, iconName) {
|
||||
if (!element) return;
|
||||
const fileName = FLUENT_ICON_FILES[iconName] || 'question_20_regular.svg';
|
||||
element.classList.remove('material-symbols-outlined');
|
||||
element.classList.add('fluent-icon');
|
||||
element.style.setProperty('--fluent-icon-url', `url("/vendor/fluent-icons/${fileName}")`);
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
element.textContent = '';
|
||||
}
|
||||
|
||||
function hydrateFluentIcons(root) {
|
||||
root.querySelectorAll('.material-symbols-outlined').forEach((element) => {
|
||||
applyFluentIcon(element, element.textContent.trim());
|
||||
});
|
||||
}
|
||||
|
||||
function initNotiflix() {
|
||||
if (!window.Notiflix) return;
|
||||
@@ -11,8 +72,8 @@
|
||||
position: 'right-top',
|
||||
distance: '16px',
|
||||
timeout: 2600,
|
||||
borderRadius: '8px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
|
||||
fontSize: '13px',
|
||||
messageMaxLength: 180,
|
||||
clickToClose: true,
|
||||
@@ -21,35 +82,35 @@
|
||||
useIcon: true,
|
||||
zindex: 5000,
|
||||
success: {
|
||||
background: '#067647',
|
||||
background: '#2e7d32',
|
||||
textColor: '#ffffff'
|
||||
},
|
||||
failure: {
|
||||
background: '#b42318',
|
||||
background: '#c62828',
|
||||
textColor: '#ffffff'
|
||||
},
|
||||
warning: {
|
||||
background: '#b54708',
|
||||
background: '#9a5b00',
|
||||
textColor: '#ffffff'
|
||||
},
|
||||
info: {
|
||||
background: '#3755c3',
|
||||
background: '#223771',
|
||||
textColor: '#ffffff'
|
||||
}
|
||||
});
|
||||
|
||||
window.Notiflix.Confirm.init({
|
||||
width: '360px',
|
||||
borderRadius: '8px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
titleColor: '#111827',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
|
||||
titleColor: '#161824',
|
||||
titleFontSize: '16px',
|
||||
messageColor: '#475569',
|
||||
messageColor: '#565b70',
|
||||
messageFontSize: '13px',
|
||||
okButtonBackground: '#3755c3',
|
||||
okButtonBackground: '#223771',
|
||||
okButtonColor: '#ffffff',
|
||||
cancelButtonBackground: '#e2e8f0',
|
||||
cancelButtonColor: '#334155',
|
||||
cancelButtonBackground: '#eeedf6',
|
||||
cancelButtonColor: '#3c4054',
|
||||
backOverlayColor: 'rgba(15, 23, 42, 0.42)',
|
||||
zindex: 5001,
|
||||
cssAnimationStyle: 'zoom'
|
||||
@@ -393,7 +454,8 @@
|
||||
createdAt: row.dataset.userCreatedAt || '',
|
||||
updatedAt: row.dataset.userUpdatedAt || '',
|
||||
packageCount: row.dataset.userPackageCount || '0',
|
||||
applicationCount: row.dataset.userApplicationCount || '0'
|
||||
applicationCount: row.dataset.userApplicationCount || '0',
|
||||
documentCount: row.dataset.userDocumentCount || '0'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -503,7 +565,10 @@
|
||||
setText('[data-user-detail="status"]', user.status);
|
||||
setText('[data-user-detail="createdAt"]', user.createdAt);
|
||||
setText('[data-user-detail="updatedAt"]', user.updatedAt || 'Chưa cập nhật');
|
||||
setText('[data-user-detail="ownedData"]', `${user.packageCount} packages, ${user.applicationCount} apps`);
|
||||
setText(
|
||||
'[data-user-detail="ownedData"]',
|
||||
`${user.packageCount} packages, ${user.applicationCount} apps, ${user.documentCount} tài liệu`
|
||||
);
|
||||
|
||||
openModal('userDetailModal');
|
||||
}
|
||||
@@ -701,9 +766,7 @@
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'notification-item-icon';
|
||||
const iconGlyph = document.createElement('span');
|
||||
iconGlyph.className = 'material-symbols-outlined';
|
||||
iconGlyph.setAttribute('aria-hidden', 'true');
|
||||
iconGlyph.textContent = getNotificationIcon(notification);
|
||||
applyFluentIcon(iconGlyph, getNotificationIcon(notification));
|
||||
icon.appendChild(iconGlyph);
|
||||
|
||||
const copy = document.createElement('span');
|
||||
@@ -926,6 +989,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
hydrateFluentIcons(document);
|
||||
initNotiflix();
|
||||
initFileDropzones();
|
||||
initRegistrationUniqueChecks();
|
||||
|
||||
@@ -8,6 +8,7 @@ const path = require('path');
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const repository = require('./src/repository');
|
||||
const { normalizeDocumentFileName } = require('./src/document-file-name');
|
||||
const notificationRepository = require('./src/notification-repository');
|
||||
const mailer = require('./src/mailer');
|
||||
const { closePool, getPool } = require('./src/db');
|
||||
@@ -16,6 +17,7 @@ const notiflixVersion = require('notiflix/package.json').version;
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const uploadDir = path.join(__dirname, 'uploads', 'packages');
|
||||
const documentUploadDir = path.join(__dirname, 'uploads', 'documents');
|
||||
const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent'));
|
||||
const agentDebianPackageName = 'local-installer-agent';
|
||||
const authCookieName = 'robot_installer_session';
|
||||
@@ -31,6 +33,23 @@ const installerIdentifierPattern = /^[a-zA-Z0-9._+-]+$/;
|
||||
const installerVersionPattern = /^[a-zA-Z0-9._:+~=-]+$/;
|
||||
const installerIdentifierHint = 'Code chi duoc dung chu, so, dau ., _, +, - va khong co khoang trang.';
|
||||
const installerVersionHint = 'Version chi duoc dung chu, so va cac ky tu . _ : + ~ = -.';
|
||||
const documentCategories = Object.freeze([
|
||||
{ id: 'introduction', label: 'Giới thiệu' },
|
||||
{ id: 'guide', label: 'Hướng dẫn' },
|
||||
{ id: 'user-guide', label: 'Hướng dẫn sử dụng' },
|
||||
{ id: 'technical', label: 'Tài liệu kỹ thuật' },
|
||||
{ id: 'policy', label: 'Quy trình / chính sách' },
|
||||
{ id: 'other', label: 'Khác' }
|
||||
]);
|
||||
const documentCategoryIds = new Set(documentCategories.map((category) => category.id));
|
||||
const allowedDocumentExtensions = new Set([
|
||||
'.pdf', '.doc', '.docx', '.odt', '.rtf', '.txt', '.md',
|
||||
'.png', '.jpg', '.jpeg', '.webp',
|
||||
'.ppt', '.pptx', '.xls', '.xlsx'
|
||||
]);
|
||||
const documentTextExtensions = new Set(['.txt', '.md']);
|
||||
const documentImageExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const documentMaxContentChars = Number(process.env.DOCUMENT_MAX_CONTENT_CHARS || 500000);
|
||||
const agentVersionCollator = new Intl.Collator('en', {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
@@ -51,12 +70,14 @@ app.get('/readyz', asyncRoute(async (req, res) => {
|
||||
}));
|
||||
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
fs.mkdirSync(documentUploadDir, { recursive: true });
|
||||
fs.mkdirSync(agentPackageDir, { recursive: true });
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
|
||||
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
|
||||
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
|
||||
{ id: 'documents', label: 'Tài liệu', href: '/documents', icon: 'library_books' },
|
||||
{ id: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' },
|
||||
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
|
||||
{ id: 'users', label: 'Users', href: '/users', icon: 'group', adminOnly: true }
|
||||
@@ -106,27 +127,68 @@ const agentStorage = multer.diskStorage({
|
||||
}
|
||||
});
|
||||
|
||||
const multipartLimits = Object.freeze({
|
||||
fieldNameSize: 64,
|
||||
fieldNestingDepth: 0,
|
||||
fieldSize: 64 * 1024,
|
||||
fields: 16,
|
||||
files: 1,
|
||||
parts: 17
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
...multipartLimits,
|
||||
fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024)
|
||||
}
|
||||
});
|
||||
|
||||
const documentStorage = multer.diskStorage({
|
||||
destination: documentUploadDir,
|
||||
filename(req, file, callback) {
|
||||
const normalizedName = normalizeDocumentFileName(file.originalname)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase();
|
||||
const extension = path.extname(normalizedName).slice(0, 20);
|
||||
const baseName = normalizedName.slice(0, normalizedName.length - extension.length).slice(0, 180) || 'document';
|
||||
const safeName = `${baseName}${extension}`;
|
||||
const suffix = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
|
||||
callback(null, `${suffix}-${safeName}`);
|
||||
}
|
||||
});
|
||||
|
||||
const agentUpload = multer({
|
||||
storage: agentStorage,
|
||||
limits: {
|
||||
...multipartLimits,
|
||||
fileSize: Number(process.env.AGENT_MAX_UPLOAD_BYTES || process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024)
|
||||
}
|
||||
});
|
||||
|
||||
const documentUpload = multer({
|
||||
storage: documentStorage,
|
||||
limits: {
|
||||
...multipartLimits,
|
||||
fieldSize: Number(process.env.DOCUMENT_MAX_CONTENT_BYTES || 2 * 1024 * 1024),
|
||||
fileSize: Number(process.env.DOCUMENT_MAX_UPLOAD_BYTES || 50 * 1024 * 1024)
|
||||
}
|
||||
});
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '32kb', parameterLimit: 100 }));
|
||||
app.use(express.json({ limit: '32kb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/image', express.static(path.join(__dirname, 'image')));
|
||||
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
|
||||
app.use('/vendor/montserrat', express.static(path.join(__dirname, 'node_modules/@fontsource-variable/montserrat')));
|
||||
app.use('/vendor/fluent-icons', express.static(path.join(__dirname, 'node_modules/@fluentui/svg-icons/icons')));
|
||||
app.use(applyPublicApiCors);
|
||||
app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
|
||||
const arch = normalizeAgentArch(req.query.arch);
|
||||
@@ -214,6 +276,9 @@ function helpers() {
|
||||
if (type === 'docker') return 'badge-info';
|
||||
if (type === 'apt') return 'badge-warning';
|
||||
return 'badge-primary';
|
||||
},
|
||||
documentCategoryLabel(categoryId) {
|
||||
return documentCategories.find((category) => category.id === categoryId)?.label || 'Khác';
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1024,6 +1089,128 @@ async function getArtifactFromUpload(file) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDocumentCategory(value) {
|
||||
const category = String(value || '').trim().toLowerCase();
|
||||
return documentCategoryIds.has(category) ? category : '';
|
||||
}
|
||||
|
||||
function getDocumentUploadValidationMessage(file) {
|
||||
if (!file) return '';
|
||||
|
||||
const originalFileName = normalizeDocumentFileName(file.originalname);
|
||||
|
||||
if (originalFileName.length > 260) {
|
||||
return 'Tên file tài liệu không được vượt quá 260 ký tự.';
|
||||
}
|
||||
|
||||
const extension = path.extname(originalFileName).toLowerCase();
|
||||
if (!allowedDocumentExtensions.has(extension)) {
|
||||
return 'Định dạng tài liệu chưa được hỗ trợ. Hãy dùng PDF, Word, OpenDocument, text/Markdown, ảnh, PowerPoint hoặc Excel.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getDocumentArtifact(file) {
|
||||
if (!file) {
|
||||
return {
|
||||
filePath: null,
|
||||
originalFileName: null,
|
||||
mimeType: null,
|
||||
fileSizeBytes: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: `/uploads/documents/${file.filename}`,
|
||||
originalFileName: normalizeDocumentFileName(file.originalname),
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
fileSizeBytes: file.size
|
||||
};
|
||||
}
|
||||
|
||||
function getLocalDocumentFilePath(filePath) {
|
||||
const storedPath = String(filePath || '').trim();
|
||||
const prefix = '/uploads/documents/';
|
||||
|
||||
if (!storedPath.startsWith(prefix)) return null;
|
||||
|
||||
let relativePath;
|
||||
try {
|
||||
relativePath = decodeURIComponent(storedPath.slice(prefix.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!relativePath || relativePath.includes('\0')) return null;
|
||||
|
||||
const documentRoot = path.resolve(documentUploadDir);
|
||||
const localPath = path.resolve(documentRoot, relativePath);
|
||||
const pathDelta = path.relative(documentRoot, localPath);
|
||||
|
||||
if (!pathDelta || pathDelta.startsWith('..') || path.isAbsolute(pathDelta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localPath;
|
||||
}
|
||||
|
||||
async function removeStoredDocumentFile(filePath) {
|
||||
const localPath = getLocalDocumentFilePath(filePath);
|
||||
if (!localPath) return;
|
||||
|
||||
try {
|
||||
await fsp.unlink(localPath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.warn(`Cannot remove document file ${localPath}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDocumentPreviewKind(document) {
|
||||
if (!document?.filePath) return '';
|
||||
|
||||
const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
|
||||
if (extension === '.pdf') return 'pdf';
|
||||
if (documentImageExtensions.has(extension)) return 'image';
|
||||
if (documentTextExtensions.has(extension)) return 'text';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getDocumentResponseMimeType(document) {
|
||||
const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
|
||||
const knownTypes = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp'
|
||||
};
|
||||
|
||||
return knownTypes[extension] || document.mimeType || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function setDocumentContentDisposition(res, disposition, fileName) {
|
||||
const originalName = String(fileName || 'document')
|
||||
.replace(/[\r\n]/g, '')
|
||||
.slice(0, 260);
|
||||
const asciiName = originalName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^\x20-\x7E]/g, '_')
|
||||
.replace(/["\\;]/g, '_') || 'document';
|
||||
const encodedName = encodeURIComponent(originalName)
|
||||
.replace(/['()]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`${disposition}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`
|
||||
);
|
||||
}
|
||||
|
||||
async function getDebUploadMetadataValidationMessage(file, packageCode, version) {
|
||||
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
|
||||
|
||||
@@ -1555,7 +1742,7 @@ exit 1
|
||||
});
|
||||
|
||||
app.use(requireAuthenticated);
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
app.use('/uploads/packages', express.static(uploadDir));
|
||||
|
||||
app.get('/api/notifications', asyncRoute(async (req, res) => {
|
||||
const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true';
|
||||
@@ -2296,6 +2483,228 @@ app.get('/applications/:id', asyncRoute(async (req, res) => {
|
||||
);
|
||||
}));
|
||||
|
||||
app.get('/documents', asyncRoute(async (req, res) => {
|
||||
const [pageData, documents] = await Promise.all([
|
||||
repository.getPageData(req.currentUser),
|
||||
repository.listDocuments()
|
||||
]);
|
||||
|
||||
res.render(
|
||||
'documents',
|
||||
viewModel(req, 'documents', 'Tài liệu', pageData, { documents, documentCategories })
|
||||
);
|
||||
}));
|
||||
|
||||
app.post('/documents', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
|
||||
try {
|
||||
const title = String(req.body.title || '').trim();
|
||||
const category = normalizeDocumentCategory(req.body.category);
|
||||
const summary = String(req.body.summary || '').trim();
|
||||
const content = String(req.body.content || '').trim();
|
||||
const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
|
||||
|
||||
if (!title || title.length > 200) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summary.length > 1000 || content.length > documentMaxContentChars) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileValidationMessage) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', fileValidationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content && !req.file) {
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Hãy nhập nội dung hoặc đính kèm ít nhất một file tài liệu.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = getDocumentArtifact(req.file);
|
||||
await repository.createDocument({
|
||||
title,
|
||||
category,
|
||||
summary,
|
||||
content,
|
||||
...artifact,
|
||||
createdByUserId: req.currentUser.id
|
||||
});
|
||||
|
||||
redirectWithNotice(res, '/documents', 'success', 'Đã lưu tài liệu mới.');
|
||||
} catch (error) {
|
||||
await removeUploadedFile(req.file);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
app.get('/documents/:id/file', asyncRoute(async (req, res) => {
|
||||
const document = await repository.getDocumentById(req.params.id);
|
||||
const localPath = document ? getLocalDocumentFilePath(document.filePath) : null;
|
||||
|
||||
if (!document || !localPath) {
|
||||
res.status(404).type('text/plain').send('Không tìm thấy file tài liệu.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsp.access(localPath, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.status(404).type('text/plain').send('File tài liệu không còn tồn tại trên máy chủ.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previewKind = getDocumentPreviewKind(document);
|
||||
const shouldDownload = req.query.download === '1' || !previewKind;
|
||||
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Type', getDocumentResponseMimeType(document));
|
||||
setDocumentContentDisposition(
|
||||
res,
|
||||
shouldDownload ? 'attachment' : 'inline',
|
||||
document.originalFileName || path.basename(localPath)
|
||||
);
|
||||
res.sendFile(localPath);
|
||||
}));
|
||||
|
||||
app.post('/documents/:id/edit', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
|
||||
const documentId = String(req.params.id || '').trim();
|
||||
|
||||
try {
|
||||
const existingDocument = await repository.getDocumentById(documentId);
|
||||
if (!existingDocument) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
|
||||
return;
|
||||
}
|
||||
|
||||
const title = String(req.body.title || '').trim();
|
||||
const category = normalizeDocumentCategory(req.body.category);
|
||||
const summary = String(req.body.summary || '').trim();
|
||||
const content = String(req.body.content || '').trim();
|
||||
const removeAttachment = req.body.removeAttachment === '1';
|
||||
const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
|
||||
const willHaveAttachment = Boolean(req.file) || (Boolean(existingDocument.filePath) && !removeAttachment);
|
||||
|
||||
if (!title || title.length > 200) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summary.length > 1000 || content.length > documentMaxContentChars) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileValidationMessage) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', fileValidationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content && !willHaveAttachment) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tài liệu phải có nội dung hoặc file đính kèm.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = getDocumentArtifact(req.file);
|
||||
const replaceAttachment = Boolean(req.file) || removeAttachment;
|
||||
const result = await repository.updateDocument({
|
||||
documentId,
|
||||
title,
|
||||
category,
|
||||
summary,
|
||||
content,
|
||||
replaceAttachment,
|
||||
...artifact
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.attachmentChanged
|
||||
&& result.previousFilePath
|
||||
&& result.previousFilePath !== result.currentFilePath
|
||||
) {
|
||||
await removeStoredDocumentFile(result.previousFilePath);
|
||||
}
|
||||
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'success', 'Đã cập nhật tài liệu.');
|
||||
} catch (error) {
|
||||
await removeUploadedFile(req.file);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
app.post('/documents/:id/delete', asyncRoute(async (req, res) => {
|
||||
const documentId = String(req.params.id || '').trim();
|
||||
|
||||
if (!isUuid(documentId)) {
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần xóa.');
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedDocument = await repository.deleteDocument(documentId);
|
||||
if (deletedDocument?.filePath) {
|
||||
await removeStoredDocumentFile(deletedDocument.filePath);
|
||||
}
|
||||
|
||||
redirectWithNotice(
|
||||
res,
|
||||
'/documents',
|
||||
deletedDocument ? 'success' : 'warning',
|
||||
deletedDocument ? 'Đã xóa tài liệu và file đính kèm.' : 'Không tìm thấy tài liệu cần xóa.'
|
||||
);
|
||||
}));
|
||||
|
||||
app.get('/documents/:id', asyncRoute(async (req, res) => {
|
||||
const [pageData, document] = await Promise.all([
|
||||
repository.getPageData(req.currentUser),
|
||||
repository.getDocumentById(req.params.id)
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
res.status(404).render('not-found', viewModel(req, 'documents', 'Không tìm thấy', pageData));
|
||||
return;
|
||||
}
|
||||
|
||||
res.render(
|
||||
'document-detail',
|
||||
viewModel(req, 'documents', document.title, pageData, {
|
||||
document,
|
||||
documentCategories,
|
||||
documentPreviewKind: getDocumentPreviewKind(document)
|
||||
})
|
||||
);
|
||||
}));
|
||||
|
||||
app.get('/users', requireAdmin, asyncRoute(async (req, res) => {
|
||||
const [pageData, users] = await Promise.all([
|
||||
repository.getPageData(req.currentUser),
|
||||
@@ -2434,7 +2843,7 @@ app.post('/users/:id/delete', requireAdmin, asyncRoute(async (req, res) => {
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === 'USER_HAS_OWNED_DATA') {
|
||||
redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package hoặc application. Hãy khóa tài khoản nếu cần.');
|
||||
redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package, application hoặc tài liệu. Hãy khóa tài khoản nếu cần.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2448,6 +2857,31 @@ app.get('/builder', asyncRoute(async (req, res) => {
|
||||
}));
|
||||
|
||||
app.use(async (error, req, res, next) => {
|
||||
if (error instanceof multer.MulterError) {
|
||||
const uploadErrorMessages = {
|
||||
LIMIT_FIELD_COUNT: 'Biểu mẫu upload có quá nhiều trường.',
|
||||
LIMIT_FIELD_KEY: 'Tên trường upload quá dài.',
|
||||
LIMIT_FIELD_NESTING: 'Tên trường upload không đúng định dạng.',
|
||||
LIMIT_FIELD_VALUE: 'Nội dung một trường upload quá dài.',
|
||||
LIMIT_FILE_COUNT: 'Chỉ được upload một file mỗi lần.',
|
||||
LIMIT_FILE_SIZE: 'File upload vượt quá dung lượng cho phép.',
|
||||
LIMIT_PART_COUNT: 'Biểu mẫu upload có quá nhiều thành phần.',
|
||||
LIMIT_UNEXPECTED_FILE: 'Trường file upload không hợp lệ.'
|
||||
};
|
||||
const returnPath = req.path.startsWith('/agent/')
|
||||
? '/agent'
|
||||
: (req.path.startsWith('/documents') ? '/documents' : '/packages');
|
||||
|
||||
console.warn(`Rejected multipart upload (${error.code || 'UNKNOWN'}):`, error.message);
|
||||
redirectWithNotice(
|
||||
res,
|
||||
returnPath,
|
||||
'warning',
|
||||
uploadErrorMessages[error.code] || 'Biểu mẫu upload không hợp lệ.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
|
||||
if (req.path.startsWith('/api/notifications')) {
|
||||
|
||||
23
web-server/src/document-file-name.js
Normal file
23
web-server/src/document-file-name.js
Normal file
@@ -0,0 +1,23 @@
|
||||
function normalizeDocumentFileName(value) {
|
||||
const fileName = String(value || '');
|
||||
if (!fileName || !/[\u00C2-\u00C5\u00E1\u00E2]/.test(fileName)) return fileName;
|
||||
|
||||
const codePoints = Array.from(fileName, (character) => character.codePointAt(0));
|
||||
if (codePoints.some((codePoint) => codePoint > 0xff)) return fileName;
|
||||
|
||||
const legacyBytes = Buffer.from(codePoints);
|
||||
const decodedName = legacyBytes.toString('utf8');
|
||||
|
||||
if (
|
||||
decodedName.includes('\uFFFD')
|
||||
|| !Buffer.from(decodedName, 'utf8').equals(legacyBytes)
|
||||
) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
return decodedName;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeDocumentFileName
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const crypto = require('crypto');
|
||||
const { sql, getPool } = require('./db');
|
||||
const { normalizeDocumentFileName } = require('./document-file-name');
|
||||
|
||||
const PASSWORD_HASH_PREFIX = 'pbkdf2';
|
||||
const PASSWORD_HASH_ITERATIONS = 120000;
|
||||
@@ -11,6 +12,7 @@ const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPI
|
||||
let emailConfirmationSchemaPromise;
|
||||
let applicationOpenUrlSchemaPromise;
|
||||
let packageTypeSchemaPromise;
|
||||
let documentSchemaPromise;
|
||||
|
||||
function padDatePart(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
@@ -167,7 +169,8 @@ function mapUserRow(row) {
|
||||
createdAt: formatDate(row.CreatedAt),
|
||||
updatedAt: formatDate(row.UpdatedAt),
|
||||
packageCount: Number(row.PackageCount || 0),
|
||||
applicationCount: Number(row.ApplicationCount || 0)
|
||||
applicationCount: Number(row.ApplicationCount || 0),
|
||||
documentCount: Number(row.DocumentCount || 0)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ function duplicateApplicationError() {
|
||||
}
|
||||
|
||||
function userHasOwnedDataError() {
|
||||
const error = new Error('User owns packages or applications.');
|
||||
const error = new Error('User owns packages, applications, or documents.');
|
||||
error.code = 'USER_HAS_OWNED_DATA';
|
||||
return error;
|
||||
}
|
||||
@@ -358,6 +361,69 @@ async function ensurePackageTypeSchema() {
|
||||
return packageTypeSchemaPromise;
|
||||
}
|
||||
|
||||
async function ensureDocumentSchema() {
|
||||
if (!documentSchemaPromise) {
|
||||
documentSchemaPromise = getPool().then((pool) => pool.request().query(`
|
||||
IF OBJECT_ID(N'dbo.Documents', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Documents
|
||||
(
|
||||
Id UNIQUEIDENTIFIER NOT NULL
|
||||
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
|
||||
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
|
||||
Title NVARCHAR(200) NOT NULL,
|
||||
Category NVARCHAR(50) NOT NULL
|
||||
CONSTRAINT DF_Documents_Category DEFAULT N'other',
|
||||
Summary NVARCHAR(1000) NULL,
|
||||
Content NVARCHAR(MAX) NULL,
|
||||
FilePath NVARCHAR(1000) NULL,
|
||||
OriginalFileName NVARCHAR(260) NULL,
|
||||
MimeType NVARCHAR(200) NULL,
|
||||
FileSizeBytes BIGINT NULL,
|
||||
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
|
||||
CreatedAt DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
|
||||
UpdatedAt DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Documents_CreatedByUser
|
||||
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
|
||||
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
|
||||
CONSTRAINT CK_Documents_Category CHECK (
|
||||
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
|
||||
),
|
||||
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
|
||||
CONSTRAINT CK_Documents_HasReadableContent CHECK (
|
||||
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
|
||||
)
|
||||
);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_Category_UpdatedAt'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_Category_UpdatedAt
|
||||
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE name = N'IX_Documents_CreatedByUserId'
|
||||
AND object_id = OBJECT_ID(N'dbo.Documents')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Documents_CreatedByUserId
|
||||
ON dbo.Documents(CreatedByUserId);
|
||||
END;
|
||||
`));
|
||||
}
|
||||
|
||||
return documentSchemaPromise;
|
||||
}
|
||||
|
||||
function normalizePackageStatus(isActive) {
|
||||
return isActive ? 'Active' : 'Archived';
|
||||
}
|
||||
@@ -438,6 +504,28 @@ function mapApplicationPackageRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapDocumentRow(row) {
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
title: row.Title,
|
||||
category: row.Category,
|
||||
summary: row.Summary || '',
|
||||
content: row.Content || '',
|
||||
hasContent: row.HasContent === undefined ? Boolean(row.Content) : Boolean(row.HasContent),
|
||||
filePath: row.FilePath || '',
|
||||
originalFileName: normalizeDocumentFileName(row.OriginalFileName || ''),
|
||||
mimeType: row.MimeType || '',
|
||||
fileSizeBytes: Number(row.FileSizeBytes || 0),
|
||||
fileSize: formatFileSize(row.FileSizeBytes),
|
||||
createdByUserId: row.CreatedByUserId ? String(row.CreatedByUserId) : '',
|
||||
createdBy: row.CreatedByUsername || '',
|
||||
createdAt: formatDate(row.CreatedAt),
|
||||
updatedAt: formatDate(row.UpdatedAt || row.CreatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname) {
|
||||
const host = String(hostname || '').toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
@@ -494,23 +582,27 @@ async function getUserById(id) {
|
||||
}
|
||||
|
||||
async function getUserOwnershipCounts(userId) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('UserId', sql.UniqueIdentifier, userId)
|
||||
.query(`
|
||||
SELECT
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Packages WHERE CreatedByUserId = @UserId) AS PackageCount,
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount;
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount,
|
||||
(SELECT COUNT_BIG(*) FROM dbo.Documents WHERE CreatedByUserId = @UserId) AS DocumentCount;
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
|
||||
return {
|
||||
packageCount: Number(row.PackageCount || 0),
|
||||
applicationCount: Number(row.ApplicationCount || 0)
|
||||
applicationCount: Number(row.ApplicationCount || 0),
|
||||
documentCount: Number(row.DocumentCount || 0)
|
||||
};
|
||||
}
|
||||
|
||||
async function listUsers() {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query(`
|
||||
SELECT
|
||||
@@ -523,7 +615,8 @@ async function listUsers() {
|
||||
u.CreatedAt,
|
||||
u.UpdatedAt,
|
||||
package_count.PackageCount,
|
||||
application_count.ApplicationCount
|
||||
application_count.ApplicationCount,
|
||||
document_count.DocumentCount
|
||||
FROM dbo.Users AS u
|
||||
OUTER APPLY (
|
||||
SELECT COUNT_BIG(*) AS PackageCount
|
||||
@@ -535,6 +628,11 @@ async function listUsers() {
|
||||
FROM dbo.Applications AS a
|
||||
WHERE a.CreatedByUserId = u.Id
|
||||
) AS application_count
|
||||
OUTER APPLY (
|
||||
SELECT COUNT_BIG(*) AS DocumentCount
|
||||
FROM dbo.Documents AS d
|
||||
WHERE d.CreatedByUserId = u.Id
|
||||
) AS document_count
|
||||
ORDER BY u.CreatedAt DESC, u.Username ASC;
|
||||
`);
|
||||
|
||||
@@ -865,7 +963,7 @@ async function updateUser(input) {
|
||||
async function deleteUser(userId) {
|
||||
const counts = await getUserOwnershipCounts(userId);
|
||||
|
||||
if (counts.packageCount > 0 || counts.applicationCount > 0) {
|
||||
if (counts.packageCount > 0 || counts.applicationCount > 0 || counts.documentCount > 0) {
|
||||
throw userHasOwnedDataError();
|
||||
}
|
||||
|
||||
@@ -1588,6 +1686,174 @@ async function removeApplicationPackage(applicationId, packageId) {
|
||||
return result.recordset.length > 0;
|
||||
}
|
||||
|
||||
async function listDocuments() {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query(`
|
||||
SELECT
|
||||
d.Id,
|
||||
d.Title,
|
||||
d.Category,
|
||||
d.Summary,
|
||||
CASE WHEN NULLIF(LTRIM(RTRIM(d.Content)), N'') IS NULL THEN 0 ELSE 1 END AS HasContent,
|
||||
d.FilePath,
|
||||
d.OriginalFileName,
|
||||
d.MimeType,
|
||||
d.FileSizeBytes,
|
||||
d.CreatedByUserId,
|
||||
d.CreatedAt,
|
||||
d.UpdatedAt,
|
||||
u.Username AS CreatedByUsername
|
||||
FROM dbo.Documents AS d
|
||||
INNER JOIN dbo.Users AS u
|
||||
ON u.Id = d.CreatedByUserId
|
||||
ORDER BY COALESCE(d.UpdatedAt, d.CreatedAt) DESC, d.Title ASC;
|
||||
`);
|
||||
|
||||
return result.recordset.map(mapDocumentRow);
|
||||
}
|
||||
|
||||
async function getDocumentById(documentId) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.NVarChar(100), String(documentId || '').trim())
|
||||
.query(`
|
||||
SELECT TOP (1)
|
||||
d.Id,
|
||||
d.Title,
|
||||
d.Category,
|
||||
d.Summary,
|
||||
d.Content,
|
||||
d.FilePath,
|
||||
d.OriginalFileName,
|
||||
d.MimeType,
|
||||
d.FileSizeBytes,
|
||||
d.CreatedByUserId,
|
||||
d.CreatedAt,
|
||||
d.UpdatedAt,
|
||||
u.Username AS CreatedByUsername
|
||||
FROM dbo.Documents AS d
|
||||
INNER JOIN dbo.Users AS u
|
||||
ON u.Id = d.CreatedByUserId
|
||||
WHERE CONVERT(NVARCHAR(36), d.Id) = @Id;
|
||||
`);
|
||||
|
||||
return mapDocumentRow(result.recordset[0]);
|
||||
}
|
||||
|
||||
async function createDocument(input) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Title', sql.NVarChar(200), String(input.title || '').trim())
|
||||
.input('Category', sql.NVarChar(50), input.category)
|
||||
.input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
|
||||
.input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
|
||||
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
|
||||
.input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
|
||||
.input('MimeType', sql.NVarChar(200), input.mimeType || null)
|
||||
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
|
||||
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
|
||||
.query(`
|
||||
INSERT dbo.Documents
|
||||
(
|
||||
Title,
|
||||
Category,
|
||||
Summary,
|
||||
Content,
|
||||
FilePath,
|
||||
OriginalFileName,
|
||||
MimeType,
|
||||
FileSizeBytes,
|
||||
CreatedByUserId
|
||||
)
|
||||
OUTPUT inserted.Id
|
||||
VALUES
|
||||
(
|
||||
@Title,
|
||||
@Category,
|
||||
@Summary,
|
||||
@Content,
|
||||
@FilePath,
|
||||
@OriginalFileName,
|
||||
@MimeType,
|
||||
@FileSizeBytes,
|
||||
@CreatedByUserId
|
||||
);
|
||||
`);
|
||||
|
||||
return String(result.recordset[0].Id);
|
||||
}
|
||||
|
||||
async function updateDocument(input) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const replaceAttachment = Boolean(input.replaceAttachment);
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.UniqueIdentifier, input.documentId)
|
||||
.input('Title', sql.NVarChar(200), String(input.title || '').trim())
|
||||
.input('Category', sql.NVarChar(50), input.category)
|
||||
.input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
|
||||
.input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
|
||||
.input('ReplaceAttachment', sql.Bit, replaceAttachment ? 1 : 0)
|
||||
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
|
||||
.input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
|
||||
.input('MimeType', sql.NVarChar(200), input.mimeType || null)
|
||||
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
|
||||
.query(`
|
||||
UPDATE dbo.Documents
|
||||
SET Title = @Title,
|
||||
Category = @Category,
|
||||
Summary = @Summary,
|
||||
Content = @Content,
|
||||
FilePath = CASE WHEN @ReplaceAttachment = 1 THEN @FilePath ELSE FilePath END,
|
||||
OriginalFileName = CASE WHEN @ReplaceAttachment = 1 THEN @OriginalFileName ELSE OriginalFileName END,
|
||||
MimeType = CASE WHEN @ReplaceAttachment = 1 THEN @MimeType ELSE MimeType END,
|
||||
FileSizeBytes = CASE WHEN @ReplaceAttachment = 1 THEN @FileSizeBytes ELSE FileSizeBytes END,
|
||||
UpdatedAt = SYSUTCDATETIME()
|
||||
OUTPUT
|
||||
inserted.Id,
|
||||
deleted.FilePath AS PreviousFilePath,
|
||||
inserted.FilePath AS CurrentFilePath
|
||||
WHERE Id = @Id;
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
previousFilePath: row.PreviousFilePath || '',
|
||||
currentFilePath: row.CurrentFilePath || '',
|
||||
attachmentChanged: replaceAttachment
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteDocument(documentId) {
|
||||
await ensureDocumentSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.UniqueIdentifier, documentId)
|
||||
.query(`
|
||||
DELETE FROM dbo.Documents
|
||||
OUTPUT
|
||||
deleted.Id,
|
||||
deleted.FilePath,
|
||||
deleted.OriginalFileName
|
||||
WHERE Id = @Id;
|
||||
`);
|
||||
const row = result.recordset[0];
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
filePath: row.FilePath || '',
|
||||
originalFileName: row.OriginalFileName || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function getPageData(currentUser) {
|
||||
const [stats, packageRows, applications, activity] = await Promise.all([
|
||||
getStats(),
|
||||
@@ -1635,5 +1901,10 @@ module.exports = {
|
||||
updateApplication,
|
||||
updateApplicationStatus,
|
||||
deleteApplication,
|
||||
removeApplicationPackage
|
||||
removeApplicationPackage,
|
||||
listDocuments,
|
||||
getDocumentById,
|
||||
createDocument,
|
||||
updateDocument,
|
||||
deleteDocument
|
||||
};
|
||||
|
||||
@@ -4,19 +4,17 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> | Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/vendor/montserrat/index.css">
|
||||
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="icon" type="image/png" href="/image/logo_PNKX.png">
|
||||
</head>
|
||||
<body class="auth-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-mark">
|
||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
||||
<img class="brand-logo" src="/image/logo_PNKX.png" alt="">
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
|
||||
@@ -4,19 +4,17 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> | Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/vendor/montserrat/index.css">
|
||||
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="icon" type="image/png" href="/image/logo_PNKX.png">
|
||||
</head>
|
||||
<body class="auth-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
|
||||
<main class="auth-page">
|
||||
<section class="auth-panel">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-mark">
|
||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
||||
<img class="brand-logo" src="/image/logo_PNKX.png" alt="">
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
|
||||
161
web-server/views/document-detail.ejs
Normal file
161
web-server/views/document-detail.ejs
Normal file
@@ -0,0 +1,161 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page document-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="breadcrumb"><a href="/documents">Tài liệu</a><span>/</span><span><%= helpers.documentCategoryLabel(document.category) %></span></div>
|
||||
<h1><%= document.title %></h1>
|
||||
<p><%= document.summary || 'Tài liệu nội bộ Robot Installer.' %></p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<% if (document.filePath) { %>
|
||||
<a class="btn btn-secondary" href="/documents/<%= document.id %>/file?download=1">
|
||||
<span class="material-symbols-outlined">download</span>
|
||||
Tải file
|
||||
</a>
|
||||
<% } %>
|
||||
<button class="btn btn-primary" type="button" data-modal-open="editDocumentModal">
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
Chỉnh sửa
|
||||
</button>
|
||||
<form method="post" action="/documents/<%= document.id %>/delete" data-confirm-submit="Xóa tài liệu <%= document.title %> và file đính kèm?">
|
||||
<button class="btn btn-danger" type="submit">
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
Xóa
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-detail-grid">
|
||||
<section class="panel document-meta-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Thông tin tài liệu</h2>
|
||||
<p>Thông tin phân loại và tệp lưu trữ.</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="detail-list">
|
||||
<div><dt>Nhóm</dt><dd><span class="badge badge-info"><%= helpers.documentCategoryLabel(document.category) %></span></dd></div>
|
||||
<div><dt>Người tạo</dt><dd><%= document.createdBy %></dd></div>
|
||||
<div><dt>Ngày tạo</dt><dd><%= document.createdAt %></dd></div>
|
||||
<div><dt>Cập nhật</dt><dd><%= document.updatedAt %></dd></div>
|
||||
<div><dt>File</dt><dd><%= document.originalFileName || 'Không có' %></dd></div>
|
||||
<div><dt>Dung lượng</dt><dd><%= document.fileSize || '-' %></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel document-reader-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Nội dung</h2>
|
||||
<p>Đọc nội dung và xem trước file được trình duyệt hỗ trợ.</p>
|
||||
</div>
|
||||
<% if (document.filePath) { %>
|
||||
<a class="text-link" href="/documents/<%= document.id %>/file" target="_blank" rel="noopener">Mở file</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="document-reader">
|
||||
<% if (document.content) { %>
|
||||
<article class="document-copy"><%= document.content %></article>
|
||||
<% } %>
|
||||
|
||||
<% if (document.filePath && documentPreviewKind) { %>
|
||||
<section class="document-preview-block">
|
||||
<div class="document-section-heading">
|
||||
<span class="material-symbols-outlined">attach_file</span>
|
||||
<strong>Xem trước: <%= document.originalFileName %></strong>
|
||||
</div>
|
||||
<% if (documentPreviewKind === 'image') { %>
|
||||
<img class="document-image-preview" src="/documents/<%= document.id %>/file" alt="<%= document.title %>">
|
||||
<% } else { %>
|
||||
<iframe
|
||||
class="document-file-preview"
|
||||
src="/documents/<%= document.id %>/file"
|
||||
title="Xem trước <%= document.originalFileName %>"
|
||||
<% if (documentPreviewKind === 'text') { %>sandbox<% } %>
|
||||
></iframe>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } else if (document.filePath) { %>
|
||||
<div class="document-attachment-state">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
<div>
|
||||
<strong><%= document.originalFileName %></strong>
|
||||
<p>Trình duyệt không xem trực tiếp định dạng này. Hãy tải file để mở bằng ứng dụng phù hợp.</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary" href="/documents/<%= document.id %>/file?download=1">Tải file</a>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (!document.content && !document.filePath) { %>
|
||||
<div class="table-empty">Tài liệu chưa có nội dung.</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="editDocumentModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="editDocumentModalTitle">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 id="editDocumentModalTitle">Chỉnh sửa tài liệu</h3>
|
||||
<p>Cập nhật nội dung hoặc thay thế file đính kèm hiện tại.</p>
|
||||
</div>
|
||||
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="modal-form" method="post" action="/documents/<%= document.id %>/edit" enctype="multipart/form-data">
|
||||
<div class="form-stack">
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Tiêu đề</span>
|
||||
<input type="text" name="title" maxlength="200" required value="<%= document.title %>">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nhóm tài liệu</span>
|
||||
<select name="category" required>
|
||||
<% documentCategories.forEach((category) => { %>
|
||||
<option value="<%= category.id %>" <%= document.category === category.id ? 'selected' : '' %>><%= category.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="form-field">
|
||||
<span>Mô tả ngắn</span>
|
||||
<textarea name="summary" rows="3" maxlength="1000"><%= document.summary %></textarea>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nội dung đọc trực tiếp</span>
|
||||
<textarea name="content" rows="8" maxlength="500000"><%= document.content %></textarea>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Thay file đính kèm</span>
|
||||
<input
|
||||
type="file"
|
||||
name="documentFile"
|
||||
accept=".pdf,.doc,.docx,.odt,.rtf,.txt,.md,.png,.jpg,.jpeg,.webp,.ppt,.pptx,.xls,.xlsx"
|
||||
>
|
||||
<small>Để trống nếu muốn giữ file hiện tại. File mới sẽ thay thế file cũ.</small>
|
||||
</label>
|
||||
<% if (document.filePath) { %>
|
||||
<label class="document-remove-option">
|
||||
<input type="checkbox" name="removeAttachment" value="1">
|
||||
<span>Xóa file hiện tại: <strong><%= document.originalFileName %></strong></span>
|
||||
</label>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span class="material-symbols-outlined">save</span>
|
||||
Lưu thay đổi
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('partials/page-end') %>
|
||||
161
web-server/views/documents.ejs
Normal file
161
web-server/views/documents.ejs
Normal file
@@ -0,0 +1,161 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Tài liệu</h1>
|
||||
<p>Lưu trữ và tra cứu tài liệu giới thiệu, hướng dẫn sử dụng, quy trình và tài liệu kỹ thuật.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" type="button" data-modal-open="createDocumentModal">
|
||||
<span class="material-symbols-outlined">note_add</span>
|
||||
Thêm tài liệu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-filters">
|
||||
<label class="filter-field">
|
||||
<span>Nhóm tài liệu</span>
|
||||
<select data-filter-select data-filter-column="category" data-filter-table="documentsTable">
|
||||
<option value="">Tất cả</option>
|
||||
<% documentCategories.forEach((category) => { %>
|
||||
<option value="<%= category.id %>"><%= category.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label class="filter-field wide">
|
||||
<span>Tìm kiếm</span>
|
||||
<input type="search" placeholder="Tìm theo tiêu đề, mô tả, tác giả..." data-table-search="documentsTable">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section class="table-panel">
|
||||
<div class="table-wrap">
|
||||
<table id="documentsTable" class="data-table documents-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tài liệu</th>
|
||||
<th>Nhóm</th>
|
||||
<th>File đính kèm</th>
|
||||
<th>Cập nhật</th>
|
||||
<th>Người tạo</th>
|
||||
<th class="action-col">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (documents.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="6" class="table-empty">Chưa có tài liệu. Bấm Thêm tài liệu để tạo nội dung đầu tiên.</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% documents.forEach((item) => { %>
|
||||
<tr
|
||||
data-search="<%= `${item.title} ${item.summary} ${item.createdBy} ${helpers.documentCategoryLabel(item.category)}`.toLowerCase() %>"
|
||||
data-category="<%= item.category %>"
|
||||
>
|
||||
<td class="document-title-cell">
|
||||
<a class="table-title" href="/documents/<%= item.id %>"><%= item.title %></a>
|
||||
<span class="table-subtitle"><%= item.summary || (item.hasContent ? 'Có nội dung đọc trực tiếp' : 'Tài liệu đính kèm') %></span>
|
||||
</td>
|
||||
<td><span class="badge badge-info"><%= helpers.documentCategoryLabel(item.category) %></span></td>
|
||||
<td>
|
||||
<% if (item.filePath) { %>
|
||||
<span class="document-file-name" title="<%= item.originalFileName %>"><%= item.originalFileName %></span>
|
||||
<span class="table-subtitle"><%= item.fileSize %></span>
|
||||
<% } else { %>
|
||||
<span class="table-subtitle">Không có file</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td><%= item.updatedAt %></td>
|
||||
<td><%= item.createdBy %></td>
|
||||
<td class="action-col">
|
||||
<div class="action-group">
|
||||
<a class="icon-button subtle" href="/documents/<%= item.id %>" title="Đọc tài liệu" aria-label="Đọc tài liệu <%= item.title %>">
|
||||
<span class="material-symbols-outlined">visibility</span>
|
||||
</a>
|
||||
<% if (item.filePath) { %>
|
||||
<a class="icon-button subtle" href="/documents/<%= item.id %>/file?download=1" title="Tải file" aria-label="Tải file <%= item.title %>">
|
||||
<span class="material-symbols-outlined">download</span>
|
||||
</a>
|
||||
<% } %>
|
||||
<form method="post" action="/documents/<%= item.id %>/delete" data-confirm-submit="Xóa tài liệu <%= item.title %> và file đính kèm?">
|
||||
<button class="icon-button danger" type="submit" title="Xóa tài liệu" aria-label="Xóa tài liệu <%= item.title %>">
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="page-pager">
|
||||
<span>Hiển thị <%= documents.length %> tài liệu</span>
|
||||
<div>
|
||||
<button type="button" disabled>Trước</button>
|
||||
<span>Trang 1 / 1</span>
|
||||
<button type="button" disabled>Sau</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div id="createDocumentModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="createDocumentModalTitle">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 id="createDocumentModalTitle">Thêm tài liệu</h3>
|
||||
<p>Nhập nội dung để đọc trực tiếp, đính kèm file, hoặc sử dụng cả hai.</p>
|
||||
</div>
|
||||
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="modal-form" method="post" action="/documents" enctype="multipart/form-data">
|
||||
<div class="form-stack">
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Tiêu đề</span>
|
||||
<input type="text" name="title" maxlength="200" required placeholder="Ví dụ: Hướng dẫn cài đặt Robot">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nhóm tài liệu</span>
|
||||
<select name="category" required>
|
||||
<% documentCategories.forEach((category) => { %>
|
||||
<option value="<%= category.id %>"><%= category.label %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="form-field">
|
||||
<span>Mô tả ngắn</span>
|
||||
<textarea name="summary" rows="3" maxlength="1000" placeholder="Nội dung chính và đối tượng sử dụng tài liệu này."></textarea>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Nội dung đọc trực tiếp</span>
|
||||
<textarea name="content" rows="8" maxlength="500000" placeholder="Nhập nội dung hướng dẫn tại đây. Xuống dòng và khoảng trắng sẽ được giữ nguyên khi hiển thị."></textarea>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>File đính kèm</span>
|
||||
<input
|
||||
type="file"
|
||||
name="documentFile"
|
||||
accept=".pdf,.doc,.docx,.odt,.rtf,.txt,.md,.png,.jpg,.jpeg,.webp,.ppt,.pptx,.xls,.xlsx"
|
||||
>
|
||||
<small>Hỗ trợ PDF, Word, OpenDocument, text/Markdown, ảnh, PowerPoint và Excel; tối đa 50 MB mặc định.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span class="material-symbols-outlined">save</span>
|
||||
Lưu tài liệu
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('partials/page-end') %>
|
||||
@@ -4,18 +4,16 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= title %> | Robot Installer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/vendor/montserrat/index.css">
|
||||
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<link rel="icon" type="image/png" href="/image/logo_PNKX.png">
|
||||
</head>
|
||||
<body class="app-shell" <% if (notice) { %>data-notice-type="<%= notice.type %>" data-notice="<%= notice.message %>"<% } %>>
|
||||
<aside id="appSidebar" class="sidebar" aria-label="Main navigation">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark">
|
||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
||||
<img class="brand-logo" src="/image/logo_PNKX.png" alt="">
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Robot Installer</strong>
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
data-user-updated-at="<%= user.updatedAt %>"
|
||||
data-user-package-count="<%= user.packageCount %>"
|
||||
data-user-application-count="<%= user.applicationCount %>"
|
||||
data-user-document-count="<%= user.documentCount %>"
|
||||
>
|
||||
<td>
|
||||
<span class="table-title"><%= user.name %></span>
|
||||
@@ -126,6 +127,7 @@
|
||||
<td>
|
||||
<span class="table-subtitle"><%= user.packageCount %> packages</span>
|
||||
<span class="table-subtitle"><%= user.applicationCount %> apps</span>
|
||||
<span class="table-subtitle"><%= user.documentCount %> tài liệu</span>
|
||||
</td>
|
||||
<td>
|
||||
<% if (user.id === currentUser.id) { %>
|
||||
|
||||
Reference in New Issue
Block a user