Forráskód Böngészése

Merge branch 'develop'

Iñigo Valentin 10 hónapja
szülő
commit
d32cbd2f2f

+ 4 - 1
backend/src/routers/assetRouter.js

@@ -12,7 +12,9 @@ var mime = {
     jpg: 'image/jpeg',
     png: 'image/png',
     svg: 'image/svg+xml',
-    js: 'application/javascript'
+    js: 'application/javascript',
+    mp4: 'video/mp4',
+    ogv: 'video/ogg'
 };
 
 // Read (GET) an asset
@@ -33,6 +35,7 @@ router.get("/images/projects/:projectId/:imagePath", async (req, res) => {
     var s = fs.createReadStream(file);
     s.on('open', function () {
         res.setHeader('Content-Type', type);
+        res.setHeader('Accept-Ranges', 'bytes');
         res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
         s.pipe(res);
     });

+ 5 - 1
backend/src/routers/projectRouter.js

@@ -72,7 +72,11 @@ router.get("/:projectId/images/:imageId", cors({origin: '*', methods: 'GET'}), a
     const data
       = await projectData.getProjectImage(req.params.projectId, req.params.imageId, lang);
     if (!data) res.status(404).json({ error: "Image not found" });
-    else res.json(data);
+    else{
+        res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+        res.setHeader('Access-Control-Allow-Origin', '*');
+        res.json(data);
+    }
 });
 
 module.exports = router;

+ 6 - 1
frontend/angular.json

@@ -32,7 +32,12 @@
             ],
             "styles": [
               "src/styles.scss"
-            ]
+            ],
+            "server": "src/main.server.ts",
+            "outputMode": "server",
+            "ssr": {
+              "entry": "src/server.ts"
+            }
           },
           "configurations": {
             "production": {

+ 176 - 83
frontend/package-lock.json

@@ -14,9 +14,13 @@
         "@angular/core": "^20.3.7",
         "@angular/forms": "^20.3.7",
         "@angular/platform-browser": "^20.3.7",
+        "@angular/platform-server": "^20.3.7",
         "@angular/router": "^20.3.7",
+        "@angular/ssr": "^20.3.7",
         "@ngx-translate/core": "^17.0.0",
         "@ngx-translate/http-loader": "^17.0.0",
+        "express": "^5.1.0",
+        "ngx-cookie-service-ssr": "^20.1.0",
         "rxjs": "~7.8.0",
         "tslib": "^2.3.0",
         "zone.js": "~0.15.0"
@@ -26,7 +30,9 @@
         "@angular/cli": "^20.3.7",
         "@angular/compiler-cli": "^20.3.7",
         "@angular/localize": "^20.3.7",
+        "@types/express": "^5.0.1",
         "@types/jasmine": "~5.1.0",
+        "@types/node": "^20.17.19",
         "jasmine-core": "~5.9.0",
         "karma": "~6.4.0",
         "karma-chrome-launcher": "~3.2.0",
@@ -580,6 +586,25 @@
         }
       }
     },
+    "node_modules/@angular/platform-server": {
+      "version": "20.3.7",
+      "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-20.3.7.tgz",
+      "integrity": "sha512-ADqOwqeUpTkp97SUpNO4jZ0o9Du7oBpi0mqzLx/c1dQYgL5hKAZYpa7bpG/edn2nSMHXwQAaGw7t+MTmU7elxQ==",
+      "dependencies": {
+        "tslib": "^2.3.0",
+        "xhr2": "^0.2.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "@angular/common": "20.3.7",
+        "@angular/compiler": "20.3.7",
+        "@angular/core": "20.3.7",
+        "@angular/platform-browser": "20.3.7",
+        "rxjs": "^6.5.3 || ^7.4.0"
+      }
+    },
     "node_modules/@angular/router": {
       "version": "20.3.7",
       "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.7.tgz",
@@ -597,6 +622,25 @@
         "rxjs": "^6.5.3 || ^7.4.0"
       }
     },
+    "node_modules/@angular/ssr": {
+      "version": "20.3.7",
+      "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-20.3.7.tgz",
+      "integrity": "sha512-munpdYSgU2jDdRb5xujmaAXEpnOhFVLNO0ghqPugeDOH8ejeum3oBsHkdWK1w6rhj1PmX32/Q0DlAiuY4VlXCQ==",
+      "dependencies": {
+        "tslib": "^2.3.0"
+      },
+      "peerDependencies": {
+        "@angular/common": "^20.0.0",
+        "@angular/core": "^20.0.0",
+        "@angular/platform-server": "^20.0.0",
+        "@angular/router": "^20.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@angular/platform-server": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@babel/code-frame": {
       "version": "7.27.1",
       "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -3390,6 +3434,25 @@
         "@babel/types": "^7.28.2"
       }
     },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/cors": {
       "version": "2.8.19",
       "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -3405,19 +3468,96 @@
       "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
       "dev": true
     },
+    "node_modules/@types/express": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
+      "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
+      "dev": true,
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^5.0.0",
+        "@types/serve-static": "*"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
+      "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true
+    },
     "node_modules/@types/jasmine": {
       "version": "5.1.10",
       "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.10.tgz",
       "integrity": "sha512-ctILpOSFD58PTyGwr400GadefIHPqdWQ8VlZI9D65USOWR/aIw8dkDAw17KPCu/Wn+e+r9BWpPtmuB5T/pP8ng==",
       "dev": true
     },
+    "node_modules/@types/mime": {
+      "version": "1.3.5",
+      "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
+      "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
+      "dev": true
+    },
     "node_modules/@types/node": {
-      "version": "24.7.2",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
-      "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
+      "version": "20.19.23",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
+      "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
+      "dev": true,
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/qs": {
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+      "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+      "dev": true
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true
+    },
+    "node_modules/@types/send": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
+      "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==",
       "dev": true,
       "dependencies": {
-        "undici-types": "~7.14.0"
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "1.15.9",
+      "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz",
+      "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==",
+      "dev": true,
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*",
+        "@types/send": "<1"
+      }
+    },
+    "node_modules/@types/serve-static/node_modules/@types/send": {
+      "version": "0.17.5",
+      "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
+      "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
+      "dev": true,
+      "dependencies": {
+        "@types/mime": "^1",
+        "@types/node": "*"
       }
     },
     "node_modules/@vitejs/plugin-basic-ssl": {
@@ -3451,7 +3591,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
       "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
-      "dev": true,
       "dependencies": {
         "mime-types": "^3.0.0",
         "negotiator": "^1.0.0"
@@ -3650,7 +3789,6 @@
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
       "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
-      "dev": true,
       "dependencies": {
         "bytes": "^3.1.2",
         "content-type": "^1.0.5",
@@ -3670,7 +3808,6 @@
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
       "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
-      "dev": true,
       "dependencies": {
         "safer-buffer": ">= 2.1.2 < 3.0.0"
       },
@@ -3749,7 +3886,6 @@
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
       "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -3865,7 +4001,6 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
       "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
-      "dev": true,
       "dependencies": {
         "es-errors": "^1.3.0",
         "function-bind": "^1.1.2"
@@ -3878,7 +4013,6 @@
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
       "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
-      "dev": true,
       "dependencies": {
         "call-bind-apply-helpers": "^1.0.2",
         "get-intrinsic": "^1.3.0"
@@ -4147,7 +4281,6 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
       "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
-      "dev": true,
       "dependencies": {
         "safe-buffer": "5.2.1"
       },
@@ -4159,7 +4292,6 @@
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
       "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
-      "dev": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -4174,7 +4306,6 @@
       "version": "0.7.2",
       "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
       "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
-      "dev": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -4183,7 +4314,6 @@
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
       "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
-      "dev": true,
       "engines": {
         "node": ">=6.6.0"
       }
@@ -4262,7 +4392,6 @@
       "version": "4.4.3",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
       "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
-      "dev": true,
       "dependencies": {
         "ms": "^2.1.3"
       },
@@ -4279,7 +4408,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
       "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -4381,7 +4509,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
       "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
-      "dev": true,
       "dependencies": {
         "call-bind-apply-helpers": "^1.0.1",
         "es-errors": "^1.3.0",
@@ -4400,8 +4527,7 @@
     "node_modules/ee-first": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
-      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
-      "dev": true
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
     },
     "node_modules/electron-to-chromium": {
       "version": "1.5.237",
@@ -4419,7 +4545,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
       "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -4594,7 +4719,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
       "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
-      "dev": true,
       "engines": {
         "node": ">= 0.4"
       }
@@ -4603,7 +4727,6 @@
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
       "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
-      "dev": true,
       "engines": {
         "node": ">= 0.4"
       }
@@ -4612,7 +4735,6 @@
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
       "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
-      "dev": true,
       "dependencies": {
         "es-errors": "^1.3.0"
       },
@@ -4673,14 +4795,12 @@
     "node_modules/escape-html": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
-      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
-      "dev": true
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
     },
     "node_modules/etag": {
       "version": "1.8.1",
       "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
       "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
-      "dev": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -4722,7 +4842,6 @@
       "version": "5.1.0",
       "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
       "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
-      "dev": true,
       "dependencies": {
         "accepts": "^2.0.0",
         "body-parser": "^2.2.0",
@@ -4842,7 +4961,6 @@
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
       "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
-      "dev": true,
       "dependencies": {
         "debug": "^4.4.0",
         "encodeurl": "^2.0.0",
@@ -4901,7 +5019,6 @@
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
       "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
-      "dev": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -4910,7 +5027,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
       "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -4965,7 +5081,6 @@
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
       "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
-      "dev": true,
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
       }
@@ -5004,7 +5119,6 @@
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
       "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
-      "dev": true,
       "dependencies": {
         "call-bind-apply-helpers": "^1.0.2",
         "es-define-property": "^1.0.1",
@@ -5028,7 +5142,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
       "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
-      "dev": true,
       "dependencies": {
         "dunder-proto": "^1.0.1",
         "es-object-atoms": "^1.0.0"
@@ -5080,7 +5193,6 @@
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
       "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
-      "dev": true,
       "engines": {
         "node": ">= 0.4"
       },
@@ -5107,7 +5219,6 @@
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
       "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
-      "dev": true,
       "engines": {
         "node": ">= 0.4"
       },
@@ -5134,7 +5245,6 @@
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
       "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
-      "dev": true,
       "dependencies": {
         "function-bind": "^1.1.2"
       },
@@ -5210,7 +5320,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
       "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
-      "dev": true,
       "dependencies": {
         "depd": "2.0.0",
         "inherits": "2.0.4",
@@ -5226,7 +5335,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
       "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -5275,7 +5383,6 @@
       "version": "0.7.0",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
       "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
-      "dev": true,
       "dependencies": {
         "safer-buffer": ">= 2.1.2 < 3.0.0"
       },
@@ -5343,8 +5450,7 @@
     "node_modules/inherits": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
     },
     "node_modules/ini": {
       "version": "5.0.0",
@@ -5368,7 +5474,6 @@
       "version": "1.9.1",
       "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
       "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
-      "dev": true,
       "engines": {
         "node": ">= 0.10"
       }
@@ -5457,8 +5562,7 @@
     "node_modules/is-promise": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
-      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
-      "dev": true
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
     },
     "node_modules/is-regex": {
       "version": "1.2.1",
@@ -6350,7 +6454,6 @@
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
       "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
-      "dev": true,
       "engines": {
         "node": ">= 0.4"
       }
@@ -6359,7 +6462,6 @@
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
       "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -6368,7 +6470,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
       "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
-      "dev": true,
       "engines": {
         "node": ">=18"
       },
@@ -6419,7 +6520,6 @@
       "version": "1.54.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
       "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
-      "dev": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -6428,7 +6528,6 @@
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
       "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
-      "dev": true,
       "dependencies": {
         "mime-db": "^1.54.0"
       },
@@ -6633,8 +6732,7 @@
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-      "dev": true
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
     },
     "node_modules/msgpackr": {
       "version": "1.11.5",
@@ -6699,11 +6797,23 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
       "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
-      "dev": true,
       "engines": {
         "node": ">= 0.6"
       }
     },
+    "node_modules/ngx-cookie-service-ssr": {
+      "version": "20.1.0",
+      "resolved": "https://registry.npmjs.org/ngx-cookie-service-ssr/-/ngx-cookie-service-ssr-20.1.0.tgz",
+      "integrity": "sha512-EarzkcsqhLJHnffhRl5JdusqI3n7KDjYyeuFI0TByuhp/goCpz4DsaJ3Y0p8lQVW9z7zqFe9k6v24sca6rIbzg==",
+      "dependencies": {
+        "tslib": "^2.8.0"
+      },
+      "peerDependencies": {
+        "@angular/common": "^20.0.0",
+        "@angular/core": "^20.0.0",
+        "@angular/ssr": "^20.0.0"
+      }
+    },
     "node_modules/node-addon-api": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
@@ -7024,7 +7134,6 @@
       "version": "1.13.4",
       "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
       "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
-      "dev": true,
       "engines": {
         "node": ">= 0.4"
       },
@@ -7036,7 +7145,6 @@
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
       "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
-      "dev": true,
       "dependencies": {
         "ee-first": "1.1.1"
       },
@@ -7048,7 +7156,6 @@
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
       "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
-      "dev": true,
       "dependencies": {
         "wrappy": "1"
       }
@@ -7246,7 +7353,6 @@
       "version": "1.3.3",
       "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
       "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -7301,7 +7407,6 @@
       "version": "8.3.0",
       "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
       "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
-      "dev": true,
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/express"
@@ -7406,7 +7511,6 @@
       "version": "2.0.7",
       "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
       "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
-      "dev": true,
       "dependencies": {
         "forwarded": "0.2.0",
         "ipaddr.js": "1.9.1"
@@ -7434,7 +7538,6 @@
       "version": "6.14.0",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
       "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
-      "dev": true,
       "dependencies": {
         "side-channel": "^1.1.0"
       },
@@ -7449,7 +7552,6 @@
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
       "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
-      "dev": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -7458,7 +7560,6 @@
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
       "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
-      "dev": true,
       "dependencies": {
         "bytes": "3.1.2",
         "http-errors": "2.0.0",
@@ -7624,7 +7725,6 @@
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
       "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
-      "dev": true,
       "dependencies": {
         "debug": "^4.4.0",
         "depd": "^2.0.0",
@@ -7648,7 +7748,6 @@
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
       "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
-      "dev": true,
       "funding": [
         {
           "type": "github",
@@ -7684,8 +7783,7 @@
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
-      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
-      "dev": true
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
     },
     "node_modules/sass": {
       "version": "1.90.0",
@@ -7723,7 +7821,6 @@
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
       "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
-      "dev": true,
       "dependencies": {
         "debug": "^4.3.5",
         "encodeurl": "^2.0.0",
@@ -7745,7 +7842,6 @@
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
       "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
-      "dev": true,
       "dependencies": {
         "encodeurl": "^2.0.0",
         "escape-html": "^1.0.3",
@@ -7759,8 +7855,7 @@
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
-      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
-      "dev": true
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
     },
     "node_modules/shebang-command": {
       "version": "2.0.0",
@@ -7787,7 +7882,6 @@
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
       "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
-      "dev": true,
       "dependencies": {
         "es-errors": "^1.3.0",
         "object-inspect": "^1.13.3",
@@ -7806,7 +7900,6 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
       "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
-      "dev": true,
       "dependencies": {
         "es-errors": "^1.3.0",
         "object-inspect": "^1.13.3"
@@ -7822,7 +7915,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
       "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
-      "dev": true,
       "dependencies": {
         "call-bound": "^1.0.2",
         "es-errors": "^1.3.0",
@@ -7840,7 +7932,6 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
       "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
-      "dev": true,
       "dependencies": {
         "call-bound": "^1.0.2",
         "es-errors": "^1.3.0",
@@ -8158,7 +8249,6 @@
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
       "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -8452,7 +8542,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
       "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
-      "dev": true,
       "engines": {
         "node": ">=0.6"
       }
@@ -8480,7 +8569,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
       "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
-      "dev": true,
       "dependencies": {
         "content-type": "^1.0.5",
         "media-typer": "^1.1.0",
@@ -8530,9 +8618,9 @@
       }
     },
     "node_modules/undici-types": {
-      "version": "7.14.0",
-      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
-      "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
       "dev": true
     },
     "node_modules/unique-filename": {
@@ -8572,7 +8660,6 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
       "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -8657,7 +8744,6 @@
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
       "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
-      "dev": true,
       "engines": {
         "node": ">= 0.8"
       }
@@ -8961,8 +9047,7 @@
     "node_modules/wrappy": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-      "dev": true
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
     },
     "node_modules/ws": {
       "version": "8.17.1",
@@ -8985,6 +9070,14 @@
         }
       }
     },
+    "node_modules/xhr2": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz",
+      "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/y18n": {
       "version": "5.0.8",
       "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

+ 13 - 2
frontend/package.json

@@ -4,14 +4,19 @@
   "author": "Iñigo Valentin",
   "authorURL": "https://inigovalentin.com",
   "sourceSite": "Github",
-  "sourceURL": "https://github.com/InigoValentin/LeatherWeb",
+  "sourceURL": "https://github.com/InigoValentin/leather.inigovalentin.com",
   "license": "GPLv3",
+  "languages": {
+    "available": "es|en|eu",
+    "default": "es"
+  },
   "scripts": {
     "ng": "ng",
     "start": "ng serve",
     "build": "ng build",
     "watch": "ng build --watch --configuration development",
-    "test": "ng test"
+    "test": "ng test",
+    "serve:ssr:leather-frontend": "node dist/leather-frontend/server/server.mjs"
   },
   "prettier": {
     "printWidth": 100,
@@ -32,9 +37,13 @@
     "@angular/core": "^20.3.7",
     "@angular/forms": "^20.3.7",
     "@angular/platform-browser": "^20.3.7",
+    "@angular/platform-server": "^20.3.7",
     "@angular/router": "^20.3.7",
+    "@angular/ssr": "^20.3.7",
     "@ngx-translate/core": "^17.0.0",
     "@ngx-translate/http-loader": "^17.0.0",
+    "express": "^5.1.0",
+    "ngx-cookie-service-ssr": "^20.1.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "zone.js": "~0.15.0"
@@ -44,7 +53,9 @@
     "@angular/cli": "^20.3.7",
     "@angular/compiler-cli": "^20.3.7",
     "@angular/localize": "^20.3.7",
+    "@types/express": "^5.0.1",
     "@types/jasmine": "~5.1.0",
+    "@types/node": "^20.17.19",
     "jasmine-core": "~5.9.0",
     "karma": "~6.4.0",
     "karma-chrome-launcher": "~3.2.0",

BIN
frontend/public/img/logo/leather.png


+ 69 - 0
frontend/public/scripts/home.js

@@ -0,0 +1,69 @@
+var intervalId;
+var apiURL;
+
+/**
+ * Generates a srcset atribute from a src attribute.
+ * 
+ * @param src The original src.
+ * @return The generated srcset attribute.
+ */
+function generateSrcset(src){
+    var srcset = "";
+    for (let i = 100; i <= 1000; i += 100) srcset += src + "?w=" + i + " " + i + "w, ";
+    srcset.substring(0, srcset.length - 1);
+    return srcset;
+}
+
+/**
+ * Delays execution when called asynchronously.
+ * 
+ * @param ms Milliseconds to wait.
+ */
+function delay(ms){ return new Promise(resolve => setTimeout(resolve, ms)); }
+
+/**
+ * Swaps an imaga in the homepage
+ * 
+ * @param id Id of the project which image must be swapped.
+ * @param apiURL The api URL (host only).
+ * @param image Retrieved image data.
+ */
+async function swapImage(id, apiURL, image){
+    var img = document.getElementById("img-project-" + id);
+    var imgAlt = document.getElementById("img-project-" + id + "-alt");
+    imgAlt.setAttribute("src", apiURL + image.path);
+    imgAlt.setAttribute("srcset", generateSrcset(apiURL + image.path));
+    await delay(1000);
+    while (imgAlt.complete == false) await this.delay(100);
+    img.style.opacity = '0';
+    await delay(1000);
+    img.setAttribute("src", "" + imgAlt.getAttribute("src"));
+    img.setAttribute("srcset", "" + imgAlt.getAttribute("srcset"));
+    while (img.complete == false) await this.delay(100);
+    img.style.opacity = '1';
+    await delay(1000);
+    imgAlt.removeAttribute("src");
+    imgAlt.removeAttribute("srcset");
+}
+
+/**
+ * Starts the interval for image swapping in the home page.
+ */
+async function startInterval(){
+    while (document.getElementById("apiURL") == undefined) await delay(500);
+    apiURL = document.getElementById("apiURL").value;
+    intervalId = setInterval(() => {
+        const projects = document.getElementsByClassName('project');
+        const id = projects[Math.floor(Math.random() * projects.length)].id.substring(8);
+
+        // TODO: ADD lang
+        fetch(apiURL + "/projects/" + id + "/images/random")
+          .then(response => response.json())
+          .then(data => swapImage(id, apiURL, data))
+          .catch(error => console.error(
+            'Error rerieving image from ' + apiURL + "/projects/" + id + "/images/random : " , error
+          ));
+    }, 2000);
+}
+
+startInterval();

+ 12 - 0
frontend/src/app/app.config.server.ts

@@ -0,0 +1,12 @@
+import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
+import { provideServerRendering, withRoutes } from '@angular/ssr';
+import { appConfig } from './app.config';
+import { serverRoutes } from './app.routes.server';
+
+const serverConfig: ApplicationConfig = {
+  providers: [
+    provideServerRendering(withRoutes(serverRoutes))
+  ]
+};
+
+export const config = mergeApplicationConfig(appConfig, serverConfig);

+ 4 - 3
frontend/src/app/app.config.ts

@@ -2,9 +2,10 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChang
 import { provideTranslateService, provideTranslateLoader } from '@ngx-translate/core';
 import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
 import { provideRouter } from '@angular/router';
-import { HttpClient, provideHttpClient } from '@angular/common/http';
+import { HttpClient, provideHttpClient, withFetch } from '@angular/common/http';
 
 import { routes } from './app.routes';
+import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
 
 
 export const appConfig: ApplicationConfig = {
@@ -12,10 +13,10 @@ export const appConfig: ApplicationConfig = {
     provideBrowserGlobalErrorListeners(),
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideRouter(routes),
-    provideHttpClient(),
+    provideHttpClient(withFetch()),
     provideTranslateService({
       loader: provideTranslateHttpLoader({prefix: './i18n/', suffix: '.json' }),
       fallbackLang: 'en', lang: 'en'
-    })
+    }), provideClientHydration(withEventReplay())
   ]
 };

+ 8 - 0
frontend/src/app/app.routes.server.ts

@@ -0,0 +1,8 @@
+import { RenderMode, ServerRoute } from '@angular/ssr';
+
+export const serverRoutes: ServerRoute[] = [
+  {
+    path: '**',
+    renderMode: RenderMode.Server
+  }
+];

+ 53 - 7
frontend/src/app/app.ts

@@ -1,9 +1,13 @@
 import { Component, ViewEncapsulation, inject } from '@angular/core';
-import { RouterOutlet } from '@angular/router';
+import { RouterOutlet, ActivatedRoute } from '@angular/router';
 import { TranslateService, TranslatePipe, TranslateDirective } from '@ngx-translate/core';
+import { SsrCookieService } from 'ngx-cookie-service-ssr';
+import { Meta } from '@angular/platform-browser';
+import { author, languages} from '../../package.json';
 import { Header } from './header/header';
 import { Footer } from './footer/footer';
 
+
 @Component({
   selector: 'app-root',
   imports: [RouterOutlet, Header, Footer],
@@ -14,11 +18,53 @@ import { Footer } from './footer/footer';
 export class App {
     
     private translate = inject(TranslateService);
+    private cookieService = inject(SsrCookieService);
+    
+    author: string = author;
+    private languages: any = languages;
+
+    /**
+     * Selects the best language to serve the page in.
+     * 
+     * It considers available languages, browser languages, query parameters, local storage...
+     * 
+     * @return Two letter language code of the language to show the page in.
+     */
+    private selectLanguage(): string{
+        
+        let available: String[] = this.languages.available.split("|");
+        // If the language is set as a request parameter and its valid, use it.
+        // TODO: Reimplement in SSR
+        //const queryLang = new URLSearchParams(window.location.search).get('lang');
+        //if (queryLang && available.indexOf(queryLang.substring(0, 2).toLowerCase()) != -1)
+        //    return queryLang.substring(0, 2).toLowerCase();
+
+        // If the language has been previously set and a valid one is its in local storage, done.
+        if (
+          this.cookieService.check('language')
+          && available.indexOf(this.cookieService.get('language')) != -1
+        )return "" + this.cookieService.get('language');
+        
+        // If the language is not set, loop the browser accepted languages.
+        // When there is a match with the app available languages, return it.
+        // TODO: Reimplement in SSR
+        /*for (let l of navigator.languages){
+            if ( l.length >= 2 && available.indexOf(l.substring(0, 2).toLowerCase()) != -1)
+                return l.substring(0, 2).toLowerCase();
+        }*/
+        
+        // If everything else failed, return the default language.
+        return this.languages.default;
+    }
     
-    constructor(){
-        this.translate.addLangs(['en', 'eu']);
-        this.translate.setFallbackLang('es');
-        // TODO: Get language from browset if not in storage
-        this.translate.use(localStorage.getItem('language') || this.translate.getFallbackLang() || 'es');
+    constructor(private metaService: Meta, private route: ActivatedRoute){
+        this.translate.addLangs(this.languages.available.split("|"));
+        this.translate.setFallbackLang(this.languages.default);
+        // Detect the best language.
+        const lang: string = this.selectLanguage()
+        this.translate.use(lang);        
+        // Set meta tags
+        this.metaService.addTag({ property: 'author', author });
+
     }
-}
+}

+ 13 - 0
frontend/src/app/footer/footer.scss

@@ -18,6 +18,19 @@ footer{
         span{display: block;}
     }
     
+    @media screen and (max-width: 800px){
+        
+        div#footer-left{
+            width: 60%;
+            padding: 0.3em 0.2em;
+        }
+        div#footer-right{
+            width: 30%;
+            text-align: right;
+            padding: 0.3em 0.1em;
+        }
+    }
+    
     div#footer-left span{
         font-size: 70%;
         margin: 0.3em auto;

+ 8 - 5
frontend/src/app/footer/footer.ts

@@ -1,6 +1,7 @@
 import { Component, inject } from '@angular/core';
 import { version, author, authorURL, sourceSite, sourceURL, license } from '../../../package.json';
 import { TranslateService, TranslatePipe } from '@ngx-translate/core';
+import { SsrCookieService } from 'ngx-cookie-service-ssr';
 
 @Component({
     selector: 'app-footer', templateUrl: './footer.html', styleUrl: './footer.scss',
@@ -13,25 +14,27 @@ export class Footer{
     sourceSite: string = sourceSite;
     sourceURL: string = sourceURL;
     license: string = license;
+    currentLanguage: string; 
+    
+    private cookieService = inject(SsrCookieService)
     
     languages = [
-        { code: 'en', name: 'English' },
         { code: 'es', name: 'Español' },
+        { code: 'en', name: 'English' },
         { code: 'eu', name: 'Euskara' },
     ];
 
-    currentLanguage = localStorage.getItem('language');
-    
     private translate = inject(TranslateService)
     
     constructor(){
+        this.currentLanguage = this.translate.getCurrentLang();
     }
     
     switchLanguage(languageCode: string): void {
         this.currentLanguage = languageCode;
-        localStorage.setItem('language', languageCode);
+        this.cookieService.set('language', languageCode);
         this.translate.use(languageCode);
-        window.location.reload();
+        setTimeout(location.reload.bind(location), 100);
     }
 
 }

+ 15 - 12
frontend/src/app/home/home.html

@@ -1,17 +1,19 @@
-<section id='profile'>
-  <img
-    src="{{ apiURL }}{{ profile.image }}"
-    srcset="{{ utilService.generateSrcset(apiURL + profile.image) }}"
-  />
-  <div id="profile-text">
-    <p>{{ profile.bio }}</p>
-    <p>{{ profile.description }}</p>
-  </div>
-</section>
+@if (profile){
+  <section id='profile'>
+    <img
+      src="{{ apiURL }}{{ profile.image }}"
+      srcset="{{ utilService.generateSrcset(apiURL + profile.image) }}"
+    />
+    <div id="profile-text">
+      <p>{{ profile.bio }}</p>
+      <p>{{ profile.description }}</p>
+    </div>
+  </section>
+}
 <section id='catalog'>
   @for (project of projects; track project) {
-    <article class="project">
-      <a href="/projects/{{ project.permalink }}" class='project'>
+    <article class="project" id="project-{{ project.id }}">
+      <a href="/projects/{{ project.permalink }}">
         <img class="project-image-alt" id="img-project-{{project.id}}-alt"/>
         <img
           class="project-image" id="img-project-{{project.id}}"
@@ -25,3 +27,4 @@
     </article>
   }
 </section>
+<input id='apiURL' type='hidden' value='{{ apiURL }}'>

+ 21 - 39
frontend/src/app/home/home.ts

@@ -1,66 +1,48 @@
 import { Component, OnInit, inject } from '@angular/core';
-import { Title } from '@angular/platform-browser';
+import { PlatformLocation } from '@angular/common';
+import { Meta, Title } from '@angular/platform-browser';
 import { TranslateService, _ } from '@ngx-translate/core';
 import { ProjectService } from '../service/project-service';
 import { ProfileService } from '../service/profile-service';
 import { UtilService } from '../service/util-service';
 import { ProjectModel } from '../model/project';
-import { ProfileModel } from '../model/profile';
 import { environment } from '../../environments/environment';
 
 @Component({ selector: 'app-home', templateUrl: './home.html', styleUrl: './home.scss'})
 export class Home implements OnInit {
     projects: ProjectModel[] = [];
     profile: any;
-    private intervalId: any;
     apiURL: string = environment.apiUrl;
     utilService: UtilService;
     
     private translate = inject(TranslateService)
+    
 
     constructor(
       private projectService: ProjectService, private profileService: ProfileService,
-      private titleService: Title
+      private titleService: Title, private metaService: Meta,
+      private platformLocation: PlatformLocation
     ){
-        this.utilService = new UtilService()
-        this.translate.use("" + localStorage.getItem("language"));
+        this.utilService = new UtilService();
+        
+        // Set meta tags
+        const url = this.platformLocation.protocol + "//" + this.platformLocation.hostname;
+        this.metaService.addTag({ property: 'canonical', content: url });
+        this.metaService.addTag({ property: 'og:url', content: url });
+        this.metaService.addTag({ property: 'og:image', content: url + '/img/logo/leather.png' });
+        this.translate.get(_('SITE.TITLE')).subscribe((res: string) => {
+            this.titleService.setTitle(res);
+            this.metaService.addTag({ property: 'og:title', content: res});
+            this.metaService.addTag({ property: 'title', content: res});
+        });
+        this.translate.get(_('SITE.DESCRIPTION')).subscribe((res: string) => {
+            this.metaService.addTag({ property: 'og:description', content: res});
+            this.metaService.addTag({ property: 'description', content: res});
+        });
     }
 
     ngOnInit(): void {
         this.projectService.getProjects(1).subscribe(data => { this.projects = data; });
         this.profileService.getProfile().subscribe(data => { this.profile = data; });
-        this.startInterval();
-        this.translate.use("" + localStorage.getItem("language"));
-        //this.translate.use
-        this.translate.get(_('SITE.TITLE')).subscribe((res: string) => {
-            this.titleService.setTitle(res)
-        });
-    }
-  
-    private delay(ms: number){ return new Promise(resolve => setTimeout(resolve, ms)); }
-  
-    startInterval() {
-        this.intervalId = setInterval(() => {
-            const id: number = this.projects[Math.floor(Math.random() * this.projects.length)].id;
-            this.projectService.getProjectRandomImage(id.toString()).subscribe(async image => {
-                var img: HTMLImageElement
-                  = <HTMLImageElement>document.getElementById("img-project-" + id);
-                var imgAlt: HTMLImageElement
-                  = <HTMLImageElement>document.getElementById("img-project-" + id + "-alt");
-                imgAlt.setAttribute("src", this.apiURL + image.path);
-                imgAlt.setAttribute(
-                  "srcset", this.utilService.generateSrcset(this.apiURL + image.path)
-                );
-                await this.delay(1000);
-                img.style.opacity = '0';
-                await this.delay(1000);
-                img.setAttribute("src", "" + imgAlt.getAttribute("src"));
-                img.setAttribute("srcset", "" + imgAlt.getAttribute("srcset"));
-                img.style.opacity = '1';
-                await this.delay(1000);
-                imgAlt.removeAttribute("src");
-                imgAlt.removeAttribute("srcset");
-            });
-        }, 2000);
     }
 }

+ 37 - 6
frontend/src/app/project/project.ts

@@ -1,8 +1,9 @@
 import { Component, inject } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
-import { Title } from '@angular/platform-browser';
+import { PlatformLocation } from '@angular/common';
+import { Title, Meta } from '@angular/platform-browser';
 import { ProjectService } from '../service/project-service';
-import { TranslateService } from '@ngx-translate/core';
+import { TranslateService, _ } from '@ngx-translate/core';
 import { UtilService } from '../service/util-service';
 import { ProjectModel } from '../model/project';
 import { environment } from '../../environments/environment';
@@ -29,8 +30,9 @@ export class Project {
     private maxIndex: number = -1;
 
     constructor(
-      private route: ActivatedRoute, private projectService:
-      ProjectService, private titleService: Title
+      private route: ActivatedRoute, private projectService: ProjectService,
+      private titleService: Title, private metaService: Meta,
+      private platformLocation: PlatformLocation
     ){
         this.utilService = new UtilService();
     }
@@ -41,9 +43,38 @@ export class Project {
         this.projectService.getProject("" + id).subscribe(data => {
             this.project = data;
             this.maxIndex = this.project.images.length;
-            this.titleService.setTitle(
-              this.project.title + " - " + this.translate.instant('SITE.TITLE')
+            
+            // Set meta tags
+            const siteUrl: string
+              = this.platformLocation.protocol + "//" + this.platformLocation.hostname;
+            const projectUrl: string = siteUrl + '/projects' + this.project.permalink;
+            this.metaService.addTag({ property: 'canonical', content: projectUrl });
+            this.metaService.addTag({ property: 'og:url', content: projectUrl });
+            this.metaService.addTag(
+              { property: 'og:description', content: this.project.description}
             );
+            this.metaService.addTag(
+              { property: 'description', content: this.project.description}
+            );
+            if (this.project.images.length > 0){
+                this.metaService.addTag(
+                  { property: 'og:image', content: this.apiURL + this.project.images[0].path }
+                );
+            }
+            else{
+                this.metaService.addTag(
+                  { property: 'og:image', content: siteUrl + '/img/logo/leather.png' }
+                );
+            }
+            this.translate.get(_('SITE.TITLE')).subscribe((res: string) => {
+                this.titleService.setTitle(this.project?.title + " - " + res);
+                this.metaService.addTag(
+                  { property: 'title', content: this.project?.title + " - " + res }
+                );
+                this.metaService.addTag(
+                  { property: 'og:title', content: this.project?.title + " - " + res }
+                );
+            });
         });
     }
 

+ 4 - 2
frontend/src/app/service/profile-service.ts

@@ -1,4 +1,5 @@
-import { Injectable } from '@angular/core';
+import { Injectable, inject } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
 import { HttpClient } from '@angular/common/http';
 import { Observable } from 'rxjs';
 import { ProfileModel } from '../model/profile';
@@ -8,9 +9,10 @@ import { environment } from '../../environments/environment';
 
 export class ProfileService {
   private apiUrl = environment.apiUrl + '/profile';
+  private translate = inject(TranslateService);
   constructor(private http: HttpClient) { }
 
   getProfile(): Observable<ProfileModel[]> {
-    return this.http.get<ProfileModel[]>(this.apiUrl + "?lang=" + localStorage.getItem('language'));
+    return this.http.get<ProfileModel[]>(this.apiUrl + "?lang=" + this.translate.getCurrentLang());
   }
 }

+ 7 - 6
frontend/src/app/service/project-service.ts

@@ -1,6 +1,7 @@
-import { Injectable } from '@angular/core';
+import { Injectable, inject } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
 import { Observable } from 'rxjs';
+import { TranslateService } from '@ngx-translate/core';
 import { ProjectModel } from '../model/project';
 import { ProjectImageModel } from '../model/project-image';
 import { environment } from '../../environments/environment';
@@ -10,25 +11,25 @@ import { environment } from '../../environments/environment';
 export class ProjectService {
   private apiUrl = environment.apiUrl + '/projects';
   constructor(private http: HttpClient) { }
+  
+  private translate = inject(TranslateService);
 
   getProjects(images: any): Observable<ProjectModel[]> {
     var paramImages: string = "";
     if (images == true || parseInt(images) >= 0)
         paramImages = "&images=" + images;
-    return this.http.get<ProjectModel[]>(
-      this.apiUrl + "?lang=" + localStorage.getItem('language') + paramImages
-    );
+    return this.http.get<ProjectModel[]>(this.apiUrl + "?lang=" + this.translate.getCurrentLang());
   }
 
   getProject(id: string): Observable<ProjectModel> {
     return this.http.get<ProjectModel>(
-      `${this.apiUrl}/${id}` + "?lang=" + localStorage.getItem('language')
+      `${this.apiUrl}/${id}` + "?lang=" + this.translate.getCurrentLang()
     );
   }
   
   getProjectRandomImage(id: string): Observable<ProjectImageModel> {
     return this.http.get<ProjectImageModel>(
-      `${this.apiUrl}/${id}/images/random` + "?lang=" + localStorage.getItem('language')
+      `${this.apiUrl}/${id}/images/random` + "?lang=" + this.translate.getCurrentLang()
     );
   }
   

+ 1 - 1
frontend/src/app/service/util-service.ts

@@ -6,7 +6,7 @@ import { Injectable } from '@angular/core';
  * Provides utilities to use across the app.
  */
 export class UtilService {
-    
+
     /**
      * Generates a srcset attribute for an image from it's src.
      * 

+ 1 - 1
frontend/src/index.html

@@ -2,10 +2,10 @@
 <html lang="en">
 <head>
   <meta charset="utf-8">
-  <title>Frontend</title>
   <base href="/">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <link rel="icon" type="image/x-icon" href="favicon.ico">
+  <script src="/scripts/home.js"></script>
 </head>
 <body>
   <app-root></app-root>

+ 8 - 0
frontend/src/main.server.ts

@@ -0,0 +1,8 @@
+import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
+import { App } from './app/app';
+import { config } from './app/app.config.server';
+
+const bootstrap = (context: BootstrapContext) =>
+    bootstrapApplication(App, config, context);
+
+export default bootstrap;

+ 66 - 0
frontend/src/server.ts

@@ -0,0 +1,66 @@
+import {
+  AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse,
+} from '@angular/ssr/node';
+import express from 'express';
+import { join } from 'node:path';
+
+const browserDistFolder = join(import.meta.dirname, '../browser');
+
+const app = express();
+const angularApp = new AngularNodeAppEngine();
+
+
+/**
+ * Example Express Rest API endpoints can be defined here.
+ * Uncomment and define endpoints as necessary.
+ *
+ * Example:
+ * ```ts
+ * app.get('/api/{*splat}', (req, res) => {
+ *   // Handle API request
+ * });
+ * ```
+ */
+
+/**
+ * Serve static files from /browser
+ */
+app.use(
+  express.static(browserDistFolder, {
+    maxAge: '1y',
+    index: false,
+    redirect: false,
+  }),
+);
+
+/**
+ * Handle all other requests by rendering the Angular application.
+ */
+app.use((req, res, next) => {
+  angularApp
+    .handle(req)
+    .then((response) =>
+      response ? writeResponseToNodeResponse(response, res) : next(),
+    )
+    .catch(next);
+});
+
+/**
+ * Start the server if this module is the main entry point, or it is ran via PM2.
+ * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
+ */
+if (isMainModule(import.meta.url) || process.env['pm_id']) {
+  const port = process.env['PORT'] || 4000;
+  app.listen(port, (error) => {
+    if (error) {
+      throw error;
+    }
+
+    console.log(`Node Express server listening on http://localhost:${port}`);
+  });
+}
+
+/**
+ * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
+ */
+export const reqHandler = createNodeRequestHandler(app);

+ 2 - 1
frontend/tsconfig.app.json

@@ -3,7 +3,8 @@
   "compilerOptions": {
     "outDir": "./out-tsc/app",
     "types": [
-      "@angular/localize"
+      "@angular/localize",
+      "node"
     ]
   },
   "include": [

+ 2 - 1
frontend/tsconfig.json

@@ -11,7 +11,8 @@
     "experimentalDecorators": true,
     "importHelpers": true,
     "target": "ES2022",
-    "module": "preserve"
+    "module": "preserve",
+    "allowJs": true
   },
   "angularCompilerOptions": {
     "enableI18nLegacyMessageIdFormat": false,