diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b009dfb --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/package-lock.json b/package-lock.json index ab5bb7a..a4533f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,11 @@ "@angular/ssr": "^21.0.3", "@ng-icons/core": "^33.1.0", "@ng-icons/css.gg": "^33.1.0", - "@openpanel/web": "^1.2.0", + "@openpanel/web": "^1.3.0", "express": "^5.1.0", + "marked": "^17.0.5", "rxjs": "~7.8.0", + "shiki": "^4.0.2", "tslib": "^2.3.0" }, "devDependencies": { @@ -28,6 +30,7 @@ "@angular/cli": "^21.2.3", "@angular/compiler-cli": "^21.2.5", "@types/express": "^5.0.1", + "@types/marked": "^5.0.2", "@types/node": "^20.19.37", "jsdom": "^27.1.0", "typescript": "~5.9.2", @@ -287,6 +290,7 @@ "version": "21.2.3", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.3.tgz", "integrity": "sha512-i++JVHOijyFckjdYqKbSXUpKnvmO2a0Utt/wQVwiLAT0O9H1hR/2NGPzubB4hnLMNSyVWY8diminaF23mZ0xjA==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "8.18.0", @@ -311,12 +315,12 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "21.2.3", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.3.tgz", - "integrity": "sha512-tc/bBloRTVIBWGRiMPln1QbW+2QPj+YnWL/nG79abLKWkdrL9dJLcCRXY7dsPNrxOc/QF+8tVpnr8JofhWL9cQ==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.5.tgz", + "integrity": "sha512-gEg84eipTX6lcpNTDVUXBBwp0vs3rXM319Qom+sCLOKBGyqE0mvb1RM1WwfNcyOqeSMQC/vLUwRKqnP0wg1UDg==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.3", + "@angular-devkit/core": "21.2.5", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -328,6 +332,45 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.5.tgz", + "integrity": "sha512-9z9w7UxKKVmib5QHFZTOfJpAiSudqQwwEZFpQy31yaXR3tJw85xO5owi+66sgTpEvNh9Ix2THhcUq//ToP/0VA==", + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@angular/build": { "version": "21.2.3", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.3.tgz", @@ -429,19 +472,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.3", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.3.tgz", - "integrity": "sha512-QzDxnSy8AUOz6ca92xfbNuEmRdWRDi1dfFkxDVr+4l6XUnA9X6VmOi7ioCO1I9oDR73LXHybOqkqHBYDlqt/Ag==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.5.tgz", + "integrity": "sha512-nLpyqXQ0s96jC/vR8CsKM3q94/F/nZwtbjM3E6g5lXpKe7cHfJkCfERPexx+jzzYP5JBhtm+u61aH6auu9KYQw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.3", - "@angular-devkit/core": "21.2.3", - "@angular-devkit/schematics": "21.2.3", + "@angular-devkit/architect": "0.2102.5", + "@angular-devkit/core": "21.2.5", + "@angular-devkit/schematics": "21.2.5", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.3", + "@schematics/angular": "21.2.5", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -463,6 +506,66 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { + "version": "0.2102.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.5.tgz", + "integrity": "sha512-9xE7G177R9G9Kte+4AtbEMlEeZUupnvdBUMVBlZRa/n4UDUyAkB/vj58KrzRCCIVQ/ypHVMwUilaDTO484dd+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.5", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/@angular-devkit/core": { + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.5.tgz", + "integrity": "sha512-9z9w7UxKKVmib5QHFZTOfJpAiSudqQwwEZFpQy31yaXR3tJw85xO5owi+66sgTpEvNh9Ix2THhcUq//ToP/0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular/cli/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@angular/common": { "version": "21.2.5", "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.5.tgz", @@ -2913,16 +3016,16 @@ } }, "node_modules/@openpanel/sdk": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.2.0.tgz", - "integrity": "sha512-N7JU2UWsZo0MUS2cyqsncmw+xYIswOtrpPMJXjSXJlba4ctS/S7JbtcgkcywoFHtds8+ZARdVk1jTHhJNgECoQ==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.0.tgz", + "integrity": "sha512-VK/1oawBjGdxA+oYtqcWlNXlLT1zRJ9tslHoMvqqsqlcLNOhH26ltcHpyGp5RhtIF7uIkCltiicALfFN7fyldw==" }, "node_modules/@openpanel/web": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@openpanel/web/-/web-1.2.0.tgz", - "integrity": "sha512-njHW9XtV+ahXcaIkrFgKAu+2zOC7RRPxe2M14e/c1optTepc30EuL5UrJnymnlGgARSrSqTs54rRN5/luJKBRg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@openpanel/web/-/web-1.3.0.tgz", + "integrity": "sha512-geUPcn35oMqWlBS7rB4ejP6qzKGs4VDAZhoSw9MD3q/UYkD/pfTEx70z1ydGVJMjHREdXoAL1XVhBLdZmu1gsw==", "dependencies": { - "@openpanel/sdk": "1.2.0", + "@openpanel/sdk": "1.3.0", "@rrweb/types": "2.0.0-alpha.20", "rrweb": "2.0.0-alpha.20" } @@ -3915,13 +4018,13 @@ "license": "MIT" }, "node_modules/@schematics/angular": { - "version": "21.2.3", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.3.tgz", - "integrity": "sha512-rCEprgpNbJLl9Rm/t92eRYc1eIqD4BAJqB1OO8fzQolyDajCcOBpohjXkuLYSwK9RMyS6f+szNnYGOQawlrPYw==", + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.5.tgz", + "integrity": "sha512-orOiXcG86t34ejqbkm7ZHEkGfwTU/ySYFgY7BOQdaYFCoNQXxtU87fZoHckJ2xYpVitoKTvbf1bxDDphXb3ycw==", "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.3", - "@angular-devkit/schematics": "21.2.3", + "@angular-devkit/core": "21.2.5", + "@angular-devkit/schematics": "21.2.5", "jsonc-parser": "3.3.1" }, "engines": { @@ -3930,6 +4033,145 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@schematics/angular/node_modules/@angular-devkit/core": { + "version": "21.2.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.5.tgz", + "integrity": "sha512-9z9w7UxKKVmib5QHFZTOfJpAiSudqQwwEZFpQy31yaXR3tJw85xO5owi+66sgTpEvNh9Ix2THhcUq//ToP/0VA==", + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@schematics/angular/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@sigstore/bundle": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", @@ -4128,6 +4370,15 @@ "@types/send": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4135,6 +4386,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/marked": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", + "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/node": { "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", @@ -4180,6 +4447,18 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", @@ -4552,9 +4831,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4697,6 +4976,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -4719,6 +5008,26 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -4884,6 +5193,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -5091,6 +5410,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5102,6 +5430,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -5723,6 +6064,42 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hono": { "version": "4.12.7", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", @@ -5769,6 +6146,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -6369,6 +6756,18 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/marked": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", + "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6378,6 +6777,27 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -6406,6 +6826,95 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -6967,6 +7476,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/ora": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", @@ -7156,9 +7682,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", "license": "MIT", "funding": { "type": "opencollective", @@ -7182,6 +7708,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7309,6 +7836,16 @@ "node": ">= 4" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7392,6 +7929,30 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7727,6 +8288,25 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -7933,6 +8513,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", @@ -8022,6 +8612,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -8170,6 +8774,16 @@ "node": ">=20" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8262,6 +8876,74 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8321,6 +9003,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -8823,6 +9533,16 @@ "peerDependencies": { "zod": "^3.25 || ^4" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 6c14fca..c644fe6 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,11 @@ "@angular/ssr": "^21.0.3", "@ng-icons/core": "^33.1.0", "@ng-icons/css.gg": "^33.1.0", - "@openpanel/web": "^1.2.0", + "@openpanel/web": "^1.3.0", "express": "^5.1.0", + "marked": "^17.0.5", "rxjs": "~7.8.0", + "shiki": "^4.0.2", "tslib": "^2.3.0" }, "devDependencies": { @@ -44,6 +46,7 @@ "@angular/cli": "^21.2.3", "@angular/compiler-cli": "^21.2.5", "@types/express": "^5.0.1", + "@types/marked": "^5.0.2", "@types/node": "^20.19.37", "jsdom": "^27.1.0", "typescript": "~5.9.2", diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 7152bfb..0c4d21a 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,16 +1,23 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { ApplicationConfig, provideBrowserGlobalErrorListeners, LOCALE_ID } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient, withFetch } from '@angular/common/http'; import { provideNgIconsConfig } from '@ng-icons/core'; import { environment } from '../environments/openpanel'; import { provideOpenPanel } from '@core/provider/openpanel.provider'; +import { registerLocaleData } from '@angular/common'; +import localeDe from '@angular/common/locales/de'; + +registerLocaleData(localeDe); import { routes } from './app.routes'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; export const appConfig: ApplicationConfig = { providers: [ + { provide: LOCALE_ID, useValue: 'de' }, provideBrowserGlobalErrorListeners(), - provideRouter(routes), + provideRouter(routes), + provideHttpClient(withFetch()), provideClientHydration(withEventReplay()), provideNgIconsConfig({}), provideOpenPanel({ diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 35b6109..5030bd9 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -2,7 +2,18 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ { - path: "", - loadComponent: () => import('@features/landing/').then(m => m.LandingpageComponent) + path: '', + loadComponent: () => import('@features/landing/').then(m => m.LandingpageComponent), + data: { trackName: 'Home' } + }, + { + path: 'blog', + loadComponent: () => import('@features/blog').then(m => m.BlogListComponent), + data: { trackName: 'Blog' } + }, + { + path: 'blog/:slug', + loadComponent: () => import('@features/blog').then(m => m.BlogDetailComponent), + data: { trackName: 'BlogDetail' } } ]; diff --git a/src/app/app.ts b/src/app/app.ts index a36c593..bd91dc4 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,9 +1,9 @@ -import { Component, signal, OnInit } from '@angular/core'; +import { Component, signal, inject } from '@angular/core'; import { RouterOutlet, Router, NavigationEnd } from '@angular/router'; import {provideIcons} from "@ng-icons/core"; import { filter } from 'rxjs/operators'; import {cssMenu} from "@ng-icons/css.gg"; -import { UmamiService } from '@core/services/umami.service'; +import { OpenPanelService } from '@core/services/openpanel.service'; @Component({ selector: 'app-root', @@ -12,19 +12,17 @@ import { UmamiService } from '@core/services/umami.service'; styleUrl: './app.scss', viewProviders: [provideIcons({cssMenu})] }) -export class App implements OnInit { +export class App { protected readonly title = signal('hurler-webdesign-saas'); + private readonly router = inject(Router); + private readonly opService = inject(OpenPanelService); - constructor( - private router: Router, - private umami: UmamiService - ) {} - - ngOnInit(): void { + constructor() { + // Optional: Manuelles Tracking von Seitenaufrufen, falls nicht automatisch in OpenPanelService konfiguriert this.router.events.pipe( filter(event => event instanceof NavigationEnd) - ).subscribe(() => { - this.umami.trackPageview(); + ).subscribe((event) => { + this.opService.trackScreenView((event as NavigationEnd).urlAfterRedirects); }); } } diff --git a/src/app/core/models/blog-posts.model.ts b/src/app/core/models/blog-posts.model.ts new file mode 100644 index 0000000..99d8e69 --- /dev/null +++ b/src/app/core/models/blog-posts.model.ts @@ -0,0 +1,24 @@ +// src/app/blog/models/blog-post.model.ts + +export interface Tag { + tags_id: { + id: string; + name: string; + }; +} + +export interface BlogPost { + id: string; + title: string; + slug: string; + summary: string; + content: string; + cover_image: string | null; + published_at: string; + status: 'Entwurf' | 'Veröffentlicht' | 'Archiviert'; + tags: Tag[]; +} + +export interface DirectusResponse { + data: T; +} \ No newline at end of file diff --git a/src/app/core/services/blog.service.spec.ts b/src/app/core/services/blog.service.spec.ts new file mode 100644 index 0000000..64866b7 --- /dev/null +++ b/src/app/core/services/blog.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { BlogService } from './blog.service'; + +describe('BlogService', () => { + let service: BlogService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(BlogService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/core/services/blog.service.ts b/src/app/core/services/blog.service.ts new file mode 100644 index 0000000..f139653 --- /dev/null +++ b/src/app/core/services/blog.service.ts @@ -0,0 +1,51 @@ +// src/app/blog/services/blog.service.ts +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; +import { environment } from '../../../environments/environment.directus'; +import { BlogPost, DirectusResponse } from '@core/models/blog-posts.model'; + +@Injectable({ providedIn: 'root' }) +export class BlogService { + private http = inject(HttpClient); + private baseUrl = environment.directusUrl; + + private readonly defaultFields = [ + 'id', + 'title', + 'slug', + 'summary', + 'cover_image', + 'published_at', + 'tags.tags_id.id', + 'tags.tags_id.name' + ].join(','); + + getPosts(): Observable { + const params = new HttpParams() + .set('sort', '-published_at') + .set('fields', this.defaultFields); + + return this.http + .get>(`${this.baseUrl}/items/blog_posts`, { params }) + .pipe(map(res => res.data)); + } + + getPostBySlug(slug: string): Observable { + const params = new HttpParams() + .set('filter', JSON.stringify({ slug: { _eq: slug }, status: { _eq: 'published' } })) + .set('fields', `${this.defaultFields},content`); + + return this.http + .get>(`${this.baseUrl}/items/blog_posts`, { params }) + .pipe(map(res => res.data[0] ?? null)); + } + + getAssetUrl(assetId: string, params?: { width?: number; height?: number; quality?: number }): string { + const url = new URL(`${this.baseUrl}/assets/${assetId}`); + if (params?.width) url.searchParams.set('width', String(params.width)); + if (params?.height) url.searchParams.set('height', String(params.height)); + if (params?.quality) url.searchParams.set('quality', String(params.quality)); + return url.toString(); + } +} \ No newline at end of file diff --git a/src/app/core/services/navigation.service.ts b/src/app/core/services/navigation.service.ts index 93732d0..d79327e 100644 --- a/src/app/core/services/navigation.service.ts +++ b/src/app/core/services/navigation.service.ts @@ -14,15 +14,15 @@ export class NavigationService { { label: 'Features', type: 'anchor', target: 'features-section'}, { label: 'Projekte', type: 'anchor', target: 'projects' }, { label: 'Pricing', type: 'anchor', target: 'pricing' }, - - // Route-Links ( andere Pages) + + // Route-Links + { label: 'Blog', type: 'route', target: '/blog' }, { label: 'Login', type: 'route', target: '/login' }, - { - label: 'Dashboard', - type: 'route', - target: '/dashboard', + { + label: 'Dashboard', + type: 'route', + target: '/dashboard', icon: 'layout', - // Geschützte Route - wird später gefiltert } ]); @@ -30,9 +30,9 @@ export class NavigationService { readonly navigationItems = this._navigationItems.asReadonly(); // Gefiltert nach Kontext (Landingpage vs. App) - readonly landingNavigation = computed(() => - this._navigationItems().filter(item => - isAnchor(item) || item.target === '/login' + readonly landingNavigation = computed(() => + this._navigationItems().filter(item => + isAnchor(item) || item.target === '/blog' || item.target === '/login' ) ); diff --git a/src/app/core/services/openpanel.service.ts b/src/app/core/services/openpanel.service.ts index 9b7ffe1..19dd7e5 100644 --- a/src/app/core/services/openpanel.service.ts +++ b/src/app/core/services/openpanel.service.ts @@ -48,19 +48,21 @@ export class OpenPanelService implements OnDestroy { } private setupRouteTracking(): void { - // Nur einmalig subscriben, vorherige Sub zerstören this.routerSubscription?.unsubscribe(); - this.routerSubscription = this.router.events - .pipe( - filter((event) => event instanceof NavigationEnd), - // Ersten initialNavigation-Event überspringen – SSR hat ihn schon getriggert - skip(1), - ) - .subscribe((event) => { - const navEvent = event as NavigationEnd; - this.trackScreenView(navEvent.urlAfterRedirects); - }); + this.routerSubscription = this.router.events.pipe( + filter((event) => event instanceof NavigationEnd), + ).subscribe(() => { + const route = this.getActiveRoute(); + const trackName = route.snapshot.data['trackName'] ?? this.router.url; + this.trackScreenView(trackName); + }); + } + + private getActiveRoute() { + let route = this.router.routerState.root; + while (route.firstChild) route = route.firstChild; + return route; } // ─── Public API ──────────────────────────────────────────────────────────── @@ -121,7 +123,7 @@ export class OpenPanelService implements OnDestroy { /** * Decrements a numeric property on the user profile. - * @example opService.decrement('credits', 5); + * @example opService.decrement('credits'); */ decrement(property: string): void { if (!this.op) return; diff --git a/src/app/features/blog/components/blog-nav/blog-nav.component.html b/src/app/features/blog/components/blog-nav/blog-nav.component.html new file mode 100644 index 0000000..3c287f4 --- /dev/null +++ b/src/app/features/blog/components/blog-nav/blog-nav.component.html @@ -0,0 +1,20 @@ + diff --git a/src/app/features/blog/components/blog-nav/blog-nav.component.scss b/src/app/features/blog/components/blog-nav/blog-nav.component.scss new file mode 100644 index 0000000..947ae35 --- /dev/null +++ b/src/app/features/blog/components/blog-nav/blog-nav.component.scss @@ -0,0 +1,57 @@ +@use 'abstracts'; + +.blog-nav { + height: var(--nav-height); + background-color: var(--nav-bg); + backdrop-filter: var(--nav-backdrop); + box-shadow: var(--nav-shadow); + border-radius: 0 0 10px 10px; + position: sticky; + top: 0; + z-index: var(--z-index-sticky); + + &__inner { + @include abstracts.container-wrapper; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + } + + &__logo { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-base); + font-weight: 700; + color: var(--text-main); + } + + &__logo-icon { + display: flex; + align-items: center; + justify-content: center; + width: abstracts.rem(30); + height: abstracts.rem(30); + background-color: var(--accent); + color: var(--text-on-accent); + border-radius: 5px; + font-weight: 700; + font-size: 1.1rem; + flex-shrink: 0; + } + + &__logo-accent { + color: var(--accent); + } + + &__back { + font-size: var(--font-size-base); + color: var(--text-muted); + transition: color 0.15s ease; + + &:hover { + color: var(--accent); + } + } +} diff --git a/src/app/features/blog/components/blog-nav/blog-nav.component.ts b/src/app/features/blog/components/blog-nav/blog-nav.component.ts new file mode 100644 index 0000000..4cc4fb5 --- /dev/null +++ b/src/app/features/blog/components/blog-nav/blog-nav.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +@Component({ + selector: 'app-blog-nav', + imports: [RouterModule], + templateUrl: './blog-nav.component.html', + styleUrl: './blog-nav.component.scss', +}) +export class BlogNavComponent { + @Input() showBack = false; +} diff --git a/src/app/features/blog/index.ts b/src/app/features/blog/index.ts new file mode 100644 index 0000000..8e4db2e --- /dev/null +++ b/src/app/features/blog/index.ts @@ -0,0 +1,2 @@ +export { BlogListComponent } from './pages/blog-list/blog-list.component'; +export { BlogDetailComponent } from './pages/blog-detail/blog-detail.component'; diff --git a/src/app/features/blog/pages/blog-detail/blog-detail.component.html b/src/app/features/blog/pages/blog-detail/blog-detail.component.html new file mode 100644 index 0000000..8b5fd44 --- /dev/null +++ b/src/app/features/blog/pages/blog-detail/blog-detail.component.html @@ -0,0 +1,57 @@ + + +
+ + @if (loading()) { +
+
+
+
+
+
+
+
+
+
+
+ } @else if (notFound()) { +
+

Artikel nicht gefunden

+

Dieser Artikel existiert nicht oder wurde entfernt.

+
+ } @else if (post(); as post) { + + @if (getCoverUrl(post); as coverUrl) { +
+ +
+ } + +
+ +
+ @if (post.tags.length > 0) { +
+ @for (tag of post.tags; track tag.tags_id.id) { + {{ tag.tags_id.name }} + } +
+ } + +

{{ post.title }}

+ + + +

{{ post.summary }}

+
+ +
+ +
+ +
+ } + +
\ No newline at end of file diff --git a/src/app/features/blog/pages/blog-detail/blog-detail.component.scss b/src/app/features/blog/pages/blog-detail/blog-detail.component.scss new file mode 100644 index 0000000..da8896f --- /dev/null +++ b/src/app/features/blog/pages/blog-detail/blog-detail.component.scss @@ -0,0 +1,243 @@ +@use 'abstracts'; + +@keyframes skeleton-shimmer { + from { + background-position: -400px 0; + } + + to { + background-position: 400px 0; + } +} + +.blog-detail { + min-height: calc(100vh - var(--nav-height)); + padding-bottom: calc(var(--space-4) * 3); + + &__wrapper { + @include abstracts.container-wrapper; + max-width: 740px; + } + + // ── Cover ────────────────────────────────────────────────────────────── + + &__cover { + display: flex; + width: 100%; + height: abstracts.em(500); + overflow: hidden; + margin-bottom: calc(var(--space-4) * 1.5); + + img { + margin: auto; + width: auto; + height: 100%; + object-fit: cover; + display: block; + } + } + + // ── Header ───────────────────────────────────────────────────────────── + + &__header { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-4); + + &:not(:first-child) { + margin-top: calc(var(--space-4) * 1.5); + } + } + + &__tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + } + + &__tag { + font-size: 0.75rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; + background-color: oklch(from var(--accent) l c h / 0.12); + color: var(--accent); + } + + &__title { + font-size: var(--font-size-xl); + font-weight: 700; + line-height: 1.2; + } + + &__date { + font-size: 0.85rem; + color: var(--text-muted); + } + + &__summary { + font-size: var(--font-size-lg); + color: var(--text-muted); + line-height: 1.6; + font-weight: 400; + } + + &__divider { + border: none; + border-top: 1px solid var(--border-color); + margin-block: var(--space-4); + } + + // ── Article content (from Directus HTML) ────────────────────────────── + + &__content { + font-size: var(--font-size-base); + line-height: 1.75; + color: var(--text-main); + + h2 { + font-size: var(--font-size-lg); + font-weight: 700; + margin-top: var(--space-4); + margin-bottom: var(--space-2); + } + + h3 { + font-size: var(--font-size-base); + font-weight: 700; + margin-top: var(--space-3); + margin-bottom: var(--space-2); + } + + p { + margin-bottom: var(--space-3); + } + + a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 3px; + + &:hover { + color: var(--accent-hover); + } + } + + ul, + ol { + padding-left: var(--space-4); + margin-bottom: var(--space-3); + + li { + margin-bottom: var(--space-1); + } + } + + blockquote { + border-left: 3px solid var(--accent); + padding-left: var(--space-3); + margin-inline: 0; + margin-block: var(--space-3); + color: var(--text-muted); + font-style: italic; + } + + code { + font-family: 'Courier New', Courier, monospace; + font-size: 0.9em; + background-color: var(--bg-muted); + padding: 2px 6px; + border-radius: 4px; + } + + pre { + background-color: var(--bg-muted); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--space-3); + overflow-x: auto; + margin-bottom: var(--space-3); + + code { + background: none; + padding: 0; + font-size: 0.875em; + } + } + + img { + max-width: 100%; + border-radius: var(--border-radius); + margin-block: var(--space-3); + } + + hr { + border: none; + border-top: 1px solid var(--border-color); + margin-block: var(--space-4); + } + } + + // ── Not Found ────────────────────────────────────────────────────────── + + &__not-found { + padding-top: calc(var(--space-4) * 2); + text-align: center; + + h1 { + font-size: var(--font-size-xl); + margin-bottom: var(--space-2); + } + } + + // ── Skeleton ─────────────────────────────────────────────────────────── + + &__cover-skeleton { + width: 100%; + height: 380px; + background-color: var(--bg-muted); + margin-bottom: calc(var(--space-4) * 1.5); + } + + &__skeleton .blog-detail__wrapper { + display: flex; + flex-direction: column; + gap: var(--space-2); + } +} + +.skeleton-line { + border-radius: 4px; + background: linear-gradient(90deg, + var(--bg-muted) 25%, + var(--border-color) 50%, + var(--bg-muted) 75%); + background-size: 800px 100%; + animation: skeleton-shimmer 1.4s ease-in-out infinite; + + &--tag { + height: 1.2em; + width: 80px; + } + + &--title { + height: 2em; + width: 75%; + margin-bottom: var(--space-1); + } + + &--meta { + height: 0.9em; + width: 120px; + } + + &--body { + height: 1em; + width: 100%; + } + + &--short { + width: 60%; + } +} \ No newline at end of file diff --git a/src/app/features/blog/pages/blog-detail/blog-detail.component.ts b/src/app/features/blog/pages/blog-detail/blog-detail.component.ts new file mode 100644 index 0000000..c76fa31 --- /dev/null +++ b/src/app/features/blog/pages/blog-detail/blog-detail.component.ts @@ -0,0 +1,101 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { DatePipe } from '@angular/common'; +import { marked, Renderer } from 'marked'; +import { createHighlighter, Highlighter } from 'shiki'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { BlogService } from '@core/services/blog.service'; +import { SeoService } from '@core/services/seo.service'; +import { OpenPanelService } from '@core/services/openpanel.service'; +import { BlogPost } from '@core/models/blog-posts.model'; +import { BlogNavComponent } from '../../components/blog-nav/blog-nav.component'; + +const SHIKI_LANGS = ['html', 'css', 'scss', 'javascript', 'typescript', 'sql', 'python'] as const; +const SHIKI_THEME = 'ayu-light'; + +@Component({ + selector: 'app-blog-detail', + imports: [DatePipe, BlogNavComponent], + templateUrl: './blog-detail.component.html', + styleUrl: './blog-detail.component.scss', +}) +export class BlogDetailComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly blogService = inject(BlogService); + private readonly seo = inject(SeoService); + private readonly op = inject(OpenPanelService); + private readonly sanitizer = inject(DomSanitizer); + + post = signal(null); + loading = signal(true); + notFound = signal(false); + parsedContent = signal(''); + + private highlighter: Highlighter | null = null; + + private async getHighlighter(): Promise { + if (!this.highlighter) { + this.highlighter = await createHighlighter({ + themes: [SHIKI_THEME], + langs: [...SHIKI_LANGS], + }); + } + return this.highlighter; + } + + private async parseContent(content: string): Promise { + const highlighter = await this.getHighlighter(); + const loadedLangs = highlighter.getLoadedLanguages(); + + const renderer = new Renderer(); + renderer.code = ({ text, lang }) => { + const language = lang && loadedLangs.includes(lang as any) ? lang : 'text'; + return highlighter.codeToHtml(text, { lang: language, theme: SHIKI_THEME }); + }; + + marked.use({ renderer }); + const html = await marked(content); + return this.sanitizer.bypassSecurityTrustHtml(html); + } + + async ngOnInit(): Promise { + const slug = this.route.snapshot.paramMap.get('slug') ?? ''; + + this.blogService.getPostBySlug(slug).subscribe({ + next: async (post) => { + if (!post) { + this.notFound.set(true); + this.loading.set(false); + return; + } + this.post.set(post); + + if (post.content) { + this.parsedContent.set(await this.parseContent(post.content)); + } + + this.loading.set(false); + + this.seo.updateMetadata({ + title: post.title, + description: post.summary, + image: post.cover_image + ? this.blogService.getAssetUrl(post.cover_image, { width: 1200, quality: 85 }) + : undefined, + type: 'article', + }); + + this.op.track('blog_post_view', { slug, title: post.title }); + }, + error: () => { + this.notFound.set(true); + this.loading.set(false); + }, + }); + } + + getCoverUrl(post: BlogPost): string | null { + if (!post.cover_image) return null; + return this.blogService.getAssetUrl(post.cover_image, { width: 1200, quality: 85 }); + } +} diff --git a/src/app/features/blog/pages/blog-list/blog-list.component.html b/src/app/features/blog/pages/blog-list/blog-list.component.html new file mode 100644 index 0000000..ca2f6fb --- /dev/null +++ b/src/app/features/blog/pages/blog-list/blog-list.component.html @@ -0,0 +1,70 @@ + + +
+
+ +
+

Blog

+

Einblicke, Tipps und Hintergründe rund um Webdesign & digitale Präsenz.

+
+ + @if (loading()) { +
+ @for (_ of [1, 2, 3]; track $index) { +
+
+
+
+
+
+
+
+ } +
+ } @else if (error()) { +
+

{{ error() }}

+
+ } @else if (posts().length === 0) { +
+

Noch keine Artikel veröffentlicht – schau bald wieder vorbei.

+
+ } @else { + + } + +
+
diff --git a/src/app/features/blog/pages/blog-list/blog-list.component.scss b/src/app/features/blog/pages/blog-list/blog-list.component.scss new file mode 100644 index 0000000..ded5256 --- /dev/null +++ b/src/app/features/blog/pages/blog-list/blog-list.component.scss @@ -0,0 +1,183 @@ +@use 'abstracts'; + +@keyframes skeleton-shimmer { + from { background-position: -400px 0; } + to { background-position: 400px 0; } +} + +.blog-list { + min-height: calc(100vh - var(--nav-height)); + padding-block: calc(var(--space-4) * 2); + + &__wrapper { + @include abstracts.container-wrapper; + } + + &__header { + text-align: center; + margin-bottom: calc(var(--space-4) * 1.5); + + h1 { + font-size: var(--font-size-xl); + margin-bottom: var(--space-2); + } + + p { + font-size: var(--font-size-lg); + } + } + + &__grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-3); + + @include abstracts.breakpoint('md') { + grid-template-columns: repeat(2, 1fr); + } + + @include abstracts.breakpoint('lg') { + grid-template-columns: repeat(3, 1fr); + } + } + + &__error, + &__empty { + text-align: center; + padding: var(--space-4); + color: var(--text-muted); + font-size: var(--font-size-lg); + border: 1px dashed var(--border-color); + border-radius: var(--border-radius); + } +} + +// ── Blog Card ──────────────────────────────────────────────────────────────── + +.blog-card { + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + overflow: hidden; + background-color: var(--bg-surface); + color: var(--text-main); + transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.22s ease, + border-color 0.22s ease; + + &:hover { + transform: translateY(-4px); + border-color: var(--accent); + box-shadow: 0 8px 24px oklch(0% 0 0 / 0.07); + } + + &__image { + aspect-ratio: 16 / 9; + overflow: hidden; + background-color: var(--bg-muted); + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.4s ease; + + .blog-card:hover & { + transform: scale(1.04); + } + } + } + + &__image-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--bg-muted) 0%, var(--border-color) 100%); + } + + &__body { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + flex: 1; + } + + &__tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + } + + &__tag { + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + background-color: oklch(from var(--accent) l c h / 0.12); + color: var(--accent); + } + + &__title { + font-size: var(--font-size-lg); + font-weight: 700; + line-height: 1.25; + } + + &__summary { + font-size: var(--font-size-base); + color: var(--text-muted); + line-height: 1.6; + flex: 1; + // Clamp to 3 lines + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + + &__date { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: auto; + } + + // ── Skeleton ──────────────────────────────────────────────────────────── + + &--skeleton { + pointer-events: none; + + .blog-card__image--skeleton { + background-color: var(--bg-muted); + } + } +} + +.skeleton-line { + height: 1em; + border-radius: 4px; + background: linear-gradient( + 90deg, + var(--bg-muted) 25%, + var(--border-color) 50%, + var(--bg-muted) 75% + ); + background-size: 800px 100%; + animation: skeleton-shimmer 1.4s ease-in-out infinite; + + &--title { + height: 1.4em; + width: 80%; + margin-bottom: var(--space-1); + } + + &--text { + width: 100%; + } + + &--short { + width: 50%; + } +} diff --git a/src/app/features/blog/pages/blog-list/blog-list.component.ts b/src/app/features/blog/pages/blog-list/blog-list.component.ts new file mode 100644 index 0000000..7a18400 --- /dev/null +++ b/src/app/features/blog/pages/blog-list/blog-list.component.ts @@ -0,0 +1,51 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { BlogService } from '@core/services/blog.service'; +import { SeoService } from '@core/services/seo.service'; +import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive'; +import { BlogPost } from '@core/models/blog-posts.model'; +import { BlogNavComponent } from '../../components/blog-nav/blog-nav.component'; + +@Component({ + selector: 'app-blog-list', + imports: [RouterModule, DatePipe, OpenPanelTrackDirective, BlogNavComponent], + templateUrl: './blog-list.component.html', + styleUrl: './blog-list.component.scss', +}) +export class BlogListComponent implements OnInit { + private readonly blogService = inject(BlogService); + private readonly seo = inject(SeoService); + + posts = signal([]); + loading = signal(true); + error = signal(null); + + ngOnInit(): void { + this.seo.updateMetadata({ + title: 'Blog – Webdesign-Tipps & Einblicke', + description: 'Artikel rund um Webdesign, Performance und digitale Präsenz für Handwerk und Vereine.', + type: 'website', + }); + + this.blogService.getPosts().subscribe({ + next: (posts) => { + this.posts.set(posts); + this.loading.set(false); + }, + error: () => { + this.error.set('Die Artikel konnten nicht geladen werden. Bitte versuche es später erneut.'); + this.loading.set(false); + }, + }); + } + + getCoverUrl(post: BlogPost): string | null { + if (!post.cover_image) return null; + return this.blogService.getAssetUrl(post.cover_image, { width: 640, quality: 80 }); + } + + formatDate(dateStr: string): string { + return dateStr; + } +} diff --git a/src/app/features/landing/components/features-section/features-section.component.html b/src/app/features/landing/components/features-section/features-section.component.html index 23a5e51..d04c070 100644 --- a/src/app/features/landing/components/features-section/features-section.component.html +++ b/src/app/features/landing/components/features-section/features-section.component.html @@ -1,15 +1,30 @@
-
-
- @for (feature of featuresList; track feature.id) { -
-

{{ feature.claim }}

-

{{ feature.description }}

- @if (feature.icon) { - - } -
- } -
+
+
+

Warum Hurler Webdesign?

+

Handwerk statt Baukasten – das sind die Unterschiede, die zählen.

-
\ No newline at end of file +
+ @for (feature of featuresList; track feature.id; let i = $index) { +
+ + 0{{ feature.id }} + +
+ +
+ +

{{ feature.claim }}

+

{{ feature.description }}

+ +
+
+ } +
+ + diff --git a/src/app/features/landing/components/features-section/features-section.component.scss b/src/app/features/landing/components/features-section/features-section.component.scss index bacbefb..778bfd1 100644 --- a/src/app/features/landing/components/features-section/features-section.component.scss +++ b/src/app/features/landing/components/features-section/features-section.component.scss @@ -1,55 +1,175 @@ @use 'abstracts'; +@keyframes card-fade-up { + from { + opacity: 0; + transform: translateY(36px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .features-section { - min-height: calc(100vh - var(--neg-nav-height)); - margin-top: var(--neg-nav-height); + min-height: 100vh; + display: flex; + align-items: center; + padding-block: calc(var(--space-4) * 2); + + &__wrapper { + @include abstracts.container-wrapper; + width: 100%; + } + + // ── Header ──────────────────────────────────────────────────────────────── + + &__header { + text-align: center; + margin-bottom: calc(var(--space-4) * 1.5); + + h2 { + font-size: var(--font-size-xl); + margin-bottom: var(--space-2); + } + + p { + font-size: var(--font-size-lg); + } + } + + // ── Grid ────────────────────────────────────────────────────────────────── + + &__grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-3); + + @include abstracts.breakpoint('md') { + grid-template-columns: repeat(3, 1fr); + + .features-section__card { + &:nth-child(1) { grid-column: 1 / span 2; grid-row: 1; } + &:nth-child(2) { grid-column: 3; grid-row: 1; } + &:nth-child(3) { grid-column: 1; grid-row: 2; } + &:nth-child(4) { grid-column: 2 / span 2; grid-row: 2; } + } + } + } + + // ── Card ────────────────────────────────────────────────────────────────── + + &__card { + position: relative; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + min-height: abstracts.rem(220); + display: flex; + flex-direction: column; + padding: var(--space-4) var(--space-3) var(--space-3); + gap: var(--space-2); + background-color: var(--bg-surface); + cursor: default; + + // Scroll-entrance: start hidden + opacity: 0; + + &.is-visible { + animation: card-fade-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both; + } + + // Hover: lift + deepen shadow + transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 0.25s ease, + border-color 0.25s ease; + + &:hover { + transform: translateY(-5px); + border-color: var(--accent); + box-shadow: + 0 8px 24px oklch(0% 0 0 / 0.07), + 0 2px 6px oklch(0% 0 0 / 0.04); + } + + @include abstracts.breakpoint('md') { + min-height: abstracts.rem(280); + } + } + + // ── Ghost number (decorative) ───────────────────────────────────────────── + + &__card-number { + position: absolute; + top: -0.1em; + right: var(--space-3); + font-size: 5rem; + font-weight: 900; + line-height: 1; + color: var(--accent); + opacity: 0.06; + pointer-events: none; + user-select: none; + transition: opacity 0.25s ease; + + .features-section__card:hover & { + opacity: 0.1; + } + } + + // ── Icon ───────────────────────────────────────────────────────────────── + + &__icon-wrap { display: flex; align-items: center; + justify-content: center; + width: abstracts.rem(44); + height: abstracts.rem(44); + border-radius: 10px; + background-color: oklch(from var(--accent) l c h / 0.12); + flex-shrink: 0; + transition: background-color 0.25s ease, transform 0.25s cubic-bezier(0.22, 1, 0.36, 1); - &__wrapper { - @include abstracts.container-wrapper; + .features-section__card:hover & { + background-color: oklch(from var(--accent) l c h / 0.2); + transform: scale(1.1) rotate(-4deg); } + } - &__grid { - width: 100%; - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: var(--space-3); + &__icon { + font-size: abstracts.rem(22); + color: var(--accent); + } + // ── Text ────────────────────────────────────────────────────────────────── + + &__claim { + font-size: var(--font-size-lg); + font-weight: 700; + line-height: 1.2; + } + + &__description { + font-size: var(--font-size-base); + color: var(--text-muted); + line-height: 1.65; + flex: 1; + } + + // ── Accent bar (grows on hover) ─────────────────────────────────────────── + + &__bar { + position: absolute; + bottom: 0; + left: 0; + height: 3px; + width: 0; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: 0 3px 0 var(--border-radius); + transition: width 0.4s cubic-bezier(0.22, 1, 0.36, 1); + + .features-section__card:hover & { + width: 100%; } - - &__card { - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - height: abstracts.rem(300); - display: flex; - flex-direction: column; - padding-inline: var(--space-3); - justify-content: center; - - &:nth-child(1) { - grid-column: 1 / span 2; - grid-row: 1; - } - &:nth-child(2) { - grid-column: 3; - grid-row: 1; - } - &:nth-child(3) { - grid-column: 1; - grid-row: 2; - } - &:nth-child(4) { - grid-column: 2 / span 2; - grid-row: 2; - } - } - - &__claim { - font-size: var(--font-size-xl); - } - - &__description { - font-size: var(--font-size-lg); - } -} \ No newline at end of file + } +} diff --git a/src/app/features/landing/components/features-section/features-section.component.ts b/src/app/features/landing/components/features-section/features-section.component.ts index 32e519d..e6e3f69 100644 --- a/src/app/features/landing/components/features-section/features-section.component.ts +++ b/src/app/features/landing/components/features-section/features-section.component.ts @@ -1,40 +1,81 @@ -import { Component } from '@angular/core'; +import { + AfterViewInit, + Component, + ElementRef, + PLATFORM_ID, + QueryList, + ViewChildren, + inject, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { cssCode, cssLock, cssDatabase, cssBrowser } from '@ng-icons/css.gg'; +import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive'; -interface Features { - id: number, - claim: string, - description: string, - icon?: string, - iconDescription?: string +interface Feature { + id: number; + claim: string; + description: string; + icon: string; } @Component({ selector: 'app-features-section', - imports: [], + imports: [NgIcon, OpenPanelTrackDirective], + viewProviders: [provideIcons({ cssCode, cssLock, cssDatabase, cssBrowser })], templateUrl: './features-section.component.html', styleUrl: './features-section.component.scss', }) -export class FeaturesSectionComponent { - featuresList: Features[] = [ +export class FeaturesSectionComponent implements AfterViewInit { + @ViewChildren('cardRef') cardElements!: QueryList>; + private readonly platformId = inject(PLATFORM_ID); + + featuresList: Feature[] = [ { id: 1, - claim: "Code statt Baukasten", - description: "Handgefertigte Performance, die Google und Ihre Nutzer lieben werden." + claim: 'Code statt Baukasten', + description: + 'Handgefertigter Code statt träger WordPress-Templates. Ihre Seite lädt in unter einer Sekunde – und das merken Google und Ihre Besucher.', + icon: 'cssCode', }, { id: 2, - claim: "Sicher per Design", - description: "Maximale Rechtskonformität durch eRecht24 und hauseigene Server-Infrastruktur." + claim: 'Sicher per Design', + description: + 'Kein Plugin-Dschungel, keine veralteten CMS-Versionen. Maximale Rechtskonformität durch eRecht24-Integration und eine klar strukturierte Infrastruktur.', + icon: 'cssLock', }, { id: 3, - claim: "Heimat für Ihre Daten", - description: "Hosting und Services strikt nach europäischem Datenschutzstandard." + claim: 'Heimat für Ihre Daten', + description: + 'Hosting und alle Services laufen ausschließlich auf europäischen Servern – vollständig DSGVO-konform und ohne US-Cloudabhängigkeit.', + icon: 'cssDatabase', }, { id: 4, - claim: "Alles im Blick", - description: "Ein Portal für alles: Kommunikation, Verwaltung und Erfolgskontrolle" + claim: 'Alles im Blick', + description: + 'Ein Verwaltungsportal für alles: Inhalte pflegen, Anfragen verwalten und Ihren Webauftritt jederzeit selbst aktualisieren – ohne Programmierkenntnisse.', + icon: 'cssBrowser', }, - ] + ]; + + ngAfterViewInit(): void { + if (!isPlatformBrowser(this.platformId)) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.1 } + ); + + this.cardElements.forEach((el) => observer.observe(el.nativeElement)); + } } diff --git a/src/app/features/landing/components/footer/footer.component.html b/src/app/features/landing/components/footer/footer.component.html index 2d48577..1f22aeb 100644 --- a/src/app/features/landing/components/footer/footer.component.html +++ b/src/app/features/landing/components/footer/footer.component.html @@ -1,8 +1,44 @@ -
- +
diff --git a/src/app/features/landing/components/footer/footer.component.scss b/src/app/features/landing/components/footer/footer.component.scss index c0ff0b6..1490acc 100644 --- a/src/app/features/landing/components/footer/footer.component.scss +++ b/src/app/features/landing/components/footer/footer.component.scss @@ -1,11 +1,102 @@ @use 'abstracts'; .footer { - background-color: var(--accent); - color: var(--bg-surface); - padding: 20px 0; + background-color: var(--accent); + color: var(--text-on-accent); + padding-top: var(--space-4); - &__wrapper { - @include abstracts.container-wrapper; + &__wrapper { + @include abstracts.container-wrapper; + } + + &__grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); + padding-bottom: var(--space-4); + + @include abstracts.breakpoint('md') { + grid-template-columns: 2fr 1fr 1fr; } -} \ No newline at end of file + } + + &__col-title { + font-size: var(--font-size-base); + font-weight: 700; + margin-bottom: var(--space-3); + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__logo { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-lg); + font-weight: 700; + margin-bottom: var(--space-3); + } + + &__logo-icon { + display: flex; + align-items: center; + justify-content: center; + width: abstracts.rem(32); + height: abstracts.rem(32); + background-color: oklch(100% 0 0 / 0.2); + border: 1px solid oklch(100% 0 0 / 0.3); + border-radius: 5px; + font-weight: 700; + } + + &__logo-accent { + // "Hurler" in slightly brighter shade for contrast on accent bg + opacity: 0.85; + } + + &__tagline { + font-size: var(--font-size-base); + line-height: 1.6; + opacity: 0.8; + margin-bottom: var(--space-3); + max-width: 34ch; + } + + &__address { + font-size: var(--font-size-base); + font-style: normal; + line-height: 1.7; + opacity: 0.7; + } + + &__links { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + + a { + font-size: var(--font-size-base); + opacity: 0.8; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + } + } + + &__bottom { + border-top: 1px solid oklch(100% 0 0 / 0.15); + padding-block: var(--space-3); + text-align: center; + + p { + font-size: 0.85rem; + opacity: 0.6; + } + } +} diff --git a/src/app/features/landing/components/footer/footer.component.ts b/src/app/features/landing/components/footer/footer.component.ts index ba1c9c2..2da475f 100644 --- a/src/app/features/landing/components/footer/footer.component.ts +++ b/src/app/features/landing/components/footer/footer.component.ts @@ -1,11 +1,13 @@ import { Component } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive'; @Component({ selector: 'app-footer', - imports: [], + imports: [RouterModule, OpenPanelTrackDirective], templateUrl: './footer.component.html', styleUrl: './footer.component.scss', }) export class FooterComponent { - + readonly currentYear = new Date().getFullYear(); } diff --git a/src/app/features/landing/components/hero/hero.component.html b/src/app/features/landing/components/hero/hero.component.html index 9992481..f49d509 100644 --- a/src/app/features/landing/components/hero/hero.component.html +++ b/src/app/features/landing/components/hero/hero.component.html @@ -1,23 +1,31 @@
-

- Digitales Handwerk
+ Digitales Handwerk
statt Standard-Baukasten

- Wir programmieren Blitzschnelle, sichere und maßgeschneiderte Webseiten für kleine, - mittelständische Unternehmen und Vereine. Ohne CMS-Ballast, dafür mit maximaler Performance. + Wir programmieren blitzschnelle, sichere und maßgeschneiderte Webseiten + für kleine Unternehmen und Vereine – ohne CMS-Ballast, dafür mit maximaler Performance.

-
-
\ No newline at end of file + diff --git a/src/app/features/landing/components/hero/hero.component.scss b/src/app/features/landing/components/hero/hero.component.scss index ec339c0..1b3021c 100644 --- a/src/app/features/landing/components/hero/hero.component.scss +++ b/src/app/features/landing/components/hero/hero.component.scss @@ -1,7 +1,7 @@ @use "abstracts"; .hero-section { - position: relative; // WICHTIG: Bezugspunkt für das Video + position: relative; min-height: calc(100vh + var(--nav-height)); margin-top: var(--neg-nav-height); overflow: hidden; @@ -21,6 +21,10 @@ &__wrapper { @include abstracts.container-wrapper; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; } &__video-container { @@ -29,19 +33,20 @@ left: 0; width: 100%; height: 100%; - z-index: -2; // Hinter den Text legen + z-index: -2; } - h1 { - color: var(--text-main); // Dein Wunsch-Style + &__header { + color: var(--text-main); font-size: var(--font-size-xxl); - position: relative; // Stellt sicher, dass der Text über dem Video-Layer bleibt + position: relative; margin-bottom: var(--space-4); } &__claim { color: var(--text-main); - font-size: var(--font-size-xl); + font-size: var(--font-size-lg); + max-width: 60ch; margin-bottom: var(--space-4); } @@ -50,18 +55,15 @@ flex-direction: row; gap: var(--space-2); justify-content: center; + flex-wrap: wrap; } video { - /* Das hier ist der entscheidende Teil */ width: 100%; height: 100%; - object-fit: cover; // WICHTIG: Füllt den Container komplett aus, ohne zu verzerren - object-position: center; // Zentriert das Video, falls Ränder abgeschnitten werden - mask-image: linear-gradient(to bottom, - black 0%, - black 70%, - transparent 100%); + object-fit: cover; + object-position: center; + mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%); } -} \ No newline at end of file +} diff --git a/src/app/features/landing/components/hero/hero.component.ts b/src/app/features/landing/components/hero/hero.component.ts index aed2583..b8af56d 100644 --- a/src/app/features/landing/components/hero/hero.component.ts +++ b/src/app/features/landing/components/hero/hero.component.ts @@ -1,17 +1,11 @@ import { Component } from '@angular/core'; import { ButtonComponent } from '@shared/ui/button/button.component'; -import { UmamiService } from '@core/services/umami.service'; +import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive'; @Component({ selector: 'app-hero', - imports: [ButtonComponent], + imports: [ButtonComponent, OpenPanelTrackDirective], templateUrl: './hero.component.html', styleUrl: './hero.component.scss', }) -export class HeroComponent { - constructor(private umami: UmamiService) {} - - onFeaturesClick(): void { - this.umami.trackEvent('features-anchor-click') - } -} +export class HeroComponent {} diff --git a/src/app/features/landing/components/navigation/navigation.component.html b/src/app/features/landing/components/navigation/navigation.component.html index 2106f31..05a9949 100644 --- a/src/app/features/landing/components/navigation/navigation.component.html +++ b/src/app/features/landing/components/navigation/navigation.component.html @@ -2,7 +2,7 @@
-

Hurler Webdesign

+

Hurler Webdesign

@@ -14,5 +14,4 @@
- - \ No newline at end of file + diff --git a/src/app/features/landing/components/navigation/navigation.component.scss b/src/app/features/landing/components/navigation/navigation.component.scss index 265138b..f286b6d 100644 --- a/src/app/features/landing/components/navigation/navigation.component.scss +++ b/src/app/features/landing/components/navigation/navigation.component.scss @@ -29,6 +29,7 @@ .logo-container { display: flex; align-items: center; + justify-items: center; gap: var(--space-2); padding-left: var(--space-4); @@ -48,6 +49,7 @@ font-size: var(--font-size-base); font-weight: 700; color: var(--text-main); + margin: auto; span { color: var(--accent); diff --git a/src/app/features/landing/components/navigation/navigation.component.ts b/src/app/features/landing/components/navigation/navigation.component.ts index f646cfe..9f7d4ca 100644 --- a/src/app/features/landing/components/navigation/navigation.component.ts +++ b/src/app/features/landing/components/navigation/navigation.component.ts @@ -3,20 +3,13 @@ import { NgIcon } from '@ng-icons/core'; import { ToogleThemeComponent } from '@shared/utils/toogle-theme/toogle-theme.component'; import { NavMenuComponent } from '@shared/ui/nav-menu/nav-menu.component'; import { NavigationService } from '@core/services/navigation.service'; -import { OpenPanelService } from '@core/services/openpanel.service'; -import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive'; @Component({ selector: 'app-navigation', - imports: [NgIcon, ToogleThemeComponent, NavMenuComponent, OpenPanelTrackDirective], + imports: [NgIcon, ToogleThemeComponent, NavMenuComponent], templateUrl: './navigation.component.html', styleUrl: './navigation.component.scss', }) export class NavigationComponent { protected readonly navigationService = inject(NavigationService); - private op = inject(OpenPanelService) - - onFeaturesClick(blindplan: string): void { - this.op.track('features_clicked', { blindplan }) - } } diff --git a/src/app/features/landing/components/pricing/pricing.component.html b/src/app/features/landing/components/pricing/pricing.component.html index 310d99e..1470e44 100644 --- a/src/app/features/landing/components/pricing/pricing.component.html +++ b/src/app/features/landing/components/pricing/pricing.component.html @@ -1 +1,39 @@ -

pricing works!

+
+
+
+

Preise & Pakete

+

Transparente Preise – kein Abo, kein Versteckspiel.

+
+
+ @for (tier of tiers; track tier.id) { +
+ @if (tier.highlighted) { + Beliebteste Wahl + } +
+

{{ tier.name }}

+
{{ tier.price }}
+

{{ tier.priceNote }}

+
+

{{ tier.description }}

+
    + @for (feature of tier.features; track $index) { +
  • + + {{ feature }} +
  • + } +
+
+ + +
+
+ } +
+
+
diff --git a/src/app/features/landing/components/pricing/pricing.component.scss b/src/app/features/landing/components/pricing/pricing.component.scss index e69de29..0cafdfb 100644 --- a/src/app/features/landing/components/pricing/pricing.component.scss +++ b/src/app/features/landing/components/pricing/pricing.component.scss @@ -0,0 +1,142 @@ +@use 'abstracts'; + +.pricing { + min-height: 100vh; + display: flex; + align-items: center; + padding-block: var(--space-4); + background-color: var(--bg-muted); + + &__wrapper { + @include abstracts.container-wrapper; + width: 100%; + } + + &__header { + text-align: center; + margin-bottom: var(--space-4); + + h2 { + font-size: var(--font-size-xl); + margin-bottom: var(--space-2); + } + + p { + font-size: var(--font-size-lg); + } + } + + &__grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-3); + align-items: start; + + @include abstracts.breakpoint('md') { + grid-template-columns: repeat(3, 1fr); + align-items: stretch; + } + } + + &__card { + position: relative; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--space-4) var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-3); + background-color: var(--bg-surface); + transition: box-shadow 0.2s ease; + + &:hover { + box-shadow: 0 8px 32px oklch(0% 0 0 / 0.08); + } + + &--highlighted { + border-color: var(--accent); + box-shadow: 0 4px 24px oklch(45% 0.22 250 / 0.15); + + &:hover { + box-shadow: 0 8px 32px oklch(45% 0.22 250 / 0.2); + } + } + } + + &__badge { + position: absolute; + top: -1px; + left: 50%; + transform: translateX(-50%) translateY(-50%); + background-color: var(--accent); + color: var(--text-on-accent); + font-size: 0.75rem; + font-weight: 600; + padding: 4px 12px; + border-radius: 999px; + white-space: nowrap; + } + + &__card-header { + text-align: center; + } + + &__tier-name { + font-size: var(--font-size-lg); + font-weight: 700; + margin-bottom: var(--space-2); + } + + &__price { + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--accent); + margin-bottom: var(--space-1); + } + + &__price-note { + font-size: 0.8rem; + color: var(--text-muted); + } + + &__description { + font-size: var(--font-size-base); + color: var(--text-muted); + line-height: 1.6; + text-align: center; + } + + &__features { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + flex: 1; + } + + &__feature-item { + display: flex; + align-items: flex-start; + gap: var(--space-2); + font-size: var(--font-size-base); + line-height: 1.5; + } + + &__check { + color: var(--accent); + font-weight: 700; + flex-shrink: 0; + } + + &__cta { + display: flex; + justify-content: center; + margin-top: auto; + + app-button { + width: 100%; + } + } +} diff --git a/src/app/features/landing/components/pricing/pricing.component.ts b/src/app/features/landing/components/pricing/pricing.component.ts index d38f3d1..e986150 100644 --- a/src/app/features/landing/components/pricing/pricing.component.ts +++ b/src/app/features/landing/components/pricing/pricing.component.ts @@ -1,11 +1,76 @@ import { Component } from '@angular/core'; +import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive'; +import { ButtonComponent } from '@shared/ui/button/button.component'; + +interface PricingTier { + id: string; + name: string; + price: string; + priceNote: string; + description: string; + features: string[]; + cta: string; + highlighted: boolean; +} @Component({ selector: 'app-pricing', - imports: [], + imports: [OpenPanelTrackDirective, ButtonComponent], templateUrl: './pricing.component.html', styleUrl: './pricing.component.scss', }) export class PricingComponent { - + tiers: PricingTier[] = [ + { + id: 'starter', + name: 'Starter', + price: '799 €', + priceNote: 'einmalig zzgl. MwSt.', + description: 'Ideal für Handwerker und Vereine mit klarem Fokus auf eine starke Online-Präsenz.', + features: [ + '1-Pager / Landingpage', + 'Individuelles Design', + 'Suchmaschinenoptimierung (SEO)', + 'Kontaktformular', + 'Cookie-Banner & Datenschutz', + '12 Monate Hosting inklusive', + ], + cta: 'Jetzt anfragen', + highlighted: false, + }, + { + id: 'business', + name: 'Business', + price: '1.499 €', + priceNote: 'einmalig zzgl. MwSt.', + description: 'Für Unternehmen, die mehr wollen: mehrere Seiten, eigenes CMS-Portal und Analysen.', + features: [ + 'Mehrseiter (bis 5 Seiten)', + 'Alles aus Starter', + 'Verwaltungsportal (CMS)', + 'Blog / Neuigkeiten', + 'Performance-Analyse', + 'Prioritäts-Support', + ], + cta: 'Jetzt anfragen', + highlighted: true, + }, + { + id: 'individual', + name: 'Individual', + price: 'Auf Anfrage', + priceNote: 'individuelles Angebot', + description: 'Shops, Web-Applikationen, API-Anbindungen – wir setzen komplexe Projekte um.', + features: [ + 'Online-Shops', + 'Web-Applikationen', + 'API-Integration', + 'Individuelle Funktionen', + 'Langfristige Betreuung', + 'Auf Ihre Bedürfnisse zugeschnitten', + ], + cta: 'Kontakt aufnehmen', + highlighted: false, + }, + ]; } diff --git a/src/app/features/landing/components/projects/projects.component.html b/src/app/features/landing/components/projects/projects.component.html index 107296c..de8b58f 100644 --- a/src/app/features/landing/components/projects/projects.component.html +++ b/src/app/features/landing/components/projects/projects.component.html @@ -1,20 +1,27 @@
-
-
- @for(project of projects; track project.id) { -
- -
-

{{ project.company }}

-

{{ project.shortDescription }}

-
- @for(feature of project.features; track $index) { -

{{ feature }}

- } -
-
-
- } -
+
+
+

Unsere Projekte

+

Echte Webseiten für echte Unternehmen – sehen Sie selbst.

-
\ No newline at end of file +
+ @for (project of projects; track project.id) { +
+ +
+

{{ project.company }}

+

{{ project.shortDescription }}

+
+ @for (feature of project.features; track $index) { + {{ feature }} + } +
+
+
+ } +
+ + diff --git a/src/app/features/landing/components/projects/projects.component.scss b/src/app/features/landing/components/projects/projects.component.scss index 166fa6c..5baacb2 100644 --- a/src/app/features/landing/components/projects/projects.component.scss +++ b/src/app/features/landing/components/projects/projects.component.scss @@ -1,45 +1,99 @@ @use 'abstracts'; .projects { + min-height: 100vh; + display: flex; + align-items: center; + padding-block: var(--space-4); + + &__wrapper { + @include abstracts.container-wrapper; + width: 100%; + } + + &__header { + text-align: center; + margin-bottom: var(--space-4); + + h2 { + font-size: var(--font-size-xl); + margin-bottom: var(--space-2); + } + + p { + font-size: var(--font-size-lg); + } + } + + &__card-container { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-3); + + @include abstracts.breakpoint('md') { + grid-template-columns: repeat(3, 1fr); + } + } + + &__card { + position: relative; + border-radius: var(--border-radius); + overflow: hidden; + aspect-ratio: 4 / 3; + background-color: var(--bg-muted); + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.4s ease; + } + + &:hover img { + transform: scale(1.04); + } + + &__overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: var(--space-3); + background: linear-gradient(to top, oklch(0% 0 0 / 0.8) 0%, transparent 60%); + opacity: 0; + transition: opacity 0.3s ease-in-out; + color: var(--color-white); + + h3 { + font-size: var(--font-size-lg); + margin-bottom: var(--space-1); + } + + p { + font-size: var(--font-size-base); + margin-bottom: var(--space-2); + opacity: 0.85; + } + } + + &:hover &__overlay { + opacity: 1; + } + } + + &__card-features { display: flex; - min-height: 100vh; - margin-top: var(--neg-nav-height); - align-items: center; + flex-wrap: wrap; + gap: var(--space-1); + } - &__wrapper { - @include abstracts.container-wrapper; - } - - &__card-container { - display: flex; - gap: var(--space-3); - } - - &__card { - position: relative; - border-radius: var(--border-radius); - overflow: hidden; - - &__description { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - justify-content: flex-end; - padding: var(--space-2); - background: rgba(0, 0, 0, 0.7); - opacity: 0; - transition: opacity 0.3s ease-in-out; - } - - &:hover &__description { - opacity: 1; - color: var(--color-white); - } - } - - &__card-features { - display: flex; - gap: var(--space-2); - } -} \ No newline at end of file + &__tag { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid oklch(100% 0 0 / 0.4); + color: var(--color-white); + } +} diff --git a/src/app/features/landing/components/projects/projects.component.ts b/src/app/features/landing/components/projects/projects.component.ts index 51f3ca2..3a5bce1 100644 --- a/src/app/features/landing/components/projects/projects.component.ts +++ b/src/app/features/landing/components/projects/projects.component.ts @@ -1,16 +1,17 @@ import { Component } from '@angular/core'; +import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive'; interface Project { - id: number, - image: string, - company: string, - shortDescription: string, - features: string[] + id: number; + image: string; + company: string; + shortDescription: string; + features: string[]; } @Component({ selector: 'app-projects', - imports: [], + imports: [OpenPanelTrackDirective], templateUrl: './projects.component.html', styleUrl: './projects.component.scss', }) @@ -18,24 +19,24 @@ export class ProjectsComponent { projects: Project[] = [ { id: 1, - company: "Backerei Müller", - image: "/images/bakery.jpg", - shortDescription: "Landingpage mit wechselnden Angeboten", - features: ["SEO", "Angebote", "Dark/Light"], + company: 'Schreiner Müller GmbH', + image: '/images/schreiner-mueller.jpg', + shortDescription: 'Handwerkswebsite mit Leistungsübersicht und Kontaktformular', + features: ['SEO', 'Kontaktformular', 'Dark/Light'], }, { id: 2, - company: "Backerei Müller", - image: "/images/bakery.jpg", - shortDescription: "Landingpage mit wechselnden Angeboten", - features: ["SEO", "Angebote", "Dark/Light"], + company: 'Schützenverein Nördlingen e.V.', + image: '/images/schuetzenverein.jpg', + shortDescription: 'Vereinswebsite mit Terminen und Veranstaltungskalender', + features: ['SEO', 'Termine', 'Mitglieder'], }, { id: 3, - company: "Backerei Müller", - image: "/images/bakery.jpg", - shortDescription: "Landingpage mit wechselnden Angeboten", - features: ["SEO", "Angebote", "Dark/Light"], - } - ] + company: 'Bäckerei Huber', + image: '/images/baeckerei-huber.jpg', + shortDescription: 'Landingpage mit täglich wechselnden Tagesangeboten', + features: ['SEO', 'Angebote', 'Responsive'], + }, + ]; } diff --git a/src/app/features/landing/pages/landingpage.component.html b/src/app/features/landing/pages/landingpage.component.html index 094f9d2..cd174e0 100644 --- a/src/app/features/landing/pages/landingpage.component.html +++ b/src/app/features/landing/pages/landingpage.component.html @@ -2,4 +2,5 @@ - \ No newline at end of file + + diff --git a/src/app/features/landing/pages/landingpage.component.ts b/src/app/features/landing/pages/landingpage.component.ts index 940c997..61400c1 100644 --- a/src/app/features/landing/pages/landingpage.component.ts +++ b/src/app/features/landing/pages/landingpage.component.ts @@ -4,6 +4,7 @@ import { HeroComponent } from '../components/hero/hero.component'; import { FeaturesSectionComponent } from '../components/features-section/features-section.component'; import { FooterComponent } from '../components/footer/footer.component'; import { ProjectsComponent } from '../components/projects/projects.component'; +import { PricingComponent } from '../components/pricing/pricing.component'; import { SeoService } from '@core/services/seo.service'; @Component({ @@ -13,8 +14,9 @@ import { SeoService } from '@core/services/seo.service'; HeroComponent, FeaturesSectionComponent, ProjectsComponent, + PricingComponent, FooterComponent, -], + ], templateUrl: './landingpage.component.html', styleUrl: './landingpage.component.scss', }) diff --git a/src/app/shared/ui/nav-menu/nav-menu.component.html b/src/app/shared/ui/nav-menu/nav-menu.component.html index 7890e8e..ca4bd6f 100644 --- a/src/app/shared/ui/nav-menu/nav-menu.component.html +++ b/src/app/shared/ui/nav-menu/nav-menu.component.html @@ -1,27 +1,28 @@ + \ No newline at end of file diff --git a/src/environments/environment.directus.ts b/src/environments/environment.directus.ts new file mode 100644 index 0000000..3d36ad2 --- /dev/null +++ b/src/environments/environment.directus.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + directusUrl: 'https://backend.hurler-webdesign.de', +}; \ No newline at end of file diff --git a/src/environments/openpanel.ts b/src/environments/openpanel.ts index d65dd15..35bc031 100644 --- a/src/environments/openpanel.ts +++ b/src/environments/openpanel.ts @@ -1,9 +1,8 @@ // environment.ts export const environment = { production: false, - secret: "sec_4aa70c091e704023c6df", openPanel: { - clientId: '727b9649-26ac-4083-96ea-92c3a60fe7a8', - apiUrl: 'https://analytics.hurler-webdesign.de/api', // oder self-hosted URL + clientId: 'c0e6dcf4-3eca-4b0b-a631-a93aa5df1477', + apiUrl: 'https://data.hurler-webdesign.de', } }; \ No newline at end of file diff --git a/src/index.html b/src/index.html index 5f752bc..0430343 100644 --- a/src/index.html +++ b/src/index.html @@ -1,14 +1,16 @@ + HurlerWebdesignSaas - + - + + \ No newline at end of file