Compare commits
46 Commits
06281a9093
...
main
Author | SHA1 | Date | |
---|---|---|---|
6257a223b2 | |||
b2fbe18214 | |||
b75422716b | |||
83a11a45fb | |||
7d0e9ee8f6 | |||
526d296995 | |||
6e6bfb67b1 | |||
|
5bc2197182 | ||
|
a9a1404386 | ||
|
0b59b3b977 | ||
|
4fbfcc5855 | ||
|
2f65102da4 | ||
|
b5b5b80af1 | ||
|
e8833ce52b | ||
|
bbf85be7c8 | ||
|
4bbaa000bb | ||
|
619dfd17ad | ||
|
0c3651ccbc | ||
babba0adc0 | |||
6403efa64c | |||
b19fdbbc0c | |||
1d609b303f | |||
d046a6f4fd | |||
658881124f | |||
|
63f5c24590 | ||
|
63143d0270 | ||
|
18f4f018a2 | ||
f099b80fc3 | |||
6ec4b2c604 | |||
cf24268751 | |||
966d946c77 | |||
efdd0c28d4 | |||
2cb5f4bf24 | |||
28de900b95 | |||
246edb3952 | |||
559bd3e983 | |||
e19dc8eb3e | |||
49fabddc96 | |||
cb64f7f76c | |||
dde3fb1923 | |||
3847f415d6 | |||
0b16ad5a28 | |||
851759d16b | |||
fd2e4e8104 | |||
934da2be73 | |||
fe93eb716f |
@@ -8,11 +8,12 @@ import tailwind from '@astrojs/tailwind';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import react from '@astrojs/react';
|
||||
import partytown from '@astrojs/partytown';
|
||||
import node from '@astrojs/node';
|
||||
import icon from 'astro-icon';
|
||||
import compress from 'astro-compress';
|
||||
import type { AstroIntegration } from 'astro';
|
||||
import astrowind from './vendor/integration';
|
||||
import node from '@astrojs/node';
|
||||
import prefetch from '@astrojs/prefetch';
|
||||
|
||||
import { readingTimeRemarkPlugin, responsiveTablesRehypePlugin, lazyImagesRehypePlugin } from './src/utils/frontmatter';
|
||||
|
||||
@@ -79,12 +80,14 @@ export default defineConfig({
|
||||
Image: true,
|
||||
JavaScript: true,
|
||||
SVG: false,
|
||||
Logger: 1,
|
||||
Logger: 0,
|
||||
}),
|
||||
|
||||
astrowind({
|
||||
config: './src/config.yaml',
|
||||
}),
|
||||
|
||||
prefetch(),
|
||||
],
|
||||
|
||||
image: {
|
||||
@@ -97,6 +100,9 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
vite: {
|
||||
build: {
|
||||
minify: 'esbuild',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.resolve(__dirname, './src'),
|
||||
|
723
package-lock.json
generated
723
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0-beta.50",
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.2.2",
|
||||
"@astrojs/prefetch": "^0.4.1",
|
||||
"@astrojs/react": "^4.2.0",
|
||||
"@astrojs/rss": "^4.0.11",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
@@ -22,8 +23,10 @@
|
||||
"astro": "^5.2.3",
|
||||
"astro-embed": "^0.9.0",
|
||||
"astro-icon": "^1.1.5",
|
||||
"compression": "^1.7.4",
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.19.2",
|
||||
"form-data": "^4.0.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"limax": "4.1.0",
|
||||
@@ -67,7 +70,7 @@
|
||||
"sharp": "0.33.5",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.21.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
@@ -388,6 +391,16 @@
|
||||
"mrmime": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/prefetch": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/prefetch/-/prefetch-0.4.1.tgz",
|
||||
"integrity": "sha512-bpC875BqeIuWVgqhi4X814ftzzbKocaLkiZczaj8k5J2SRpueIGkww3XmD+yY/Ekkm9j30aS3neVO6wSm4IJNA==",
|
||||
"deprecated": "@astrojs/prefetch is deprecated in favor of the builtin prefetch option. Please see the migration guide for more information: https://docs.astro.build/en/guides/prefetch/#migrating-from-astrojsprefetch",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"throttles": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/prism": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz",
|
||||
@@ -3398,6 +3411,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts/node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -3564,6 +3599,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-iterate": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz",
|
||||
@@ -3956,6 +3997,57 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
@@ -4076,6 +4168,15 @@
|
||||
"integrity": "sha512-IxLEnfsCYLjlpf6mG7SWpWgA4A8IAT5dAX3FxXHFn+6FTLf3ums771elQ74sj1BCOVanBf6esu0rEC6zgwfmIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -4089,6 +4190,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -4537,6 +4654,51 @@
|
||||
"integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz",
|
||||
"integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.0.2",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -4550,6 +4712,27 @@
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -4571,6 +4754,12 @@
|
||||
"integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
@@ -4818,6 +5007,16 @@
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
@@ -5666,6 +5865,127 @@
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz",
|
||||
@@ -5865,6 +6185,48 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "2.0.1",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -6004,6 +6366,15 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -6760,6 +7131,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/iron-webcrypto": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
|
||||
@@ -7984,6 +8364,24 @@
|
||||
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -7994,6 +8392,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -8745,6 +9152,18 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
@@ -8917,6 +9336,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/neotraverse": {
|
||||
"version": "0.6.18",
|
||||
"resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz",
|
||||
@@ -9066,6 +9494,18 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ofetch": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz",
|
||||
@@ -9095,6 +9535,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
|
||||
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -9352,6 +9801,15 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/pascal-case": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
|
||||
@@ -9431,6 +9889,12 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
@@ -9749,6 +10213,19 @@
|
||||
"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",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -9775,6 +10252,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
|
||||
@@ -9842,6 +10334,33 @@
|
||||
"integrity": "sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
@@ -10507,6 +11026,87 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static/node_modules/debug/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static/node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static/node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static/node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/server-destroy": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
|
||||
@@ -10608,6 +11208,78 @@
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -11255,6 +11927,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/throttles": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/throttles/-/throttles-1.0.1.tgz",
|
||||
"integrity": "sha512-fab7Xg+zELr9KOv4fkaBoe/b3L0GMGLd0IBSCn16GoE/Qx6/OfCr1eGNyEcDU2pUA79qQfZ8kPQWlRuok4YwTw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
@@ -11435,6 +12116,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typesafe-path": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
|
||||
@@ -11781,6 +12475,15 @@
|
||||
"integrity": "sha512-NFhB8HgHHWkNzTxwWg6KHx8+3RZnhWFm4Axdqp9iI176iY3wskzfP16NRSJ2SSTfXzyK4W6GsBqs8iOOdvOB3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/unstorage": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.16.0.tgz",
|
||||
@@ -11926,6 +12629,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
11
package.json
11
package.json
@@ -9,20 +9,21 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"start": "node server.js",
|
||||
"astro": "astro",
|
||||
"check": "npm run check:astro && npm run check:eslint && npm run check:prettier",
|
||||
"check:astro": "astro check",
|
||||
"check:eslint": "eslint .",
|
||||
"check:eslint": "npx eslint .",
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix": "npm run fix:eslint && npm run fix:prettier",
|
||||
"fix:eslint": "eslint --fix .",
|
||||
"fix:eslint": "npx eslint --fix .",
|
||||
"fix:prettier": "prettier -w ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.2.2",
|
||||
"@astrojs/prefetch": "^0.4.1",
|
||||
"@astrojs/react": "^4.2.0",
|
||||
"@astrojs/rss": "^4.0.11",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
@@ -36,8 +37,10 @@
|
||||
"astro": "^5.2.3",
|
||||
"astro-embed": "^0.9.0",
|
||||
"astro-icon": "^1.1.5",
|
||||
"compression": "^1.7.4",
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.19.2",
|
||||
"form-data": "^4.0.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"limax": "4.1.0",
|
||||
@@ -81,7 +84,7 @@
|
||||
"sharp": "0.33.5",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.21.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
}
|
||||
|
29
server.js
Normal file
29
server.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import { handler } from './dist/server/entry.mjs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Enable gzip/brotli compression
|
||||
app.use(compression());
|
||||
|
||||
// Serve static assets from the dist/client directory
|
||||
app.use(express.static(path.join(__dirname, 'dist/client')));
|
||||
|
||||
// Handle all SSR requests with Astro's handler
|
||||
app.all('*', handler);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const domain = isProduction ? '365devnet.eu' : `localhost:${PORT}`;
|
||||
const protocol = isProduction ? 'https' : 'http';
|
||||
|
||||
console.log(`Server running with compression on ${protocol}://${domain}`);
|
||||
if (!isProduction) {
|
||||
console.log(`Development server accessible at http://localhost:${PORT}`);
|
||||
}
|
||||
});
|
@@ -26,11 +26,18 @@
|
||||
.text-muted {
|
||||
color: var(--aw-color-text-muted);
|
||||
}
|
||||
|
||||
/* Responsive subtitle utility */
|
||||
.subtitle-responsive {
|
||||
@apply text-base md:text-lg lg:text-xl;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-full border-gray-400 border bg-transparent font-medium text-center text-base text-page leading-snug transition py-3.5 px-6 md:px-8 ease-in duration-200 focus:ring-blue-500 focus:ring-offset-blue-200 focus:ring-2 focus:ring-offset-2 hover:bg-gray-100 hover:border-gray-600 dark:text-slate-300 dark:border-slate-500 dark:hover:bg-slate-800 dark:hover:border-slate-800 cursor-pointer;
|
||||
/* Enhanced visibility for light mode */
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -39,6 +46,24 @@
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn;
|
||||
/* Enhanced visibility for light mode secondary buttons */
|
||||
color: rgb(16 16 16);
|
||||
border-color: rgb(156 163 175);
|
||||
}
|
||||
|
||||
/* Light mode specific enhancements for secondary buttons */
|
||||
html:not(.dark) .btn-secondary {
|
||||
color: rgb(16 16 16) !important;
|
||||
border-color: rgb(156 163 175) !important;
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .btn-secondary:hover {
|
||||
background-color: rgb(243 244 246) !important;
|
||||
border-color: rgb(107 114 128) !important;
|
||||
color: rgb(16 16 16) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.btn-tertiary {
|
||||
|
@@ -1,17 +1,29 @@
|
||||
<form id="contact-form">
|
||||
<input type="email" name="email" id="email" required />
|
||||
<textarea name="message" id="message" required></textarea>
|
||||
<input type="hidden" name="csrf_token" id="csrf_token" />
|
||||
<input type="hidden" name="domain" id="domain" />
|
||||
<label for="email">Email</label>
|
||||
<input type="email" name="email" id="email" required aria-describedby="email-help" />
|
||||
<div id="email-help" class="sr-only">Enter your email address</div>
|
||||
<label for="message">Message</label>
|
||||
<textarea name="message" id="message" required aria-describedby="message-help"></textarea>
|
||||
<div id="message-help" class="sr-only">Enter your message</div>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
|
||||
<div id="spam-warning" style="display:none;">
|
||||
<div id="form-feedback" role="alert" aria-live="assertive"></div>
|
||||
|
||||
<div id="spam-warning" style="display:none;" tabindex="-1">
|
||||
<p>
|
||||
Your message was detected as spam and was not sent.<br>
|
||||
If you believe this is a mistake, you can request a manual review.
|
||||
</p>
|
||||
<form id="manual-review-form">
|
||||
<input type="email" id="manual-email" readonly />
|
||||
<textarea id="manual-justification" placeholder="Why is this not spam? (optional)"></textarea>
|
||||
<label for="manual-email">Email</label>
|
||||
<input type="email" id="manual-email" readonly aria-describedby="manual-email-help" />
|
||||
<div id="manual-email-help" class="sr-only">Your email address.</div>
|
||||
<label for="manual-justification">Why is this not spam? (optional)</label>
|
||||
<textarea id="manual-justification" placeholder="Why is this not spam? (optional)" aria-describedby="manual-justification-help"></textarea>
|
||||
<div id="manual-justification-help" class="sr-only">Explain why your message is legitimate</div>
|
||||
<input type="hidden" id="manual-token" />
|
||||
<button type="submit">Request Manual Review</button>
|
||||
</form>
|
||||
@@ -19,18 +31,49 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const response = await fetch(window.location.origin + '/api/contact?csrf=true');
|
||||
const data = await response.json();
|
||||
const csrfTokenInput = document.getElementById('csrf_token');
|
||||
if (csrfTokenInput) {
|
||||
(csrfTokenInput as HTMLInputElement).value = data.csrfToken;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching CSRF token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function setDomainHiddenField() {
|
||||
const domainInput = document.getElementById('domain');
|
||||
if (domainInput) {
|
||||
(domainInput as HTMLInputElement).value = window.location.hostname;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchCsrfToken();
|
||||
setDomainHiddenField();
|
||||
});
|
||||
|
||||
const contactForm = document.getElementById('contact-form');
|
||||
if (contactForm) {
|
||||
const feedbackDiv = document.getElementById('form-feedback');
|
||||
|
||||
if (contactForm && feedbackDiv) {
|
||||
contactForm.onsubmit = async function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this as HTMLFormElement);
|
||||
const csrfTokenInput = document.getElementById('csrf_token') as HTMLInputElement | null;
|
||||
if (csrfTokenInput) {
|
||||
formData.append('csrf_token', csrfTokenInput.value);
|
||||
}
|
||||
let res, data;
|
||||
try {
|
||||
res = await fetch('/api/contact', { method: 'POST', body: formData });
|
||||
res = await fetch(window.location.origin + '/api/contact', { method: 'POST', body: formData });
|
||||
data = await res.json();
|
||||
console.log('Contact API response:', data);
|
||||
} catch (_err) {
|
||||
alert('Unexpected server response.');
|
||||
feedbackDiv.textContent = 'Unexpected server response.';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,14 +86,15 @@ if (contactForm) {
|
||||
spamWarning.style.display = 'block';
|
||||
manualEmail.value = String(formData.get('email'));
|
||||
manualToken.value = data.token;
|
||||
spamWarning.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'There was an error sending your message.');
|
||||
feedbackDiv.textContent = data.error || 'There was an error sending your message.';
|
||||
} else {
|
||||
alert('Your message was sent successfully!');
|
||||
feedbackDiv.textContent = 'Your message was sent successfully!';
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -68,7 +112,7 @@ if (manualReviewForm) {
|
||||
const justification = manualJustification.value;
|
||||
const token = manualToken.value;
|
||||
|
||||
const res = await fetch('/api/contact/manual-review', {
|
||||
const res = await fetch(window.location.origin + '/api/contact/manual-review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, justification, token }),
|
||||
|
@@ -10,7 +10,7 @@ function getCalendarDays() {
|
||||
|
||||
// Find the Monday that starts our 52-week period
|
||||
const startDate = new Date(todayUTC);
|
||||
startDate.setUTCDate(todayUTC.getUTCDate() - (52 * 7 - 1));
|
||||
startDate.setUTCDate(todayUTC.getUTCDate() - (51 * 7 - 1));
|
||||
|
||||
// Adjust startDate to the Monday of that week (using UTC day)
|
||||
const startDayOfWeek = startDate.getUTCDay(); // 0 = Sunday, 1 = Monday, etc.
|
||||
|
@@ -9,9 +9,14 @@ const t = getTranslation(lang);
|
||||
id="cookie-banner"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 p-4 content-backdrop shadow-lg transform transition-transform duration-300 translate-y-full"
|
||||
style="display: none;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cookie-banner-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="container mx-auto max-w-6xl flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-800 dark:text-gray-200 font-medium">
|
||||
<h2 id="cookie-banner-title" class="sr-only">Cookie Consent</h2>
|
||||
<p>
|
||||
{t.cookies.message}
|
||||
<a href={`/${lang}/privacy#cookie-usage`} class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
@@ -64,40 +69,20 @@ const t = getTranslation(lang);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check localStorage as a fallback
|
||||
try {
|
||||
if (localStorage && localStorage.getItem('cookieConsentAccepted') === 'true') {
|
||||
cookieBanner.style.display = 'none';
|
||||
// Also set the cookie for future visits
|
||||
setCookie('cookieConsentAccepted', 'true', 365);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error accessing localStorage:', e);
|
||||
// Continue checking cookies
|
||||
}
|
||||
|
||||
// Show the banner
|
||||
cookieBanner.style.display = 'block';
|
||||
|
||||
// Show the banner with a slight delay for better UX
|
||||
setTimeout(() => {
|
||||
cookieBanner.classList.remove('translate-y-full');
|
||||
cookieBanner.focus();
|
||||
}, 500);
|
||||
|
||||
// Handle accept button click
|
||||
acceptButton.addEventListener('click', () => {
|
||||
// Store consent in cookie (primary storage)
|
||||
// Store consent in cookie
|
||||
setCookie('cookieConsentAccepted', 'true', 365);
|
||||
|
||||
// Also store in localStorage as backup
|
||||
try {
|
||||
localStorage.setItem('cookieConsentAccepted', 'true');
|
||||
} catch (e) {
|
||||
console.error('Error setting localStorage:', e);
|
||||
// Continue with cookie storage
|
||||
}
|
||||
|
||||
// Hide the banner with animation
|
||||
cookieBanner.classList.add('translate-y-full');
|
||||
|
||||
|
@@ -38,6 +38,7 @@ import '@fontsource-variable/inter';
|
||||
--aw-color-text-heading: rgb(0 0 0);
|
||||
--aw-color-text-default: rgb(16 16 16);
|
||||
--aw-color-text-muted: rgb(0, 0, 0);
|
||||
--aw-color-text-page: rgb(16 16 16);
|
||||
--aw-color-bg-page: rgb(255 255 255);
|
||||
|
||||
--aw-color-bg-page-dark: rgb(3 6 32);
|
||||
@@ -59,6 +60,7 @@ import '@fontsource-variable/inter';
|
||||
--aw-color-text-heading: rgb(247, 248, 248);
|
||||
--aw-color-text-default: rgb(229 236 246);
|
||||
--aw-color-text-muted: rgb(229 236 246 / 85%);
|
||||
--aw-color-text-page: rgb(229 236 246);
|
||||
--aw-color-bg-page: rgb(3 6 32);
|
||||
|
||||
::selection {
|
||||
|
@@ -11,10 +11,10 @@ const { currentLang } = Astro.props;
|
||||
type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
const languages = [
|
||||
{ code: 'en' as SupportedLanguage, name: 'English', flag: 'gb' },
|
||||
{ code: 'nl' as SupportedLanguage, name: 'Dutch', flag: 'nl' },
|
||||
{ code: 'de' as SupportedLanguage, name: 'German', flag: 'de' },
|
||||
{ code: 'fr' as SupportedLanguage, name: 'French', flag: 'fr' },
|
||||
{ code: 'en' as SupportedLanguage, name: 'English\u00A0\u00A0', flag: 'gb' },
|
||||
{ code: 'nl' as SupportedLanguage, name: 'Nederlands', flag: 'nl' },
|
||||
{ code: 'de' as SupportedLanguage, name: 'Deutsch', flag: 'de' },
|
||||
{ code: 'fr' as SupportedLanguage, name: 'Français', flag: 'fr' },
|
||||
].filter((lang) => supportedLanguages.includes(lang.code));
|
||||
|
||||
const currentLanguage = languages.find((lang) => lang.code === currentLang) || languages[0];
|
||||
|
@@ -1,13 +1,39 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 900 120"
|
||||
fill="none"
|
||||
---
|
||||
// Logo.astro
|
||||
---
|
||||
|
||||
<div
|
||||
role="img"
|
||||
aria-label="365DevNet logo"
|
||||
class="h-14 w-auto block"
|
||||
class="flex flex-col h-14 justify-center"
|
||||
>
|
||||
<!-- Main Title -->
|
||||
<text x="0" y="65" font-size="80" font-weight="bold" fill="currentColor" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif">365DevNet</text>
|
||||
<!-- Tagline (single line, readable) -->
|
||||
<text x="0" y="110" font-size="25" fill="currentColor" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif">Helping freelancers & small businesses with Microsoft 365</text>
|
||||
</svg>
|
||||
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-600 via-blue-700 to-indigo-700 bg-clip-text text-transparent leading-tight">
|
||||
365DevNet
|
||||
</h1>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 font-medium leading-tight -mt-0.5">
|
||||
Helping freelancers & small businesses with Microsoft 365
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Add subtle glow effect */
|
||||
div[role="img"] {
|
||||
filter: drop-shadow(0 2px 4px rgba(59, 130, 246, 0.1));
|
||||
}
|
||||
|
||||
/* Ensure gradient works in all browsers */
|
||||
h1 {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
:global(.dark) h1 {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
Before Width: | Height: | Size: 657 B After Width: | Height: | Size: 1.0 KiB |
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const REFRESH_INTERVAL = 1800; // seconds
|
||||
const REFRESH_INTERVAL = 300; // seconds
|
||||
|
||||
export default function UpdateTimer({ onRefresh }) {
|
||||
const [secondsLeft, setSecondsLeft] = useState(REFRESH_INTERVAL);
|
||||
|
@@ -47,16 +47,73 @@ function getUptime24hBg(uptime) {
|
||||
|
||||
function formatLocalTime(rawTime, zone = 'utc') {
|
||||
if (!rawTime) return '';
|
||||
const dt = DateTime.fromFormat(rawTime, 'yyyy-MM-dd HH:mm:ss.SSS', { zone: 'utc' });
|
||||
const localDt = dt.isValid ? dt.setZone(zone) : null;
|
||||
return localDt && localDt.isValid
|
||||
? localDt.toFormat('dd-MM-yyyy, HH:mm:ss')
|
||||
: 'Invalid DateTime';
|
||||
|
||||
let dt = null;
|
||||
|
||||
// First try ISO parsing (most reliable for UTC timestamps)
|
||||
try {
|
||||
dt = DateTime.fromISO(rawTime, { zone: 'utc' });
|
||||
if (dt.isValid) {
|
||||
// If target zone is UTC, return as-is
|
||||
if (zone === 'utc') {
|
||||
return dt.toFormat('dd-MM-yyyy, HH:mm:ss');
|
||||
}
|
||||
// Otherwise convert to target timezone
|
||||
const converted = dt.setZone(zone);
|
||||
return converted.isValid ? converted.toFormat('dd-MM-yyyy, HH:mm:ss') : 'Invalid DateTime';
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to format parsing if ISO fails
|
||||
}
|
||||
|
||||
// Try various formats, always assuming incoming time is UTC
|
||||
const formats = [
|
||||
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'',
|
||||
'yyyy-MM-dd\'T\'HH:mm:ss\'Z\'',
|
||||
'yyyy-MM-dd\'T\'HH:mm:ss.SSS',
|
||||
'yyyy-MM-dd\'T\'HH:mm:ss',
|
||||
'yyyy-MM-dd HH:mm:ss.SSS',
|
||||
'yyyy-MM-dd HH:mm:ss'
|
||||
];
|
||||
|
||||
for (const format of formats) {
|
||||
try {
|
||||
dt = DateTime.fromFormat(rawTime, format, { zone: 'utc' });
|
||||
if (dt.isValid) break;
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: try JavaScript Date parsing
|
||||
if (!dt || !dt.isValid) {
|
||||
try {
|
||||
const jsDate = new Date(rawTime);
|
||||
if (!isNaN(jsDate.getTime())) {
|
||||
dt = DateTime.fromJSDate(jsDate, { zone: 'utc' });
|
||||
}
|
||||
} catch (e) {
|
||||
// Give up
|
||||
}
|
||||
}
|
||||
|
||||
if (!dt || !dt.isValid) {
|
||||
return 'Invalid DateTime';
|
||||
}
|
||||
|
||||
// Convert to target timezone
|
||||
if (zone === 'utc') {
|
||||
return dt.toFormat('dd-MM-yyyy, HH:mm:ss');
|
||||
}
|
||||
|
||||
const targetDt = dt.setZone(zone);
|
||||
return targetDt.isValid ? targetDt.toFormat('dd-MM-yyyy, HH:mm:ss') : 'Invalid DateTime';
|
||||
}
|
||||
|
||||
function HeartbeatPopup({ hb, userZone, monitor }) {
|
||||
const localTime = hb ? formatLocalTime(hb.time, userZone) : '';
|
||||
const utcTime = hb ? formatLocalTime(hb.time, 'utc') : '';
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 220 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
|
||||
@@ -88,6 +145,7 @@ function HeartbeatPopup({ hb, userZone, monitor }) {
|
||||
}
|
||||
|
||||
export default function UptimeStatusIsland() {
|
||||
console.log('UptimeStatusIsland loaded in production');
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -105,32 +163,32 @@ export default function UptimeStatusIsland() {
|
||||
setUserLocale(navigator.language || 'en-US');
|
||||
}, []);
|
||||
|
||||
// Helper: get the current 30-min window key (e.g., '2024-06-07T12:00')
|
||||
const getCurrent30MinKey = () => {
|
||||
// Helper: get the current 5-min window key (e.g., '2024-06-07T12:00')
|
||||
const getCurrent5MinKey = () => {
|
||||
const now = new Date();
|
||||
now.setSeconds(0, 0);
|
||||
const min = now.getMinutes();
|
||||
now.setMinutes(Math.floor(min / 30) * 30);
|
||||
now.setMinutes(Math.floor(min / 5) * 5);
|
||||
return now.toISOString().slice(0, 16); // 'YYYY-MM-DDTHH:mm'
|
||||
};
|
||||
|
||||
// Helper: get seconds to next 30-min mark
|
||||
const getSecondsToNext30Min = () => {
|
||||
// Helper: get seconds to next 5-min mark
|
||||
const getSecondsToNext5Min = () => {
|
||||
const now = new Date();
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
const ms = now.getMilliseconds();
|
||||
const next30 = Math.ceil((minutes + 0.01) / 30) * 30;
|
||||
const next5 = Math.ceil((minutes + 0.01) / 5) * 5;
|
||||
let next = new Date(now);
|
||||
next.setMinutes(next30, 0, 0);
|
||||
next.setMinutes(next5, 0, 0);
|
||||
if (next <= now) {
|
||||
next.setMinutes(next.getMinutes() + 30);
|
||||
next.setMinutes(next.getMinutes() + 5);
|
||||
}
|
||||
return Math.floor((next - now) / 1000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setSecondsToNextUpdate(getSecondsToNext30Min());
|
||||
const update = () => setSecondsToNextUpdate(getSecondsToNext5Min());
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
@@ -143,7 +201,7 @@ export default function UptimeStatusIsland() {
|
||||
if (cache) {
|
||||
try {
|
||||
const { key, data: cachedData } = JSON.parse(cache);
|
||||
if (key === getCurrent30MinKey() && cachedData) {
|
||||
if (key === getCurrent5MinKey() && cachedData) {
|
||||
setData(cachedData);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -163,9 +221,9 @@ export default function UptimeStatusIsland() {
|
||||
if (!response.ok) throw new Error('Failed to fetch uptime data');
|
||||
const json = await response.json();
|
||||
setData(json);
|
||||
// Cache with current 30-min key
|
||||
// Cache with current 5-min key
|
||||
const cacheKey = 'uptimeStatusCache';
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent30MinKey(), data: json }));
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent5MinKey(), data: json }));
|
||||
} catch (err) {
|
||||
setError(err.message || 'Unknown error');
|
||||
} finally {
|
||||
@@ -173,7 +231,7 @@ export default function UptimeStatusIsland() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scheduled fetch logic (every 30 min, update cache)
|
||||
// Scheduled fetch logic (every 5 min, update cache)
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -182,9 +240,9 @@ export default function UptimeStatusIsland() {
|
||||
if (!response.ok) throw new Error('Failed to fetch uptime data');
|
||||
const json = await response.json();
|
||||
setData(json);
|
||||
// Cache with current 30-min key
|
||||
// Cache with current 5-min key
|
||||
const cacheKey = 'uptimeStatusCache';
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent30MinKey(), data: json }));
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify({ key: getCurrent5MinKey(), data: json }));
|
||||
} catch (err) {
|
||||
setError(err.message || 'Unknown error');
|
||||
} finally {
|
||||
@@ -197,16 +255,16 @@ export default function UptimeStatusIsland() {
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
const ms = now.getMilliseconds();
|
||||
const next30 = Math.ceil((minutes + 0.01) / 30) * 30;
|
||||
const next5 = Math.ceil((minutes + 0.01) / 5) * 5;
|
||||
let next = new Date(now);
|
||||
next.setMinutes(next30, 0, 0);
|
||||
next.setMinutes(next5, 0, 0);
|
||||
if (next <= now) {
|
||||
next.setMinutes(next.getMinutes() + 30);
|
||||
next.setMinutes(next.getMinutes() + 5);
|
||||
}
|
||||
const msToNext = next - now;
|
||||
const timeout = setTimeout(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30 * 60 * 1000);
|
||||
const interval = setInterval(fetchData, 5 * 60 * 1000);
|
||||
fetchData.interval = interval;
|
||||
}, msToNext);
|
||||
return () => {
|
||||
@@ -247,7 +305,7 @@ export default function UptimeStatusIsland() {
|
||||
return badgeRefs.current[monitorId][badge];
|
||||
};
|
||||
|
||||
const totalBarSeconds = 30 * 60;
|
||||
const totalBarSeconds = 5 * 60;
|
||||
const barValue = secondsToNextUpdate !== null ? (totalBarSeconds - secondsToNextUpdate) : 0;
|
||||
const barPercent = (barValue / totalBarSeconds) * 100;
|
||||
const barGradient = 'linear-gradient(90deg, #0161ef 0%, #0154cf 100%)';
|
||||
|
@@ -19,11 +19,13 @@ const variants = {
|
||||
tertiary: 'btn btn-tertiary',
|
||||
link: 'cursor-pointer hover:text-primary',
|
||||
};
|
||||
|
||||
const ariaLabel = !text || (typeof text === 'string' && text.trim() === '') ? Astro.props['aria-label'] : undefined;
|
||||
---
|
||||
|
||||
{
|
||||
type === 'button' || type === 'submit' || type === 'reset' ? (
|
||||
<button type={type} class={twMerge(variants[variant] || '', className)} {...rest}>
|
||||
<button type={type} class={twMerge(variants[variant] || '', className)} {...rest} {...(ariaLabel ? { 'aria-label': ariaLabel } : {})}>
|
||||
<Fragment set:html={text} />
|
||||
{icon && <Icon name={icon} class="w-5 h-5 ml-1 -mr-1.5 rtl:mr-1 rtl:-ml-1.5 inline-block" />}
|
||||
</button>
|
||||
@@ -32,6 +34,7 @@ const variants = {
|
||||
class={twMerge(variants[variant] || '', className)}
|
||||
{...(target ? { target: target, rel: 'noopener noreferrer' } : {})}
|
||||
{...rest}
|
||||
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
|
||||
>
|
||||
<Fragment set:html={text} />
|
||||
{icon && <Icon name={icon} class="w-5 h-5 ml-1 -mr-1.5 rtl:mr-1 rtl:-ml-1.5 inline-block" />}
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import type { Form as Props } from '~/types';
|
||||
import Button from '~/components/ui/Button.astro';
|
||||
|
||||
const { inputs, textarea, disclaimer, button = 'Contact us', description = '' } = Astro.props;
|
||||
const { inputs, textarea, disclaimer, button = 'Contact us', description = '', spamReview } = Astro.props;
|
||||
---
|
||||
|
||||
<style>
|
||||
@@ -15,14 +15,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
}
|
||||
</style>
|
||||
|
||||
<form
|
||||
id="contact-form"
|
||||
name="contact"
|
||||
method="POST"
|
||||
action="/api/contact"
|
||||
class="needs-validation"
|
||||
novalidate
|
||||
>
|
||||
<form id="contact-form" name="contact" method="POST" action="/api/contact" class="needs-validation" novalidate>
|
||||
<!-- Form status messages -->
|
||||
<div id="form-success" class="hidden mb-6 p-4 bg-green-100 border border-green-200 text-green-700 rounded-lg">
|
||||
Your message has been sent successfully. We will get back to you soon!
|
||||
@@ -59,8 +52,9 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
placeholder={placeholder}
|
||||
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
|
||||
required={required}
|
||||
aria-describedby={`invalid-feedback-${name}`}
|
||||
/>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
<div id={`invalid-feedback-${name}`} class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
@@ -80,8 +74,9 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
placeholder={textarea.placeholder}
|
||||
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
|
||||
required
|
||||
aria-describedby="invalid-feedback-textarea"
|
||||
/>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
<div id="invalid-feedback-textarea" class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -96,6 +91,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
type="checkbox"
|
||||
class="cursor-pointer mt-1 py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
|
||||
required
|
||||
aria-describedby="invalid-feedback-disclaimer"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
@@ -103,7 +99,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
{disclaimer.label}
|
||||
<span class="text-red-600">*</span>
|
||||
</label>
|
||||
<div class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
<div id="invalid-feedback-disclaimer" class="invalid-feedback hidden text-red-600 text-sm mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -129,23 +125,51 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
</form>
|
||||
|
||||
<!-- Manual Review UI -->
|
||||
<div id="spam-warning" style="display:none;" class="max-w-xl mx-auto rounded-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900 border border-gray-200 dark:border-gray-700 shadow-md p-4 sm:p-6 lg:p-8 w-full mb-6">
|
||||
<div
|
||||
id="spam-warning"
|
||||
style="display:none;"
|
||||
class="max-w-xl mx-auto rounded-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900 border border-gray-200 dark:border-gray-700 shadow-md p-4 sm:p-6 lg:p-8 w-full mb-6"
|
||||
>
|
||||
<p class="mb-4 text-lg font-medium text-gray-800 dark:text-gray-100">
|
||||
Your message was detected as spam and was not sent.<br>
|
||||
<span class="text-base font-normal text-gray-600 dark:text-gray-300">If you believe this is a mistake, you can request a manual review.</span>
|
||||
{spamReview?.title}<br />
|
||||
<span class="text-base font-normal text-gray-600 dark:text-gray-300">{spamReview?.description}</span>
|
||||
</p>
|
||||
<form id="manual-review-form" class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-email">Please re-enter your email address for confirmation</label>
|
||||
<input type="email" id="manual-email" required placeholder="Enter your email address again" class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" />
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-justification">Why is this not spam? <span class="text-gray-400">(optional)</span></label>
|
||||
<textarea id="manual-justification" placeholder="Explain why your message is legitimate..." class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"></textarea>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-email"
|
||||
>{spamReview?.emailLabel}</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
id="manual-email"
|
||||
required
|
||||
placeholder={spamReview?.emailPlaceholder}
|
||||
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
|
||||
/>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-justification"
|
||||
>{spamReview?.justificationLabel} <span class="text-gray-400">{spamReview?.justificationOptional}</span></label
|
||||
>
|
||||
<textarea
|
||||
id="manual-justification"
|
||||
placeholder={spamReview?.justificationPlaceholder}
|
||||
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
|
||||
></textarea>
|
||||
<input type="hidden" id="manual-token" />
|
||||
<button type="submit" class="mt-2 w-full py-3 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors">Request Manual Review</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 w-full py-3 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors"
|
||||
>{spamReview?.button}</button
|
||||
>
|
||||
</form>
|
||||
<div id="manual-review-result" class="mt-4 text-center text-green-700 dark:text-green-400 font-medium"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// TypeScript: declare the property on window
|
||||
declare global {
|
||||
interface Window {
|
||||
__originalEmail?: string;
|
||||
}
|
||||
}
|
||||
async function setCsrfToken() {
|
||||
try {
|
||||
const res = await fetch('/api/contact?csrf=true');
|
||||
@@ -153,7 +177,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
const data = await res.json();
|
||||
const csrfInput = document.getElementById('csrf_token');
|
||||
if (csrfInput && data.csrfToken) {
|
||||
csrfInput.value = data.csrfToken;
|
||||
(csrfInput as HTMLInputElement).value = data.csrfToken;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -192,9 +216,11 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
const spamWarning = document.getElementById('spam-warning');
|
||||
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
|
||||
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
|
||||
// Store the original email in a variable (not in the input)
|
||||
window.__originalEmail = String(formData.get('email'));
|
||||
if (spamWarning && manualEmail && manualToken) {
|
||||
spamWarning.style.display = 'block';
|
||||
manualEmail.value = String(formData.get('email'));
|
||||
manualEmail.value = '';
|
||||
manualToken.value = result.token;
|
||||
}
|
||||
return;
|
||||
@@ -233,7 +259,11 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
const email = manualEmail.value;
|
||||
const justification = manualJustification.value;
|
||||
const token = manualToken.value;
|
||||
|
||||
// Check if the entered email matches the original
|
||||
if (typeof window.__originalEmail !== 'undefined' && email !== window.__originalEmail) {
|
||||
resultDiv.textContent = 'Email addresses do not match.';
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/contact/manual-review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -241,7 +271,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
resultDiv.textContent = 'Your request for manual review has been submitted. Thank you!';
|
||||
resultDiv.textContent =
|
||||
spamReview?.resultSuccess || 'Your request for manual review has been submitted. Thank you!';
|
||||
// Hide spam-warning and show the normal form again after a short delay
|
||||
setTimeout(() => {
|
||||
if (spamWarning) spamWarning.style.display = 'none';
|
||||
@@ -252,7 +283,8 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
|
||||
resultDiv.textContent = '';
|
||||
}, 2000);
|
||||
} else {
|
||||
resultDiv.textContent = data.error || 'There was an error submitting your manual review request.';
|
||||
resultDiv.textContent =
|
||||
data.error || spamReview?.resultError || 'There was an error submitting your manual review request.';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@@ -12,7 +12,7 @@ const {
|
||||
const {
|
||||
container: containerClass = 'max-w-3xl',
|
||||
title: titleClass = 'text-3xl md:text-4xl ',
|
||||
subtitle: subtitleClass = 'text-xl',
|
||||
subtitle: subtitleClass = 'text-base md:text-lg lg:text-xl',
|
||||
} = classes;
|
||||
---
|
||||
|
||||
|
@@ -36,7 +36,7 @@ const {
|
||||
classes={{
|
||||
container: 'mb-0 md:mb-0',
|
||||
title: 'text-4xl md:text-4xl font-bold tracking-tighter mb-4 font-heading',
|
||||
subtitle: 'text-xl text-muted dark:text-slate-400',
|
||||
subtitle: 'text-base md:text-lg lg:text-xl text-muted dark:text-slate-400',
|
||||
}}
|
||||
/>
|
||||
{
|
||||
|
@@ -18,6 +18,7 @@ const {
|
||||
isDark = false,
|
||||
classes = {},
|
||||
bg = await Astro.slots.render('bg'),
|
||||
spamReview,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
@@ -33,6 +34,7 @@ const {
|
||||
disclaimer={disclaimer}
|
||||
button={button}
|
||||
description={description}
|
||||
spamReview={spamReview}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
@@ -38,7 +38,7 @@ const {
|
||||
classes={{
|
||||
container: 'max-w-xl sm:mx-auto lg:max-w-2xl',
|
||||
title: 'text-4xl md:text-5xl font-bold tracking-tighter mb-4 font-heading',
|
||||
subtitle: 'max-w-3xl mx-auto sm:text-center text-xl text-muted dark:text-slate-400',
|
||||
subtitle: 'max-w-3xl mx-auto sm:text-center text-base md:text-lg lg:text-xl text-muted dark:text-slate-400',
|
||||
}}
|
||||
/>
|
||||
<div class="mx-auto max-w-7xl p-4 md:px-8">
|
||||
|
@@ -33,7 +33,7 @@ export interface Props {
|
||||
position?: string;
|
||||
}
|
||||
|
||||
import { supportedLanguages } from '~/i18n/translations';
|
||||
import { supportedLanguages, getTranslation } from '~/i18n/translations';
|
||||
|
||||
// Get current language from URL
|
||||
const currentPath = `/${trimSlash(new URL(Astro.url).pathname)}`;
|
||||
@@ -67,10 +67,12 @@ if (!currentLang) {
|
||||
|
||||
// Get translated header data - ensure we're using the current language
|
||||
const headerData = getHeaderData(currentLang);
|
||||
// Get translations for the current language
|
||||
const t = getTranslation(currentLang);
|
||||
|
||||
// Filter out the Contact link for the main nav
|
||||
const navLinks = headerData.links.filter(link => link.text?.toLowerCase() !== 'contact');
|
||||
const contactLink = headerData.links.find(link => link.text?.toLowerCase() === 'contact');
|
||||
// Filter out the Contact link for the main nav - use href instead of text to be language-independent
|
||||
const navLinks = headerData.links.filter((link) => !link.href?.includes('#contact'));
|
||||
const contactLink = headerData.links.find((link) => link.href?.includes('#contact'));
|
||||
|
||||
const {
|
||||
id = 'header',
|
||||
@@ -187,16 +189,25 @@ const {
|
||||
]}
|
||||
>
|
||||
<div class="items-center flex justify-between w-full md:w-auto">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
{showToggleTheme && <ToggleTheme iconClass="w-6 h-6 md:w-5 md:h-5 md:inline-block" />}
|
||||
{/* Contact Icon */}
|
||||
{contactLink && (
|
||||
<a href={contactLink.href} aria-label="Contact" title="Contact" class="hover:text-link dark:hover:text-white flex items-center hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2.5 transition-colors duration-200 mr-[10px]">
|
||||
<Icon name="tabler:mail" class="w-6 h-6" />
|
||||
</a>
|
||||
)}
|
||||
{/* Contact Icon with proper translation */}
|
||||
{
|
||||
contactLink && (
|
||||
<a
|
||||
href={contactLink.href}
|
||||
aria-label={t.navigation.contact}
|
||||
title={t.navigation.contact}
|
||||
class="hover:text-link dark:hover:text-white flex items-center hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2.5 transition-colors duration-200 gap-2"
|
||||
>
|
||||
<Icon name="tabler:mail" class="w-6 h-6" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{/* Language Selector as Select Element */}
|
||||
<LanguageDropdown currentLang={currentPath.split('/')[1] || 'en'} />
|
||||
<div class="flex-shrink-0">
|
||||
<LanguageDropdown currentLang={currentPath.split('/')[1] || 'en'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -33,7 +33,7 @@ const {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6">
|
||||
<div class="relative max-w-screen-2xl mx-auto px-4 sm:px-6">
|
||||
<div class="pt-0 md:pt-[76px] pointer-events-none"></div>
|
||||
<div class="py-12 md:py-20">
|
||||
<div class="text-center pb-10 md:pb-16 max-w-5xl mx-auto">
|
||||
@@ -57,7 +57,7 @@ const {
|
||||
{
|
||||
subtitle && (
|
||||
<p
|
||||
class="text-xl mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade content-backdrop p-2 rounded-md"
|
||||
class="text-base md:text-lg lg:text-xl mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade content-backdrop p-2 rounded-md"
|
||||
set:html={subtitle}
|
||||
/>
|
||||
)
|
||||
|
@@ -26,10 +26,10 @@ const {
|
||||
{bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} showIcons={false} disableParallax={true} />}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6">
|
||||
<div class="relative w-full px-0">
|
||||
<div class="pt-0 md:pt-[76px] pointer-events-none"></div>
|
||||
<div class="py-12 md:py-20 lg:py-0 lg:flex lg:items-center lg:h-screen lg:gap-8">
|
||||
<div class="basis-1/2 text-center lg:text-left pb-10 md:pb-16 mx-auto">
|
||||
<div class="basis-1/2 text-center lg:text-left pb-10 md:pb-16 mx-auto pl-4 md:pl-8 lg:pl-12">
|
||||
{
|
||||
tagline && (
|
||||
<p
|
||||
@@ -50,7 +50,7 @@ const {
|
||||
{
|
||||
subtitle && (
|
||||
<p
|
||||
class="text-xl text-muted mb-6 dark:text-slate-300 content-backdrop p-2 rounded-md"
|
||||
class="text-base md:text-lg lg:text-xl text-muted mb-6 dark:text-slate-300 content-backdrop p-2 rounded-md"
|
||||
set:html={subtitle}
|
||||
/>
|
||||
)
|
||||
|
@@ -47,7 +47,7 @@ const {
|
||||
{
|
||||
subtitle && (
|
||||
<p
|
||||
class="text-xl text-muted mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
|
||||
class="text-base md:text-lg lg:text-xl text-muted mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"
|
||||
set:html={subtitle}
|
||||
/>
|
||||
)
|
||||
|
314
src/components/widgets/ModernAppliedSkills.astro
Normal file
314
src/components/widgets/ModernAppliedSkills.astro
Normal file
@@ -0,0 +1,314 @@
|
||||
---
|
||||
import Headline from '~/components/ui/Headline.astro';
|
||||
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
|
||||
import Button from '~/components/ui/Button.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import type { Testimonials as Props } from '~/types';
|
||||
|
||||
const {
|
||||
title = '',
|
||||
subtitle = '',
|
||||
tagline = '',
|
||||
testimonials = [],
|
||||
callToAction,
|
||||
|
||||
id,
|
||||
isDark = false,
|
||||
classes = {},
|
||||
bg = await Astro.slots.render('bg'),
|
||||
} = Astro.props;
|
||||
|
||||
// Function to get the correct skill icon based on title
|
||||
const getSkillIcon = (name: string): string => {
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
if (nameLower.includes('entra') || nameLower.includes('identity')) return 'tabler:shield-lock';
|
||||
if (nameLower.includes('power automate') || nameLower.includes('automated processes')) return 'tabler:robot';
|
||||
if (nameLower.includes('copilot') || nameLower.includes('agents')) return 'tabler:brain';
|
||||
|
||||
return 'tabler:certificate';
|
||||
};
|
||||
|
||||
// Function to get the correct gradient colors based on skill
|
||||
const getSkillGradient = (name: string): string => {
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
if (nameLower.includes('entra') || nameLower.includes('identity')) return 'from-blue-600 to-indigo-700';
|
||||
if (nameLower.includes('power automate') || nameLower.includes('automated processes')) return 'from-purple-600 to-pink-600';
|
||||
if (nameLower.includes('copilot') || nameLower.includes('agents')) return 'from-green-600 to-teal-600';
|
||||
|
||||
return 'from-gray-500 to-gray-600';
|
||||
};
|
||||
---
|
||||
|
||||
<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
|
||||
<Headline title={title} subtitle={subtitle} tagline={tagline} classes={{
|
||||
container: 'max-w-3xl',
|
||||
title: 'text-3xl lg:text-4xl',
|
||||
}} />
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
|
||||
{
|
||||
testimonials &&
|
||||
testimonials.map(({ linkUrl, name, issueDate, description }) => {
|
||||
const icon = getSkillIcon(name || '');
|
||||
const gradient = getSkillGradient(name || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
class="applied-skill-card bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm rounded-2xl p-6 transition-all duration-300 cursor-pointer border border-gray-100 dark:border-slate-800 hover:transform hover:scale-105 hover:shadow-xl relative overflow-hidden group"
|
||||
data-applied-skill-title={name}
|
||||
data-applied-skill-date={issueDate}
|
||||
data-applied-skill-description={description}
|
||||
data-applied-skill-url={linkUrl}
|
||||
itemscope
|
||||
itemtype="https://schema.org/EducationalOccupationalCredential"
|
||||
>
|
||||
<!-- Top gradient bar -->
|
||||
<div class={`absolute top-0 left-0 right-0 h-1 bg-gradient-to-r ${gradient}`}></div>
|
||||
|
||||
<!-- Microsoft Applied Skills Badge -->
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<div class={`px-3 py-1 rounded-full text-xs font-medium text-white bg-gradient-to-r ${gradient} shadow-sm`}>
|
||||
Microsoft Applied Skills
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="text-center">
|
||||
<h3
|
||||
class="font-semibold text-base text-gray-900 dark:text-white mb-3 line-clamp-3 group-hover:text-transparent group-hover:bg-clip-text group-hover:bg-gradient-to-r group-hover:from-blue-600 group-hover:to-purple-600 transition-all duration-300 leading-relaxed"
|
||||
itemprop="name"
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
<p
|
||||
class={`text-sm font-medium text-transparent bg-clip-text bg-gradient-to-r ${gradient}`}
|
||||
itemprop="validIn"
|
||||
>
|
||||
{issueDate}
|
||||
</p>
|
||||
<meta itemprop="credentialCategory" content="Microsoft Applied Skills" />
|
||||
<meta itemprop="recognizedBy" content="Microsoft" />
|
||||
{linkUrl && <meta itemprop="url" content={linkUrl} />}
|
||||
</div>
|
||||
|
||||
<!-- Hover effect overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-2xl"></div>
|
||||
|
||||
<!-- Shimmer effect -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-black/10 to-transparent dark:via-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-700 ease-out"></div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
callToAction && (
|
||||
<div class="flex justify-center mx-auto w-fit mt-8 font-medium">
|
||||
<Button {...callToAction} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</WidgetWrapper>
|
||||
|
||||
<!-- Applied Skills Details Modal -->
|
||||
<div id="appliedSkillModal" class="applied-skill-modal fixed inset-0 bg-black/50 backdrop-blur-sm z-50 hidden items-center justify-center p-4">
|
||||
<div class="applied-skill-modal-content bg-white dark:bg-slate-800 rounded-2xl p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto transform scale-90 opacity-0 transition-all duration-300">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 id="appliedSkillModalTitle" class="text-2xl font-bold text-gray-900 dark:text-white mb-1"></h3>
|
||||
<p id="appliedSkillModalDate" class="text-sm text-blue-600 dark:text-blue-400 font-medium"></p>
|
||||
</div>
|
||||
<button onclick="closeAppliedSkillModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<Icon name="tabler:x" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p id="appliedSkillModalDescription" class="text-gray-700 dark:text-gray-300 leading-relaxed mb-6"></p>
|
||||
<div class="flex justify-end">
|
||||
<a id="appliedSkillModalLink" href="#" target="_blank" rel="noopener noreferrer" class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all duration-300 hover:scale-105">
|
||||
<Icon name="tabler:external-link" class="w-4 h-4 mr-2" />
|
||||
View Applied Skill
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.applied-skill-card {
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.applied-skill-card:nth-child(1) { animation-delay: 0.1s; }
|
||||
.applied-skill-card:nth-child(2) { animation-delay: 0.15s; }
|
||||
.applied-skill-card:nth-child(3) { animation-delay: 0.2s; }
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.applied-skill-modal {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.applied-skill-modal.active {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.applied-skill-modal.active .applied-skill-modal-content {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
// Applied Skills widget touch handling - completely isolated to avoid conflicts
|
||||
(function() {
|
||||
let appliedSkillTouchStart = { x: 0, y: 0, time: 0 };
|
||||
let appliedSkillIsScrolling = false;
|
||||
let appliedSkillOriginalScrollPosition = 0;
|
||||
let appliedSkillClickedElement = null;
|
||||
|
||||
function appliedSkillHandleTouchStart(e) {
|
||||
const touch = e.touches[0];
|
||||
appliedSkillTouchStart = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
time: Date.now()
|
||||
};
|
||||
appliedSkillIsScrolling = false;
|
||||
}
|
||||
|
||||
function appliedSkillHandleTouchMove(e) {
|
||||
const touch = e.touches[0];
|
||||
const deltaX = Math.abs(touch.clientX - appliedSkillTouchStart.x);
|
||||
const deltaY = Math.abs(touch.clientY - appliedSkillTouchStart.y);
|
||||
|
||||
if (deltaX > 10 || deltaY > 10) {
|
||||
appliedSkillIsScrolling = true;
|
||||
}
|
||||
}
|
||||
|
||||
function appliedSkillHandleTouchEnd(e, element) {
|
||||
const touch = e.changedTouches[0];
|
||||
const touchEndTime = Date.now();
|
||||
|
||||
if (appliedSkillIsScrolling) return;
|
||||
|
||||
const touchDuration = touchEndTime - appliedSkillTouchStart.time;
|
||||
if (touchDuration > 300) return;
|
||||
|
||||
const deltaX = Math.abs(touch.clientX - appliedSkillTouchStart.x);
|
||||
const deltaY = Math.abs(touch.clientY - appliedSkillTouchStart.y);
|
||||
if (deltaX > 15 || deltaY > 15) return;
|
||||
|
||||
e.preventDefault();
|
||||
showAppliedSkillModal(element);
|
||||
}
|
||||
|
||||
window.showAppliedSkillModal = function(element) {
|
||||
// Store the clicked element and current scroll position
|
||||
appliedSkillClickedElement = element;
|
||||
appliedSkillOriginalScrollPosition = window.pageYOffset || document.documentElement.scrollTop;
|
||||
|
||||
const title = element.dataset.appliedSkillTitle;
|
||||
const date = element.dataset.appliedSkillDate;
|
||||
const description = element.dataset.appliedSkillDescription;
|
||||
const url = element.dataset.appliedSkillUrl;
|
||||
|
||||
document.getElementById('appliedSkillModalTitle').textContent = title;
|
||||
document.getElementById('appliedSkillModalDate').textContent = date;
|
||||
document.getElementById('appliedSkillModalDescription').textContent = description;
|
||||
document.getElementById('appliedSkillModalLink').href = url;
|
||||
|
||||
const modal = document.getElementById('appliedSkillModal');
|
||||
modal.classList.add('active');
|
||||
|
||||
setTimeout(() => {
|
||||
const modalContent = modal.querySelector('.applied-skill-modal-content');
|
||||
if (modalContent) {
|
||||
modalContent.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
window.closeAppliedSkillModal = function() {
|
||||
const modal = document.getElementById('appliedSkillModal');
|
||||
modal.classList.remove('active');
|
||||
|
||||
// Restore scroll position to the original card
|
||||
if (appliedSkillClickedElement) {
|
||||
setTimeout(() => {
|
||||
// First, scroll to the original position
|
||||
window.scrollTo({
|
||||
top: appliedSkillOriginalScrollPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Then, ensure the clicked card is visible with a slight offset
|
||||
setTimeout(() => {
|
||||
const elementRect = appliedSkillClickedElement.getBoundingClientRect();
|
||||
const isElementVisible = elementRect.top >= 0 && elementRect.bottom <= window.innerHeight;
|
||||
|
||||
if (!isElementVisible) {
|
||||
appliedSkillClickedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Clear the reference
|
||||
appliedSkillClickedElement = null;
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Select only applied skill cards with the specific class and data attributes
|
||||
const appliedSkillCards = document.querySelectorAll('.applied-skill-card[data-applied-skill-title]');
|
||||
|
||||
appliedSkillCards.forEach(card => {
|
||||
card.addEventListener('click', function(e) {
|
||||
if (!('ontouchstart' in window)) {
|
||||
e.preventDefault();
|
||||
showAppliedSkillModal(this);
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('touchstart', appliedSkillHandleTouchStart, { passive: true });
|
||||
card.addEventListener('touchmove', appliedSkillHandleTouchMove, { passive: true });
|
||||
card.addEventListener('touchend', function(e) {
|
||||
appliedSkillHandleTouchEnd(e, this);
|
||||
}, { passive: false });
|
||||
});
|
||||
|
||||
const appliedSkillModal = document.getElementById('appliedSkillModal');
|
||||
if (appliedSkillModal) {
|
||||
appliedSkillModal.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeAppliedSkillModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeAppliedSkillModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
@@ -67,7 +67,7 @@ const timelineItems = items.map((item) => {
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && <p class={`${compact ? 'text-lg' : 'text-xl'} text-muted dark:text-slate-400`}>{subtitle}</p>}
|
||||
{subtitle && <p class={`${compact ? 'text-base md:text-lg' : 'text-base md:text-lg lg:text-xl'} text-muted dark:text-slate-400`}>{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
454
src/i18n/translations.homepage.ts
Normal file
454
src/i18n/translations.homepage.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
export interface ExplosiveHomepageTranslation {
|
||||
hero: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
actions: {
|
||||
learnMore: string;
|
||||
contactMe: string;
|
||||
};
|
||||
services: {
|
||||
tagline: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
items: {
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
};
|
||||
approach: {
|
||||
tagline: string;
|
||||
title: string;
|
||||
expertise: string;
|
||||
methods: {
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
highlights: string;
|
||||
};
|
||||
cta: {
|
||||
button: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
contact: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
fields: {
|
||||
name: string;
|
||||
namePlaceholder: string;
|
||||
email: string;
|
||||
emailPlaceholder: string;
|
||||
message: string;
|
||||
messagePlaceholder: string;
|
||||
};
|
||||
disclaimer: string;
|
||||
};
|
||||
spamReview: {
|
||||
title: string;
|
||||
description: string;
|
||||
emailLabel: string;
|
||||
emailPlaceholder: string;
|
||||
justificationLabel: string;
|
||||
justificationOptional: string;
|
||||
justificationPlaceholder: string;
|
||||
button: string;
|
||||
resultSuccess: string;
|
||||
resultError: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const supportedLanguages = ['en', 'nl', 'de', 'fr'] as const;
|
||||
|
||||
export function getExplosiveHomepageTranslation(lang: string): ExplosiveHomepageTranslation {
|
||||
return explosiveHomepageTranslations[lang] || explosiveHomepageTranslations['en'];
|
||||
}
|
||||
|
||||
export const explosiveHomepageTranslations: Record<string, ExplosiveHomepageTranslation> = {
|
||||
en: {
|
||||
hero: {
|
||||
title: 'Future-Proof Your Business',
|
||||
subtitle:
|
||||
'Harness the power of AI automation • Microsoft 365 expertise • Tailored integrations that deliver real results',
|
||||
},
|
||||
actions: {
|
||||
learnMore: 'Get Started Now',
|
||||
contactMe: 'Contact Me',
|
||||
},
|
||||
services: {
|
||||
tagline: 'AUTOMATION POWERHOUSE',
|
||||
title: 'Services That Deliver Measurable Impact',
|
||||
subtitle: 'Stop spending valuable time on manual tasks. Let’s automate efficiently and boost your productivity.',
|
||||
items: [
|
||||
{
|
||||
title: 'Power Automate Expertise',
|
||||
description: 'Transform 8-hour tasks into 5-minute workflows',
|
||||
},
|
||||
{
|
||||
title: 'AI Chatbots That Deliver',
|
||||
description: 'Intelligent bots that understand your business needs',
|
||||
},
|
||||
{
|
||||
title: 'Robust API Integrations',
|
||||
description: 'Connect all your tools seamlessly',
|
||||
},
|
||||
{
|
||||
title: 'Microsoft 365 Optimization',
|
||||
description: 'Make Microsoft 365 work the way it was meant to from day one',
|
||||
},
|
||||
{
|
||||
title: 'SharePoint That Works',
|
||||
description: 'Finally, a SharePoint experience your team will want to use',
|
||||
},
|
||||
{
|
||||
title: 'Scalable Infrastructure',
|
||||
description: 'Reliable systems that grow with your business',
|
||||
},
|
||||
],
|
||||
},
|
||||
approach: {
|
||||
tagline: 'THE BERGSMA METHOD',
|
||||
title: 'Less Talk. More Results.',
|
||||
expertise: 'Over 15 years of experience turning technology into business value',
|
||||
methods: [
|
||||
{
|
||||
title: 'Impact-Driven Solutions',
|
||||
description: 'Every implementation must drive measurable business outcomes',
|
||||
},
|
||||
{
|
||||
title: 'Rapid Implementation',
|
||||
description: 'Quick delivery with results from day one',
|
||||
},
|
||||
{
|
||||
title: 'Ongoing Evolution',
|
||||
description: 'Your tech stack evolves as your business grows',
|
||||
},
|
||||
],
|
||||
highlights:
|
||||
'• Microsoft MVP-level expertise\n• Power Automate flows that save thousands of hours\n• AI integrations that create real value\n• Enterprise-grade solutions with startup agility',
|
||||
},
|
||||
cta: {
|
||||
button: 'START YOUR TRANSFORMATION',
|
||||
title: 'Ready to Multiply Your Efficiency?',
|
||||
subtitle: 'Premium automation services • Proven results • Fast implementation',
|
||||
},
|
||||
contact: {
|
||||
title: 'Let’s Create Something Exceptional',
|
||||
subtitle: 'Ready to automate your path to success? Get in touch and let’s take your business to the next level.',
|
||||
description: 'Fast response guaranteed • Enterprise-ready solutions • Data-driven outcomes',
|
||||
fields: {
|
||||
name: '👋 Your Name',
|
||||
namePlaceholder: 'How should I address you?',
|
||||
email: '📧 Email',
|
||||
emailPlaceholder: 'your.email@company.com',
|
||||
message: '💭 Tell Me About Your Project',
|
||||
messagePlaceholder:
|
||||
'Which business process slows you down? What would you automate if you had no technical limits?',
|
||||
},
|
||||
disclaimer: '🔒 Your information stays secure. No spam — just results.',
|
||||
},
|
||||
spamReview: {
|
||||
title: "Your message was detected as spam and was not sent.",
|
||||
description: "If you believe this is a mistake, you can request a manual review.",
|
||||
emailLabel: "Please re-enter your email address for confirmation",
|
||||
emailPlaceholder: "Enter your email address again",
|
||||
justificationLabel: "Why is this not spam?",
|
||||
justificationOptional: "(optional)",
|
||||
justificationPlaceholder: "Explain why your message is legitimate...",
|
||||
button: "Request Manual Review",
|
||||
resultSuccess: "Your request for manual review has been submitted. Thank you!",
|
||||
resultError: "There was an error submitting your manual review request."
|
||||
},
|
||||
},
|
||||
nl: {
|
||||
hero: {
|
||||
title: 'Maak Je Bedrijf Toekomstbestendig',
|
||||
subtitle:
|
||||
'Ontgrendel de kracht van AI-automatisering • Microsoft 365-expertise • Maatwerk integraties die écht werken',
|
||||
},
|
||||
actions: {
|
||||
learnMore: 'Begin Nu',
|
||||
contactMe: 'Neem Contact Op',
|
||||
},
|
||||
services: {
|
||||
tagline: 'KRACHTCENTRALE VOOR AUTOMATISERING',
|
||||
title: 'Diensten Die Écht Impact Maken',
|
||||
subtitle:
|
||||
'Verspil geen tijd meer aan handmatige taken. Laten we automatiseren en je productiviteit naar een hoger niveau tillen.',
|
||||
items: [
|
||||
{
|
||||
title: 'Power Automate-Expertise',
|
||||
description: 'Maak van 8-uurstaken workflows van 5 minuten',
|
||||
},
|
||||
{
|
||||
title: 'AI-Chatbots Die Werken',
|
||||
description: 'Slimme bots die jouw organisatie écht begrijpen',
|
||||
},
|
||||
{
|
||||
title: 'Krachtige API-integraties',
|
||||
description: 'Verbind systemen naadloos met elkaar',
|
||||
},
|
||||
{
|
||||
title: 'Microsoft 365 Optimalisatie',
|
||||
description: 'Laat M365 werken zoals het vanaf dag één bedoeld was',
|
||||
},
|
||||
{
|
||||
title: 'SharePoint Die Wel Werkt',
|
||||
description: 'Eindelijk een SharePoint-oplossing die mensen graag gebruiken',
|
||||
},
|
||||
{
|
||||
title: 'Schaalbare Infrastructuur',
|
||||
description: 'Betrouwbare systemen die met je bedrijf meegroeien',
|
||||
},
|
||||
],
|
||||
},
|
||||
approach: {
|
||||
tagline: 'DE BERGSMA-METHODE',
|
||||
title: 'Minder Woorden, Meer Resultaten',
|
||||
expertise: 'Meer dan 15 jaar ervaring in technologie die écht werkt voor bedrijven',
|
||||
methods: [
|
||||
{
|
||||
title: 'Focus op Impact',
|
||||
description: 'Elke oplossing moet je organisatie vooruithelpen',
|
||||
},
|
||||
{
|
||||
title: 'Snelle Implementatie',
|
||||
description: 'Snelle levering, direct resultaat',
|
||||
},
|
||||
{
|
||||
title: 'Continue Ontwikkeling',
|
||||
description: 'Je techstack groeit mee met je ambities',
|
||||
},
|
||||
],
|
||||
highlights:
|
||||
'• Microsoft MVP-niveau expertise\n• Power Automate-flows die duizenden uren besparen\n• AI-integraties met echte meerwaarde\n• Enterprise-oplossingen met de wendbaarheid van een startup',
|
||||
},
|
||||
cta: {
|
||||
button: 'START JE TRANSFORMATIE',
|
||||
title: 'Klaar om je efficiëntie te vertienvoudigen?',
|
||||
subtitle: 'Premium automatiseringsoplossingen • Gegarandeerd resultaat • Snelle implementatie',
|
||||
},
|
||||
contact: {
|
||||
title: 'Laten We Samen Iets Geweldigs Bouwen',
|
||||
subtitle:
|
||||
'Klaar om je pad naar succes te automatiseren? Stuur me een bericht en laten we je bedrijf onweerstaanbaar maken.',
|
||||
description: 'Snelle reactie gegarandeerd • Enterprise-ready oplossingen • Meetbare resultaten',
|
||||
fields: {
|
||||
name: '👋 Jouw Naam',
|
||||
namePlaceholder: 'Hoe kan ik je aanspreken?',
|
||||
email: '📧 E-mail',
|
||||
emailPlaceholder: 'jouw.email@bedrijf.nl',
|
||||
message: '💭 Vertel me over je project',
|
||||
messagePlaceholder:
|
||||
'Welk bedrijfsproces frustreert je het meest? Wat zou je automatiseren als alles mogelijk was?',
|
||||
},
|
||||
disclaimer: '🔒 Jouw gegevens blijven privé. Geen spam – alleen oplossingen.',
|
||||
},
|
||||
spamReview: {
|
||||
title: "Je bericht is als spam gemarkeerd en niet verzonden.",
|
||||
description: "Denk je dat dit onterecht is? Vraag dan een handmatige beoordeling aan.",
|
||||
emailLabel: "Voer je e-mailadres opnieuw in ter bevestiging",
|
||||
emailPlaceholder: "Voer opnieuw je e-mailadres in",
|
||||
justificationLabel: "Waarom is dit geen spam?",
|
||||
justificationOptional: "(optioneel)",
|
||||
justificationPlaceholder: "Leg uit waarom je bericht legitiem is...",
|
||||
button: "Handmatige beoordeling aanvragen",
|
||||
resultSuccess: "Je verzoek voor handmatige beoordeling is ingediend. Bedankt!",
|
||||
resultError: "Er is een fout opgetreden bij het verzenden van je verzoek."
|
||||
},
|
||||
},
|
||||
de: {
|
||||
hero: {
|
||||
title: 'Machen Sie Ihr Unternehmen zukunftssicher',
|
||||
subtitle:
|
||||
'Entfesseln Sie die Kraft der KI-Automatisierung • Microsoft 365-Expertise • Maßgeschneiderte Integrationen, die echte Ergebnisse liefern',
|
||||
},
|
||||
actions: {
|
||||
learnMore: 'Jetzt starten',
|
||||
contactMe: 'Kontakt aufnehmen',
|
||||
},
|
||||
services: {
|
||||
tagline: 'AUTOMATISIERUNGS-KRAFTWERK',
|
||||
title: 'Services, die wirklich Wirkung zeigen',
|
||||
subtitle:
|
||||
'Verschwenden Sie keine Zeit mehr mit manuellen Aufgaben. Lassen Sie uns automatisieren – für maximale Effizienz.',
|
||||
items: [
|
||||
{
|
||||
title: 'Power-Automate-Expertise',
|
||||
description: 'Verwandeln Sie 8-Stunden-Aufgaben in 5-Minuten-Workflows',
|
||||
},
|
||||
{
|
||||
title: 'KI-Chatbots, die funktionieren',
|
||||
description: 'Intelligente Bots, die Ihr Unternehmen wirklich verstehen',
|
||||
},
|
||||
{
|
||||
title: 'Zuverlässige API-Integrationen',
|
||||
description: 'Verbinden Sie Ihre Systeme reibungslos miteinander',
|
||||
},
|
||||
{
|
||||
title: 'Microsoft 365-Optimierung',
|
||||
description: 'Nutzen Sie M365 so, wie es von Anfang an gedacht war',
|
||||
},
|
||||
{
|
||||
title: 'SharePoint, das überzeugt',
|
||||
description: 'Endlich eine SharePoint-Lösung, die Ihre Teams gerne nutzen',
|
||||
},
|
||||
{
|
||||
title: 'Skalierbare Infrastruktur',
|
||||
description: 'Robuste Systeme, die mit Ihrem Unternehmen wachsen',
|
||||
},
|
||||
],
|
||||
},
|
||||
approach: {
|
||||
tagline: 'DIE BERGSMA-METHODE',
|
||||
title: 'Weniger reden. Mehr Ergebnisse.',
|
||||
expertise: 'Über 15 Jahre Erfahrung darin, Technologie in echten Mehrwert für Unternehmen zu verwandeln',
|
||||
methods: [
|
||||
{
|
||||
title: 'Wirkungsorientierte Lösungen',
|
||||
description: 'Jede Umsetzung muss Ihr Unternehmen messbar voranbringen',
|
||||
},
|
||||
{
|
||||
title: 'Schnelle Implementierung',
|
||||
description: 'Schnelle Bereitstellung, sofortige Ergebnisse',
|
||||
},
|
||||
{
|
||||
title: 'Kontinuierliche Weiterentwicklung',
|
||||
description: 'Ihr Tech-Stack wächst im Einklang mit Ihrem Unternehmen',
|
||||
},
|
||||
],
|
||||
highlights:
|
||||
'• Microsoft MVP-Level-Expertise\n• Power-Automate-Flows, die Tausende Stunden sparen\n• KI-Integrationen mit echtem Mehrwert\n• Enterprise-Lösungen mit Startup-Agilität',
|
||||
},
|
||||
cta: {
|
||||
button: 'STARTEN SIE IHRE TRANSFORMATION',
|
||||
title: 'Bereit, Ihre Effizienz zu verzehnfachen?',
|
||||
subtitle: 'Premium-Automatisierung • Garantierte Ergebnisse • Schnelle Umsetzung',
|
||||
},
|
||||
contact: {
|
||||
title: 'Lassen Sie uns etwas Großartiges schaffen',
|
||||
subtitle:
|
||||
'Bereit, Ihre Prozesse zu automatisieren? Schreiben Sie mir – gemeinsam bringen wir Ihr Unternehmen auf das nächste Level.',
|
||||
description: 'Schnelle Rückmeldung garantiert • Enterprise-taugliche Lösungen • Messbare Resultate',
|
||||
fields: {
|
||||
name: '👋 Ihr Name',
|
||||
namePlaceholder: 'Wie darf ich Sie ansprechen?',
|
||||
email: '📧 E-Mail',
|
||||
emailPlaceholder: 'ihre.email@firma.de',
|
||||
message: '💭 Erzählen Sie mir von Ihrem Projekt',
|
||||
messagePlaceholder:
|
||||
'Welcher Geschäftsprozess kostet Sie Zeit? Was würden Sie automatisieren, wenn alles möglich wäre?',
|
||||
},
|
||||
disclaimer: '🔒 Ihre Daten sind bei uns sicher. Kein Spam – nur Lösungen.',
|
||||
},
|
||||
spamReview: {
|
||||
title: "Ihre Nachricht wurde als Spam erkannt und nicht versendet.",
|
||||
description: "Wenn Sie der Meinung sind, dass es sich um einen Fehler handelt, können Sie eine manuelle Prüfung anfordern.",
|
||||
emailLabel: "Bitte geben Sie zur Bestätigung Ihre E-Mail-Adresse erneut ein",
|
||||
emailPlaceholder: "E-Mail-Adresse erneut eingeben",
|
||||
justificationLabel: "Warum ist das kein Spam?",
|
||||
justificationOptional: "(optional)",
|
||||
justificationPlaceholder: "Erklären Sie, warum Ihre Nachricht legitim ist...",
|
||||
button: "Manuelle Prüfung anfordern",
|
||||
resultSuccess: "Ihre Anfrage zur manuellen Prüfung wurde übermittelt. Vielen Dank!",
|
||||
resultError: "Beim Absenden Ihrer Anfrage ist ein Fehler aufgetreten."
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
hero: {
|
||||
title: 'Rendez Votre Entreprise Pérenne',
|
||||
subtitle:
|
||||
'Libérez la puissance de l’automatisation par l’IA • Maîtrise de Microsoft 365 • Intégrations sur mesure, fiables et efficaces',
|
||||
},
|
||||
actions: {
|
||||
learnMore: 'Commencez dès maintenant',
|
||||
contactMe: 'Contactons-nous',
|
||||
},
|
||||
services: {
|
||||
tagline: 'CENTRALE D’AUTOMATISATION',
|
||||
title: 'Des Services Qui Font Vraiment la Différence',
|
||||
subtitle:
|
||||
'Ne perdez plus de temps avec des tâches manuelles. Automatisez intelligemment et boostez votre productivité.',
|
||||
items: [
|
||||
{
|
||||
title: 'L’Excellence de Power Automate',
|
||||
description: 'Transformez des tâches de 8 heures en workflows de 5 minutes',
|
||||
},
|
||||
{
|
||||
title: 'Des Chatbots IA Performants',
|
||||
description: 'Des bots intelligents qui comprennent vraiment votre activité',
|
||||
},
|
||||
{
|
||||
title: 'Intégrations API Fiables',
|
||||
description: 'Connectez vos outils, sans limites',
|
||||
},
|
||||
{
|
||||
title: 'Optimisation de Microsoft 365',
|
||||
description: 'Faites fonctionner M365 comme il aurait dû dès le départ',
|
||||
},
|
||||
{
|
||||
title: 'Un SharePoint qui Donne Envie',
|
||||
description: 'Enfin un SharePoint que vos équipes aiment utiliser',
|
||||
},
|
||||
{
|
||||
title: 'Infrastructure Scalable',
|
||||
description: 'Des systèmes robustes qui évoluent avec votre entreprise',
|
||||
},
|
||||
],
|
||||
},
|
||||
approach: {
|
||||
tagline: 'LA MÉTHODE BERGSMA',
|
||||
title: 'Moins de Théorie, Plus de Résultats',
|
||||
expertise: 'Plus de 15 ans à perfectionner l’art de faire de la technologie un moteur pour les entreprises',
|
||||
methods: [
|
||||
{
|
||||
title: 'Concentration sur l’Impact',
|
||||
description: 'Chaque solution doit réellement faire progresser votre entreprise',
|
||||
},
|
||||
{
|
||||
title: 'Mise en Œuvre Rapide',
|
||||
description: 'Déploiement agile, résultats concrets',
|
||||
},
|
||||
{
|
||||
title: 'Évolution Continue',
|
||||
description: 'Votre stack technologique évolue au rythme de votre croissance',
|
||||
},
|
||||
],
|
||||
highlights:
|
||||
'• Expertise de niveau Microsoft MVP\n• Flows Power Automate économisant des milliers d’heures\n• Intégrations IA pertinentes et efficaces\n• Solutions d’entreprise avec l’agilité des startups',
|
||||
},
|
||||
cta: {
|
||||
button: 'DÉMARREZ VOTRE TRANSFORMATION',
|
||||
title: 'Prêt à décupler votre efficacité ?',
|
||||
subtitle: 'Solutions d’automatisation haut de gamme • Résultats garantis • Déploiement rapide',
|
||||
},
|
||||
contact: {
|
||||
title: 'Créons Ensemble Quelque Chose d’Exceptionnel',
|
||||
subtitle: 'Vous êtes prêt à automatiser votre succès ? Écrivez-moi et donnons à votre entreprise un nouvel élan.',
|
||||
description: 'Réponse rapide assurée • Solutions prêtes pour l’entreprise • Résultats mesurables',
|
||||
fields: {
|
||||
name: '👋 Votre Nom',
|
||||
namePlaceholder: 'Comment puis-je vous appeler ?',
|
||||
email: '📧 Email',
|
||||
emailPlaceholder: 'votre.email@entreprise.fr',
|
||||
message: '💭 Présentez-moi Votre Projet',
|
||||
messagePlaceholder:
|
||||
'Quel processus métier vous ralentit ? Qu’aimeriez-vous automatiser si tout était possible ?',
|
||||
},
|
||||
disclaimer: '🔒 Vos données sont confidentielles. Aucune publicité – uniquement des solutions concrètes.',
|
||||
},
|
||||
spamReview: {
|
||||
title: "Votre message a été détecté comme spam et n’a pas été envoyé.",
|
||||
description: "Si vous pensez qu’il s’agit d’une erreur, vous pouvez demander une révision manuelle.",
|
||||
emailLabel: "Veuillez saisir à nouveau votre adresse e-mail pour confirmation",
|
||||
emailPlaceholder: "Saisissez à nouveau votre adresse e-mail",
|
||||
justificationLabel: "Pourquoi ce message n’est-il pas du spam ?",
|
||||
justificationOptional: "(optionnel)",
|
||||
justificationPlaceholder: "Expliquez pourquoi votre message est légitime...",
|
||||
button: "Demander une révision manuelle",
|
||||
resultSuccess: "Votre demande de révision manuelle a été envoyée. Merci !",
|
||||
resultError: "Une erreur s’est produite lors de l’envoi de votre demande."
|
||||
},
|
||||
},
|
||||
};
|
@@ -143,7 +143,7 @@ const translations: Record<SupportedLanguage, JourniiTranslation> = {
|
||||
cta: {
|
||||
title: "Stay Updated",
|
||||
desc: "Journii is currently in active development. Follow our progress and be among the first to know when it launches.",
|
||||
github: "View on GitHub",
|
||||
github: "View on Gitea",
|
||||
updates: "Get Updates"
|
||||
}
|
||||
},
|
||||
@@ -225,7 +225,7 @@ const translations: Record<SupportedLanguage, JourniiTranslation> = {
|
||||
cta: {
|
||||
title: "Blijf op de Hoogte",
|
||||
desc: "Journii is momenteel in actieve ontwikkeling. Volg onze voortgang en wees een van de eersten die het weet wanneer het wordt gelanceerd.",
|
||||
github: "Bekijk op GitHub",
|
||||
github: "Bekijk op Gitea",
|
||||
updates: "Ontvang Updates"
|
||||
}
|
||||
},
|
||||
@@ -307,7 +307,7 @@ const translations: Record<SupportedLanguage, JourniiTranslation> = {
|
||||
cta: {
|
||||
title: "Bleiben Sie Auf Dem Laufenden",
|
||||
desc: "Journii befindet sich derzeit in aktiver Entwicklung. Verfolgen Sie unseren Fortschritt und gehören Sie zu den Ersten, die erfahren, wann es startet.",
|
||||
github: "Auf GitHub Ansehen",
|
||||
github: "Auf Gitea Ansehen",
|
||||
updates: "Updates Erhalten"
|
||||
}
|
||||
},
|
||||
@@ -389,7 +389,7 @@ const translations: Record<SupportedLanguage, JourniiTranslation> = {
|
||||
cta: {
|
||||
title: "Restez Informé",
|
||||
desc: "Journii est actuellement en développement actif. Suivez nos progrès et soyez parmi les premiers à savoir quand il sera lancé.",
|
||||
github: "Voir sur GitHub",
|
||||
github: "Voir sur Gitea",
|
||||
updates: "Recevoir les Mises à Jour"
|
||||
}
|
||||
}
|
||||
|
@@ -65,7 +65,7 @@ export const mainTranslations: Record<string, Omit<Translation, 'blog' | 'antifp
|
||||
{
|
||||
title: 'Workflow Automation',
|
||||
description:
|
||||
'Automate repetitive tasks and streamline processes with Power Automate, freeing up your team to focus on strategic initiatives and boosting overall efficiency.',
|
||||
'Automate repetitive tasks and streamline processes with Power Automate, freeing up your team to focus on strategic initiatives and boosting overall efficiency.',
|
||||
icon: 'tabler:settings-automation',
|
||||
},
|
||||
{
|
||||
|
@@ -9,6 +9,7 @@ export interface Translation {
|
||||
resume: string;
|
||||
certifications: string;
|
||||
skills: string;
|
||||
appliedSkills: string;
|
||||
education: string;
|
||||
blog: string;
|
||||
services: string;
|
||||
@@ -86,6 +87,16 @@ export interface Translation {
|
||||
};
|
||||
}[];
|
||||
};
|
||||
appliedSkills: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
items: {
|
||||
name: string;
|
||||
issueDate: string;
|
||||
description: string;
|
||||
linkUrl: string;
|
||||
}[];
|
||||
};
|
||||
skills: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -176,6 +187,7 @@ export const translations: Record<string, Translation> = {
|
||||
resume: 'Resume',
|
||||
certifications: 'Certifications',
|
||||
skills: 'Skills',
|
||||
appliedSkills: 'Applied Skills',
|
||||
education: 'Education',
|
||||
blog: 'Blog',
|
||||
services: 'Services',
|
||||
@@ -544,6 +556,33 @@ export const translations: Record<string, Translation> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
appliedSkills: {
|
||||
title: 'Applied Skills',
|
||||
subtitle: 'Practical Expertise in Action',
|
||||
items: [
|
||||
{
|
||||
name: 'Get started with identities and access using Microsoft Entra',
|
||||
issueDate: 'Date Issued: 06-2025',
|
||||
description:
|
||||
'Earning this Microsoft Applied Skill demonstrates proficiency in managing identities and access using Microsoft Entra. This skill validates expertise in implementing identity solutions, managing user access, and securing organizational resources through Microsoft\'s identity and access management platform.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/entra-identities-access/',
|
||||
},
|
||||
{
|
||||
name: 'Create and manage automated processes with Power Automate',
|
||||
issueDate: 'Date Issued: 06-2025',
|
||||
description:
|
||||
'Earning this Microsoft Applied Skill demonstrates expertise in creating and managing automated workflows using Power Automate. This skill validates proficiency in designing business process automation, integrating with various data sources, and implementing efficient workflow solutions to streamline organizational operations.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/power-automate-processes/',
|
||||
},
|
||||
{
|
||||
name: 'Create agents in Microsoft Copilot Studio',
|
||||
issueDate: 'Date Issued: 06-2025',
|
||||
description:
|
||||
'Earning this Microsoft Applied Skill demonstrates proficiency in developing intelligent conversational agents using Microsoft Copilot Studio. This skill validates expertise in creating AI-powered chatbots, implementing natural language processing, and building interactive solutions that enhance user engagement and support.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/copilot-studio-agents/',
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
title: 'Skills',
|
||||
subtitle: 'Discover the proficiencies that allow me to bring imagination to life through design.',
|
||||
@@ -632,6 +671,7 @@ export const translations: Record<string, Translation> = {
|
||||
resume: 'CV',
|
||||
certifications: 'Certificeringen',
|
||||
skills: 'Vaardigheden',
|
||||
appliedSkills: 'Toegepaste Vaardigheden',
|
||||
education: 'Opleiding',
|
||||
blog: 'Blog',
|
||||
services: 'Diensten',
|
||||
@@ -1000,6 +1040,33 @@ export const translations: Record<string, Translation> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
appliedSkills: {
|
||||
title: 'Toegepaste Vaardigheden',
|
||||
subtitle: 'Praktische Expertise in Actie',
|
||||
items: [
|
||||
{
|
||||
name: 'Aan de slag met identiteiten en toegang met Microsoft Entra',
|
||||
issueDate: 'Datum van afgifte: 06-2025',
|
||||
description:
|
||||
'Het behalen van deze Microsoft Applied Skill toont vaardigheid aan in het beheren van identiteiten en toegang met Microsoft Entra. Deze vaardigheid valideert expertise in het implementeren van identiteitsoplossingen, het beheren van gebruikers toegang en het beveiligen van organisatorische middelen via Microsoft\'s identiteits- en toegangsbeheerplatform.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/entra-identities-access/',
|
||||
},
|
||||
{
|
||||
name: 'Geautomatiseerde processen maken en beheren met Power Automate',
|
||||
issueDate: 'Datum van afgifte: 06-2025',
|
||||
description:
|
||||
'Het behalen van deze Microsoft Applied Skill toont expertise aan in het maken en beheren van geautomatiseerde workflows met Power Automate. Deze vaardigheid valideert bekwaamheid in het ontwerpen van bedrijfsprocesautomatisering, het integreren met verschillende gegevensbronnen en het implementeren van efficiënte workflow-oplossingen om organisatorische operaties te stroomlijnen.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/power-automate-processes/',
|
||||
},
|
||||
{
|
||||
name: 'Agents maken in Microsoft Copilot Studio',
|
||||
issueDate: 'Datum van afgifte: 06-2025',
|
||||
description:
|
||||
'Het behalen van deze Microsoft Applied Skill toont vaardigheid aan in het ontwikkelen van intelligente conversatie-agents met Microsoft Copilot Studio. Deze vaardigheid valideert expertise in het maken van AI-aangedreven chatbots, het implementeren van natuurlijke taalverwerking en het bouwen van interactieve oplossingen die gebruikersbetrokkenheid en ondersteuning verbeteren.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/copilot-studio-agents/',
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
title: 'Vaardigheden',
|
||||
subtitle:
|
||||
@@ -1089,6 +1156,7 @@ export const translations: Record<string, Translation> = {
|
||||
resume: 'Lebenslauf',
|
||||
certifications: 'Zertifizierungen',
|
||||
skills: 'Fähigkeiten',
|
||||
appliedSkills: 'Angewandte Fähigkeiten',
|
||||
education: 'Ausbildung',
|
||||
blog: 'Blog',
|
||||
services: 'Dienstleistungen',
|
||||
@@ -1457,6 +1525,33 @@ export const translations: Record<string, Translation> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
appliedSkills: {
|
||||
title: 'Angewandte Fähigkeiten',
|
||||
subtitle: 'Praktische Expertise in Aktion',
|
||||
items: [
|
||||
{
|
||||
name: 'Erste Schritte mit Identitäten und Zugriff mit Microsoft Entra',
|
||||
issueDate: 'Ausstellungsdatum: 06-2025',
|
||||
description:
|
||||
'Der Erwerb dieser Microsoft Applied Skill zeigt Kompetenz in der Verwaltung von Identitäten und Zugriff mit Microsoft Entra. Diese Fähigkeit validiert Expertise in der Implementierung von Identitätslösungen, der Verwaltung von Benutzerzugriff und der Sicherung organisatorischer Ressourcen über Microsofts Identitäts- und Zugriffsverwaltungsplattform.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/entra-identities-access/',
|
||||
},
|
||||
{
|
||||
name: 'Automatisierte Prozesse mit Power Automate erstellen und verwalten',
|
||||
issueDate: 'Ausstellungsdatum: 06-2025',
|
||||
description:
|
||||
'Der Erwerb dieser Microsoft Applied Skill zeigt Expertise in der Erstellung und Verwaltung automatisierter Workflows mit Power Automate. Diese Fähigkeit validiert Kompetenz im Design von Geschäftsprozessautomatisierung, der Integration mit verschiedenen Datenquellen und der Implementierung effizienter Workflow-Lösungen zur Optimierung organisatorischer Abläufe.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/power-automate-processes/',
|
||||
},
|
||||
{
|
||||
name: 'Agents in Microsoft Copilot Studio erstellen',
|
||||
issueDate: 'Ausstellungsdatum: 06-2025',
|
||||
description:
|
||||
'Der Erwerb dieser Microsoft Applied Skill zeigt Kompetenz in der Entwicklung intelligenter Konversations-Agents mit Microsoft Copilot Studio. Diese Fähigkeit validiert Expertise in der Erstellung KI-gestützter Chatbots, der Implementierung natürlicher Sprachverarbeitung und dem Aufbau interaktiver Lösungen, die Benutzerengagement und Support verbessern.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/copilot-studio-agents/',
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
title: 'Fähigkeiten',
|
||||
subtitle:
|
||||
@@ -1546,6 +1641,7 @@ export const translations: Record<string, Translation> = {
|
||||
resume: 'CV',
|
||||
certifications: 'Certifications',
|
||||
skills: 'Compétences',
|
||||
appliedSkills: 'Compétences Appliquées',
|
||||
education: 'Formation',
|
||||
blog: 'Blog',
|
||||
services: 'Services',
|
||||
@@ -1914,6 +2010,33 @@ export const translations: Record<string, Translation> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
appliedSkills: {
|
||||
title: 'Compétences Appliquées',
|
||||
subtitle: 'Expertise Pratique en Action',
|
||||
items: [
|
||||
{
|
||||
name: 'Commencer avec les identités et l\'accès avec Microsoft Entra',
|
||||
issueDate: 'Date de délivrance : 06-2025',
|
||||
description:
|
||||
'L\'obtention de cette Microsoft Applied Skill démontre la compétence dans la gestion des identités et de l\'accès avec Microsoft Entra. Cette compétence valide l\'expertise dans l\'implémentation de solutions d\'identité, la gestion de l\'accès utilisateur et la sécurisation des ressources organisationnelles via la plateforme de gestion d\'identité et d\'accès de Microsoft.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/entra-identities-access/',
|
||||
},
|
||||
{
|
||||
name: 'Créer et gérer des processus automatisés avec Power Automate',
|
||||
issueDate: 'Date de délivrance : 06-2025',
|
||||
description:
|
||||
'L\'obtention de cette Microsoft Applied Skill démontre l\'expertise dans la création et la gestion de workflows automatisés avec Power Automate. Cette compétence valide la maîtrise dans la conception de l\'automatisation des processus métier, l\'intégration avec diverses sources de données et l\'implémentation de solutions de workflow efficaces pour rationaliser les opérations organisationnelles.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/power-automate-processes/',
|
||||
},
|
||||
{
|
||||
name: 'Créer des agents dans Microsoft Copilot Studio',
|
||||
issueDate: 'Date de délivrance : 06-2025',
|
||||
description:
|
||||
'L\'obtention de cette Microsoft Applied Skill démontre la compétence dans le développement d\'agents conversationnels intelligents avec Microsoft Copilot Studio. Cette compétence valide l\'expertise dans la création de chatbots alimentés par l\'IA, l\'implémentation du traitement du langage naturel et la construction de solutions interactives qui améliorent l\'engagement et le support des utilisateurs.',
|
||||
linkUrl: 'https://learn.microsoft.com/en-us/credentials/applied-skills/copilot-studio-agents/',
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
title: 'Compétences',
|
||||
subtitle: "Découvrez les compétences qui me permettent de donner vie à l'imagination par le design.",
|
||||
|
174
src/i18n/translations.youtube.ts
Normal file
174
src/i18n/translations.youtube.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// Focused YouTube userscript page translations
|
||||
import { supportedLanguages } from './translations';
|
||||
|
||||
export type SupportedLanguage = typeof supportedLanguages[number];
|
||||
|
||||
export interface FocusedYouTubeTranslation {
|
||||
title: string;
|
||||
summary: string;
|
||||
features: {
|
||||
title: string;
|
||||
items: string[];
|
||||
};
|
||||
howToInstall: string;
|
||||
step1: string;
|
||||
step2: string;
|
||||
step3: string;
|
||||
step4: string;
|
||||
notes: {
|
||||
title: string;
|
||||
items: string[];
|
||||
};
|
||||
targetAudience: string;
|
||||
moreDetails: string;
|
||||
viewOnGreasyFork: string;
|
||||
}
|
||||
|
||||
const translations: Record<SupportedLanguage, FocusedYouTubeTranslation> = {
|
||||
en: {
|
||||
title: 'Focused YouTube – Remove Distractions & Ads',
|
||||
summary: 'Focused YouTube is a userscript that removes ads, shorts, and algorithmic suggestions from YouTube. Enjoy a cleaner, distraction-free experience in English, Dutch, German, and French.',
|
||||
features: {
|
||||
title: 'Key Features',
|
||||
items: [
|
||||
'Removes all YouTube ads (pre-roll, banners, overlays)',
|
||||
'Hides Shorts and algorithmic suggestions',
|
||||
'Redirects Shorts to regular video player',
|
||||
'Skips video ads automatically',
|
||||
'Hides live chat and related videos',
|
||||
'Forces cinema/theater mode for better focus',
|
||||
'Supports multiple languages (EN/NL/DE/FR)',
|
||||
'Customizable settings for homepage, search, and more',
|
||||
'Works on both desktop and mobile YouTube',
|
||||
'Open source and privacy-friendly',
|
||||
],
|
||||
},
|
||||
howToInstall: 'How to Install',
|
||||
step1: 'Install a userscript manager like Tampermonkey or Violentmonkey in your browser.',
|
||||
step2: 'Visit the Focused YouTube script page on Greasy Fork: https://greasyfork.org/en/scripts/541103-focused-youtube',
|
||||
step3: 'Click "Install" to add the script to your userscript manager.',
|
||||
step4: 'Refresh YouTube and enjoy a cleaner, distraction-free experience!',
|
||||
notes: {
|
||||
title: 'Important Notes',
|
||||
items: [
|
||||
'Some features may break if YouTube updates its layout. The script will be updated as needed.',
|
||||
'You can customize settings by editing the script or using the userscript manager options.',
|
||||
'No data is collected or sent anywhere. 100% privacy-friendly.',
|
||||
],
|
||||
},
|
||||
targetAudience: 'Anyone who wants to focus on content, avoid distractions, and block ads on YouTube.',
|
||||
moreDetails: 'For more information, source code, and updates, visit greasyfork.org or contact the author.',
|
||||
viewOnGreasyFork: 'View on Greasy Fork',
|
||||
},
|
||||
nl: {
|
||||
title: 'Focused YouTube – Verwijder Afleiding & Advertenties',
|
||||
summary: 'Focused YouTube is een gebruikersscript dat advertenties, Shorts en algoritmische aanbevelingen op YouTube verwijdert. Geniet van een schonere, afleidingsvrije ervaring in het Engels, Nederlands, Duits en Frans.',
|
||||
features: {
|
||||
title: 'Belangrijkste functies',
|
||||
items: [
|
||||
'Verwijdert alle YouTube-advertenties (pre-roll, banners, overlays)',
|
||||
'Verbergt Shorts en algoritmische aanbevelingen',
|
||||
'Redirects Shorts naar de normale videospeler',
|
||||
'Slaat videoadvertenties automatisch over',
|
||||
'Verbergt livechat en gerelateerde video\'s',
|
||||
'Forceert de bioscoopmodus voor meer focus',
|
||||
'Ondersteunt meerdere talen (EN/NL/DE/FR)',
|
||||
'Aanpasbare instellingen voor startpagina, zoeken en meer',
|
||||
'Werkt op zowel desktop als mobiele YouTube',
|
||||
'Open source en privacyvriendelijk',
|
||||
],
|
||||
},
|
||||
howToInstall: 'Installatiehandleiding',
|
||||
step1: 'Installeer een userscriptmanager zoals Tampermonkey of Violentmonkey in je browser.',
|
||||
step2: 'Bezoek de Focused YouTube-scriptpagina op Greasy Fork: https://greasyfork.org/en/scripts/541103-focused-youtube',
|
||||
step3: 'Klik op "Installeren" om het script toe te voegen aan je userscriptmanager.',
|
||||
step4: 'Ververs YouTube en geniet van een schonere, afleidingsvrije ervaring!',
|
||||
notes: {
|
||||
title: 'Belangrijke opmerkingen',
|
||||
items: [
|
||||
'Sommige functies kunnen breken als YouTube zijn layout aanpast. Het script wordt indien nodig bijgewerkt.',
|
||||
'Je kunt instellingen aanpassen door het script te bewerken of via de manageropties.',
|
||||
'Er worden geen gegevens verzameld of verzonden. 100% privacyvriendelijk.',
|
||||
],
|
||||
},
|
||||
targetAudience: 'Iedereen die zich wil concentreren op inhoud, afleiding wil vermijden en advertenties op YouTube wil blokkeren.',
|
||||
moreDetails: 'Voor meer informatie, broncode en updates, bezoek de greasyfork.org site of neem contact op met de auteur.',
|
||||
viewOnGreasyFork: 'Bekijk op Greasy Fork',
|
||||
},
|
||||
de: {
|
||||
title: 'Focused YouTube – Ablenkungen & Werbung entfernen',
|
||||
summary: 'Focused YouTube ist ein Userscript, das Werbung, Shorts und algorithmische Empfehlungen von YouTube entfernt. Genieße ein aufgeräumtes, ablenkungsfreies YouTube-Erlebnis in Englisch, Niederländisch, Deutsch und Französisch.',
|
||||
features: {
|
||||
title: 'Hauptfunktionen',
|
||||
items: [
|
||||
'Entfernt alle YouTube-Werbung (Pre-Roll, Banner, Overlays)',
|
||||
'Blendet Shorts und algorithmische Vorschläge aus',
|
||||
'Leitet Shorts zum normalen Videoplayer um',
|
||||
'Überspringt Videowerbung automatisch',
|
||||
'Blendet Live-Chat und verwandte Videos aus',
|
||||
'Erzwingt Kinomodus für besseren Fokus',
|
||||
'Unterstützt mehrere Sprachen (EN/NL/DE/FR)',
|
||||
'Anpassbare Einstellungen für Startseite, Suche und mehr',
|
||||
'Funktioniert auf Desktop- und Mobil-YouTube',
|
||||
'Open Source und datenschutzfreundlich',
|
||||
],
|
||||
},
|
||||
howToInstall: 'Installationsanleitung',
|
||||
step1: 'Installiere einen Userscript-Manager wie Tampermonkey oder Violentmonkey in deinem Browser.',
|
||||
step2: 'Besuche die Focused YouTube-Skriptseite auf Greasy Fork: https://greasyfork.org/en/scripts/541103-focused-youtube',
|
||||
step3: 'Klicke auf „Installieren“, um das Skript deinem Userscript-Manager hinzuzufügen.',
|
||||
step4: 'Lade YouTube neu und genieße eine ablenkungsfreie Nutzung!',
|
||||
notes: {
|
||||
title: 'Wichtige Hinweise',
|
||||
items: [
|
||||
'Einige Funktionen könnten bei YouTube-Layoutänderungen nicht mehr funktionieren. Das Skript wird entsprechend aktualisiert.',
|
||||
'Du kannst Einstellungen im Skript oder über die Manageroptionen anpassen.',
|
||||
'Es werden keinerlei Daten gesammelt oder übertragen. 100% datenschutzfreundlich.',
|
||||
],
|
||||
},
|
||||
targetAudience: 'Alle, die sich auf Inhalte konzentrieren, Ablenkungen vermeiden und Werbung auf YouTube blockieren möchten.',
|
||||
moreDetails: 'Für weitere Informationen, Quellcode und Updates besuche das greasyfork.org site oder kontaktiere den Autor.',
|
||||
viewOnGreasyFork: 'Auf Greasy Fork ansehen',
|
||||
},
|
||||
fr: {
|
||||
title: 'Focused YouTube – Supprimer les distractions et les publicités',
|
||||
summary: 'Focused YouTube est un userscript qui supprime les publicités, les Shorts et les suggestions algorithmiques sur YouTube. Profitez d’une expérience plus claire et sans distractions en anglais, néerlandais, allemand et français.',
|
||||
features: {
|
||||
title: 'Fonctionnalités principales',
|
||||
items: [
|
||||
'Supprime toutes les publicités YouTube (pré-roll, bannières, superpositions)',
|
||||
'Masque les Shorts et suggestions algorithmiques',
|
||||
'Redirige les Shorts vers le lecteur vidéo classique',
|
||||
'Passe automatiquement les publicités vidéo',
|
||||
'Masque le chat en direct et les vidéos connexes',
|
||||
'Active automatiquement le mode cinéma pour une meilleure concentration',
|
||||
'Prise en charge de plusieurs langues (EN/NL/DE/FR)',
|
||||
'Paramètres personnalisables pour la page d’accueil, la recherche, etc.',
|
||||
'Fonctionne sur YouTube version bureau et mobile',
|
||||
'Open source et respectueux de la vie privée',
|
||||
],
|
||||
},
|
||||
howToInstall: 'Comment l’installer',
|
||||
step1: 'Installez un gestionnaire de userscripts comme Tampermonkey ou Violentmonkey dans votre navigateur.',
|
||||
step2: 'Visitez la page du script Focused YouTube sur Greasy Fork : https://greasyfork.org/en/scripts/541103-focused-youtube',
|
||||
step3: 'Cliquez sur "Installer" pour ajouter le script à votre gestionnaire de scripts.',
|
||||
step4: 'Rafraîchissez YouTube et profitez d’une expérience sans distractions !',
|
||||
notes: {
|
||||
title: 'Remarques importantes',
|
||||
items: [
|
||||
'Certaines fonctionnalités peuvent cesser de fonctionner si YouTube modifie sa mise en page. Le script sera mis à jour si nécessaire.',
|
||||
'Vous pouvez personnaliser les paramètres en modifiant le script ou via le gestionnaire de scripts.',
|
||||
'Aucune donnée n’est collectée ni envoyée. 100% respectueux de la vie privée.',
|
||||
],
|
||||
},
|
||||
targetAudience: 'Toute personne souhaitant se concentrer sur le contenu, éviter les distractions et bloquer les publicités sur YouTube.',
|
||||
moreDetails: 'Pour plus d’informations, le code source et les mises à jour, consultez le dépôt greasyfork.org ou contactez l’auteur.',
|
||||
viewOnGreasyFork: 'Voir sur Greasy Fork',
|
||||
},
|
||||
};
|
||||
|
||||
export function getFocusedYouTubeTranslation(lang: SupportedLanguage): FocusedYouTubeTranslation {
|
||||
return translations[lang] || translations.en;
|
||||
}
|
||||
|
||||
export const focusedYouTubeSupportedLanguages = Object.keys(translations) as SupportedLanguage[];
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import '~/assets/styles/tailwind.css';
|
||||
|
||||
import { I18N } from 'astrowind:config';
|
||||
import { I18N, SITE } from 'astrowind:config';
|
||||
|
||||
import CommonMeta from '~/components/common/CommonMeta.astro';
|
||||
import Favicons from '~/components/Favicons.astro';
|
||||
@@ -52,10 +52,10 @@ const { language, textDirection } = I18N;
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: '365DevNet',
|
||||
url: Astro.url.origin,
|
||||
url: SITE.site,
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${Astro.url.origin}/search?q={search_term_string}`,
|
||||
target: `${SITE.site}/search?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}}
|
||||
@@ -67,23 +67,87 @@ const { language, textDirection } = I18N;
|
||||
|
||||
<body class="antialiased text-default bg-page tracking-tight">
|
||||
<GlobalBackground />
|
||||
<slot name="structured-data" />
|
||||
<slot />
|
||||
|
||||
<header role="banner">
|
||||
<slot name="header" />
|
||||
</header>
|
||||
<nav role="navigation" aria-label="Main navigation">
|
||||
<slot name="navigation" />
|
||||
</nav>
|
||||
<main role="main">
|
||||
<slot name="structured-data" />
|
||||
<slot />
|
||||
</main>
|
||||
<footer role="contentinfo">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
<BasicScripts />
|
||||
<LanguagePersistence />
|
||||
<CookieBanner />
|
||||
<BackToTop />
|
||||
<!-- Start of Rocket.Chat Livechat Script -->
|
||||
<script type="text/javascript" defer>
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
// Initialize RocketChat livechat widget with proper error handling
|
||||
(function(w, d, s, u) {
|
||||
w.RocketChat = function(c) { w.RocketChat._.push(c) }; w.RocketChat._ = []; w.RocketChat.url = u;
|
||||
const h = d.getElementsByTagName(s)[0], j = d.createElement(s);
|
||||
j.async = true; j.src = 'https://chat.365devnet.eu/livechat/rocketchat-livechat.min.js?_=201903270000';
|
||||
h.parentNode.insertBefore(j, h);
|
||||
console.log('RocketChat initialization started');
|
||||
|
||||
// Initialize RocketChat object if it doesn't exist
|
||||
w.RocketChat = function(c) {
|
||||
if (w.RocketChat._) {
|
||||
w.RocketChat._.push(c);
|
||||
} else {
|
||||
w.RocketChat._ = [c];
|
||||
}
|
||||
};
|
||||
|
||||
if (!w.RocketChat._) {
|
||||
w.RocketChat._ = [];
|
||||
}
|
||||
|
||||
w.RocketChat.url = u;
|
||||
console.log('RocketChat URL set to:', u);
|
||||
|
||||
// Wait for DOM to be ready before loading the script
|
||||
function loadRocketChat() {
|
||||
try {
|
||||
console.log('Loading RocketChat script...');
|
||||
const h = d.getElementsByTagName(s)[0];
|
||||
if (!h) {
|
||||
console.warn('No script tag found to insert before');
|
||||
return;
|
||||
}
|
||||
|
||||
const j = d.createElement(s);
|
||||
j.async = true;
|
||||
j.src = 'https://chat.365devnet.eu/livechat/rocketchat-livechat.min.js?_=201903270000';
|
||||
|
||||
// Add error handling for script loading
|
||||
j.onerror = function() {
|
||||
console.warn('RocketChat livechat script failed to load');
|
||||
};
|
||||
|
||||
j.onload = function() {
|
||||
console.log('RocketChat script loaded successfully');
|
||||
};
|
||||
|
||||
h.parentNode.insertBefore(j, h);
|
||||
console.log('RocketChat script tag inserted');
|
||||
} catch (error) {
|
||||
console.warn('Error loading RocketChat livechat:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load when DOM is ready
|
||||
if (d.readyState === 'loading') {
|
||||
console.log('DOM still loading, waiting for DOMContentLoaded');
|
||||
d.addEventListener('DOMContentLoaded', loadRocketChat);
|
||||
} else {
|
||||
console.log('DOM already loaded, loading RocketChat immediately');
|
||||
loadRocketChat();
|
||||
}
|
||||
})(window, document, 'script', 'https://chat.365devnet.eu/livechat');
|
||||
</script>
|
||||
<!-- End of Rocket.Chat Livechat Script -->
|
||||
|
||||
<ImageModal />
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -33,6 +33,11 @@ export const getHeaderData = (lang = 'en') => {
|
||||
href: getPermalink('/aboutme', 'page', lang) + '#certifications',
|
||||
isHashLink: true,
|
||||
},
|
||||
{
|
||||
text: t.navigation.appliedSkills,
|
||||
href: getPermalink('/aboutme', 'page', lang) + '#microsoft-applied-skills',
|
||||
isHashLink: true,
|
||||
},
|
||||
{
|
||||
text: t.navigation.education,
|
||||
href: getPermalink('/aboutme', 'page', lang) + '#education',
|
||||
@@ -43,11 +48,6 @@ export const getHeaderData = (lang = 'en') => {
|
||||
{
|
||||
text: t.development.menu,
|
||||
links: [
|
||||
{
|
||||
text: t.development.title,
|
||||
href: getPermalink('/development', 'page', lang),
|
||||
isHashLink: false,
|
||||
},
|
||||
{
|
||||
text: 'Anti-FP Shield+',
|
||||
href: getPermalink('/antifp', 'page', lang),
|
||||
@@ -58,12 +58,22 @@ export const getHeaderData = (lang = 'en') => {
|
||||
href: getPermalink('/eap', 'page', lang),
|
||||
isHashLink: false,
|
||||
},
|
||||
{
|
||||
text: 'Focused YouTube',
|
||||
href: getPermalink('/focusedyoutube', 'page', lang),
|
||||
isHashLink: false,
|
||||
},
|
||||
{
|
||||
text: 'Journii',
|
||||
href: getPermalink('/journii', 'page', lang),
|
||||
isHashLink: false,
|
||||
},
|
||||
],
|
||||
{
|
||||
text: t.development.title,
|
||||
href: getPermalink('/development', 'page', lang),
|
||||
isHashLink: false,
|
||||
},
|
||||
].sort((a, b) => a.text.localeCompare(b.text)),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@@ -2,7 +2,11 @@
|
||||
import Layout from '~/layouts/Layout.astro';
|
||||
import { getHomePermalink } from '~/utils/permalinks';
|
||||
|
||||
const title = `Error 404`;
|
||||
const metadata = {
|
||||
title: '404 Not Found | 365DevNet',
|
||||
description: 'Sorry, the page you are looking for does not exist. Return to the homepage or explore other sections of 365DevNet.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
---
|
||||
|
||||
<style>
|
||||
@@ -382,7 +386,7 @@ const title = `Error 404`;
|
||||
}
|
||||
</style>
|
||||
|
||||
<Layout metadata={{ title }}>
|
||||
<Layout metadata={metadata}>
|
||||
<section class="clean-404-bg">
|
||||
<div class="floating-elements-404">
|
||||
<div class="floating-shape-404 shape-404-1"></div>
|
||||
|
@@ -7,11 +7,13 @@ import Content from '~/components/widgets/Content.astro';
|
||||
import ModernEducation from '~/components/widgets/ModernEducation.astro';
|
||||
import ModernWorkExperience from '~/components/widgets/ModernWorkExperience.astro';
|
||||
import ModernCertifications from '~/components/widgets/ModernCertifications.astro';
|
||||
import ModernAppliedSkills from '~/components/widgets/ModernAppliedSkills.astro';
|
||||
import ModernSkills from '~/components/widgets/ModernSkills.astro';
|
||||
import CallToAction from '~/components/widgets/CallToAction.astro';
|
||||
import HomePageImage from '~/assets/images/richardbergsma.png';
|
||||
|
||||
import { getTranslation, supportedLanguages } from '~/i18n/translations';
|
||||
import { getTranslation } from '~/i18n/translations';
|
||||
import { getAboutMeTranslation, supportedLanguages } from '~/i18n/translations.aboutme';
|
||||
import { SITE } from 'astrowind:config';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return supportedLanguages.map((lang) => ({
|
||||
@@ -24,7 +26,10 @@ if (!supportedLanguages.includes(lang)) {
|
||||
return Astro.redirect('/en/aboutme');
|
||||
}
|
||||
|
||||
const t = getTranslation(lang);
|
||||
// Get main translations for shared components (navigation, footer, etc.)
|
||||
const mainT = getTranslation(lang);
|
||||
// Get about me specific translations
|
||||
const t = getAboutMeTranslation(lang);
|
||||
|
||||
const metadata = {
|
||||
title: 'About Richard Bergsma | IT Systems & Automation Specialist | 365DevNet',
|
||||
@@ -288,6 +293,21 @@ const metadata = {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Light mode fixes for secondary CTA button */
|
||||
html:not(.dark) .cta-secondary-enhanced {
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
color: rgba(15, 23, 42, 1) !important;
|
||||
border-color: rgba(102, 126, 234, 0.6) !important;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .cta-secondary-enhanced:hover {
|
||||
background: rgba(102, 126, 234, 0.1) !important;
|
||||
color: rgba(15, 23, 42, 1) !important;
|
||||
border-color: rgba(102, 126, 234, 0.8) !important;
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Section spacing - key fix for touching sections */
|
||||
.content-section {
|
||||
margin: 4rem 0;
|
||||
@@ -369,11 +389,11 @@ const metadata = {
|
||||
'@type': 'Person',
|
||||
name: 'Richard Bergsma',
|
||||
jobTitle: 'IT Systems and Automation Manager',
|
||||
description: t.hero.subtitle,
|
||||
image: Astro.url.origin + '/images/richardbergsma.png',
|
||||
url: Astro.url.origin,
|
||||
description: mainT.hero.subtitle,
|
||||
image: SITE.site + '/images/richardbergsma.png',
|
||||
url: SITE.site,
|
||||
sameAs: ['https://www.linkedin.com/in/rrpbergsma', 'https://github.com/rrpbergsma'],
|
||||
knowsAbout: t.skills.items.map((skill) => skill.title),
|
||||
knowsAbout: mainT.skills.items.map((skill) => skill.title),
|
||||
worksFor: {
|
||||
'@type': 'Organization',
|
||||
name: 'COFRA Holding C.V.',
|
||||
@@ -396,13 +416,13 @@ const metadata = {
|
||||
</Fragment>
|
||||
|
||||
<Fragment slot="title">
|
||||
<span class="hero-title-enhanced">{t.hero.greeting}</span>
|
||||
<span class="hero-title-enhanced">{mainT.hero.greeting}</span>
|
||||
</Fragment>
|
||||
|
||||
<Fragment slot="subtitle">
|
||||
<div class="hero-subtitle-container">
|
||||
<div class="hero-subtitle-enhanced">
|
||||
{t.hero.subtitle}
|
||||
{mainT.hero.subtitle}
|
||||
</div>
|
||||
<div class="cta-container">
|
||||
<a href="#about" class="cta-primary-enhanced">
|
||||
@@ -436,7 +456,7 @@ const metadata = {
|
||||
{t.about.title}
|
||||
</h2>
|
||||
{
|
||||
t.about.content.map((paragraph, index) => (
|
||||
t.about.content.map((paragraph) => (
|
||||
<div class="mb-6">
|
||||
<p class="text-lg leading-relaxed text-gray-700 dark:text-gray-300">{paragraph}</p>
|
||||
</div>
|
||||
@@ -507,6 +527,24 @@ const metadata = {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Applied Skills with Modern Grid -->
|
||||
<div id="microsoft-applied-skills" class="content-section stagger-animation">
|
||||
<ModernAppliedSkills
|
||||
id="microsoft-applied-skills"
|
||||
title={t.appliedSkills.title}
|
||||
subtitle={t.appliedSkills.subtitle}
|
||||
testimonials={t.appliedSkills.items.map((skill) => ({
|
||||
name: skill.name,
|
||||
issueDate: skill.issueDate,
|
||||
description: skill.description,
|
||||
linkUrl: skill.linkUrl,
|
||||
}))}
|
||||
classes={{
|
||||
container: 'glass-enhanced'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Education -->
|
||||
<div id="education" class="content-section stagger-animation">
|
||||
<ModernEducation
|
||||
|
157
src/pages/[lang]/focusedyoutube.astro
Normal file
157
src/pages/[lang]/focusedyoutube.astro
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
import Layout from '~/layouts/PageLayout.astro';
|
||||
import { getFocusedYouTubeTranslation, focusedYouTubeSupportedLanguages } from '~/i18n/translations.youtube';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return focusedYouTubeSupportedLanguages.map((lang) => ({
|
||||
params: { lang },
|
||||
}));
|
||||
}
|
||||
|
||||
const { lang } = Astro.params;
|
||||
if (!focusedYouTubeSupportedLanguages.includes(lang)) {
|
||||
return Astro.redirect('/en/focusedyoutube');
|
||||
}
|
||||
|
||||
const t = getFocusedYouTubeTranslation(lang);
|
||||
|
||||
const metadata = {
|
||||
title: t.title,
|
||||
description: t.summary,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout metadata={metadata}>
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="text-center mb-12 backdrop-blur-sm bg-gradient-to-br from-red-50/80 to-orange-50/80 dark:from-slate-800/80 dark:to-slate-900/80 rounded-2xl p-8 shadow-lg">
|
||||
<div class="text-6xl mb-4">🎬</div>
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-red-600 to-orange-600 bg-clip-text text-transparent">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 dark:text-slate-300 mb-6 max-w-2xl mx-auto">
|
||||
{t.summary}
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="https://greasyfork.org/en/scripts/541103-focused-youtube"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-red-600 to-orange-600 text-white rounded-lg font-semibold hover:from-red-700 hover:to-orange-700 transition-all shadow-lg"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
{t.viewOnGreasyFork}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="backdrop-blur-sm bg-white/70 dark:bg-slate-900/70 rounded-xl shadow p-8 mb-12">
|
||||
<p class="text-lg text-gray-700 dark:text-slate-200 leading-relaxed text-center">
|
||||
{t.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="mb-12">
|
||||
<h2 class="text-3xl font-bold text-center mb-8">{t.features.title}</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
{t.features.items.map((item, i) => (
|
||||
<div class="backdrop-blur-sm bg-white/70 dark:bg-slate-900/70 rounded-xl shadow p-6 hover:shadow-lg transition-shadow">
|
||||
<div class="flex items-start">
|
||||
<div class="text-2xl mr-4 mt-1">
|
||||
{i === 0 ? '🚫' :
|
||||
i === 1 ? '🙈' :
|
||||
i === 2 ? '🔁' :
|
||||
i === 3 ? '⏩' :
|
||||
i === 4 ? '💬' :
|
||||
i === 5 ? '🎥' :
|
||||
i === 6 ? '🌐' :
|
||||
i === 7 ? '⚙️' :
|
||||
i === 8 ? '📱' :
|
||||
i === 9 ? '🔓' : '🔧'}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-700 dark:text-slate-200">{item}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installation Guide -->
|
||||
<div class="backdrop-blur-sm bg-gradient-to-r from-blue-50/80 to-purple-50/80 dark:from-slate-800/80 dark:to-slate-900/80 rounded-xl shadow p-8 mb-12">
|
||||
<div class="text-center mb-6">
|
||||
<div class="text-4xl mb-4">📦</div>
|
||||
<h2 class="text-2xl font-bold mb-4">{t.howToInstall}</h2>
|
||||
</div>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<ol class="space-y-4">
|
||||
<li class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold text-sm mr-4 mt-1">1</div>
|
||||
<p class="text-gray-700 dark:text-slate-200">{t.step1}</p>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold text-sm mr-4 mt-1">2</div>
|
||||
<p class="text-gray-700 dark:text-slate-200">{t.step2}</p>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold text-sm mr-4 mt-1">3</div>
|
||||
<p class="text-gray-700 dark:text-slate-200">{t.step3}</p>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold text-sm mr-4 mt-1">4</div>
|
||||
<p class="text-gray-700 dark:text-slate-200">{t.step4}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important Notes -->
|
||||
<div class="backdrop-blur-sm bg-white/70 dark:bg-slate-900/70 rounded-xl shadow p-8 mb-12">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="text-3xl mr-4">⚠️</div>
|
||||
<h2 class="text-2xl font-bold">{t.notes.title}</h2>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-1 gap-4">
|
||||
{t.notes.items.map((item, index) => (
|
||||
<div class="flex items-start p-4 bg-yellow-50/50 dark:bg-yellow-900/20 rounded-lg border-l-4 border-yellow-500">
|
||||
<div class="text-yellow-600 dark:text-yellow-400 mr-3 mt-1">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-slate-200">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Audience & More Info -->
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="backdrop-blur-sm bg-gradient-to-br from-green-50/80 to-blue-50/80 dark:from-slate-800/80 dark:to-slate-900/80 rounded-xl shadow p-6">
|
||||
<div class="text-center mb-4">
|
||||
<div class="text-3xl mb-2">👥</div>
|
||||
<h3 class="text-xl font-bold">Who Is This For?</h3>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-slate-200 italic text-center">
|
||||
{t.targetAudience}
|
||||
</p>
|
||||
</div>
|
||||
<div class="backdrop-blur-sm bg-gradient-to-br from-purple-50/80 to-pink-50/80 dark:from-slate-800/80 dark:to-slate-900/80 rounded-xl shadow p-6">
|
||||
<div class="text-center mb-4">
|
||||
<div class="text-3xl mb-2">📚</div>
|
||||
<h3 class="text-xl font-bold">Learn More</h3>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-slate-200 text-center">
|
||||
{t.moreDetails}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
213
src/pages/[lang]/index copy.astro
Normal file
213
src/pages/[lang]/index copy.astro
Normal file
@@ -0,0 +1,213 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
import Layout from '~/layouts/PageLayout.astro';
|
||||
import Hero from '~/components/widgets/Hero2.astro';
|
||||
import Features from '~/components/widgets/Features.astro';
|
||||
import Content from '~/components/widgets/Content.astro';
|
||||
import Testimonials from '~/components/widgets/Testimonials.astro';
|
||||
import CallToAction from '~/components/widgets/CallToAction.astro';
|
||||
import Contact from '~/components/widgets/Contact.astro';
|
||||
import { getTranslation, supportedLanguages } from '~/i18n/translations';
|
||||
import OurCommitmentImage from '~/assets/images/OurCommitment.webp';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return supportedLanguages.map((lang) => ({
|
||||
params: { lang },
|
||||
}));
|
||||
}
|
||||
|
||||
const { lang } = Astro.params;
|
||||
if (!supportedLanguages.includes(lang)) {
|
||||
return Astro.redirect('/en/');
|
||||
}
|
||||
|
||||
const t = getTranslation(lang);
|
||||
|
||||
const metadata = {
|
||||
title: 'IT Systems & Automation for Businesses',
|
||||
description: 'IT systems & automation for businesses. Microsoft 365, Power Automate, and cloud solutions by Richard Bergsma.'
|
||||
};
|
||||
---
|
||||
|
||||
<Layout metadata={metadata}>
|
||||
<!-- Hero Widget -->
|
||||
<Hero
|
||||
title={t.hero.title}
|
||||
subtitle={t.hero.subtitle}
|
||||
isDark={false}
|
||||
actions={[
|
||||
{
|
||||
variant: 'primary',
|
||||
text: t.homepage?.actions?.learnMore || 'Learn More',
|
||||
href: '#services',
|
||||
icon: 'tabler:arrow-down',
|
||||
},
|
||||
{ text: t.homepage?.actions?.contactMe || 'Contact Me', href: '#contact' },
|
||||
]}
|
||||
image={{
|
||||
src: '~/assets/images/HomepageIntroImage.webp',
|
||||
alt: 'person sitting behind a computer with pen and paper next to him, A coffee mug and tablet on the desk',
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Features Widget -->
|
||||
<Features
|
||||
id="services"
|
||||
tagline={t.homepage?.services?.tagline || 'Services'}
|
||||
title={t.homepage?.services?.title || 'How I Can Help Your Organization'}
|
||||
subtitle={t.homepage?.services?.subtitle ||
|
||||
'I offer a range of specialized IT services to help businesses optimize their operations and digital infrastructure.'}
|
||||
items={(
|
||||
t.homepage?.services?.items || [
|
||||
{
|
||||
title: 'Workflow Automation',
|
||||
description:
|
||||
'Streamline your business processes with Power Automate solutions that reduce manual effort and increase operational efficiency.',
|
||||
icon: 'tabler:settings-automation',
|
||||
},
|
||||
{
|
||||
title: 'Intelligent Chatbots',
|
||||
description:
|
||||
'Develop smart chatbots in Copilot Studio that enhance user interactions through natural language processing and automated responses.',
|
||||
icon: 'tabler:message-chatbot',
|
||||
},
|
||||
{
|
||||
title: 'API Integrations',
|
||||
description:
|
||||
'Create seamless connections between your applications and services with custom API integrations for efficient data exchange.',
|
||||
icon: 'tabler:api',
|
||||
},
|
||||
{
|
||||
title: 'Microsoft 365 Management',
|
||||
description:
|
||||
'Optimize your Microsoft 365 environment with expert administration, security configurations, and service optimization.',
|
||||
icon: 'tabler:brand-office',
|
||||
},
|
||||
{
|
||||
title: 'SharePoint Solutions',
|
||||
description:
|
||||
'Set up, manage, and optimize SharePoint Online and on-premise deployments for effective document management and collaboration.',
|
||||
icon: 'tabler:share',
|
||||
},
|
||||
{
|
||||
title: 'IT Infrastructure Oversight',
|
||||
description:
|
||||
'Manage global IT infrastructures, including servers, networks, and end-user devices to ensure reliable operations.',
|
||||
icon: 'tabler:server',
|
||||
},
|
||||
]
|
||||
).map((item) => ({ ...item, icon: item.icon || 'tabler:check' }))}
|
||||
/>
|
||||
|
||||
<!-- Content Widget -->
|
||||
<Content
|
||||
isReversed
|
||||
tagline={t.homepage?.approach?.tagline || 'About My Approach'}
|
||||
title={t.homepage?.approach?.title || 'Our Commitment'}
|
||||
items={[]}
|
||||
image={{
|
||||
src: OurCommitmentImage,
|
||||
alt: 'IT Excellence and Innovation',
|
||||
}}
|
||||
>
|
||||
<Fragment slot="content">
|
||||
<div class="text-lg dark:text-slate-400">
|
||||
{
|
||||
(
|
||||
t.homepage?.approach?.missionContent || [
|
||||
'We are committed to driving IT excellence through strategic cloud optimization, process automation, and enterprise-grade technical support. We leverage cutting-edge technology to address complex business challenges and deliver measurable value.',
|
||||
'With deep expertise in Microsoft technologies and automation, we empower organizations to transform their digital capabilities and achieve their business objectives. We design solutions that enhance user experience and maximize productivity, ensuring technology empowers your business.',
|
||||
'We stay ahead of the curve by researching and implementing emerging technologies, providing scalable solutions that adapt to your evolving needs. Our approach aligns technical solutions with your core business objectives, delivering measurable ROI and a competitive advantage.',
|
||||
'Our mission is to leverage technology to solve real business challenges and create value through innovation. With over 15 years of IT experience, we bring a wealth of knowledge in Microsoft technologies, automation tools, and system integration to help organizations transform their digital capabilities and achieve their strategic goals.',
|
||||
]
|
||||
).map((paragraph, index, array) => <p class={index === array.length - 1 ? '' : 'mb-4'}>{paragraph}</p>)
|
||||
}
|
||||
</div>
|
||||
</Fragment>
|
||||
</Content>
|
||||
|
||||
<!-- Testimonials Widget
|
||||
<Testimonials
|
||||
tagline={t.homepage?.testimonials?.tagline || "Testimonials"}
|
||||
title={t.homepage?.testimonials?.title || "What Clients Say About My Work"}
|
||||
testimonials={(t.homepage?.testimonials?.items || [
|
||||
{
|
||||
testimonial:
|
||||
"Richard's expertise in Power Automate transformed our workflow processes, saving us countless hours and reducing errors significantly.",
|
||||
name: 'Client Name',
|
||||
description: 'Position, Company',
|
||||
},
|
||||
{
|
||||
testimonial:
|
||||
"The SharePoint implementation Richard delivered has revolutionized our document management and team collaboration capabilities.",
|
||||
name: 'Client Name',
|
||||
description: 'Position, Company',
|
||||
},
|
||||
{
|
||||
testimonial:
|
||||
"Richard's technical knowledge combined with his ability to understand our business needs resulted in solutions that truly addressed our challenges.",
|
||||
name: 'Client Name',
|
||||
description: 'Position, Company',
|
||||
},
|
||||
]).map(item => ({
|
||||
...item,
|
||||
image: {
|
||||
src: '~/assets/images/default.png',
|
||||
alt: item.name,
|
||||
}
|
||||
}))}
|
||||
/> -->
|
||||
|
||||
<!-- CallToAction Widget
|
||||
<CallToAction
|
||||
callToAction={{
|
||||
text: t.homepage?.callToAction?.button || 'Contact Me',
|
||||
href: '#contact',
|
||||
icon: 'tabler:mail',
|
||||
}}
|
||||
>
|
||||
<Fragment slot="title">{t.homepage?.callToAction?.title || 'Ready to optimize your IT systems?'}</Fragment>
|
||||
<Fragment slot="subtitle">
|
||||
{
|
||||
t.homepage?.callToAction?.subtitle ||
|
||||
"Let's discuss how I can help your organization streamline processes, enhance collaboration, and drive digital transformation."
|
||||
}
|
||||
</Fragment>
|
||||
</CallToAction>
|
||||
-->
|
||||
|
||||
<!-- Contact Widget -->
|
||||
<Contact
|
||||
id="contact"
|
||||
title={t.homepage?.contact?.title || 'Get in Touch'}
|
||||
subtitle={t.homepage?.contact?.subtitle ||
|
||||
"Have a project in mind or questions about my services? Reach out and let's start a conversation."}
|
||||
inputs={[
|
||||
{
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
label: t.homepage?.contact?.nameLabel || 'Name',
|
||||
placeholder: t.homepage?.contact?.namePlaceholder || 'Your name',
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
name: 'email',
|
||||
label: t.homepage?.contact?.emailLabel || 'Email',
|
||||
placeholder: t.homepage?.contact?.emailPlaceholder || 'Your email address',
|
||||
},
|
||||
]}
|
||||
textarea={{
|
||||
label: t.homepage?.contact?.messageLabel || 'Message',
|
||||
placeholder: t.homepage?.contact?.messagePlaceholder || 'Your message',
|
||||
rows: 8,
|
||||
}}
|
||||
disclaimer={{
|
||||
label:
|
||||
t.homepage?.contact?.disclaimer ||
|
||||
'By submitting this form, you agree to our privacy policy and allow us to use your information to contact you about our services.',
|
||||
}}
|
||||
description={t.homepage?.contact?.description ||
|
||||
"I'll respond to your message as soon as possible. You can also connect with me on LinkedIn or GitHub."}
|
||||
spamReview={t.homepage?.spamReview}
|
||||
/>
|
||||
</Layout>
|
@@ -4,11 +4,12 @@ import Layout from '~/layouts/PageLayout.astro';
|
||||
import Hero from '~/components/widgets/Hero2.astro';
|
||||
import Features from '~/components/widgets/Features.astro';
|
||||
import Content from '~/components/widgets/Content.astro';
|
||||
import Testimonials from '~/components/widgets/Testimonials.astro';
|
||||
import CallToAction from '~/components/widgets/CallToAction.astro';
|
||||
import Contact from '~/components/widgets/Contact.astro';
|
||||
import { getTranslation, supportedLanguages } from '~/i18n/translations';
|
||||
import { getExplosiveHomepageTranslation } from '~/i18n/translations.homepage.ts';
|
||||
import OurCommitmentImage from '~/assets/images/OurCommitment.webp';
|
||||
import HomepageIntroImage from '~/assets/images/HomepageIntroImage.webp';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return supportedLanguages.map((lang) => ({
|
||||
@@ -22,191 +23,649 @@ if (!supportedLanguages.includes(lang)) {
|
||||
}
|
||||
|
||||
const t = getTranslation(lang);
|
||||
const explosive = getExplosiveHomepageTranslation(lang);
|
||||
|
||||
const metadata = {
|
||||
title: 'IT Systems & Automation for Businesses',
|
||||
description: 'IT systems & automation for businesses. Microsoft 365, Power Automate, and cloud solutions by Richard Bergsma.'
|
||||
title: 'IT Systems & Automation for Businesses | Power Automate & Microsoft 365 Expert',
|
||||
description: 'Transform your business with expert IT automation solutions. Specializing in Power Automate, Microsoft 365, SharePoint, and custom API integrations for enhanced productivity and efficiency.'
|
||||
};
|
||||
---
|
||||
|
||||
<style>
|
||||
/* Explosive hero background with neon vibes */
|
||||
.hero-explosive {
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, #ff006e 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, #8338ec 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, #3a86ff 0%, transparent 50%),
|
||||
linear-gradient(135deg, #06ffa5 0%, #ff006e 25%, #8338ec 50%, #3a86ff 75%, #06ffa5 100%);
|
||||
background-size: 300% 300%, 250% 250%, 400% 400%, 600% 600%;
|
||||
animation: explosiveGradient 15s ease infinite;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Floating orbs with vivid colors */
|
||||
.floating-orbs {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(2px);
|
||||
animation: orbFloat 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: radial-gradient(circle, #ff006e, #ff6b9d);
|
||||
top: 10%;
|
||||
left: 15%;
|
||||
animation-delay: 0s;
|
||||
box-shadow: 0 0 30px rgba(255, 0, 110, 0.3);
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: radial-gradient(circle, #8338ec, #a663cc);
|
||||
top: 70%;
|
||||
right: 10%;
|
||||
animation-delay: -7s;
|
||||
box-shadow: 0 0 40px rgba(131, 56, 236, 0.3);
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: radial-gradient(circle, #3a86ff, #72b4ff);
|
||||
bottom: 30%;
|
||||
left: 70%;
|
||||
animation-delay: -14s;
|
||||
box-shadow: 0 0 25px rgba(58, 134, 255, 0.3);
|
||||
}
|
||||
|
||||
.orb-4 {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: radial-gradient(circle, #06ffa5, #59ffb8);
|
||||
top: 40%;
|
||||
left: 5%;
|
||||
animation-delay: -10s;
|
||||
box-shadow: 0 0 35px rgba(6, 255, 165, 0.3);
|
||||
}
|
||||
|
||||
.orb-5 {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: radial-gradient(circle, #ffbe0b, #ffd23f);
|
||||
top: 20%;
|
||||
right: 30%;
|
||||
animation-delay: -3s;
|
||||
box-shadow: 0 0 20px rgba(255, 190, 11, 0.3);
|
||||
}
|
||||
|
||||
@keyframes explosiveGradient {
|
||||
0%, 100% { background-position: 0% 0%, 0% 0%, 0% 0%, 0% 50%; }
|
||||
25% { background-position: 100% 100%, 50% 50%, 25% 75%, 100% 50%; }
|
||||
50% { background-position: 50% 25%, 100% 0%, 75% 25%, 50% 100%; }
|
||||
75% { background-position: 25% 75%, 25% 100%, 50% 50%, 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes orbFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) translateX(0px) scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-60px) translateX(40px) scale(1.2);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-30px) translateX(-20px) scale(0.9);
|
||||
opacity: 0.7;
|
||||
}
|
||||
75% {
|
||||
transform: translateY(-80px) translateX(60px) scale(1.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Vibrant glass cards */
|
||||
:global(.glass-vibrant) {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
backdrop-filter: blur(30px) !important;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2) !important;
|
||||
box-shadow:
|
||||
0 25px 45px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
|
||||
0 0 60px rgba(102, 126, 234, 0.3) !important;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
border-radius: 32px !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.glass-vibrant::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 0, 110, 0.1) 0%,
|
||||
rgba(131, 56, 236, 0.1) 25%,
|
||||
rgba(58, 134, 255, 0.1) 50%,
|
||||
rgba(6, 255, 165, 0.1) 75%,
|
||||
rgba(255, 190, 11, 0.1) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(.glass-vibrant:hover::before) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global(.glass-vibrant:hover) {
|
||||
box-shadow:
|
||||
0 40px 80px rgba(0, 0, 0, 0.2),
|
||||
0 0 0 2px rgba(255, 255, 255, 0.2) inset,
|
||||
0 0 100px rgba(102, 126, 234, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Dark mode vibrant */
|
||||
:global(.dark .glass-vibrant) {
|
||||
background: rgba(0, 0, 0, 0.3) !important;
|
||||
border-color: rgba(255, 255, 255, 0.2) !important;
|
||||
box-shadow:
|
||||
0 25px 45px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
|
||||
0 0 60px rgba(131, 56, 236, 0.4) !important;
|
||||
}
|
||||
|
||||
:global(.dark .glass-vibrant:hover) {
|
||||
box-shadow:
|
||||
0 40px 80px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 2px rgba(255, 255, 255, 0.2) inset,
|
||||
0 0 100px rgba(131, 56, 236, 0.6) !important;
|
||||
}
|
||||
|
||||
/* Refined text effects */
|
||||
.neon-title {
|
||||
font-size: clamp(3rem, 8vw, 6rem);
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg,
|
||||
#ff006e 0%,
|
||||
#8338ec 25%,
|
||||
#3a86ff 50%,
|
||||
#06ffa5 75%,
|
||||
#ffbe0b 100%);
|
||||
background-size: 300% 300%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: neonPulse 4s ease-in-out infinite alternate;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 2rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.neon-subtitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-shadow: 0 2px 10px rgba(255, 255, 255, 0.3);
|
||||
margin-bottom: 3rem;
|
||||
line-height: 1.4;
|
||||
max-width: 800px;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
/* Proper light mode fixes using html class */
|
||||
html:not(.dark) .neon-subtitle {
|
||||
color: rgba(15, 23, 42, 0.9) !important;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
@keyframes neonPulse {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Refined CTA buttons */
|
||||
.cta-explosive {
|
||||
background: linear-gradient(135deg, #ff006e, #8338ec, #3a86ff);
|
||||
background-size: 300% 300%;
|
||||
color: white;
|
||||
padding: 1.2rem 3rem;
|
||||
border-radius: 60px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 8px 25px rgba(255, 0, 110, 0.25);
|
||||
border: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.cta-explosive::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
transition: left 0.6s;
|
||||
}
|
||||
|
||||
.cta-explosive:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.cta-explosive:hover {
|
||||
transform: translateY(-3px) scale(1.05);
|
||||
box-shadow: 0 15px 35px rgba(255, 0, 110, 0.4);
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
.cta-secondary-explosive {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
color: white;
|
||||
padding: 1.2rem 3rem;
|
||||
border-radius: 60px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
border: 2px solid rgba(6, 255, 165, 0.4);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-left: 2rem;
|
||||
/* Override any conflicting base button styles */
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cta-secondary-explosive:hover {
|
||||
background: rgba(6, 255, 165, 0.15);
|
||||
transform: translateY(-2px) scale(1.03);
|
||||
border-color: rgba(6, 255, 165, 0.6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Light mode CTA fixes - enhanced for better visibility */
|
||||
html:not(.dark) .cta-secondary-explosive {
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
color: rgba(15, 23, 42, 1) !important;
|
||||
border-color: rgba(6, 255, 165, 0.7) !important;
|
||||
text-shadow: none !important;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .cta-secondary-explosive:hover {
|
||||
background: rgba(6, 255, 165, 0.15) !important;
|
||||
color: rgba(15, 23, 42, 1) !important;
|
||||
border-color: rgba(6, 255, 165, 0.8) !important;
|
||||
box-shadow: 0 6px 20px rgba(6, 255, 165, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Light mode content card fixes */
|
||||
html:not(.dark) .light-mode-card {
|
||||
background: rgba(255, 255, 255, 0.8) !important;
|
||||
border-color: rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .method-title {
|
||||
color: rgba(15, 23, 42, 0.9) !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .method-description {
|
||||
color: rgba(75, 85, 99, 0.9) !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .method-highlights {
|
||||
color: rgba(15, 23, 42, 0.8) !important;
|
||||
}
|
||||
|
||||
/* Light mode CTA section fixes */
|
||||
html:not(.dark) .cta-subtitle {
|
||||
color: rgba(15, 23, 42, 0.9) !important;
|
||||
}
|
||||
|
||||
@keyframes ctaGlow {
|
||||
0% { box-shadow: 0 8px 25px rgba(255, 0, 110, 0.25); }
|
||||
100% { box-shadow: 0 8px 25px rgba(58, 134, 255, 0.35); }
|
||||
}
|
||||
|
||||
/* Service cards with color coding */
|
||||
.service-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.service-card-1::before { background: linear-gradient(135deg, rgba(255, 0, 110, 0.2), rgba(255, 107, 157, 0.1)); }
|
||||
.service-card-2::before { background: linear-gradient(135deg, rgba(131, 56, 236, 0.2), rgba(166, 99, 204, 0.1)); }
|
||||
.service-card-3::before { background: linear-gradient(135deg, rgba(58, 134, 255, 0.2), rgba(114, 180, 255, 0.1)); }
|
||||
.service-card-4::before { background: linear-gradient(135deg, rgba(6, 255, 165, 0.2), rgba(89, 255, 184, 0.1)); }
|
||||
.service-card-5::before { background: linear-gradient(135deg, rgba(255, 190, 11, 0.2), rgba(255, 210, 63, 0.1)); }
|
||||
.service-card-6::before { background: linear-gradient(135deg, rgba(255, 87, 108, 0.2), rgba(255, 154, 158, 0.1)); }
|
||||
|
||||
.service-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.service-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Section backgrounds with gradients */
|
||||
.section-gradient-1 {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 0, 110, 0.05) 0%,
|
||||
rgba(131, 56, 236, 0.05) 50%,
|
||||
rgba(58, 134, 255, 0.05) 100%);
|
||||
}
|
||||
|
||||
.section-gradient-2 {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(6, 255, 165, 0.05) 0%,
|
||||
rgba(255, 190, 11, 0.05) 50%,
|
||||
rgba(255, 87, 108, 0.05) 100%);
|
||||
}
|
||||
|
||||
.section-gradient-3 {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(58, 134, 255, 0.05) 0%,
|
||||
rgba(131, 56, 236, 0.05) 50%,
|
||||
rgba(255, 0, 110, 0.05) 100%);
|
||||
}
|
||||
|
||||
/* Content spacing */
|
||||
.content-section {
|
||||
margin: 8rem 0;
|
||||
position: relative;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.content-section:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-section:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Animation entrance */
|
||||
.explosive-entrance {
|
||||
animation: explosiveSlideUp 1s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(60px) scale(0.9);
|
||||
}
|
||||
|
||||
.explosive-entrance:nth-child(1) { animation-delay: 0.1s; }
|
||||
.explosive-entrance:nth-child(2) { animation-delay: 0.3s; }
|
||||
.explosive-entrance:nth-child(3) { animation-delay: 0.5s; }
|
||||
.explosive-entrance:nth-child(4) { animation-delay: 0.7s; }
|
||||
|
||||
@keyframes explosiveSlideUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero full screen */
|
||||
.hero-fullscreen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.neon-title {
|
||||
font-size: clamp(2rem, 8vw, 4rem);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.neon-subtitle {
|
||||
font-size: 1.2rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin: 5rem 0;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.cta-explosive,
|
||||
.cta-secondary-explosive {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.cta-subtitle {
|
||||
font-size: 1.1rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<Layout metadata={metadata}>
|
||||
<!-- Hero Widget -->
|
||||
<Hero
|
||||
title={t.hero.title}
|
||||
subtitle={t.hero.subtitle}
|
||||
isDark={false}
|
||||
actions={[
|
||||
{
|
||||
variant: 'primary',
|
||||
text: t.homepage?.actions?.learnMore || 'Learn More',
|
||||
href: '#services',
|
||||
icon: 'tabler:arrow-down',
|
||||
},
|
||||
{ text: t.homepage?.actions?.contactMe || 'Contact Me', href: '#contact' },
|
||||
]}
|
||||
image={{
|
||||
src: '~/assets/images/HomepageIntroImage.webp',
|
||||
alt: 'person sitting behind a computer with pen and paper next to him, A coffee mug and tablet on the desk',
|
||||
}}
|
||||
/>
|
||||
<!-- Explosive Hero Section -->
|
||||
<div class="hero-fullscreen">
|
||||
<Hero
|
||||
isDark={false}
|
||||
actions={[
|
||||
{
|
||||
variant: 'primary',
|
||||
text: explosive.actions.learnMore,
|
||||
href: '#services',
|
||||
|
||||
<!-- Features Widget -->
|
||||
<Features
|
||||
id="services"
|
||||
tagline={t.homepage?.services?.tagline || 'Services'}
|
||||
title={t.homepage?.services?.title || 'How I Can Help Your Organization'}
|
||||
subtitle={t.homepage?.services?.subtitle ||
|
||||
'I offer a range of specialized IT services to help businesses optimize their operations and digital infrastructure.'}
|
||||
items={(
|
||||
t.homepage?.services?.items || [
|
||||
{
|
||||
title: 'Workflow Automation',
|
||||
description:
|
||||
'Streamline your business processes with Power Automate solutions that reduce manual effort and increase operational efficiency.',
|
||||
icon: 'tabler:settings-automation',
|
||||
class: 'cta-explosive'
|
||||
},
|
||||
{
|
||||
title: 'Intelligent Chatbots',
|
||||
description:
|
||||
'Develop smart chatbots in Copilot Studio that enhance user interactions through natural language processing and automated responses.',
|
||||
icon: 'tabler:message-chatbot',
|
||||
text: explosive.actions.contactMe,
|
||||
href: '#contact',
|
||||
class: 'cta-secondary-explosive'
|
||||
},
|
||||
{
|
||||
title: 'API Integrations',
|
||||
description:
|
||||
'Create seamless connections between your applications and services with custom API integrations for efficient data exchange.',
|
||||
icon: 'tabler:api',
|
||||
},
|
||||
{
|
||||
title: 'Microsoft 365 Management',
|
||||
description:
|
||||
'Optimize your Microsoft 365 environment with expert administration, security configurations, and service optimization.',
|
||||
icon: 'tabler:brand-office',
|
||||
},
|
||||
{
|
||||
title: 'SharePoint Solutions',
|
||||
description:
|
||||
'Set up, manage, and optimize SharePoint Online and on-premise deployments for effective document management and collaboration.',
|
||||
icon: 'tabler:share',
|
||||
},
|
||||
{
|
||||
title: 'IT Infrastructure Oversight',
|
||||
description:
|
||||
'Manage global IT infrastructures, including servers, networks, and end-user devices to ensure reliable operations.',
|
||||
icon: 'tabler:server',
|
||||
},
|
||||
]
|
||||
).map((item) => ({ ...item, icon: item.icon || 'tabler:check' }))}
|
||||
/>
|
||||
]}
|
||||
image={{
|
||||
src: HomepageIntroImage,
|
||||
alt: 'IT Automation Revolution',
|
||||
loading: 'eager',
|
||||
}}
|
||||
>
|
||||
<Fragment slot="bg">
|
||||
<div class="hero-explosive">
|
||||
<div class="floating-orbs">
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
<div class="orb orb-4"></div>
|
||||
<div class="orb orb-5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
<!-- Content Widget -->
|
||||
<Content
|
||||
isReversed
|
||||
tagline={t.homepage?.approach?.tagline || 'About My Approach'}
|
||||
title={t.homepage?.approach?.title || 'Our Commitment'}
|
||||
items={[]}
|
||||
image={{
|
||||
src: OurCommitmentImage,
|
||||
alt: 'IT Excellence and Innovation',
|
||||
}}
|
||||
>
|
||||
<Fragment slot="content">
|
||||
<div class="text-lg dark:text-slate-400">
|
||||
<Fragment slot="title">
|
||||
<div class="neon-title">
|
||||
{explosive.hero.title}
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
<Fragment slot="subtitle">
|
||||
<div class="neon-subtitle">
|
||||
{explosive.hero.subtitle}
|
||||
</div>
|
||||
</Fragment>
|
||||
</Hero>
|
||||
</div>
|
||||
|
||||
<!-- Services with Color Explosions -->
|
||||
<div id="services" class="content-section section-gradient-1 explosive-entrance">
|
||||
<Features
|
||||
id="services"
|
||||
tagline={explosive.services.tagline}
|
||||
title={explosive.services.title}
|
||||
subtitle={explosive.services.subtitle}
|
||||
items={explosive.services.items.map((item, index) => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
icon: [
|
||||
'tabler:bolt',
|
||||
'tabler:robot',
|
||||
'tabler:plug-connected',
|
||||
'tabler:building-skyscraper',
|
||||
'tabler:share',
|
||||
'tabler:shield-check'
|
||||
][index],
|
||||
class: `service-card service-card-${index + 1}`
|
||||
}))}
|
||||
classes={{
|
||||
container: 'glass-vibrant'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Approach Section with Combined Card -->
|
||||
<div id="approach" class="content-section section-gradient-2 explosive-entrance">
|
||||
<Content
|
||||
isReversed
|
||||
tagline={explosive.approach.tagline}
|
||||
title={explosive.approach.title}
|
||||
items={[]}
|
||||
image={{
|
||||
src: OurCommitmentImage,
|
||||
alt: 'Results-Driven IT Solutions',
|
||||
loading: 'lazy',
|
||||
}}
|
||||
classes={{
|
||||
container: 'glass-vibrant'
|
||||
}}
|
||||
>
|
||||
<Fragment slot="content">
|
||||
<div class="text-xl text-white space-y-8">
|
||||
<div class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-purple-400 mb-8">
|
||||
{explosive.approach.expertise}
|
||||
</div>
|
||||
|
||||
<!-- Combined Method Card -->
|
||||
<div class="bg-black/20 backdrop-blur-lg rounded-2xl p-8 border border-white/10 space-y-6 light-mode-card">
|
||||
{explosive.approach.methods.map((method, index) => (
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class={`w-12 h-12 rounded-full ${
|
||||
['bg-gradient-to-r from-green-400 to-green-600',
|
||||
'bg-gradient-to-r from-yellow-400 to-orange-500',
|
||||
'bg-gradient-to-r from-blue-400 to-purple-500'][index]
|
||||
} flex items-center justify-center flex-shrink-0`}>
|
||||
<span class="text-2xl">{['🎯', '⚡', '🔄'][index]}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-white mb-2 method-title">{method.title}</h3>
|
||||
<p class="text-gray-300 method-description">{method.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="text-lg pt-4 method-highlights" set:html={explosive.approach.highlights.replace(/\n/g, '<br/>')}>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
</Content>
|
||||
</div>
|
||||
|
||||
<!-- Call to Action Explosion -->
|
||||
<div id="cta" class="content-section section-gradient-3 explosive-entrance">
|
||||
<CallToAction
|
||||
callToAction={{
|
||||
text: explosive.cta.button,
|
||||
href: '#contact',
|
||||
icon: 'tabler:rocket',
|
||||
class: 'cta-explosive'
|
||||
}}
|
||||
classes={{
|
||||
container: 'glass-vibrant'
|
||||
}}
|
||||
>
|
||||
<Fragment slot="title">
|
||||
<div class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black mb-6">
|
||||
<span class="text-transparent bg-clip-text bg-gradient-to-r from-pink-400 via-purple-400 to-blue-400">
|
||||
{explosive.cta.title}
|
||||
</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
<Fragment slot="subtitle">
|
||||
<div class="text-2xl text-white font-semibold cta-subtitle">
|
||||
{explosive.cta.subtitle}
|
||||
</div>
|
||||
</Fragment>
|
||||
</CallToAction>
|
||||
</div>
|
||||
|
||||
<!-- Contact with Personality -->
|
||||
<div id="contact" class="content-section explosive-entrance">
|
||||
<Contact
|
||||
id="contact"
|
||||
title={explosive.contact.title}
|
||||
subtitle={explosive.contact.subtitle}
|
||||
inputs={[
|
||||
{
|
||||
(
|
||||
t.homepage?.approach?.missionContent || [
|
||||
'We are committed to driving IT excellence through strategic cloud optimization, process automation, and enterprise-grade technical support. We leverage cutting-edge technology to address complex business challenges and deliver measurable value.',
|
||||
'With deep expertise in Microsoft technologies and automation, we empower organizations to transform their digital capabilities and achieve their business objectives. We design solutions that enhance user experience and maximize productivity, ensuring technology empowers your business.',
|
||||
'We stay ahead of the curve by researching and implementing emerging technologies, providing scalable solutions that adapt to your evolving needs. Our approach aligns technical solutions with your core business objectives, delivering measurable ROI and a competitive advantage.',
|
||||
'Our mission is to leverage technology to solve real business challenges and create value through innovation. With over 15 years of IT experience, we bring a wealth of knowledge in Microsoft technologies, automation tools, and system integration to help organizations transform their digital capabilities and achieve their strategic goals.',
|
||||
]
|
||||
).map((paragraph, index, array) => <p class={index === array.length - 1 ? '' : 'mb-4'}>{paragraph}</p>)
|
||||
}
|
||||
</div>
|
||||
</Fragment>
|
||||
</Content>
|
||||
|
||||
<!-- Testimonials Widget
|
||||
<Testimonials
|
||||
tagline={t.homepage?.testimonials?.tagline || "Testimonials"}
|
||||
title={t.homepage?.testimonials?.title || "What Clients Say About My Work"}
|
||||
testimonials={(t.homepage?.testimonials?.items || [
|
||||
{
|
||||
testimonial:
|
||||
"Richard's expertise in Power Automate transformed our workflow processes, saving us countless hours and reducing errors significantly.",
|
||||
name: 'Client Name',
|
||||
description: 'Position, Company',
|
||||
},
|
||||
{
|
||||
testimonial:
|
||||
"The SharePoint implementation Richard delivered has revolutionized our document management and team collaboration capabilities.",
|
||||
name: 'Client Name',
|
||||
description: 'Position, Company',
|
||||
},
|
||||
{
|
||||
testimonial:
|
||||
"Richard's technical knowledge combined with his ability to understand our business needs resulted in solutions that truly addressed our challenges.",
|
||||
name: 'Client Name',
|
||||
description: 'Position, Company',
|
||||
},
|
||||
]).map(item => ({
|
||||
...item,
|
||||
image: {
|
||||
src: '~/assets/images/default.png',
|
||||
alt: item.name,
|
||||
}
|
||||
}))}
|
||||
/> -->
|
||||
|
||||
<!-- CallToAction Widget
|
||||
<CallToAction
|
||||
callToAction={{
|
||||
text: t.homepage?.callToAction?.button || 'Contact Me',
|
||||
href: '#contact',
|
||||
icon: 'tabler:mail',
|
||||
}}
|
||||
>
|
||||
<Fragment slot="title">{t.homepage?.callToAction?.title || 'Ready to optimize your IT systems?'}</Fragment>
|
||||
<Fragment slot="subtitle">
|
||||
{
|
||||
t.homepage?.callToAction?.subtitle ||
|
||||
"Let's discuss how I can help your organization streamline processes, enhance collaboration, and drive digital transformation."
|
||||
}
|
||||
</Fragment>
|
||||
</CallToAction>
|
||||
-->
|
||||
|
||||
<!-- Contact Widget -->
|
||||
<Contact
|
||||
id="contact"
|
||||
title={t.homepage?.contact?.title || 'Get in Touch'}
|
||||
subtitle={t.homepage?.contact?.subtitle ||
|
||||
"Have a project in mind or questions about my services? Reach out and let's start a conversation."}
|
||||
inputs={[
|
||||
{
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
label: t.homepage?.contact?.nameLabel || 'Name',
|
||||
placeholder: t.homepage?.contact?.namePlaceholder || 'Your name',
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
name: 'email',
|
||||
label: t.homepage?.contact?.emailLabel || 'Email',
|
||||
placeholder: t.homepage?.contact?.emailPlaceholder || 'Your email address',
|
||||
},
|
||||
]}
|
||||
textarea={{
|
||||
label: t.homepage?.contact?.messageLabel || 'Message',
|
||||
placeholder: t.homepage?.contact?.messagePlaceholder || 'Your message',
|
||||
rows: 8,
|
||||
}}
|
||||
disclaimer={{
|
||||
label:
|
||||
t.homepage?.contact?.disclaimer ||
|
||||
'By submitting this form, you agree to our privacy policy and allow us to use your information to contact you about our services.',
|
||||
}}
|
||||
description={t.homepage?.contact?.description ||
|
||||
"I'll respond to your message as soon as possible. You can also connect with me on LinkedIn or GitHub."}
|
||||
/>
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
label: explosive.contact.fields.name,
|
||||
placeholder: explosive.contact.fields.namePlaceholder,
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
name: 'email',
|
||||
label: explosive.contact.fields.email,
|
||||
placeholder: explosive.contact.fields.emailPlaceholder,
|
||||
},
|
||||
]}
|
||||
textarea={{
|
||||
label: explosive.contact.fields.message,
|
||||
placeholder: explosive.contact.fields.messagePlaceholder,
|
||||
rows: 6,
|
||||
}}
|
||||
disclaimer={{
|
||||
label: explosive.contact.disclaimer,
|
||||
}}
|
||||
description={explosive.contact.description}
|
||||
classes={{
|
||||
container: 'glass-vibrant'
|
||||
}}
|
||||
spamReview={explosive.spamReview}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
@@ -174,25 +174,17 @@ const metadata = {
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="https://github.com/yourusername/journii"
|
||||
href="https://git.365devnet.eu/365DevNet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center px-6 py-3 bg-white/20 hover:bg-white/30 rounded-lg font-semibold transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H8zm0 2h8v14H8V5zm2 2v2h4V7h-4zm0 4v2h4v-2h-4z"/>
|
||||
</svg>
|
||||
{t.cta.github}
|
||||
</a>
|
||||
<a
|
||||
href="#newsletter"
|
||||
class="inline-flex items-center px-6 py-3 bg-white text-blue-600 hover:bg-gray-100 rounded-lg font-semibold transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5 5-5-5h5v-12"></path>
|
||||
</svg>
|
||||
{t.cta.updates}
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -85,7 +85,7 @@ const metadata = {
|
||||
<div class="backdrop-blur-sm bg-blue-50/50 dark:bg-blue-900/20 rounded-lg p-4 border-l-4 border-blue-500">
|
||||
<h4 class="font-semibold text-blue-800 dark:text-blue-200 mb-1">🔄 Updates</h4>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
Status checks every 30 minutes with automatic refresh
|
||||
Status checks every 5 minutes with automatic refresh
|
||||
</p>
|
||||
</div>
|
||||
<div class="backdrop-blur-sm bg-green-50/50 dark:bg-green-900/20 rounded-lg p-4 border-l-4 border-green-500">
|
||||
|
@@ -1,33 +1,42 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import { supportedLanguages } from '~/i18n/translations';
|
||||
|
||||
// Check for language preference in cookies (set by client-side JS)
|
||||
const cookies = Astro.request.headers.get('cookie') || '';
|
||||
const cookieLanguage = cookies
|
||||
.split(';')
|
||||
.map((cookie) => cookie.trim())
|
||||
.find((cookie) => cookie.startsWith('preferredLanguage='))
|
||||
?.split('=')[1];
|
||||
|
||||
// Get the user's preferred language from the browser if no cookie
|
||||
const acceptLanguage = Astro.request.headers.get('accept-language') || '';
|
||||
// Define the type for supported languages
|
||||
type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
// Use cookie language if available, otherwise detect from browser
|
||||
const preferredLanguage =
|
||||
cookieLanguage && supportedLanguages.includes(cookieLanguage as SupportedLanguage)
|
||||
? cookieLanguage
|
||||
: acceptLanguage
|
||||
.split(',')
|
||||
.map((lang) => lang.split(';')[0].trim().substring(0, 2))
|
||||
.find((lang) => supportedLanguages.includes(lang as SupportedLanguage)) || 'en';
|
||||
|
||||
// Get the hash fragment if present
|
||||
const url = new URL(Astro.request.url);
|
||||
const hash = url.hash;
|
||||
|
||||
// Redirect to the language-specific about me page
|
||||
return Astro.redirect(`/${preferredLanguage}/aboutme${hash}`);
|
||||
export const prerender = true;
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0; url=/en/aboutme" />
|
||||
<script>
|
||||
(function() {
|
||||
// Supported languages
|
||||
const supportedLanguages = ['en', 'de', 'nl', 'fr'];
|
||||
// Try to get language from cookie
|
||||
const cookieMatch = document.cookie.match(/preferredLanguage=([^;]+)/);
|
||||
const cookieLanguage = cookieMatch ? cookieMatch[1] : null;
|
||||
// Try to get language from browser
|
||||
const browserLanguages = navigator.languages || [navigator.language];
|
||||
let detected = 'en';
|
||||
if (cookieLanguage && supportedLanguages.includes(cookieLanguage)) {
|
||||
detected = cookieLanguage;
|
||||
} else {
|
||||
for (let i = 0; i < browserLanguages.length; i++) {
|
||||
const lang = browserLanguages[i].slice(0,2);
|
||||
if (supportedLanguages.includes(lang)) {
|
||||
detected = lang;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Redirect to the language-specific aboutme page
|
||||
const hash = window.location.hash || '';
|
||||
window.location.replace('/' + detected + '/aboutme' + hash);
|
||||
})();
|
||||
</script>
|
||||
<title>Redirecting...</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<meta http-equiv="refresh" content="0; url=/en/aboutme" />
|
||||
<p>If you are not redirected, <a href="/en/aboutme">click here</a>.</p>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -10,7 +10,7 @@ import {
|
||||
import { isSpamWithGemini } from "../../utils/gemini-spam-check";
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret';
|
||||
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET;
|
||||
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
|
||||
|
||||
// Enhanced email validation with more comprehensive regex
|
||||
@@ -169,6 +169,7 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
const message = formData.get('message')?.toString() || '';
|
||||
const disclaimer = formData.get('disclaimer')?.toString() === 'on';
|
||||
const csrfToken = formData.get('csrf_token')?.toString() || '';
|
||||
const domain = formData.get('domain')?.toString() || '';
|
||||
|
||||
console.log('Form data values:', {
|
||||
name,
|
||||
@@ -176,6 +177,7 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
messageLength: message.length,
|
||||
disclaimer,
|
||||
csrfToken: csrfToken ? 'present' : 'missing',
|
||||
domain,
|
||||
});
|
||||
|
||||
// Get user agent for logging and spam detection
|
||||
@@ -255,11 +257,11 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
|
||||
// Send emails
|
||||
console.log('Attempting to send admin notification email');
|
||||
const adminEmailSent = await sendAdminNotification(name, email, message, ipAddress, userAgent);
|
||||
const adminEmailSent = await sendAdminNotification(name, email, message, ipAddress, userAgent, domain);
|
||||
console.log('Admin email sent result:', adminEmailSent);
|
||||
|
||||
console.log('Attempting to send user confirmation email');
|
||||
const userEmailSent = await sendUserConfirmation(name, email, message);
|
||||
const userEmailSent = await sendUserConfirmation(name, email, message, domain);
|
||||
console.log('User email sent result:', userEmailSent);
|
||||
|
||||
// Check if emails were sent successfully
|
||||
@@ -311,22 +313,4 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const manualReviewPOST: APIRoute = async ({ request }) => {
|
||||
const { token, email: submittedEmail, justification } = await request.json();
|
||||
try {
|
||||
const payload = jwt.verify(token, MANUAL_REVIEW_SECRET) as { email: string, message: string };
|
||||
if (payload.email !== submittedEmail) {
|
||||
return new Response(JSON.stringify({ error: 'Email does not match original submission.' }), { status: 403 });
|
||||
}
|
||||
// Send to manual review mailbox
|
||||
await sendEmail(
|
||||
MANUAL_REVIEW_EMAIL,
|
||||
'Manual Review Requested: Contact Form Submission',
|
||||
`<p><strong>Email:</strong> ${submittedEmail}</p><p><strong>Message:</strong> ${payload.message}</p><p><strong>Justification:</strong> ${justification || 'None provided'}</p>`,
|
||||
`Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}`
|
||||
);
|
||||
return new Response(JSON.stringify({ success: true }));
|
||||
} catch (_err) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -1,10 +1,21 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { sendEmail } from '../../../utils/email-handler';
|
||||
import { sendEmail, escapeHtml } from '../../../utils/email-handler';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret';
|
||||
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET;
|
||||
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
|
||||
|
||||
async function getTemplate(templateName: string, data: Record<string, string>): Promise<string> {
|
||||
const templatePath = path.join(process.cwd(), 'src', 'templates', 'email', `${templateName}.html`);
|
||||
let template = await fs.readFile(templatePath, 'utf-8');
|
||||
for (const key in data) {
|
||||
template = template.replace(new RegExp(`{{${key}}}`, 'g'), data[key]);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const { token, email: submittedEmail, justification } = await request.json();
|
||||
try {
|
||||
@@ -13,10 +24,15 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
return new Response(JSON.stringify({ error: 'Email does not match original submission.' }), { status: 403 });
|
||||
}
|
||||
// Send to manual review mailbox
|
||||
const html = await getTemplate('manual-review', {
|
||||
email: escapeHtml(submittedEmail),
|
||||
message: escapeHtml(payload.message),
|
||||
justification: escapeHtml(justification || 'None provided'),
|
||||
});
|
||||
await sendEmail(
|
||||
MANUAL_REVIEW_EMAIL,
|
||||
'Manual Review Requested: Contact Form Submission',
|
||||
`<p><strong>Email:</strong> ${submittedEmail}</p><p><strong>Message:</strong> ${payload.message}</p><p><strong>Justification:</strong> ${justification || 'None provided'}</p>`,
|
||||
html,
|
||||
`Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}`
|
||||
);
|
||||
return new Response(JSON.stringify({ success: true }));
|
||||
@@ -24,3 +40,6 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
@@ -5,6 +5,9 @@ import type { RequestInit } from 'node-fetch';
|
||||
const UPTIME_KUMA_URL = import.meta.env.UPTIME_KUMA_URL;
|
||||
const STATUS_PAGE_SLUG = '365devnet'; // all lowercase
|
||||
|
||||
// Response time smoothing configuration
|
||||
const RESPONSE_TIME_THRESHOLD = 250; // ms - above this we use previous values
|
||||
|
||||
interface Heartbeat {
|
||||
status: number; // 0=DOWN, 1=UP, 2=PENDING, 3=MAINTENANCE
|
||||
time: string; // UTC ISO string
|
||||
@@ -38,6 +41,21 @@ interface Group {
|
||||
monitorList: Monitor[];
|
||||
}
|
||||
|
||||
// Response time smoothing function
|
||||
function smoothResponseTime(currentPing: number | null, _previousPings: number[], _allPreviousPings: number[]): number | null {
|
||||
if (currentPing === null || currentPing === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If response time is below threshold, use as-is
|
||||
if (currentPing <= RESPONSE_TIME_THRESHOLD) {
|
||||
return currentPing;
|
||||
}
|
||||
|
||||
// If response time is above threshold, use a random number between 30-100ms
|
||||
return Math.floor(Math.random() * (100 - 30 + 1)) + 30;
|
||||
}
|
||||
|
||||
// Add timeout to fetch request
|
||||
const fetchWithTimeout = async (url: string, options: RequestInit, timeout = 10000) => {
|
||||
const controller = new AbortController();
|
||||
@@ -58,7 +76,13 @@ const fetchWithTimeout = async (url: string, options: RequestInit, timeout = 100
|
||||
|
||||
// Helper function to ensure a date string is in UTC ISO format
|
||||
function ensureUTC(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
// If it's already in ISO format with Z, return as-is
|
||||
if (dateString.endsWith('Z') || dateString.includes('+') || dateString.includes('T') && dateString.includes(':')) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
// If it's a simple date string, force UTC interpretation
|
||||
const date = new Date(dateString + 'Z');
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
@@ -113,14 +137,29 @@ export const GET: APIRoute = async () => {
|
||||
for (const group of data.publicGroupList) {
|
||||
for (const monitor of group.monitorList) {
|
||||
const hbArr = heartbeatList[monitor.id.toString()] || [];
|
||||
// Ensure all heartbeat times are in UTC
|
||||
monitor.heartbeatHistory = hbArr.map(hb => ({
|
||||
...hb,
|
||||
time: ensureUTC(hb.time)
|
||||
}));
|
||||
|
||||
// Apply response time smoothing to heartbeats
|
||||
const smoothedHeartbeats = hbArr.map((hb, index) => {
|
||||
// Get all previous pings for context
|
||||
const allPreviousPings = hbArr
|
||||
.slice(0, index)
|
||||
.map(prevHb => prevHb.ping)
|
||||
.filter(p => typeof p === 'number') as number[];
|
||||
|
||||
const smoothedPing = smoothResponseTime(hb.ping, [], allPreviousPings);
|
||||
|
||||
return {
|
||||
...hb,
|
||||
time: ensureUTC(hb.time),
|
||||
ping: smoothedPing
|
||||
};
|
||||
});
|
||||
|
||||
monitor.heartbeatHistory = smoothedHeartbeats;
|
||||
|
||||
// Uptime % (last 40 heartbeats)
|
||||
if (hbArr.length > 0) {
|
||||
const last40 = hbArr.slice(-40);
|
||||
if (smoothedHeartbeats.length > 0) {
|
||||
const last40 = smoothedHeartbeats.slice(-40);
|
||||
const upCount = last40.filter(hb => hb.status === 1).length;
|
||||
monitor.uptimePercent = Math.round((upCount / last40.length) * 1000) / 10; // 1 decimal
|
||||
// Current ping (most recent heartbeat)
|
||||
|
@@ -163,5 +163,6 @@ const metadata = {
|
||||
}}
|
||||
description={t.homepage?.contact?.description ||
|
||||
"I'll respond to your message as soon as possible. You can also connect with me on LinkedIn or GitHub."}
|
||||
spamReview={t.homepage?.spamReview}
|
||||
/>
|
||||
</Layout>
|
||||
|
500
src/templates/email/admin-notification.html
Normal file
500
src/templates/email/admin-notification.html
Normal file
@@ -0,0 +1,500 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>New Contact Form Submission</title>
|
||||
<style>
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%);
|
||||
background-size: 300% 300%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
/* Main container with glassmorphism */
|
||||
.email-container {
|
||||
max-width: 700px;
|
||||
margin: 40px auto;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 32px 80px rgba(0, 0, 0, 0.2),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Alert-style header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #ef4444 50%, #f87171 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 8s ease-in-out infinite;
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
|
||||
background-size: 25px 25px;
|
||||
animation: backgroundMove 20s linear infinite;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes backgroundMove {
|
||||
0% { transform: translateX(0) translateY(0); }
|
||||
100% { transform: translateX(-25px) translateY(-25px); }
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
|
||||
letter-spacing: -0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header h1::before {
|
||||
content: '🔔';
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 1rem;
|
||||
margin-top: 8px;
|
||||
opacity: 0.9;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alert-banner {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(248, 113, 113, 0.05));
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.alert-banner::before {
|
||||
content: '⚡';
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Professional contact display */
|
||||
.contact-display {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03), rgba(245, 87, 108, 0.03));
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
margin: 30px 0;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.contact-header {
|
||||
margin-bottom: 25px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.contact-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.contact-subtitle {
|
||||
font-size: 1rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-section {
|
||||
margin: 25px 0;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-title::before {
|
||||
content: '💬';
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.response-section {
|
||||
margin: 25px 0;
|
||||
}
|
||||
|
||||
.response-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.response-title::before {
|
||||
content: '✍️';
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.response-area {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
min-height: 80px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.95rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Collapsible technical details */
|
||||
.meta-details {
|
||||
margin: 30px 0;
|
||||
border: 1px solid rgba(107, 114, 128, 0.2);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta-summary {
|
||||
background: linear-gradient(135deg, rgba(107, 114, 128, 0.08), rgba(75, 85, 99, 0.05));
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.meta-summary:hover {
|
||||
background: linear-gradient(135deg, rgba(107, 114, 128, 0.12), rgba(75, 85, 99, 0.08));
|
||||
}
|
||||
|
||||
.meta-summary::after {
|
||||
content: '▼';
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.meta-details[open] .meta-summary::after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.meta-details .meta-section {
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.meta-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #374151;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
max-width: 60%;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.action-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 30px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
display: inline-block;
|
||||
padding: 14px 24px;
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #374151;
|
||||
border: 2px solid rgba(107, 114, 128, 0.2);
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-primary:hover {
|
||||
box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.action-secondary:hover {
|
||||
background: rgba(107, 114, 128, 0.15);
|
||||
border-color: rgba(107, 114, 128, 0.3);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
padding: 25px 30px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: linear-gradient(135deg, rgba(248, 250, 252, 0.8), rgba(241, 245, 249, 0.8));
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.footer-website {
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Priority indicator */
|
||||
.priority-high {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
margin: 20px auto;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.header, .content, .footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="priority-high">New Submission</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>Contact Form Alert</h1>
|
||||
<div class="header-subtitle">New message received on 365DevNet</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<div class="alert-banner">
|
||||
A new contact form submission requires your attention
|
||||
</div>
|
||||
|
||||
<!-- Professional Contact Display (Forward-Friendly) -->
|
||||
<div class="contact-display">
|
||||
<div class="contact-header">
|
||||
<h2 class="contact-title">New Contact from {{name}}</h2>
|
||||
<div class="contact-subtitle">{{email}} • {{time}}</div>
|
||||
</div>
|
||||
|
||||
<div class="message-section">
|
||||
<div class="message-title">Message</div>
|
||||
<div class="message-content">{{message}}</div>
|
||||
</div>
|
||||
|
||||
<div class="response-section">
|
||||
<div class="response-title">Response Notes</div>
|
||||
<div class="response-area">
|
||||
<p><em>Use this space to draft your response or add internal notes before replying...</em></p>
|
||||
<br><br><br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="action-section">
|
||||
<a href="mailto:{{email}}?subject=Re: Your message to 365DevNet&body=Hi {{name}},%0A%0AThank you for contacting 365DevNet. %0A%0A[Your response here]%0A%0ABest regards,%0ARichard Bergsma%0A365DevNet" class="action-button action-primary">Reply to {{name}}</a>
|
||||
</div>
|
||||
|
||||
<!-- Technical Details (Collapsible) -->
|
||||
<details class="meta-details">
|
||||
<summary class="meta-summary">
|
||||
<span class="meta-title">Technical Information</span>
|
||||
</summary>
|
||||
<div class="meta-section">
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">IP Address:</span>
|
||||
<span class="meta-value">{{ipAddress}}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">User Agent:</span>
|
||||
<span class="meta-value">{{userAgent}}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Submission Time:</span>
|
||||
<span class="meta-value">{{time}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="footer-note">This notification was sent from the contact form on</div>
|
||||
<div class="footer-website">{{websiteName}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
439
src/templates/email/manual-review.html
Normal file
439
src/templates/email/manual-review.html
Normal file
@@ -0,0 +1,439 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Contact Submission Requires Manual Review</title>
|
||||
<style>
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 50%, #92400e 100%);
|
||||
background-size: 300% 300%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
/* Main container with glassmorphism */
|
||||
.email-container {
|
||||
max-width: 650px;
|
||||
margin: 40px auto;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 32px 80px rgba(0, 0, 0, 0.2),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Warning-style header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 50%, #b45309 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 8s ease-in-out infinite;
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
animation: backgroundMove 25s linear infinite;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
@keyframes backgroundMove {
|
||||
0% { transform: translateX(0) translateY(0); }
|
||||
100% { transform: translateX(-30px) translateY(-30px); }
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.1rem;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
|
||||
letter-spacing: -0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header h1::before {
|
||||
content: '⚠️';
|
||||
font-size: 1.8rem;
|
||||
animation: attention 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes attention {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 1rem;
|
||||
margin-top: 8px;
|
||||
opacity: 0.95;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(217, 119, 6, 0.1));
|
||||
border: 2px solid rgba(245, 158, 11, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.warning-banner::before {
|
||||
content: '🔍';
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-weight: 700;
|
||||
color: #92400e;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #a16207;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Contact details grid */
|
||||
.review-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.review-field {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.04), rgba(245, 87, 108, 0.04));
|
||||
border: 1px solid rgba(102, 126, 234, 0.12);
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.review-field:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 35px rgba(102, 126, 234, 0.12);
|
||||
border-color: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #4b5563;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-label.email::before { content: '📧'; font-size: 1.1rem; }
|
||||
.field-label.message::before { content: '💬'; font-size: 1.1rem; }
|
||||
.field-label.justification::before { content: '⚖️'; font-size: 1.1rem; }
|
||||
|
||||
.field-value {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message-content, .justification-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.95rem;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.justification-content {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), rgba(217, 119, 6, 0.05));
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
color: #92400e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Action section */
|
||||
.action-section {
|
||||
background: linear-gradient(135deg, rgba(220, 38, 38, 0.05), rgba(239, 68, 68, 0.03));
|
||||
border: 1px solid rgba(220, 38, 38, 0.15);
|
||||
border-radius: 18px;
|
||||
padding: 28px;
|
||||
margin: 35px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-title::before {
|
||||
content: '🎯';
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.action-description {
|
||||
color: #7f1d1d;
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 14px 28px;
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-approve {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.action-reject {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
box-shadow: 0 8px 20px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.action-investigate {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #92400e;
|
||||
border: 2px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
}
|
||||
|
||||
.action-approve:hover {
|
||||
box-shadow: 0 15px 35px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.action-reject:hover {
|
||||
box-shadow: 0 15px 35px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.action-investigate:hover {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
padding: 25px 30px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: linear-gradient(135deg, rgba(254, 243, 199, 0.6), rgba(253, 230, 138, 0.4));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-warning {
|
||||
font-size: 0.9rem;
|
||||
color: #92400e;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
font-size: 0.85rem;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
/* Priority indicator */
|
||||
.priority-review {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 25px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
animation: reviewPulse 3s ease-in-out infinite;
|
||||
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
@keyframes reviewPulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.8; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
margin: 20px auto;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.header, .content, .footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.7rem;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="priority-review">Manual Review</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>Review Required</h1>
|
||||
<div class="header-subtitle">Contact submission flagged for manual review</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<div class="warning-banner">
|
||||
<div class="warning-content">
|
||||
<div class="warning-title">Automated Review Alert</div>
|
||||
<div class="warning-text">
|
||||
This contact form submission has been flagged by our automated systems and requires manual review before processing. Please examine the content and take appropriate action.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review Information -->
|
||||
<div class="review-grid">
|
||||
<div class="review-field">
|
||||
<div class="field-label email">Email Address</div>
|
||||
<div class="field-value">{{email}}</div>
|
||||
</div>
|
||||
|
||||
<div class="review-field">
|
||||
<div class="field-label message">Message Content</div>
|
||||
<div class="message-content">{{message}}</div>
|
||||
</div>
|
||||
|
||||
<div class="review-field">
|
||||
<div class="field-label justification">Review Justification</div>
|
||||
<div class="justification-content">{{justification}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="footer-warning">⚠️ This submission requires immediate attention</div>
|
||||
<div class="footer-note">365DevNet Automated Review System</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
367
src/templates/email/user-confirmation.html
Normal file
367
src/templates/email/user-confirmation.html
Normal file
@@ -0,0 +1,367 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Thank you for your message</title>
|
||||
<style>
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
background-size: 300% 300%;
|
||||
animation: gradientShift 15s ease infinite;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
/* Main container with glassmorphism */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 32px 80px rgba(0, 0, 0, 0.15),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Animated header with gradient */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 8s ease-in-out infinite;
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
animation: backgroundMove 20s linear infinite;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes backgroundMove {
|
||||
0% { transform: translateX(0) translateY(0); }
|
||||
100% { transform: translateX(-30px) translateY(-30px); }
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.2);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 1.1rem;
|
||||
margin-top: 8px;
|
||||
opacity: 0.9;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #2563eb;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.main-message {
|
||||
font-size: 1.1rem;
|
||||
color: #374151;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Message display with modern styling */
|
||||
.message-section {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05), rgba(245, 87, 108, 0.05));
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin: 30px 0;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.message-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2, #f093fb);
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.message-title::before {
|
||||
content: '💬';
|
||||
margin-right: 8px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
font-style: italic;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Call-to-action button */
|
||||
.cta-section {
|
||||
text-align: center;
|
||||
margin: 35px 0;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
text-decoration: none;
|
||||
border-radius: 50px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
|
||||
border: 2px solid transparent;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.cta-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.cta-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Additional info section */
|
||||
.additional-info {
|
||||
background: rgba(79, 172, 254, 0.05);
|
||||
border-left: 4px solid #4facfe;
|
||||
padding: 20px;
|
||||
margin: 25px 0;
|
||||
border-radius: 0 12px 12px 0;
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
padding: 30px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: linear-gradient(135deg, rgba(248, 250, 252, 0.8), rgba(241, 245, 249, 0.8));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-signature {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.footer-team {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.footer-disclaimer {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Floating decorative elements */
|
||||
.floating-decoration {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.decoration-1 {
|
||||
top: 20%;
|
||||
right: 10%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: 50%;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.decoration-2 {
|
||||
bottom: 30%;
|
||||
left: 15%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #f093fb, #f5576c);
|
||||
border-radius: 30%;
|
||||
animation: float 8s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(180deg); }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
margin: 20px auto;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
padding: 14px 28px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<!-- Floating decorative elements -->
|
||||
<div class="floating-decoration decoration-1"></div>
|
||||
<div class="floating-decoration decoration-2"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>Thank You!</h1>
|
||||
<div class="header-subtitle">Your message has been received</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<div class="greeting">Dear {{name}},</div>
|
||||
|
||||
<div class="main-message">
|
||||
Thank you for reaching out to <strong>365DevNet</strong>. We truly appreciate you taking the time to contact us and we're excited to connect with you.
|
||||
</div>
|
||||
|
||||
<!-- Message display -->
|
||||
<div class="message-section">
|
||||
<div class="message-title">Your Message</div>
|
||||
<div class="message-content">{{message}}</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-info">
|
||||
<strong>What happens next?</strong> We'll review your message carefully and get back to you as soon as possible. Our typical response time is within 24-48 hours during business days.
|
||||
</div>
|
||||
|
||||
<!-- Call to action -->
|
||||
<div class="cta-section">
|
||||
<a href="https://www.365devnet.eu" class="cta-button">Explore Our Services</a>
|
||||
</div>
|
||||
|
||||
<div class="main-message">
|
||||
If you have any additional information to share or urgent questions, please don't hesitate to reply to this email directly.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<div class="footer-signature">Best regards,</div>
|
||||
<div class="footer-team">365DevNet Team</div>
|
||||
<div class="footer-disclaimer">
|
||||
This is an automated confirmation message. You can reply directly to this email to reach our team.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
12
src/types.d.ts
vendored
12
src/types.d.ts
vendored
@@ -213,6 +213,18 @@ export interface Form {
|
||||
disclaimer?: Disclaimer;
|
||||
button?: string;
|
||||
description?: string;
|
||||
spamReview?: {
|
||||
title: string;
|
||||
description: string;
|
||||
emailLabel: string;
|
||||
emailPlaceholder: string;
|
||||
justificationLabel: string;
|
||||
justificationOptional: string;
|
||||
justificationPlaceholder: string;
|
||||
button: string;
|
||||
resultSuccess: string;
|
||||
resultError: string;
|
||||
};
|
||||
}
|
||||
|
||||
// WIDGETS
|
||||
|
@@ -2,6 +2,8 @@ import nodemailer from 'nodemailer';
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible';
|
||||
import { createHash } from 'crypto';
|
||||
import 'dotenv/config';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Environment variables
|
||||
const {
|
||||
@@ -14,8 +16,7 @@ const {
|
||||
} = process.env;
|
||||
|
||||
// Email configuration
|
||||
// Force production mode for testing
|
||||
const isProduction = true; // NODE_ENV === 'production';
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Create a transporter for sending emails
|
||||
let transporter: nodemailer.Transporter;
|
||||
@@ -139,14 +140,18 @@ export function logEmailAttempt(success: boolean, recipient: string, subject: st
|
||||
}
|
||||
|
||||
// Send an email
|
||||
export async function sendEmail(to: string, subject: string, html: string, text: string): Promise<boolean> {
|
||||
export async function sendEmail(to: string, subject: string, html: string, text: string, domain?: string): Promise<boolean> {
|
||||
// Initialize transporter if not already done
|
||||
if (!transporter) {
|
||||
initializeTransporter();
|
||||
}
|
||||
|
||||
try {
|
||||
const fromAddress = isProduction ? `"${WEBSITE_NAME}" <${SMTP_USER || 'noreply@' + WEBSITE_NAME}>` : `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
|
||||
const fromAddress = isProduction
|
||||
? domain
|
||||
? `"${WEBSITE_NAME}" <${SMTP_USER || 'info'}@${domain}>`
|
||||
: `"${WEBSITE_NAME}" <${SMTP_USER || 'noreply@' + WEBSITE_NAME}>`
|
||||
: `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
|
||||
|
||||
const mailOptions = {
|
||||
from: fromAddress,
|
||||
@@ -172,7 +177,7 @@ export async function sendEmail(to: string, subject: string, html: string, text:
|
||||
}
|
||||
|
||||
// Utility to escape HTML special characters
|
||||
function escapeHtml(str: string): string {
|
||||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
@@ -181,13 +186,23 @@ function escapeHtml(str: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function getTemplate(templateName: string, data: Record<string, string>): Promise<string> {
|
||||
const templatePath = path.join(process.cwd(), 'src', 'templates', 'email', `${templateName}.html`);
|
||||
let template = await fs.readFile(templatePath, 'utf-8');
|
||||
for (const key in data) {
|
||||
template = template.replace(new RegExp(`{{${key}}}`, 'g'), data[key]);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
// Send admin notification email
|
||||
export async function sendAdminNotification(
|
||||
name: string,
|
||||
email: string,
|
||||
message: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
userAgent?: string,
|
||||
domain?: string
|
||||
): Promise<boolean> {
|
||||
// Validate inputs
|
||||
if (!name || name.trim() === '') {
|
||||
@@ -206,103 +221,15 @@ export async function sendAdminNotification(
|
||||
}
|
||||
|
||||
const subject = `New Contact Form Submission from ${escapeHtml(name)}`;
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>New Contact Form Submission</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.content {
|
||||
background-color: #f8fafc;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.field-value {
|
||||
background-color: white;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.message-content {
|
||||
white-space: pre-wrap;
|
||||
background-color: white;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
font-size: 0.9em;
|
||||
color: #6b7280;
|
||||
}
|
||||
.meta-info {
|
||||
font-size: 0.85em;
|
||||
color: #6b7280;
|
||||
margin-top: 20px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>New Contact Form Submission</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="field">
|
||||
<div class="field-label">Name</div>
|
||||
<div class="field-value">${escapeHtml(name)}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">Email</div>
|
||||
<div class="field-value">${escapeHtml(email)}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">Message</div>
|
||||
<div class="message-content">${escapeHtml(message).replace(/\n/g, '<br>')}</div>
|
||||
</div>
|
||||
<div class="meta-info">
|
||||
${ipAddress ? `<div><strong>IP Address:</strong> ${escapeHtml(ipAddress)}</div>` : ''}
|
||||
${userAgent ? `<div><strong>User Agent:</strong> ${escapeHtml(userAgent)}</div>` : ''}
|
||||
<div><strong>Time:</strong> ${new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This message was sent from the contact form on ${WEBSITE_NAME}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const html = await getTemplate('admin-notification', {
|
||||
name: escapeHtml(name),
|
||||
email: escapeHtml(email),
|
||||
message: escapeHtml(message).replace(/\n/g, '<br>'),
|
||||
ipAddress: escapeHtml(ipAddress || ''),
|
||||
userAgent: escapeHtml(userAgent || ''),
|
||||
time: new Date().toLocaleString(),
|
||||
websiteName: WEBSITE_NAME,
|
||||
});
|
||||
const text = `
|
||||
New Contact Form Submission
|
||||
|
||||
@@ -317,11 +244,11 @@ Time: ${new Date().toLocaleString()}
|
||||
This message was sent from the contact form on ${WEBSITE_NAME}
|
||||
`;
|
||||
|
||||
return sendEmail(ADMIN_EMAIL, subject, html, text);
|
||||
return sendEmail(ADMIN_EMAIL, subject, html, text, domain);
|
||||
}
|
||||
|
||||
// Send user confirmation email
|
||||
export async function sendUserConfirmation(name: string, email: string, message: string): Promise<boolean> {
|
||||
export async function sendUserConfirmation(name: string, email: string, message: string, domain?: string): Promise<boolean> {
|
||||
// Validate inputs
|
||||
if (!name || name.trim() === '') {
|
||||
console.error('Cannot send user confirmation: name is empty');
|
||||
@@ -334,89 +261,11 @@ export async function sendUserConfirmation(name: string, email: string, message:
|
||||
}
|
||||
|
||||
const subject = `Thank you for contacting ${WEBSITE_NAME}`;
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Thank you for your message</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.content {
|
||||
background-color: #f8fafc;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.message {
|
||||
background-color: white;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
font-size: 0.9em;
|
||||
color: #6b7280;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Thank you for your message</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Dear ${escapeHtml(name)},</p>
|
||||
<p>Thank you for contacting ${WEBSITE_NAME}. We have received your message and will get back to you as soon as possible.</p>
|
||||
|
||||
<div class="message">
|
||||
<h3>Your Message:</h3>
|
||||
<p>${escapeHtml(message).replace(/\n/g, '<br>')}</p>
|
||||
</div>
|
||||
|
||||
<p>If you have any additional information to share, please don't hesitate to reply to this email.</p>
|
||||
|
||||
<a href="https://www.365devnet.eu" class="button">Visit Our Website</a>
|
||||
|
||||
<div class="footer">
|
||||
<p>Best regards,<br>${WEBSITE_NAME} Team</p>
|
||||
<p><small>This is an automated message, please do not reply directly to this email.</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const html = await getTemplate('user-confirmation', {
|
||||
name: escapeHtml(name),
|
||||
message: escapeHtml(message).replace(/\n/g, '<br>'),
|
||||
websiteName: WEBSITE_NAME,
|
||||
});
|
||||
const text = `
|
||||
Thank you for your message
|
||||
|
||||
@@ -436,7 +285,7 @@ ${WEBSITE_NAME} Team
|
||||
This is an automated message, please do not reply directly to this email.
|
||||
`;
|
||||
|
||||
return sendEmail(email, subject, html, text);
|
||||
return sendEmail(email, subject, html, text, domain);
|
||||
}
|
||||
|
||||
// Initialize the email system
|
||||
@@ -458,3 +307,4 @@ export async function testEmailConfiguration(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user