Compare commits
24 Commits
b33ffa3371
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a9f261189 | |||
| c4f0767b58 | |||
| 59d2f8b4e0 | |||
| 54d5a77329 | |||
| 9bf7827aa0 | |||
| 560020632f | |||
| 85946a2b40 | |||
| efff385570 | |||
| 6664e6e3d1 | |||
| 88b6d1bade | |||
| b0fd1b6d9e | |||
| d7fb16e24e | |||
| d5450a3c0a | |||
| 58fa014341 | |||
| cc21c06641 | |||
| 960f7ff4d0 | |||
| 1b66fc8a90 | |||
| f46f4667a1 | |||
| 3abd97702d | |||
| c34f11de00 | |||
| 16a441513e | |||
| df449cd3e7 | |||
| cd88f570a0 | |||
| b136c6e63a |
31
Dockerfile
31
Dockerfile
@@ -1,31 +0,0 @@
|
|||||||
# use the official Bun image
|
|
||||||
# see all versions at https://hub.docker.com/r/oven/bun/tags
|
|
||||||
FROM oven/bun:1.0.25-alpine as base
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
# install dependencies into temp directory
|
|
||||||
# this will cache them and speed up future builds
|
|
||||||
FROM base AS install
|
|
||||||
RUN mkdir -p /temp/dev
|
|
||||||
COPY package.json bun.lockb /temp/dev/
|
|
||||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# install with --production (exclude devDependencies)
|
|
||||||
RUN mkdir -p /temp/prod
|
|
||||||
COPY package.json bun.lockb /temp/prod/
|
|
||||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
|
||||||
|
|
||||||
# copy node_modules from temp directory
|
|
||||||
# then copy all (non-ignored) project files into the image
|
|
||||||
FROM base AS release
|
|
||||||
COPY --from=install /temp/dev/node_modules node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# [optional] tests & build
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
RUN bun test
|
|
||||||
|
|
||||||
# run the app
|
|
||||||
USER bun
|
|
||||||
EXPOSE 3000/tcp
|
|
||||||
ENTRYPOINT [ "bun", "run", "./src/index.ts" ]
|
|
||||||
81
bun.lock
81
bun.lock
@@ -4,100 +4,57 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "portfolio",
|
"name": "portfolio",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/html": "1.4.0",
|
"gray-matter": "^4.0.3",
|
||||||
"@elysiajs/static": "1.4.4",
|
"marked": "^16.4.1",
|
||||||
"elysia": "^1.4.11",
|
"react-dom": "19.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.0",
|
"@types/bun": "^1.3.0",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
|
|
||||||
|
|
||||||
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
|
|
||||||
|
|
||||||
"@elysiajs/static": ["@elysiajs/static@1.4.4", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-PT/uGvBHQL5I+APAGiuRjhVfySe5YmrJdPtSc2QyM6CgNp4WDCmPfhPoVYkHNaH5QGWdP62hMq0HUnClNxR3zw=="],
|
|
||||||
|
|
||||||
"@kitajs/html": ["@kitajs/html@4.2.10", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-q9n2Ig7GlAYOdL+CeWxsIIZFIKna+eCJah15eK8PBIFHW3UcWayAMs8QYGJNYgP3uMucDimIAUBH26xnE7GILw=="],
|
|
||||||
|
|
||||||
"@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.3", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.6.2" }, "bin": { "ts-html-plugin": "dist/cli.js", "xss-scan": "dist/cli.js" } }, "sha512-NlYrID5yMxfRKiO1eiiSC4MWveKe0ffoCJOZm4idNOqwimmLXr0g1NmvCcquOU2XLRrgzynxZqw6rhwR5CY5Nw=="],
|
|
||||||
|
|
||||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
|
|
||||||
|
|
||||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
|
|
||||||
|
|
||||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
|
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||||
|
|
||||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||||
|
|
||||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
|
||||||
|
|
||||||
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
|
||||||
|
|
||||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
|
||||||
|
|
||||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||||
|
|
||||||
"elysia": ["elysia@1.4.11", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-cphuzQj0fRw1ICRvwHy2H3xQio9bycaZUVHnDHJQnKqBfMNlZ+Hzj6TMmt9lc0Az0mvbCnPXWVF7y1MCRhUuOA=="],
|
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||||
|
|
||||||
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
|
"js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
|
||||||
|
|
||||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||||
|
|
||||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
"marked": ["marked@16.4.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg=="],
|
||||||
|
|
||||||
"file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="],
|
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||||
|
|
||||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||||
|
|
||||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||||
|
|
||||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
|
||||||
|
|
||||||
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
|
|
||||||
|
|
||||||
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
|
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
||||||
|
|
||||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
|
||||||
|
|
||||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
|
||||||
|
|
||||||
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
|
|
||||||
|
|
||||||
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
bun_plugins/onImport-markdown-loader.tsx
Normal file
52
bun_plugins/onImport-markdown-loader.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { BunPlugin } from 'bun';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderToString } from "react-dom/server";
|
||||||
|
|
||||||
|
import matter from 'gray-matter';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { dbConnection } from "../src/db/index";
|
||||||
|
|
||||||
|
import { AppShell } from "../src/frontend/AppShell";
|
||||||
|
import { Post } from "../src/frontend/pages/post";
|
||||||
|
|
||||||
|
// TODO: Add better type handling for if Markdown parsing fails
|
||||||
|
const markdownLoader: BunPlugin = {
|
||||||
|
name: 'markdown-loader',
|
||||||
|
setup(build) {
|
||||||
|
// Plugin implementation
|
||||||
|
build.onLoad({filter: /\.md$/}, async args => {
|
||||||
|
console.log("Loading markdown file:", args.path);
|
||||||
|
const {data, content } = matter(await Bun.file(args.path).text());
|
||||||
|
|
||||||
|
// Remove the title from content if it matches the frontmatter title to avoid duplicate H1s
|
||||||
|
let processedContent = content;
|
||||||
|
if (data.title) {
|
||||||
|
const titleHeadingRegex = new RegExp(`^#\\s+${data.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
|
||||||
|
processedContent = content.replace(titleHeadingRegex, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyHtml = await marked.parse(processedContent);
|
||||||
|
// AppShell is required here for rendering. If used at route level
|
||||||
|
// Bun will only see an htmlBundle and fail to load anything
|
||||||
|
// Validate required fields
|
||||||
|
if (!data.title || !data.date) {
|
||||||
|
throw new Error(`Markdown files must include title and date in frontmatter: ${args.path}`);
|
||||||
|
}
|
||||||
|
const meta = {
|
||||||
|
title: data.title,
|
||||||
|
date: new Date(data.date),
|
||||||
|
readingTime: data.readingTime || `${Math.ceil(content.split(/\s+/).length / 200)} min read`
|
||||||
|
};
|
||||||
|
const renderedHtml = renderToString(<AppShell><Post meta={meta} children={bodyHtml} /></AppShell>);
|
||||||
|
dbConnection.addPost(args.path, meta, bodyHtml); // Load the post to the database for dynamic querying
|
||||||
|
|
||||||
|
// JSX Approach
|
||||||
|
return {
|
||||||
|
contents: renderedHtml,
|
||||||
|
loader: 'html',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default markdownLoader;
|
||||||
29
bun_plugins/onStartup-post-importer.ts
Normal file
29
bun_plugins/onStartup-post-importer.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import matter from 'gray-matter';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { dbConnection } from "../src/db";
|
||||||
|
|
||||||
|
// When the server starts, import all the blog post metadata into the database
|
||||||
|
// Executed on startup because it's included in <root> ./bunfig.toml
|
||||||
|
|
||||||
|
// Only import if not running in production
|
||||||
|
(async () => {
|
||||||
|
if (process.env.NODE_ENV === 'production') return;
|
||||||
|
|
||||||
|
const glob = new Bun.Glob("**/*.md");
|
||||||
|
for await (const file of glob.scan("./content")) {
|
||||||
|
const {data, content } = matter(await Bun.file(`./content/${file}`).text());
|
||||||
|
const route = `/${file.replace(/\.md$/, "")}`;
|
||||||
|
|
||||||
|
// Remove the title from content if it matches the frontmatter title to avoid duplicate H1s
|
||||||
|
let processedContent = content;
|
||||||
|
if (data.title) {
|
||||||
|
const titleHeadingRegex = new RegExp(`^#\\s+${data.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
|
||||||
|
processedContent = content.replace(titleHeadingRegex, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyHtml = await marked.parse(processedContent);
|
||||||
|
dbConnection.addPost(route, data, bodyHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Posts have been imported into db');
|
||||||
|
})();
|
||||||
4
bunfig.toml
Normal file
4
bunfig.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
preload = ["./bun_plugins/onStartup-post-importer.ts"]
|
||||||
|
|
||||||
|
[serve.static]
|
||||||
|
plugins = ["./bun_plugins/onImport-markdown-loader.tsx"]
|
||||||
34
content/.template.md
Normal file
34
content/.template.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Your Post Title
|
||||||
|
date: 2025-10-21
|
||||||
|
tags: [Web Development, TypeScript]
|
||||||
|
excerpt: A brief summary of your post (2-3 sentences). This will appear in post listings and search results.
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Your Post Title
|
||||||
|
|
||||||
|
Your content here. You can use standard markdown syntax:
|
||||||
|
|
||||||
|
## Section Heading
|
||||||
|
|
||||||
|
Write your paragraphs with proper spacing.
|
||||||
|
|
||||||
|
### Subsection
|
||||||
|
|
||||||
|
- Bullet points
|
||||||
|
- Another point
|
||||||
|
- And another
|
||||||
|
|
||||||
|
**Bold text** and *italic text* are supported.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Code blocks work too
|
||||||
|
const example = "Hello World";
|
||||||
|
console.log(example);
|
||||||
|
```
|
||||||
|
|
||||||
|
> Blockquotes for important callouts or quotes.
|
||||||
|
|
||||||
|
This is just a template - delete this file or ignore it when writing your actual posts.
|
||||||
|
|
||||||
93
content/2024/12/year-in-review.md
Normal file
93
content/2024/12/year-in-review.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
title: 2024 Year in Review
|
||||||
|
date: 2024-12-28
|
||||||
|
tags: [Career, Productivity]
|
||||||
|
excerpt: Reflecting on 2024 - the projects I built, lessons I learned, and goals for 2025. A year of growth, challenges, and accomplishments.
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2024 Year in Review!
|
||||||
|
|
||||||
|
As 2024 comes to a close, I wanted to take some time to reflect on the year. It's been a year of significant growth, both professionally and personally.
|
||||||
|
|
||||||
|
## Professional Highlights
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
This year, I worked on several exciting projects:
|
||||||
|
|
||||||
|
**E-commerce Platform**: Led the frontend architecture for a new e-commerce platform serving 100k+ daily users. We achieved a 40% improvement in page load times through careful optimization.
|
||||||
|
|
||||||
|
**Design System**: Built a comprehensive design system from scratch, complete with documentation and Storybook. This is now used across 5 different products.
|
||||||
|
|
||||||
|
**Open Source**: Made my first significant open source contributions! Contributed to several Bun-related projects and created a few small utilities that others found useful.
|
||||||
|
|
||||||
|
### Skills Developed
|
||||||
|
|
||||||
|
- **Performance Optimization**: Learned a ton about web performance, Core Web Vitals, and how to actually make sites fast
|
||||||
|
- **System Design**: Started thinking more about architecture and scalability
|
||||||
|
- **TypeScript**: Became much more proficient with advanced TypeScript patterns
|
||||||
|
- **Testing**: Finally built a solid testing practice
|
||||||
|
|
||||||
|
## Personal Growth
|
||||||
|
|
||||||
|
### Writing
|
||||||
|
|
||||||
|
Started this blog! Writing has helped me:
|
||||||
|
- Clarify my thinking
|
||||||
|
- Learn more deeply
|
||||||
|
- Connect with others in the community
|
||||||
|
|
||||||
|
### Work-Life Balance
|
||||||
|
|
||||||
|
Made a conscious effort to improve work-life balance:
|
||||||
|
- Implemented a hard stop at 6 PM
|
||||||
|
- Started exercising regularly (3x per week)
|
||||||
|
- Picked up reading again (finished 12 books!)
|
||||||
|
|
||||||
|
## Challenges
|
||||||
|
|
||||||
|
Not everything was smooth sailing:
|
||||||
|
|
||||||
|
**Burnout**: Hit a rough patch in Q2 where I was working too much. Learned the importance of rest and boundaries.
|
||||||
|
|
||||||
|
**Imposter Syndrome**: Still struggles with this occasionally, but getting better at recognizing it and pushing through.
|
||||||
|
|
||||||
|
**Saying No**: Learned that saying no to some opportunities is necessary to say yes to the right ones.
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
1. **Quality > Quantity**: Better to do fewer things well than many things poorly
|
||||||
|
2. **Ask for Help**: Nobody knows everything, and asking for help is a strength
|
||||||
|
3. **Document Everything**: Future you will thank present you
|
||||||
|
4. **Take Breaks**: Rest isn't laziness - it's necessary for sustained performance
|
||||||
|
5. **Community Matters**: Connecting with other developers has been invaluable
|
||||||
|
|
||||||
|
## Goals for 2025
|
||||||
|
|
||||||
|
Looking ahead to 2025:
|
||||||
|
|
||||||
|
### Professional
|
||||||
|
- Contribute to a major open source project
|
||||||
|
- Speak at a local meetup or conference
|
||||||
|
- Learn Rust (for real this time)
|
||||||
|
- Build and launch a side project
|
||||||
|
|
||||||
|
### Personal
|
||||||
|
- Write 24 blog posts (2 per month)
|
||||||
|
- Read 20 books
|
||||||
|
- Maintain exercise routine
|
||||||
|
- Learn to cook 10 new recipes
|
||||||
|
|
||||||
|
### Learning
|
||||||
|
- Deep dive into system design
|
||||||
|
- Master web performance
|
||||||
|
- Learn more about databases
|
||||||
|
- Get better at writing
|
||||||
|
|
||||||
|
## Thank You
|
||||||
|
|
||||||
|
Thanks to everyone who supported me this year - colleagues, friends, family, and the online tech community. Looking forward to 2025!
|
||||||
|
|
||||||
|
What were your highlights from 2024? What are you looking forward to in 2025?
|
||||||
|
|
||||||
108
content/2025/09/typescript-tips.md
Normal file
108
content/2025/09/typescript-tips.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
title: 5 TypeScript Tips I Wish I Knew Earlier
|
||||||
|
date: 2025-09-18
|
||||||
|
readingTime: 10 minutes
|
||||||
|
tags: [TypeScript, JavaScript, Productivity]
|
||||||
|
excerpt: Five practical TypeScript tips that will make your code more type-safe and your development experience smoother. From utility types to const assertions.
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5 TypeScript Tips I Wish I Knew Earlier
|
||||||
|
|
||||||
|
TypeScript is an amazing tool, but it takes time to learn all its features. Here are five tips that significantly improved my TypeScript development experience.
|
||||||
|
|
||||||
|
## 1. Use `satisfies` for Type Checking
|
||||||
|
|
||||||
|
The `satisfies` keyword (added in TS 4.9) lets you validate that a value matches a type without widening its type:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type RGB = { r: number; g: number; b: number };
|
||||||
|
|
||||||
|
// Before: Type is widened to RGB
|
||||||
|
const color: RGB = { r: 255, g: 0, b: 0 };
|
||||||
|
|
||||||
|
// After: Type is preserved as literal values
|
||||||
|
const color = { r: 255, g: 0, b: 0 } satisfies RGB;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is especially useful when you want both type safety and precise types.
|
||||||
|
|
||||||
|
## 2. Const Assertions
|
||||||
|
|
||||||
|
Adding `as const` to an object or array makes all properties readonly and infers literal types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Regular array: type is string[]
|
||||||
|
const fruits = ['apple', 'banana'];
|
||||||
|
|
||||||
|
// With const assertion: type is readonly ['apple', 'banana']
|
||||||
|
const fruits = ['apple', 'banana'] as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is perfect for configuration objects and enum-like values.
|
||||||
|
|
||||||
|
## 3. Utility Type: `Awaited<T>`
|
||||||
|
|
||||||
|
Need to get the resolved type of a Promise? Use `Awaited`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function fetchUser() {
|
||||||
|
return { id: 1, name: 'Alice' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets the return type without the Promise wrapper
|
||||||
|
type User = Awaited<ReturnType<typeof fetchUser>>;
|
||||||
|
// User is { id: number; name: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Template Literal Types
|
||||||
|
|
||||||
|
Create types based on string patterns:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type EventName = 'click' | 'focus' | 'blur';
|
||||||
|
type EventHandler = `on${Capitalize<EventName>}`;
|
||||||
|
// Result: 'onClick' | 'onFocus' | 'onBlur'
|
||||||
|
```
|
||||||
|
|
||||||
|
This is incredibly powerful for creating type-safe APIs.
|
||||||
|
|
||||||
|
## 5. Discriminated Unions
|
||||||
|
|
||||||
|
Use a common property to narrow union types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Success = { status: 'success'; data: string };
|
||||||
|
type Error = { status: 'error'; error: string };
|
||||||
|
type Result = Success | Error;
|
||||||
|
|
||||||
|
function handle(result: Result) {
|
||||||
|
if (result.status === 'success') {
|
||||||
|
// TypeScript knows this is Success
|
||||||
|
console.log(result.data);
|
||||||
|
} else {
|
||||||
|
// TypeScript knows this is Error
|
||||||
|
console.log(result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern eliminates entire classes of runtime errors.
|
||||||
|
|
||||||
|
## Bonus: Non-Null Assertion Operator
|
||||||
|
|
||||||
|
While I generally avoid the `!` operator, it's useful when you know better than TypeScript:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// When you're certain the element exists
|
||||||
|
const button = document.getElementById('submit')!;
|
||||||
|
button.click();
|
||||||
|
```
|
||||||
|
|
||||||
|
Use sparingly and only when you're absolutely sure.
|
||||||
|
|
||||||
|
## Wrapping Up
|
||||||
|
|
||||||
|
These tips have made my TypeScript code more robust and easier to maintain. The key is to leverage TypeScript's type system to catch errors at compile time rather than runtime.
|
||||||
|
|
||||||
|
What are your favorite TypeScript features? Let me know!
|
||||||
82
content/2025/10/a-new-post.md
Normal file
82
content/2025/10/a-new-post.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Modern Development Workflow
|
||||||
|
date: 2025-10-21
|
||||||
|
tags: [Web Development, TypeScript, Productivity]
|
||||||
|
excerpt: Explore modern development workflows that boost productivity and code quality. Learn about hot module replacement, automated testing, and modern tooling.
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Modern Development Workflow
|
||||||
|
|
||||||
|
In today's fast-paced development environment, having an efficient workflow is crucial for maintaining productivity and code quality.
|
||||||
|
|
||||||
|
## Hot Module Replacement
|
||||||
|
|
||||||
|
Hot Module Replacement (HMR) revolutionizes the development experience:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Vite configuration with HMR
|
||||||
|
export default {
|
||||||
|
server: {
|
||||||
|
hmr: {
|
||||||
|
overlay: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
reactRefresh()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// During development, changes appear instantly
|
||||||
|
const Component = () => {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
return (
|
||||||
|
<button onClick={() => setCount(count + 1)}>
|
||||||
|
Count: {count}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Chain Essentials
|
||||||
|
|
||||||
|
A modern tool chain should include:
|
||||||
|
|
||||||
|
- **Fast builds**: Vite or Bun for lightning-fast compilation
|
||||||
|
- **Type safety**: TypeScript for catching errors at compile time
|
||||||
|
- **Code formatting**: Prettier for consistent code style
|
||||||
|
- **Linting**: ESLint for maintaining code quality
|
||||||
|
|
||||||
|
## Automated Testing
|
||||||
|
|
||||||
|
Integrate testing into your workflow:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Component testing with React Testing Library
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import Counter from './Counter';
|
||||||
|
|
||||||
|
test('increment works correctly', () => {
|
||||||
|
render(<Counter />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByText('Count: 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- **Git hooks**: Pre-commit hooks for code quality checks
|
||||||
|
- **CI/CD**: Automated testing and deployment
|
||||||
|
- **Documentation**: Keep README files up to date
|
||||||
|
- **Code reviews**: Peer reviews for knowledge sharing
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
A modern development workflow combines the right tools with good practices. Invest time in setting up your environment properly, and it will pay dividends in productivity throughout your project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This post demonstrates the power of modern development tools and workflows.*
|
||||||
101
content/2025/10/building-with-bun.md
Normal file
101
content/2025/10/building-with-bun.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
title: Building a Modern Blog with Bun and TypeScript
|
||||||
|
date: 2025-10-20
|
||||||
|
tags: [Web Development, TypeScript, JavaScript]
|
||||||
|
excerpt: A deep dive into building a performant, modern blog using Bun's runtime, TypeScript, and server-side rendering. Learn about the architecture decisions and tradeoffs.
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Building a Modern Blog with Bun and TypeScript
|
||||||
|
|
||||||
|
|
||||||
|
## Why Bun?
|
||||||
|
|
||||||
|
Bun is an all-in-one JavaScript runtime that's significantly faster than Node.js for many operations. It includes:
|
||||||
|
|
||||||
|
- A blazing-fast JavaScript/TypeScript runtime
|
||||||
|
- Built-in bundler
|
||||||
|
- Native TypeScript support
|
||||||
|
- Package manager
|
||||||
|
- Test runner
|
||||||
|
|
||||||
|
For this blog, the combination of native TypeScript support and the built-in bundler made Bun an obvious choice.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The blog uses a hybrid approach:
|
||||||
|
|
||||||
|
### Server-Side Rendering
|
||||||
|
|
||||||
|
All HTML is generated on the server using JSX as a templating language. This means:
|
||||||
|
|
||||||
|
- **Fast initial page loads** - No waiting for JavaScript to download and execute
|
||||||
|
- **SEO friendly** - Search engines see fully rendered HTML
|
||||||
|
- **Works without JavaScript** - Core functionality doesn't depend on client-side JS
|
||||||
|
|
||||||
|
### AppShell Pattern
|
||||||
|
|
||||||
|
The blog implements an "AppShell" pattern where:
|
||||||
|
|
||||||
|
1. First visit loads the full page with AppShell (sidebar, header, etc.)
|
||||||
|
2. Navigation replaces only the `<main>` content
|
||||||
|
3. Subsequent requests send a custom header to indicate AppShell is already loaded
|
||||||
|
4. Server returns just the content, not the full shell
|
||||||
|
|
||||||
|
This gives us SPA-like navigation speed with SSR benefits.
|
||||||
|
|
||||||
|
## Markdown Processing
|
||||||
|
|
||||||
|
Blog posts are written in Markdown and processed at build time:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Simplified example
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
const html = marked.parse(markdownContent);
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin:
|
||||||
|
- Scans the content directory
|
||||||
|
- Parses frontmatter (title, date, tags, etc.)
|
||||||
|
- Converts markdown to HTML
|
||||||
|
- Stores everything in SQLite for fast queries
|
||||||
|
|
||||||
|
## Performance Results
|
||||||
|
|
||||||
|
The result? Page loads under 128KB including:
|
||||||
|
- All HTML
|
||||||
|
- All CSS (inlined)
|
||||||
|
- Minimal JavaScript for interactivity
|
||||||
|
|
||||||
|
First contentful paint happens in under 100ms on a fast connection.
|
||||||
|
|
||||||
|
## Developer Experience
|
||||||
|
|
||||||
|
With Bun's `--watch` flag, the entire development workflow is seamless:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --watch ./index.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
This watches for changes and hot-reloads the server. Combined with the markdown plugin, changing a blog post immediately reflects in the browser.
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
### What Worked Well
|
||||||
|
|
||||||
|
- **JSX for templating** - Familiar, type-safe, and no new syntax to learn
|
||||||
|
- **SQLite for search** - Fast, embedded, and perfect for a small blog
|
||||||
|
- **Bun's speed** - Development is incredibly fast
|
||||||
|
|
||||||
|
### What Could Be Better
|
||||||
|
|
||||||
|
- **Hot reloading** - Would love client-side HMR for styles
|
||||||
|
- **Build step** - Currently all processing happens at runtime
|
||||||
|
- **TypeScript types** - Virtual modules need proper type definitions
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Building with Bun has been a great experience. The performance is excellent, and the developer experience is top-notch. If you're building a new project and want to try something modern, I highly recommend giving Bun a shot.
|
||||||
|
|
||||||
|
The code for this blog is open source - check it out on GitHub!
|
||||||
51
content/2025/10/my-first-post.md
Normal file
51
content/2025/10/my-first-post.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: Welcome to My Blog
|
||||||
|
date: 2025-10-15
|
||||||
|
tags: [Career, Web Development, Another Tag, Blogging]
|
||||||
|
excerpt: Starting a new blog to share my thoughts on web development, programming, and building great software. Here's why I decided to start writing.
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Welcome to My Blog
|
||||||
|
|
||||||
|
I'm excited to finally launch my personal blog! After years of thinking about it, I've decided it's time to start sharing my experiences, learnings, and thoughts about web development and software engineering.
|
||||||
|
|
||||||
|
## Why Start a Blog?
|
||||||
|
|
||||||
|
There are a few reasons I decided to finally take the plunge:
|
||||||
|
|
||||||
|
**Learning in Public**: Writing about what I learn helps solidify my understanding. Teaching is one of the best ways to learn, and writing blog posts forces me to really understand a topic deeply.
|
||||||
|
|
||||||
|
**Building a Knowledge Base**: I often solve problems and then forget the solution months later. This blog will serve as my personal reference guide for future me.
|
||||||
|
|
||||||
|
**Contributing to the Community**: The developer community has given me so much through blog posts, tutorials, and open source. This is my way of giving back.
|
||||||
|
|
||||||
|
## What to Expect
|
||||||
|
|
||||||
|
I plan to write about:
|
||||||
|
|
||||||
|
- Web development best practices
|
||||||
|
- TypeScript and JavaScript tips
|
||||||
|
- Architecture and design patterns
|
||||||
|
- Career advice and lessons learned
|
||||||
|
- Tools and productivity
|
||||||
|
|
||||||
|
## The Tech Stack
|
||||||
|
|
||||||
|
This blog itself is built with some fun technologies:
|
||||||
|
|
||||||
|
- **Bun** - The all-in-one JavaScript runtime
|
||||||
|
- **TypeScript** - For type safety
|
||||||
|
- **Elysia** - Fast web framework for Bun
|
||||||
|
- **SQLite** - For blog post search and metadata
|
||||||
|
|
||||||
|
I'll be writing more about the technical implementation in future posts.
|
||||||
|
|
||||||
|
## Let's Connect
|
||||||
|
|
||||||
|
If you enjoy the content, feel free to reach out! You can find me on Twitter, GitHub, and LinkedIn (links in the sidebar).
|
||||||
|
|
||||||
|
Thanks for reading, and I hope you find something useful here!
|
||||||
|
|
||||||
|
Test change at Wed Oct 22 06:20:32 PDT 2025
|
||||||
|
<!-- test change -->
|
||||||
164
index.tsx
164
index.tsx
@@ -1,28 +1,142 @@
|
|||||||
import { Elysia } from "elysia";
|
import React from "react";
|
||||||
import { html } from "@elysiajs/html";
|
// Database connection is now handled by the centralized db module
|
||||||
import { staticPlugin } from "@elysiajs/static";
|
import { renderToString } from "react-dom/server";
|
||||||
|
|
||||||
import { AppShell } from "./src/frontend/AppShell";
|
import { AppShell } from "./src/frontend/AppShell";
|
||||||
import { app } from "./src/backend";
|
import { Home } from "./src/frontend/pages/home";
|
||||||
|
import { NotFound } from "./src/frontend/pages/not-found";
|
||||||
|
import { Post } from "./src/frontend/pages/post";
|
||||||
|
import { dbConnection } from "./src/db";
|
||||||
|
|
||||||
const index = new Elysia()
|
async function blogPosts(hmr: boolean) {
|
||||||
.use(html())
|
const glob = new Bun.Glob("**/*.md");
|
||||||
.onRequest(({ request }) => {
|
const blogPosts: Record<string, any> = {};
|
||||||
console.log(`Request ${request.method} ${request.url}`);
|
for await (const file of glob.scan("./content")) {
|
||||||
})
|
const post = await import(`./content/${file}`, { with: { type: "html" } });
|
||||||
.onAfterHandle(({ request, responseValue }) => {
|
const route = `/${file.replace(/\.md$/, "")}`;
|
||||||
if (request.headers.get("shell-loaded") === "true") {
|
dbConnection.getAllTags();
|
||||||
return responseValue; // Return the <main> element if the AppShell has already been loaded
|
|
||||||
|
if (hmr) {
|
||||||
|
// Use Bun Importer plugin for hot reloading in the browser
|
||||||
|
blogPosts[`/hmr${route}`] = post.default;
|
||||||
|
} else {
|
||||||
|
// Use the Database for sending just the HTML or the HTML and AppShell
|
||||||
|
blogPosts[route] = async (req: Request) => {
|
||||||
|
const path = new URL(req.url).pathname;
|
||||||
|
const post = dbConnection.getPost(path);
|
||||||
|
if (!post)
|
||||||
|
return new Response(renderToString(<NotFound />), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get adjacent posts for navigation
|
||||||
|
const { previousPost, nextPost } = dbConnection.getAdjacentPosts(post.path);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: post.title,
|
||||||
|
summary: post.summary,
|
||||||
|
date: new Date(post.date),
|
||||||
|
readingTime: post.reading_time,
|
||||||
|
tags: post.tags || [],
|
||||||
|
previousPost,
|
||||||
|
nextPost,
|
||||||
|
};
|
||||||
|
|
||||||
|
// AppShell is already loaded, just send the <main> content
|
||||||
|
if (req.headers.get("shell-loaded") === "true") {
|
||||||
|
return new Response(
|
||||||
|
renderToString(<Post meta={data} children={post.content} />),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppShell is not loaded, send the <AppShell> with the <main> content inside
|
||||||
|
return new Response(
|
||||||
|
renderToString(
|
||||||
|
<AppShell>
|
||||||
|
<Post
|
||||||
|
meta={data}
|
||||||
|
children={post.content}
|
||||||
|
/>
|
||||||
|
</AppShell>,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return AppShell(responseValue); // Return the <main> element wrapped by the AppShell
|
}
|
||||||
})
|
|
||||||
.use(staticPlugin({
|
Object.keys(blogPosts).map((route) => {
|
||||||
assets: './src/public',
|
console.info(route);
|
||||||
prefix: '/public'
|
});
|
||||||
}))
|
return blogPosts;
|
||||||
.use(app)
|
}
|
||||||
.listen(3000);
|
|
||||||
|
Bun.serve({
|
||||||
console.log(
|
development: {
|
||||||
`🦊 Elysia is running at ${index.server?.hostname}:${index.server?.port}`
|
hmr: true,
|
||||||
);
|
console: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
routes: {
|
||||||
|
// standard mounting of blog posts
|
||||||
|
...(await blogPosts(false)),
|
||||||
|
|
||||||
|
// hot module replacement in development mode
|
||||||
|
...(process.env.NODE_ENV === "development" ? (await blogPosts(true)) : {}),
|
||||||
|
|
||||||
|
// Home page
|
||||||
|
"/": (req: Request) => {
|
||||||
|
// Extract URL parameters from the request to pass to the component
|
||||||
|
const searchParams = new URLSearchParams(req.url.split('?')[1]);
|
||||||
|
|
||||||
|
if (req.headers.get("shell-loaded") === "true") {
|
||||||
|
return new Response(renderToString(<Home searchParams={searchParams} />), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
renderToString(
|
||||||
|
<AppShell searchParams={searchParams}>
|
||||||
|
<Home searchParams={searchParams} />
|
||||||
|
</AppShell>,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"/profile-picture.webp": () => {
|
||||||
|
return new Response(Bun.file("./src/public/profile-picture.webp"), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/webp",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"/*": (req) => {
|
||||||
|
if(req.headers.get("shell-loaded") === "true") {
|
||||||
|
return new Response(renderToString(<NotFound />), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(renderToString(<AppShell><NotFound /></AppShell>), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
4
markdown.d.ts
vendored
Normal file
4
markdown.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.md" {
|
||||||
|
const content: import("html").HtmlString;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
@@ -6,12 +6,13 @@
|
|||||||
"dev": "bun run --watch ./index.tsx"
|
"dev": "bun run --watch ./index.tsx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/html": "1.4.0",
|
"gray-matter": "^4.0.3",
|
||||||
"@elysiajs/static": "1.4.4",
|
"marked": "^16.4.1",
|
||||||
"elysia": "^1.4.11"
|
"react-dom": "19.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.0"
|
"@types/bun": "^1.3.0",
|
||||||
|
"@types/react-dom": "^19.2.3"
|
||||||
},
|
},
|
||||||
"module": "src/index.js"
|
"module": "src/index.js"
|
||||||
}
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Html } from "@elysiajs/html";
|
|
||||||
import { Elysia, NotFoundError } from "elysia";
|
|
||||||
|
|
||||||
import { Home } from "../frontend/home";
|
|
||||||
import { Blog } from "../frontend/blog";
|
|
||||||
import { NotFound } from "../frontend/not-found";
|
|
||||||
|
|
||||||
|
|
||||||
export const app = new Elysia()
|
|
||||||
.onError(({ error }) => {
|
|
||||||
if(error instanceof NotFoundError) {
|
|
||||||
return <NotFound />;
|
|
||||||
}
|
|
||||||
return error;
|
|
||||||
})
|
|
||||||
.get("/", () => { return <Home /> })
|
|
||||||
.get("/:path", ({ path }) => {
|
|
||||||
if(path === "/blog") {
|
|
||||||
return <Blog />;
|
|
||||||
}
|
|
||||||
throw new NotFoundError();
|
|
||||||
})
|
|
||||||
425
src/db/index.ts
Normal file
425
src/db/index.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton database connection class for managing blog posts and tags
|
||||||
|
*/
|
||||||
|
class DatabaseConnection {
|
||||||
|
private static instance: DatabaseConnection;
|
||||||
|
private db: Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private constructor to initialize database connection and schema
|
||||||
|
*/
|
||||||
|
private constructor() {
|
||||||
|
// Initialize the database if it doesn't exist
|
||||||
|
const dbPath = path.join(process.cwd(), 'blog.sqlite');
|
||||||
|
this.db = new Database(dbPath);
|
||||||
|
|
||||||
|
// Initialize database schema
|
||||||
|
this.initializeDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the singleton instance of the DatabaseConnection
|
||||||
|
*
|
||||||
|
* @returns The DatabaseConnection singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(): DatabaseConnection {
|
||||||
|
if (!DatabaseConnection.instance) {
|
||||||
|
DatabaseConnection.instance = new DatabaseConnection();
|
||||||
|
}
|
||||||
|
return DatabaseConnection.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the database connection
|
||||||
|
*
|
||||||
|
* @returns The SQLite database instance
|
||||||
|
*/
|
||||||
|
public getDatabase(): Database {
|
||||||
|
return this.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the database schema with posts, tags, and post_tags tables
|
||||||
|
*/
|
||||||
|
private initializeDatabase() {
|
||||||
|
// Create the posts table if it doesn't exist
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
date TEXT,
|
||||||
|
reading_time TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
content TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create the tags table if it doesn't exist
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
url_normalized TEXT GENERATED ALWAYS AS (lower(replace(name, ' ', '-'))) STORED
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create the post_tags junction table for many-to-many relationship
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS post_tags (
|
||||||
|
post_id INTEGER,
|
||||||
|
tag_id INTEGER,
|
||||||
|
PRIMARY KEY (post_id, tag_id),
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a post to the database with its metadata and content
|
||||||
|
*
|
||||||
|
* @param filePath - The path to the post file
|
||||||
|
* @param data - An object containing the post metadata (title, date, tags, etc.)
|
||||||
|
* @param content - The full content of the post
|
||||||
|
*/
|
||||||
|
public addPost(filePath: string, data: { [key: string]: any }, content: string) {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
// Use a transaction to ensure data consistency
|
||||||
|
const transaction = this.db.transaction(() => {
|
||||||
|
try {
|
||||||
|
// Extract common fields
|
||||||
|
const { title, date, readingTime, tags, excerpt } = data;
|
||||||
|
|
||||||
|
// Convert all values to strings or null explicitly (except tags)
|
||||||
|
// Ensure date is stored in ISO format for consistent sorting
|
||||||
|
const values = [
|
||||||
|
filePath ? String(filePath) : null,
|
||||||
|
title ? String(title) : null,
|
||||||
|
date ? (date instanceof Date ? date.toISOString().split('T')[0] : String(date)) : null,
|
||||||
|
readingTime ? String(readingTime) : null,
|
||||||
|
excerpt ? String(excerpt) : null,
|
||||||
|
content ? String(content) : null
|
||||||
|
];
|
||||||
|
|
||||||
|
// Query to insert or replace metadata (without tags)
|
||||||
|
const insertPost = this.db.query(`
|
||||||
|
INSERT OR REPLACE INTO posts
|
||||||
|
(path, title, date, reading_time, summary, content)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
insertPost.run(...values);
|
||||||
|
|
||||||
|
// Get the post ID
|
||||||
|
const getPostId = this.db.query('SELECT id FROM posts WHERE path = ?');
|
||||||
|
const postResult = getPostId.get(filePath) as { id: number } | undefined;
|
||||||
|
if (!postResult) {
|
||||||
|
throw new Error(`Failed to retrieve post ID for ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing tag associations for this post
|
||||||
|
const deleteExistingTags = this.db.query('DELETE FROM post_tags WHERE post_id = ?');
|
||||||
|
deleteExistingTags.run(postResult.id);
|
||||||
|
|
||||||
|
// If tags exist, process them
|
||||||
|
if (tags && Array.isArray(tags)) {
|
||||||
|
// Insert into junction table
|
||||||
|
const insertPostTag = this.db.query('INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)');
|
||||||
|
|
||||||
|
// Create or get tag and associate with post
|
||||||
|
const createOrGetTag = this.db.query(`
|
||||||
|
INSERT OR IGNORE INTO tags (name) VALUES (?)
|
||||||
|
RETURNING id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getTag = this.db.query('SELECT id FROM tags WHERE name = ?');
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
let tagResult = createOrGetTag.get(String(tag)) as { id: number } | undefined;
|
||||||
|
|
||||||
|
// If no result from insert, the tag already exists, so get it
|
||||||
|
if (!tagResult) {
|
||||||
|
tagResult = getTag.get(String(tag)) as { id: number } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tagResult) {
|
||||||
|
throw new Error(`Failed to retrieve tag ID for ${tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
insertPostTag.run(postResult.id, tagResult.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to store ${filePath}:`, error);
|
||||||
|
throw error; // Re-throw to make the transaction fail
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute the transaction
|
||||||
|
try {
|
||||||
|
transaction();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Transaction failed for ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total number of posts in the database
|
||||||
|
*
|
||||||
|
* @param tags - Optional array of tag names to filter posts by
|
||||||
|
* @returns The number of posts, optionally filtered by tags
|
||||||
|
*/
|
||||||
|
public getNumOfPosts(tags: string[] = []) {
|
||||||
|
if (tags.length === 0) {
|
||||||
|
const queryCount = this.db.query('SELECT COUNT(*) AS count FROM posts WHERE path NOT LIKE \'%.md\'');
|
||||||
|
const numPosts = queryCount.get() as { count: number };
|
||||||
|
return numPosts.count;
|
||||||
|
} else {
|
||||||
|
// Use url_normalized field for tag filtering
|
||||||
|
const placeholders = tags.map(() => '?').join(',');
|
||||||
|
|
||||||
|
// Get count of posts that have ALL the specified tags using url_normalized
|
||||||
|
const countQuery = this.db.query(`
|
||||||
|
SELECT COUNT(*) AS count FROM (
|
||||||
|
SELECT p.id, COUNT(DISTINCT t.id) as tag_count
|
||||||
|
FROM posts p
|
||||||
|
JOIN post_tags pt ON p.id = pt.post_id
|
||||||
|
JOIN tags t ON pt.tag_id = t.id
|
||||||
|
WHERE t.url_normalized IN (${placeholders})
|
||||||
|
AND p.path NOT LIKE '%.md'
|
||||||
|
GROUP BY p.id
|
||||||
|
HAVING COUNT(DISTINCT t.id) = ?
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const result = countQuery.get(...tags, tags.length) as { count: number };
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a paginated list of posts
|
||||||
|
*
|
||||||
|
* @param limit - Maximum number of posts to return (default: 10)
|
||||||
|
* @param offset - Number of posts to skip for pagination (default: 0)
|
||||||
|
* @param tags - Optional array of tag names to filter posts by
|
||||||
|
* @returns Array of posts with metadata and cleaned paths
|
||||||
|
*/
|
||||||
|
public getPosts(limit: number = 10, offset: number = 0, tags: string[] = []):
|
||||||
|
Array<{
|
||||||
|
id: number,
|
||||||
|
path: string,
|
||||||
|
title: string,
|
||||||
|
date: string,
|
||||||
|
readingTime: string,
|
||||||
|
summary: string,
|
||||||
|
content: string,
|
||||||
|
tags: string[],
|
||||||
|
}>
|
||||||
|
{
|
||||||
|
let posts;
|
||||||
|
|
||||||
|
if (tags.length === 0) {
|
||||||
|
// No tags specified, use the original query
|
||||||
|
const query = this.db.query(`
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE path NOT LIKE '%.md'
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
posts = query.all(limit, offset) as any[];
|
||||||
|
} else {
|
||||||
|
// Filter by tags using url_normalized field
|
||||||
|
// and ensure posts have ALL the specified tags
|
||||||
|
const placeholders = tags.map(() => '?').join(',');
|
||||||
|
const tagFilter = this.db.query(`
|
||||||
|
SELECT p.* FROM posts p
|
||||||
|
JOIN post_tags pt ON p.id = pt.post_id
|
||||||
|
JOIN tags t ON pt.tag_id = t.id
|
||||||
|
WHERE t.url_normalized IN (${placeholders})
|
||||||
|
AND p.path NOT LIKE '%.md'
|
||||||
|
GROUP BY p.id
|
||||||
|
HAVING COUNT(DISTINCT t.id) = ?
|
||||||
|
ORDER BY p.date DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Use the tags directly since we're now comparing with url_normalized
|
||||||
|
posts = tagFilter.all(...tags, tags.length, limit, offset) as any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get tags for a post
|
||||||
|
const getPostTags = (postId: number): string[] => {
|
||||||
|
const query = this.db.query(`
|
||||||
|
SELECT t.name FROM tags t
|
||||||
|
JOIN post_tags pt ON t.id = pt.tag_id
|
||||||
|
WHERE pt.post_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const results = query.all(postId) as { name: string }[];
|
||||||
|
return results.map(row => row.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add tags to each post and clean up paths
|
||||||
|
return posts.map(post => {
|
||||||
|
const basePost = post satisfies {
|
||||||
|
id: number,
|
||||||
|
path: string,
|
||||||
|
title: string,
|
||||||
|
date: string,
|
||||||
|
readingTime: string,
|
||||||
|
summary: string,
|
||||||
|
content: string
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...basePost,
|
||||||
|
tags: getPostTags(post.id),
|
||||||
|
path: post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a single post by its file path
|
||||||
|
*
|
||||||
|
* @param path - The file path of the post to retrieve
|
||||||
|
* @returns The post object with metadata and tags, or null if not found
|
||||||
|
*/
|
||||||
|
public getPost(path: string) {
|
||||||
|
const query = this.db.query(`
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE path = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const post = query.get(path) as any;
|
||||||
|
if (!post) return null;
|
||||||
|
|
||||||
|
// Helper function to get tags for a post
|
||||||
|
const getPostTags = (postId: number): string[] => {
|
||||||
|
const query = this.db.query(`
|
||||||
|
SELECT t.name FROM tags t
|
||||||
|
JOIN post_tags pt ON t.id = pt.tag_id
|
||||||
|
WHERE pt.post_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const results = query.all(postId) as { name: string }[];
|
||||||
|
return results.map(row => row.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get tags for this post
|
||||||
|
post.tags = getPostTags(post.id);
|
||||||
|
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the chronologically adjacent posts (previous and next) for navigation
|
||||||
|
*
|
||||||
|
* @param currentPostPath - The file path of the current post
|
||||||
|
* @returns Object with previousPost and nextPost properties, each containing title and path, or null if no adjacent post exists
|
||||||
|
*/
|
||||||
|
public getAdjacentPosts(currentPostPath: string) {
|
||||||
|
const allPostsQuery = this.db.query(`
|
||||||
|
SELECT path, title, date
|
||||||
|
FROM posts
|
||||||
|
WHERE path NOT LIKE '%.md'
|
||||||
|
ORDER BY date DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const allPosts = allPostsQuery.all() as any[];
|
||||||
|
|
||||||
|
// Find the current post index
|
||||||
|
const currentIndex = allPosts.findIndex(post => post.path === currentPostPath);
|
||||||
|
|
||||||
|
// If not found, return empty navigation
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return { previousPost: null, nextPost: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get previous post (newer post, which comes before current in reverse chronological order)
|
||||||
|
let previousPost = null;
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
const prevPost = allPosts[currentIndex - 1];
|
||||||
|
// Clean up the path to match the URL structure
|
||||||
|
const cleanPath = prevPost.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
|
||||||
|
previousPost = {
|
||||||
|
title: prevPost.title,
|
||||||
|
path: cleanPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next post (older post, which comes after current in reverse chronological order)
|
||||||
|
let nextPost = null;
|
||||||
|
if (currentIndex < allPosts.length - 1) {
|
||||||
|
const post = allPosts[currentIndex + 1];
|
||||||
|
// Clean up the path to match the URL structure
|
||||||
|
const cleanPath = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
|
||||||
|
nextPost = {
|
||||||
|
title: post.title,
|
||||||
|
path: cleanPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousPost, nextPost };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all unique tags from the database with their respective post counts.
|
||||||
|
*
|
||||||
|
* This method implements caching to avoid repeated database queries. After the first
|
||||||
|
* call, subsequent calls will return the cached results. Be aware that on db build, this
|
||||||
|
* likely won't be accurate because of the natural race condition and the server should be restarted.
|
||||||
|
*
|
||||||
|
* @returns {Array<{ name: string; post_count: number; urlNormalized: string }>}
|
||||||
|
* An array of tag objects containing:
|
||||||
|
* - name: The display name of the tag
|
||||||
|
* - post_count: Number of posts associated with this tag
|
||||||
|
* - urlNormalized: The URL-safe version of the tag name
|
||||||
|
*/
|
||||||
|
public getAllTags(): { name: string; post_count: number; urlNormalized: string }[] {
|
||||||
|
if (!this.getAllTagsCache) {
|
||||||
|
this.getAllTagsCache = this.initGetAllTags();
|
||||||
|
}
|
||||||
|
return this.getAllTagsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAllTagsCache: { name: string; post_count: number; urlNormalized: string }[] | null = null
|
||||||
|
|
||||||
|
private initGetAllTags() {
|
||||||
|
const query = db.query(`
|
||||||
|
SELECT t.name, t.url_normalized, COUNT(pt.post_id) as post_count
|
||||||
|
FROM tags t
|
||||||
|
JOIN post_tags pt ON t.id = pt.tag_id
|
||||||
|
JOIN posts p ON pt.post_id = p.id
|
||||||
|
GROUP BY t.id, t.name, t.url_normalized
|
||||||
|
ORDER BY post_count DESC, t.name ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const results = query.all() as { name: string; url_normalized: string; post_count: number }[];
|
||||||
|
return results.map(row => ({
|
||||||
|
name: row.name,
|
||||||
|
urlNormalized: row.url_normalized,
|
||||||
|
post_count: row.post_count
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SQLite database connection class
|
||||||
|
* Use this by default for using helper query functions
|
||||||
|
*/
|
||||||
|
export const dbConnection = DatabaseConnection.getInstance();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SQLite database connection instance
|
||||||
|
* Use this exported instance for direct database operations
|
||||||
|
*/
|
||||||
|
export const db = dbConnection.getDatabase();
|
||||||
@@ -1,76 +1,41 @@
|
|||||||
import { Html } from "@elysiajs/html";
|
import React, { ReactNode } from 'react';
|
||||||
|
import { minifyCSS, minifyJS } from './utils';
|
||||||
|
|
||||||
// Helper: Minify CSS using simple but effective regex
|
// @ts-expect-error - Importing as text, not a module
|
||||||
async function minifyCSS(css: string): Promise<string> {
|
import styles from './styles.css' with { type: "text" };
|
||||||
return css
|
// @ts-expect-error - Importing as text, not a module
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove comments
|
import headScript from './onLoad' with { type: "text" };
|
||||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
|
||||||
.replace(/\s*([{}:;,])\s*/g, '$1') // Remove space around delimiters
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Minify JS/TS using Bun.Transpiler
|
import { ThemePicker } from './components/theme-picker';
|
||||||
async function minifyJS(code: string): Promise<string> {
|
import { ProfileBadge } from './components/profile-badge';
|
||||||
const transpiler = new Bun.Transpiler({
|
import { TagPicker } from './components/tag-picker';
|
||||||
loader: 'ts',
|
import { PostArchive } from './components/post-archive';
|
||||||
minifyWhitespace: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return transpiler.transformSync(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and minify files at module load time (runs once)
|
export function AppShell({ children, searchParams }: { children: ReactNode, searchParams?: URLSearchParams }) {
|
||||||
console.log('[AppShell] Loading and minifying assets...');
|
return (
|
||||||
|
<html lang="en">
|
||||||
const rawStyles = await Bun.file("./src/public/styles.css").text();
|
|
||||||
const rawHeadScript = await Bun.file("./src/public/head.ts").text();
|
|
||||||
const rawOnLoadScript = await Bun.file("./src/public/onLoad.ts").text();
|
|
||||||
|
|
||||||
// Minify all assets
|
|
||||||
const styles = await minifyCSS(rawStyles);
|
|
||||||
const headScript = await minifyJS(rawHeadScript);
|
|
||||||
const onLoadScript = await minifyJS(rawOnLoadScript);
|
|
||||||
|
|
||||||
console.log('[AppShell] Assets minified and cached in memory');
|
|
||||||
console.log(` CSS: ${rawStyles.length} → ${styles.length} bytes (-${Math.round((1 - styles.length / rawStyles.length) * 100)}%)`);
|
|
||||||
console.log(` head.ts: ${rawHeadScript.length} → ${headScript.length} bytes (-${Math.round((1 - headScript.length / rawHeadScript.length) * 100)}%)`);
|
|
||||||
console.log(` onLoad.ts: ${rawOnLoadScript.length} → ${onLoadScript.length} bytes (-${Math.round((1 - onLoadScript.length / rawOnLoadScript.length) * 100)}%)`);
|
|
||||||
|
|
||||||
export function AppShell(responseValue: any) {
|
|
||||||
return (
|
|
||||||
<html>
|
|
||||||
<head>
|
<head>
|
||||||
|
<meta charSet="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Caleb's Blog</title>
|
<title>Caleb's Blog</title>
|
||||||
<style>
|
<style dangerouslySetInnerHTML={{ __html: minifyCSS(styles) }} />
|
||||||
{styles}
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
{headScript}
|
|
||||||
</script>
|
|
||||||
<script defer>
|
|
||||||
{onLoadScript}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{responseValue}
|
<input type="checkbox" id="menu-toggle" className="menu-toggle" />
|
||||||
|
<label htmlFor="menu-toggle" className="hamburger-button">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
<aside>
|
<aside>
|
||||||
<h3>About Me</h3>
|
<ThemePicker />
|
||||||
<p>I'm a software engineer</p>
|
<ProfileBadge />
|
||||||
<ul>
|
<TagPicker searchParams={searchParams} />
|
||||||
<li>Twitter</li>
|
<PostArchive />
|
||||||
<li>GitHub</li>
|
|
||||||
<li>LinkedIn</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Categories</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Web Development</li>
|
|
||||||
<li>UI/UX Design</li>
|
|
||||||
<li>Productivity</li>
|
|
||||||
<li>Career</li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
</aside>
|
||||||
</body>
|
</body>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: minifyJS(headScript) }} />
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Html } from "@elysiajs/html";
|
|
||||||
|
|
||||||
export function Blog() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<h1>Blog</h1>
|
|
||||||
<a href="/">Home</a>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
298
src/frontend/clientJS/post-archive.ts
Normal file
298
src/frontend/clientJS/post-archive.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
// Post Archive toggle functionality
|
||||||
|
function setupArchiveToggles() {
|
||||||
|
document.querySelectorAll('.archive-year, .archive-month').forEach(toggle => {
|
||||||
|
toggle.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const toggleIcon = this.querySelector('.archive-toggle');
|
||||||
|
const content = this.nextElementSibling;
|
||||||
|
|
||||||
|
// Toggle expanded state
|
||||||
|
if (toggleIcon) toggleIcon.classList.toggle('expanded');
|
||||||
|
if (content) content.classList.toggle('expanded');
|
||||||
|
|
||||||
|
// Update aria-expanded
|
||||||
|
const isExpanded = content.classList.contains('expanded');
|
||||||
|
this.setAttribute('aria-expanded', String(isExpanded));
|
||||||
|
|
||||||
|
// Update tabIndex for nested elements
|
||||||
|
updateTabIndex(content, isExpanded);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove active class from all post links
|
||||||
|
document.querySelectorAll('.archive-post a').forEach(link => {
|
||||||
|
link.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add click handlers for post links to toggle active class
|
||||||
|
document.querySelectorAll('.archive-post a').forEach(link => {
|
||||||
|
link.addEventListener('click', function() {
|
||||||
|
// Remove active class from all post links
|
||||||
|
document.querySelectorAll('.archive-post a').forEach(postLink => {
|
||||||
|
postLink.classList.remove('active');
|
||||||
|
});
|
||||||
|
// Add active class to clicked link
|
||||||
|
this.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the archive state with a slight delay to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
initializeArchiveState();
|
||||||
|
// Update the active post indicator after initializing the archive state
|
||||||
|
updateActivePost();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTabIndex(content: Element, isExpanded: boolean) {
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const tabIndex = isExpanded ? '0' : '-1';
|
||||||
|
|
||||||
|
// Update focusable elements based on visibility
|
||||||
|
content.querySelectorAll('a, button, [tabindex="0"], [tabindex="-1"]').forEach(el => {
|
||||||
|
el.setAttribute('tabindex', tabIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeArchiveState() {
|
||||||
|
// Get the current path
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
|
// Check if we're currently viewing a blog post (pattern: /YYYY/MM/slug)
|
||||||
|
const pathParts = currentPath.split('/').filter(part => part);
|
||||||
|
const isPostView = pathParts.length >= 3 &&
|
||||||
|
/^\d{4}$/.test(pathParts[0]) &&
|
||||||
|
/^\d{1,2}$/.test(pathParts[1]);
|
||||||
|
|
||||||
|
// Get all year and month elements
|
||||||
|
const years = Array.from(document.querySelectorAll('.archive-year')) as HTMLElement[];
|
||||||
|
const months = Array.from(document.querySelectorAll('.archive-month')) as HTMLElement[];
|
||||||
|
|
||||||
|
// Find the target year and month to keep expanded
|
||||||
|
let targetYear: HTMLElement | null = null;
|
||||||
|
let targetMonth: HTMLElement | null = null;
|
||||||
|
|
||||||
|
if (isPostView) {
|
||||||
|
// Try to find the post link in multiple ways
|
||||||
|
let postLink: HTMLAnchorElement | null = null;
|
||||||
|
|
||||||
|
// Try to find the post link with exact path or with/without trailing slash
|
||||||
|
postLink = document.querySelector(`a[href="${currentPath}"]`) as HTMLAnchorElement;
|
||||||
|
|
||||||
|
if (!postLink) {
|
||||||
|
const altPath = currentPath.endsWith('/') ?
|
||||||
|
currentPath.slice(0, -1) : currentPath + '/';
|
||||||
|
postLink = document.querySelector(`a[href="${altPath}"]`) as HTMLAnchorElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still not found, try a partial match using the slug
|
||||||
|
if (!postLink && pathParts.length >= 3) {
|
||||||
|
const slug = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
|
document.querySelectorAll('.archive-post a').forEach((link) => {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (href && href.endsWith(slug)) {
|
||||||
|
postLink = link as HTMLAnchorElement;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found the post link, get its parent month and year
|
||||||
|
if (postLink) {
|
||||||
|
const postLi = postLink.parentElement; // li.archive-post
|
||||||
|
const postsUl = postLi?.parentElement; // ul.archive-posts
|
||||||
|
const contentDiv = postsUl?.parentElement; // div.archive-content
|
||||||
|
const monthLi = contentDiv?.parentElement; // li containing the month
|
||||||
|
|
||||||
|
// Get the month toggle element
|
||||||
|
const monthElement = monthLi?.querySelector('.archive-month') as HTMLElement;
|
||||||
|
|
||||||
|
if (monthElement) {
|
||||||
|
targetMonth = monthElement;
|
||||||
|
|
||||||
|
// Get the parent year
|
||||||
|
const yearLi = monthLi?.parentElement; // ul containing the month
|
||||||
|
const yearContentDiv = yearLi?.parentElement; // div.archive-content
|
||||||
|
const yearMainLi = yearContentDiv?.parentElement; // li containing the year
|
||||||
|
const yearElement = yearMainLi?.querySelector('.archive-year') as HTMLElement;
|
||||||
|
|
||||||
|
targetYear = yearElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didn't find a specific target year, fall back to the most recent year
|
||||||
|
if (!targetYear && years.length > 0) {
|
||||||
|
targetYear = years[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didn't find a specific target month, fall back to the most recent month in that year
|
||||||
|
if (!targetMonth && targetYear) {
|
||||||
|
const yearContent = targetYear.nextElementSibling as HTMLElement;
|
||||||
|
if (yearContent) {
|
||||||
|
const yearMonths = yearContent.querySelectorAll('.archive-month') as NodeListOf<HTMLElement>;
|
||||||
|
if (yearMonths.length > 0) {
|
||||||
|
targetMonth = yearMonths[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to collapse an element
|
||||||
|
function collapse(element: HTMLElement) {
|
||||||
|
const toggleIcon = element.querySelector('.archive-toggle');
|
||||||
|
const content = element.nextElementSibling;
|
||||||
|
|
||||||
|
if (toggleIcon) toggleIcon.classList.remove('expanded');
|
||||||
|
if (content) {
|
||||||
|
content.classList.remove('expanded');
|
||||||
|
updateTabIndex(content, false);
|
||||||
|
}
|
||||||
|
element.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to expand an element
|
||||||
|
function expand(element: HTMLElement) {
|
||||||
|
const toggleIcon = element.querySelector('.archive-toggle');
|
||||||
|
const content = element.nextElementSibling;
|
||||||
|
|
||||||
|
if (toggleIcon) toggleIcon.classList.add('expanded');
|
||||||
|
if (content) {
|
||||||
|
content.classList.add('expanded');
|
||||||
|
updateTabIndex(content, true);
|
||||||
|
}
|
||||||
|
element.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse all years except our target
|
||||||
|
years.forEach(year => {
|
||||||
|
if (year !== targetYear) {
|
||||||
|
collapse(year);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collapse all months except our target
|
||||||
|
months.forEach(month => {
|
||||||
|
if (month !== targetMonth) {
|
||||||
|
collapse(month);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expand the target year if we have one
|
||||||
|
if (targetYear) {
|
||||||
|
expand(targetYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand the target month if we have one
|
||||||
|
if (targetMonth) {
|
||||||
|
expand(targetMonth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update the active post link based on the current URL
|
||||||
|
function updateActivePost() {
|
||||||
|
// Remove active class from all post links
|
||||||
|
document.querySelectorAll('.archive-post a').forEach(link => {
|
||||||
|
link.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the current path
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
|
// Find the post link matching the current path
|
||||||
|
let postLink = document.querySelector(`a[href="${currentPath}"]`) as HTMLAnchorElement;
|
||||||
|
|
||||||
|
// Try with/without trailing slash if not found
|
||||||
|
if (!postLink) {
|
||||||
|
const altPath = currentPath.endsWith('/') ?
|
||||||
|
currentPath.slice(0, -1) : currentPath + '/';
|
||||||
|
postLink = document.querySelector(`a[href="${altPath}"]`) as HTMLAnchorElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If found, add active class
|
||||||
|
if (postLink) {
|
||||||
|
postLink.classList.add('active');
|
||||||
|
} else {
|
||||||
|
// Try partial match using the slug
|
||||||
|
const pathParts = currentPath.split('/').filter(part => part);
|
||||||
|
if (pathParts.length >= 3) {
|
||||||
|
const slug = pathParts[pathParts.length - 1];
|
||||||
|
document.querySelectorAll('.archive-post a').forEach((link) => {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (href && href.endsWith(slug)) {
|
||||||
|
link.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setupArchiveToggles();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setupArchiveToggles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for browser navigation events (back/forward buttons)
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
// Re-initialize the archive state for the new URL
|
||||||
|
initializeArchiveState();
|
||||||
|
// Update the active post indicator after initializing the archive state
|
||||||
|
setTimeout(updateActivePost, 10); // Small delay to ensure DOM is updated
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for URL changes (including navigation via other components)
|
||||||
|
function setupUrlChangeListener() {
|
||||||
|
// Store the current URL to detect changes
|
||||||
|
let currentUrl = window.location.pathname;
|
||||||
|
|
||||||
|
// Use MutationObserver to detect DOM changes in the main element
|
||||||
|
// This helps catch navigation changes from other components
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
// Check if URL has changed
|
||||||
|
if (window.location.pathname !== currentUrl) {
|
||||||
|
currentUrl = window.location.pathname;
|
||||||
|
|
||||||
|
// Wait a bit for any DOM updates to complete
|
||||||
|
setTimeout(() => {
|
||||||
|
updateActivePost();
|
||||||
|
initializeArchiveState();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing the main element for changes
|
||||||
|
const mainElement = document.querySelector('main');
|
||||||
|
if (mainElement) {
|
||||||
|
observer.observe(mainElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also observe direct URL changes (e.g., when using browser back/forward)
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
if (window.location.pathname !== currentUrl) {
|
||||||
|
currentUrl = window.location.pathname;
|
||||||
|
updateActivePost();
|
||||||
|
initializeArchiveState();
|
||||||
|
}
|
||||||
|
}, 500); // Check every 500ms
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
observer.disconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the URL change listener
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setupUrlChangeListener();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setupUrlChangeListener();
|
||||||
|
}
|
||||||
119
src/frontend/clientJS/tag-picker.ts
Normal file
119
src/frontend/clientJS/tag-picker.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// Add function to 'Clear all filters'
|
||||||
|
document.querySelector('.clear-tags-btn')
|
||||||
|
?.addEventListener('click', (e) => {
|
||||||
|
const activeTags = document.querySelectorAll('a.tag-pill.active')
|
||||||
|
activeTags.forEach((activeTag) => {
|
||||||
|
activeTag.classList.remove('active')
|
||||||
|
})
|
||||||
|
updateTagUrls();
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add function to toggle tag filters
|
||||||
|
document.querySelectorAll('a.tag-pill')
|
||||||
|
.forEach((tagPill) => {
|
||||||
|
tagPill.addEventListener('click', (e) => toggleTagPill(e))
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTagPill(e: Event) {
|
||||||
|
const target = e.currentTarget as HTMLElement;
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
if (target.classList.contains('active')) {
|
||||||
|
target.classList.remove('active')
|
||||||
|
searchParams.delete('tag', getURLSafeTagName(target.innerText))
|
||||||
|
} else {
|
||||||
|
target.classList.add('active')
|
||||||
|
searchParams.append('tag', getURLSafeTagName(target.innerText))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tag urls after the loader in onLoad completes
|
||||||
|
setTimeout(() => updateTagUrls(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTagUrls() {
|
||||||
|
const activeTags = document.querySelectorAll('a.tag-pill.active')
|
||||||
|
const inactiveTags = document.querySelectorAll('a.tag-pill:not(.active)')
|
||||||
|
|
||||||
|
let baseTagParams = '';
|
||||||
|
activeTags.forEach((val) => {
|
||||||
|
baseTagParams += `&tag=${getURLSafeTagName(val.innerHTML)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
inactiveTags.forEach((val) => {
|
||||||
|
val.setAttribute('href', `/?tag=${getURLSafeTagName(val.innerHTML)}${baseTagParams}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
activeTags.forEach((val) => {
|
||||||
|
const tagName = getURLSafeTagName(val.innerHTML)
|
||||||
|
|
||||||
|
const activeTagLink = baseTagParams.split(`&tag=${getURLSafeTagName(val.innerHTML)}`).join('')
|
||||||
|
|
||||||
|
if (activeTagLink.length > 1) {
|
||||||
|
val.setAttribute('href', `/?${activeTagLink.substring(1)}`)
|
||||||
|
} else {
|
||||||
|
val.setAttribute('href', '/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function normalizing the user facing tag name into
|
||||||
|
// the url format for the tag name.
|
||||||
|
function getURLSafeTagName(tag: string) {
|
||||||
|
return tag.toLowerCase().split(" ").join("-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to sync the UI state with the current URL
|
||||||
|
function syncStateFromUrl() {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const currentTags = searchParams.getAll('tag');
|
||||||
|
|
||||||
|
// Reset all tags to inactive
|
||||||
|
document.querySelectorAll('a.tag-pill').forEach(tagPill => {
|
||||||
|
tagPill.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set active tags based on current URL
|
||||||
|
let activeTagCount = 0
|
||||||
|
currentTags.forEach(tagUrl => {
|
||||||
|
document.querySelectorAll('a.tag-pill').forEach(tagPill => {
|
||||||
|
const pill = tagPill as HTMLElement;
|
||||||
|
if (getURLSafeTagName(pill.innerText) === tagUrl) {
|
||||||
|
pill.classList.add('active');
|
||||||
|
activeTagCount++
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there is an active tag, show the clear filters button
|
||||||
|
const tagActions = document.querySelector('.tag-actions') as HTMLElement
|
||||||
|
if (activeTagCount > 0) {
|
||||||
|
// Show the clear tags button
|
||||||
|
tagActions.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
// Hide the clear tags button
|
||||||
|
tagActions.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTagUrls();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook into the popstate event to sync state when navigating back/forward
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
syncStateFromUrl();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook into the navigation system to sync state after content loads
|
||||||
|
// We'll observe the main element for changes
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === 'childList' && document.querySelector('main')) {
|
||||||
|
syncStateFromUrl();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing the document body for changes
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
168
src/frontend/clientJS/theme-picker.ts
Normal file
168
src/frontend/clientJS/theme-picker.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// JS is enabled, show theme picker interface
|
||||||
|
const elem = document.querySelector(".theme-controls")?.classList.remove('hidden')
|
||||||
|
|
||||||
|
// Theme switching functionality
|
||||||
|
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
|
||||||
|
const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
|
||||||
|
|
||||||
|
const THEME_NAMES = {
|
||||||
|
latte: 'Catppuccin Latte',
|
||||||
|
frappe: 'Catppuccin Frappe',
|
||||||
|
macchiato: 'Catppuccin Macchiato',
|
||||||
|
mocha: 'Catppuccin Mocha',
|
||||||
|
'solarized-light': 'Solarized Light',
|
||||||
|
'solarized-dark': 'Solarized Dark',
|
||||||
|
'gruvbox-light': 'Gruvbox Light',
|
||||||
|
'gruvbox-dark': 'Gruvbox Dark',
|
||||||
|
nord: 'Nord',
|
||||||
|
dracula: 'Dracula',
|
||||||
|
'one-dark': 'One Dark',
|
||||||
|
'tokyo-night': 'Tokyo Night'
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentMode = '';
|
||||||
|
let THEMES = { light: 'latte', dark: 'mocha' };
|
||||||
|
|
||||||
|
function initializeTheme() {
|
||||||
|
const savedLightTheme = localStorage.getItem('theme-light') || 'latte';
|
||||||
|
const savedDarkTheme = localStorage.getItem('theme-dark') || 'mocha';
|
||||||
|
const savedMode = localStorage.getItem('theme-mode');
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
THEMES.light = savedLightTheme;
|
||||||
|
THEMES.dark = savedDarkTheme;
|
||||||
|
currentMode = savedMode || (prefersDark ? 'dark' : 'light');
|
||||||
|
|
||||||
|
updateModeToggle();
|
||||||
|
updateThemeDropdown();
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
const theme = THEMES[currentMode];
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
updateCurrentThemeDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThemeDropdown() {
|
||||||
|
const menu = document.getElementById('themeDropdownMenu');
|
||||||
|
const themes = currentMode === 'light' ? LIGHT_THEMES : DARK_THEMES;
|
||||||
|
|
||||||
|
if (menu) {
|
||||||
|
menu.innerHTML = themes.map(theme =>
|
||||||
|
'<div class="theme-option" data-theme="' + theme + '">' +
|
||||||
|
' <div class="theme-name">' + THEME_NAMES[theme] + '</div>' +
|
||||||
|
' <button class="theme-selector-btn" data-theme="' + theme + '"></button>' +
|
||||||
|
'</div>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
attachThemeOptionListeners();
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachThemeOptionListeners() {
|
||||||
|
document.querySelectorAll('.theme-option').forEach(option => {
|
||||||
|
option.addEventListener('click', function(e) {
|
||||||
|
const theme = this.getAttribute('data-theme');
|
||||||
|
if (theme) selectTheme(theme);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTheme(theme: string) {
|
||||||
|
THEMES[currentMode] = theme;
|
||||||
|
localStorage.setItem('theme-' + currentMode, theme);
|
||||||
|
updateUI();
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCurrentThemeDisplay() {
|
||||||
|
const theme = THEMES[currentMode];
|
||||||
|
const themeName = THEME_NAMES[theme];
|
||||||
|
const display = document.getElementById('currentThemeDisplay');
|
||||||
|
if (display) {
|
||||||
|
display.textContent = themeName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
const currentTheme = THEMES[currentMode];
|
||||||
|
document.querySelectorAll('.theme-selector-btn').forEach(function(btn) {
|
||||||
|
const theme = btn.getAttribute('data-theme');
|
||||||
|
if (theme) {
|
||||||
|
btn.classList.toggle('selected', currentTheme === theme);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModeToggle() {
|
||||||
|
const lightBtn = document.getElementById('lightModeBtn');
|
||||||
|
const darkBtn = document.getElementById('darkModeBtn');
|
||||||
|
|
||||||
|
if (lightBtn) lightBtn.classList.toggle('active', currentMode === 'light');
|
||||||
|
if (darkBtn) darkBtn.classList.toggle('active', currentMode === 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
function setupEventListeners() {
|
||||||
|
const lightBtn = document.getElementById('lightModeBtn');
|
||||||
|
const darkBtn = document.getElementById('darkModeBtn');
|
||||||
|
const trigger = document.getElementById('themeDropdownTrigger');
|
||||||
|
const menu = document.getElementById('themeDropdownMenu');
|
||||||
|
|
||||||
|
if (lightBtn) {
|
||||||
|
lightBtn.addEventListener('click', function() {
|
||||||
|
currentMode = 'light';
|
||||||
|
localStorage.setItem('theme-mode', 'light');
|
||||||
|
updateModeToggle();
|
||||||
|
updateThemeDropdown();
|
||||||
|
applyTheme();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (darkBtn) {
|
||||||
|
darkBtn.addEventListener('click', function() {
|
||||||
|
currentMode = 'dark';
|
||||||
|
localStorage.setItem('theme-mode', 'dark');
|
||||||
|
updateModeToggle();
|
||||||
|
updateThemeDropdown();
|
||||||
|
applyTheme();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger && menu) {
|
||||||
|
trigger.addEventListener('click', function() {
|
||||||
|
trigger.classList.toggle('open');
|
||||||
|
menu.classList.toggle('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const target = e.target as Element;
|
||||||
|
if (!target.closest('.theme-dropdown-wrapper')) {
|
||||||
|
if (trigger) trigger.classList.remove('open');
|
||||||
|
if (menu) menu.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||||
|
if (localStorage.getItem('theme-mode') !== 'light' && localStorage.getItem('theme-mode') !== 'dark') {
|
||||||
|
currentMode = e.matches ? 'dark' : 'light';
|
||||||
|
updateModeToggle();
|
||||||
|
updateThemeDropdown();
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initializeTheme();
|
||||||
|
setupEventListeners();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
initializeTheme();
|
||||||
|
setupEventListeners();
|
||||||
|
}
|
||||||
146
src/frontend/components/post-archive.tsx
Normal file
146
src/frontend/components/post-archive.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { minifyJS } from '../utils';
|
||||||
|
import { db } from '../../db';
|
||||||
|
|
||||||
|
// @ts-expect-error - Importing as text, not a module
|
||||||
|
import postArchiveScript from '../clientJS/post-archive' with { type: "text" };
|
||||||
|
|
||||||
|
// Interface for archive data structure
|
||||||
|
export interface ArchiveMonth {
|
||||||
|
name: string;
|
||||||
|
count: string;
|
||||||
|
posts: Array<{
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveYear {
|
||||||
|
year: string;
|
||||||
|
count: string;
|
||||||
|
months: ArchiveMonth[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read posts from database once and store in a closure
|
||||||
|
const getArchiveData = (() => {
|
||||||
|
let cachedData: ReturnType<typeof getPostsByYearAndMonth> | null = null;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!cachedData) {
|
||||||
|
cachedData = getPostsByYearAndMonth();
|
||||||
|
}
|
||||||
|
return cachedData;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function PostArchive() {
|
||||||
|
const archiveData = getArchiveData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="postList sheet-background">
|
||||||
|
<h3>Posts</h3>
|
||||||
|
<div className="archive-container">
|
||||||
|
<ul className="post-archive">
|
||||||
|
{archiveData.map((yearData) => (
|
||||||
|
<li key={yearData.year}>
|
||||||
|
<div className="archive-year" tabIndex={0} role="button" aria-expanded="true">
|
||||||
|
<span className="archive-toggle expanded">▼</span>
|
||||||
|
<span>{yearData.year}</span>
|
||||||
|
<span className="post-count">{yearData.count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="archive-content expanded">
|
||||||
|
<ul className="archive-months">
|
||||||
|
{yearData.months.map((monthData) => (
|
||||||
|
<li key={monthData.name}>
|
||||||
|
<div className="archive-month" tabIndex={0} role="button" aria-expanded="true">
|
||||||
|
<span className="archive-toggle expanded">▼</span>
|
||||||
|
<span>{monthData.name}</span>
|
||||||
|
<span className="post-count">{monthData.count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="archive-content expanded">
|
||||||
|
<ul className="archive-posts">
|
||||||
|
{monthData.posts.map((post) => (
|
||||||
|
<li key={post.href} className="archive-post">
|
||||||
|
<a href={post.href} tabIndex={-1}>{post.title}</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: minifyJS(postArchiveScript) }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get posts organized by year and month for the archive
|
||||||
|
export function getPostsByYearAndMonth(): ArchiveYear[] {
|
||||||
|
const query = db.query(`
|
||||||
|
SELECT * FROM posts
|
||||||
|
WHERE path NOT LIKE '%.md'
|
||||||
|
ORDER BY date DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const posts = query.all() as any[];
|
||||||
|
|
||||||
|
// Group posts by year and month
|
||||||
|
const yearMap = new Map<string, ArchiveYear>();
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Process each post
|
||||||
|
posts.forEach(post => {
|
||||||
|
const date = new Date(post.date);
|
||||||
|
const year = String(date.getFullYear());
|
||||||
|
const monthName = monthNames[date.getMonth()];
|
||||||
|
|
||||||
|
// Create clean post href from path
|
||||||
|
const href = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '');
|
||||||
|
|
||||||
|
// Initialize year if it doesn't exist
|
||||||
|
if (!yearMap.has(year)) {
|
||||||
|
yearMap.set(year, {
|
||||||
|
year,
|
||||||
|
count: "(0)",
|
||||||
|
months: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearData = yearMap.get(year)!;
|
||||||
|
|
||||||
|
// Find or create month data
|
||||||
|
let monthData = yearData.months.find(m => m.name === monthName);
|
||||||
|
if (!monthData) {
|
||||||
|
monthData = {
|
||||||
|
name: monthName,
|
||||||
|
count: "(0)",
|
||||||
|
posts: []
|
||||||
|
};
|
||||||
|
yearData.months.push(monthData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add post to the month
|
||||||
|
monthData.posts.push({
|
||||||
|
title: post.title,
|
||||||
|
href
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
monthData.count = `(${monthData.posts.length})`;
|
||||||
|
yearData.count = `(${yearData.months.reduce((total, m) => total + m.posts.length, 0)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert map to array and sort
|
||||||
|
return Array.from(yearMap.values()).sort((a, b) =>
|
||||||
|
parseInt(b.year) - parseInt(a.year)
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/frontend/components/profile-badge.tsx
Normal file
70
src/frontend/components/profile-badge.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const urlLinkedIn = 'https://linkedin.com/in/CalebBraaten';
|
||||||
|
const urlX = 'https://x.com/CalebBraaten';
|
||||||
|
const urlGit = 'https://git.cbraaten.dev/Caleb';
|
||||||
|
|
||||||
|
export function ProfileBadge() {
|
||||||
|
return (
|
||||||
|
<div className="aboutMe sheet-background">
|
||||||
|
<div className="profile-header">
|
||||||
|
<img src="/profile-picture.webp" alt="Caleb Braaten" className="profile-picture" />
|
||||||
|
<h3 className="profile-name">Caleb Braaten</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="social-links">
|
||||||
|
<li>
|
||||||
|
<a href={urlLinkedIn} data-external target="_blank" rel="noopener noreferrer" aria-label="LinkedIn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={urlX} data-external target="_blank" rel="noopener noreferrer" aria-label="X (Twitter)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={urlGit} data-external target="_blank" rel="noopener noreferrer" aria-label="GitHub / Gitea">
|
||||||
|
<div className="git-icon-wrapper">
|
||||||
|
<svg className="icon-github" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
|
||||||
|
</svg>
|
||||||
|
<svg className="icon-gitea" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
|
<g>
|
||||||
|
<path id="teabag" d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8
|
||||||
|
c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4
|
||||||
|
c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"/>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2
|
||||||
|
c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5
|
||||||
|
c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5
|
||||||
|
c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3
|
||||||
|
c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1
|
||||||
|
C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4
|
||||||
|
c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7
|
||||||
|
S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55
|
||||||
|
c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8
|
||||||
|
l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"/>
|
||||||
|
<path d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4
|
||||||
|
c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1
|
||||||
|
c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9
|
||||||
|
c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3
|
||||||
|
c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3
|
||||||
|
c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29
|
||||||
|
c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8
|
||||||
|
C343.2,346.5,335,363.3,326.8,380.1z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
src/frontend/components/tag-picker.tsx
Normal file
63
src/frontend/components/tag-picker.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { minifyJS } from '../utils';
|
||||||
|
import { dbConnection } from '../../db/index.js';
|
||||||
|
|
||||||
|
// @ts-expect-error - Importing as text, not a module
|
||||||
|
import tagPickerScript from '../clientJS/tag-picker.js' with { type: "text" };
|
||||||
|
|
||||||
|
const tags = dbConnection.getAllTags().map(tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
post_count: tag.post_count,
|
||||||
|
urlNormalized: tag.urlNormalized
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function TagPicker({ searchParams }: { searchParams?: URLSearchParams }) {
|
||||||
|
const selectedTags = typeof searchParams === 'object' ? searchParams.getAll('tag') : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tags sheet-background">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<ul className="tag-pills">
|
||||||
|
{tags.map(tag => {
|
||||||
|
let active = selectedTags.includes(tag.urlNormalized)
|
||||||
|
let hyperlink = `/?tag=${tag.urlNormalized}`;
|
||||||
|
|
||||||
|
for (const existingTag of selectedTags) {
|
||||||
|
hyperlink += `&tag=${existingTag}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the currently selected tag from the tag link url if applicable
|
||||||
|
if (active) {
|
||||||
|
hyperlink = hyperlink.split(`?tag=${tag.urlNormalized}`).join('')
|
||||||
|
hyperlink = hyperlink.split(`&tag=${tag.urlNormalized}`).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the hyperlink starts with a ? and not a & (or the root webpage)
|
||||||
|
hyperlink = '/?' + hyperlink.substring(2)
|
||||||
|
if(hyperlink.length == 2) hyperlink = '/'
|
||||||
|
|
||||||
|
return (
|
||||||
|
< li key={tag.name} >
|
||||||
|
<a
|
||||||
|
title={active ? `Remove ${tag.name} from filter` : `Add ${tag.name} to filter`}
|
||||||
|
data-tag
|
||||||
|
className={`tag-pill ${active ? 'active' : ''}`}
|
||||||
|
href={hyperlink}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Show clear tags button if there are selected tags */}
|
||||||
|
<div className="tag-actions" style={{ display: selectedTags.length > 0 ? 'block' : 'none' }}>
|
||||||
|
<a href="/" className="clear-tags-btn">
|
||||||
|
Clear all filters
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: minifyJS(tagPickerScript) }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/frontend/components/theme-picker.tsx
Normal file
68
src/frontend/components/theme-picker.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { minifyJS } from '../utils';
|
||||||
|
|
||||||
|
// @ts-expect-error - Importing as text, not a module
|
||||||
|
import themePickerScript from '../clientJS/theme-picker' with { type: "text" };
|
||||||
|
|
||||||
|
const LIGHT_THEMES = ['latte', 'solarized-light', 'gruvbox-light'];
|
||||||
|
const DARK_THEMES = ['frappe', 'macchiato', 'mocha', 'solarized-dark', 'gruvbox-dark', 'nord', 'dracula', 'one-dark', 'tokyo-night'];
|
||||||
|
|
||||||
|
const THEME_NAMES: Record<string, string> = {
|
||||||
|
latte: 'Catppuccin Latte',
|
||||||
|
frappe: 'Catppuccin Frappe',
|
||||||
|
macchiato: 'Catppuccin Macchiato',
|
||||||
|
mocha: 'Catppuccin Mocha',
|
||||||
|
'solarized-light': 'Solarized Light',
|
||||||
|
'solarized-dark': 'Solarized Dark',
|
||||||
|
'gruvbox-light': 'Gruvbox Light',
|
||||||
|
'gruvbox-dark': 'Gruvbox Dark',
|
||||||
|
nord: 'Nord',
|
||||||
|
dracula: 'Dracula',
|
||||||
|
'one-dark': 'One Dark',
|
||||||
|
'tokyo-night': 'Tokyo Night'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemePicker() {
|
||||||
|
return (
|
||||||
|
<div className="themePicker sheet-background">
|
||||||
|
<noscript>Enable Javascript to select a theme</noscript>
|
||||||
|
<label htmlFor="theme" className="hidden">Theme</label>
|
||||||
|
<div className="theme-controls hidden">
|
||||||
|
<div className="theme-mode-toggle">
|
||||||
|
<button
|
||||||
|
className="mode-btn active"
|
||||||
|
data-mode="light"
|
||||||
|
id="lightModeBtn"
|
||||||
|
>
|
||||||
|
Light
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="mode-btn"
|
||||||
|
data-mode="dark"
|
||||||
|
id="darkModeBtn"
|
||||||
|
>
|
||||||
|
Dark
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="theme-dropdown-wrapper">
|
||||||
|
<button
|
||||||
|
className="theme-dropdown-trigger"
|
||||||
|
id="themeDropdownTrigger"
|
||||||
|
>
|
||||||
|
<span id="currentThemeDisplay">Catppuccin Latte</span>
|
||||||
|
<span className="dropdown-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
<div className="theme-dropdown-menu" id="themeDropdownMenu">
|
||||||
|
{LIGHT_THEMES.map(theme => (
|
||||||
|
<div key={theme} className="theme-option" data-theme={theme}>
|
||||||
|
<div className="theme-name">{THEME_NAMES[theme]}</div>
|
||||||
|
<button className="theme-selector-btn" data-theme={theme}></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: minifyJS(themePickerScript) }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Html } from "@elysiajs/html";
|
|
||||||
|
|
||||||
export function Home() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<h1>Home</h1>
|
|
||||||
<a href="/blog">Blog</a>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Client-side script that runs on page load
|
// Client-side script that runs on page load
|
||||||
// Example: TypeScript with type annotations
|
// Example: TypeScript with type annotations
|
||||||
|
|
||||||
async function loadContent(url: string) {
|
async function loadContent(url: string, shouldScrollToTop: boolean = true) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'shell-loaded': 'true'
|
'shell-loaded': 'true'
|
||||||
@@ -13,29 +13,31 @@ async function loadContent(url: string) {
|
|||||||
mainElement.outerHTML = html;
|
mainElement.outerHTML = html;
|
||||||
// Re-attach handlers to new links after content swap
|
// Re-attach handlers to new links after content swap
|
||||||
attachLinkHandlers();
|
attachLinkHandlers();
|
||||||
|
// Smooth scroll to top after navigation if needed
|
||||||
|
if (shouldScrollToTop) {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachLinkHandlers() {
|
function attachLinkHandlers() {
|
||||||
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a');
|
const links: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a:not([data-external])');
|
||||||
console.log('Found links:', links.length);
|
|
||||||
|
|
||||||
links.forEach(link => {
|
links.forEach(link => {
|
||||||
console.log('Attaching listener to:', link.href);
|
|
||||||
link.onclick = async (e) => {
|
link.onclick = async (e) => {
|
||||||
console.log('clicked', link.href);
|
console.log('clicked', link.href);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
window.history.pushState({}, '', link.href);
|
window.history.pushState({}, '', link.href);
|
||||||
await loadContent(link.href);
|
await loadContent(link.href, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for back/forward button clicks
|
// Listen for back/forward button clicks
|
||||||
window.addEventListener('popstate', async (event) => {
|
window.addEventListener('popstate', async (event) => {
|
||||||
await loadContent(window.location.href);
|
await loadContent(window.location.href, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
162
src/frontend/pages/home.tsx
Normal file
162
src/frontend/pages/home.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { dbConnection } from '../../db';
|
||||||
|
import { formatDate } from '../utils';
|
||||||
|
|
||||||
|
// Extract the post type from the database return type
|
||||||
|
type Post = {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
readingTime: string;
|
||||||
|
summary: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Home({ searchParams }: { searchParams: URLSearchParams }) {
|
||||||
|
const postsPerPage = 10;
|
||||||
|
const tags = searchParams.getAll('tag');
|
||||||
|
|
||||||
|
const currentPage = parseInt(searchParams.get('page') || "1", 10);
|
||||||
|
const totalPages = Math.ceil(dbConnection.getNumOfPosts(tags) / postsPerPage);
|
||||||
|
const offset = (currentPage - 1) * postsPerPage;
|
||||||
|
|
||||||
|
const posts = dbConnection.getPosts(postsPerPage, offset, tags); // Get posts for the current page
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<div className="posts-list">
|
||||||
|
{posts.length > 0 ? (
|
||||||
|
posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h2>No posts yet</h2>
|
||||||
|
<p>Check back soon for new blog posts!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Pagination searchParams={searchParams} currentPage={currentPage} totalPages={totalPages} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostCard({ post }: { post: Post }) {
|
||||||
|
const tags = post.tags;
|
||||||
|
const formattedDate = formatDate(post.date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="post-card">
|
||||||
|
<header className="post-card-header">
|
||||||
|
<h2 className="post-card-title">
|
||||||
|
<a href={`${post.path}`} className="post-card-link">{post.title}</a>
|
||||||
|
</h2>
|
||||||
|
<div className="post-card-meta">
|
||||||
|
<time dateTime={post.date}>{formattedDate}</time>
|
||||||
|
<span className="meta-separator">•</span>
|
||||||
|
<span className="read-time">5 min read</span>
|
||||||
|
</div>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="post-card-tags">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={`/?tag=${tag.toLowerCase().replace(/\s+/g, '-')}`}
|
||||||
|
className="post-tag"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{post.summary && (
|
||||||
|
<div className="post-card-summary">
|
||||||
|
<p>{post.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<footer className="post-card-footer">
|
||||||
|
<a href={`${post.path}`} className="read-more-link">Read more →</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ currentPage, totalPages, searchParams }:{ currentPage: number; totalPages: number, searchParams: URLSearchParams }) {
|
||||||
|
// Calculate the range of page numbers to show
|
||||||
|
let startPage: number;
|
||||||
|
let endPage: number;
|
||||||
|
|
||||||
|
if (totalPages <= 9) {
|
||||||
|
// If there are fewer than 9 pages, show all of them
|
||||||
|
startPage = 1;
|
||||||
|
endPage = totalPages;
|
||||||
|
} else {
|
||||||
|
// If we have more than 9 pages, calculate the window
|
||||||
|
if (currentPage <= 5) {
|
||||||
|
// When we're at the start, show pages 1-9
|
||||||
|
startPage = 1;
|
||||||
|
endPage = 9;
|
||||||
|
} else if (currentPage >= totalPages - 4) {
|
||||||
|
// When we're near the end, show the last 9 pages
|
||||||
|
startPage = totalPages - 8;
|
||||||
|
endPage = totalPages;
|
||||||
|
} else {
|
||||||
|
// Otherwise, center the window around the current page
|
||||||
|
startPage = currentPage - 4;
|
||||||
|
endPage = currentPage + 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||||
|
|
||||||
|
let passThroughParams = ''
|
||||||
|
searchParams.forEach((val, key) => {
|
||||||
|
if(key != 'page')
|
||||||
|
passThroughParams += `&${key}=${val}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="pagination">
|
||||||
|
{/* Previous button - always visible, disabled on first page */}
|
||||||
|
<a
|
||||||
|
href={`/?page=${currentPage - 1}${passThroughParams}`}
|
||||||
|
className={`pagination-link ${currentPage === 1 ? 'disabled' : ''}`}
|
||||||
|
style={{
|
||||||
|
opacity: currentPage === 1 ? 0.5 : 1,
|
||||||
|
cursor: currentPage === 1 ? 'not-allowed' : 'pointer',
|
||||||
|
pointerEvents: currentPage === 1 ? 'none' : 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Page numbers */}
|
||||||
|
{pages.map((page) => (
|
||||||
|
<a
|
||||||
|
key={page}
|
||||||
|
href={`/?page=${page}${passThroughParams}`}
|
||||||
|
className={`pagination-link ${page === currentPage ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Next button - always visible, disabled on last page */}
|
||||||
|
<a
|
||||||
|
href={`/?page=${currentPage + 1}${passThroughParams}`}
|
||||||
|
className={`pagination-link ${currentPage === totalPages ? 'disabled' : ''}`}
|
||||||
|
style={{
|
||||||
|
opacity: currentPage === totalPages ? 0.5 : 1,
|
||||||
|
cursor: currentPage === totalPages ? 'not-allowed' : 'pointer',
|
||||||
|
pointerEvents: currentPage === totalPages ? 'none' : 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Html } from "@elysiajs/html";
|
import React from 'react';
|
||||||
|
|
||||||
export function NotFound() {
|
export function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -6,4 +6,4 @@ export function NotFound() {
|
|||||||
<h1>404 Not Found</h1>
|
<h1>404 Not Found</h1>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
75
src/frontend/pages/post.tsx
Normal file
75
src/frontend/pages/post.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface NavigationPost {
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostProps {
|
||||||
|
// HTML string for the blog post body
|
||||||
|
children: string;
|
||||||
|
meta: {
|
||||||
|
title: string;
|
||||||
|
date: Date;
|
||||||
|
readingTime: string;
|
||||||
|
previousPost?: NavigationPost | null;
|
||||||
|
nextPost?: NavigationPost | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Post({ children, meta }: PostProps) {
|
||||||
|
const { previousPost, nextPost } = meta;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<article className="blog-post">
|
||||||
|
<header className="post-header">
|
||||||
|
<div className="back-button">
|
||||||
|
<a href="/" className="back-link">
|
||||||
|
<span className="back-arrow">←</span> Back to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h1>{meta.title}</h1>
|
||||||
|
<div className="post-meta">
|
||||||
|
{meta.date && meta.date instanceof Date &&
|
||||||
|
<>
|
||||||
|
|
||||||
|
<time dateTime={meta.date.toISOString()}>
|
||||||
|
{meta.date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
{meta.readingTime &&
|
||||||
|
<>
|
||||||
|
<span className="meta-separator">•</span>
|
||||||
|
<span>{meta.readingTime}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="post-content" dangerouslySetInnerHTML={{ __html: children }} />
|
||||||
|
<footer className="post-navigation">
|
||||||
|
<div className="post-nav-links">
|
||||||
|
{previousPost && (
|
||||||
|
<a href={previousPost.path} className="post-nav-link prev-nav">
|
||||||
|
<span className="nav-direction">← Previous</span>
|
||||||
|
<span className="nav-title">{previousPost.title}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{nextPost && (
|
||||||
|
<a href={nextPost.path} className="post-nav-link next-nav">
|
||||||
|
<span className="nav-direction">Next →</span>
|
||||||
|
<span className="nav-title">{nextPost.title}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
1356
src/frontend/styles.css
Normal file
1356
src/frontend/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
37
src/frontend/utils.ts
Normal file
37
src/frontend/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Minify CSS using simple but effective regex
|
||||||
|
export function minifyCSS(css: string): string {
|
||||||
|
return css
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove comments
|
||||||
|
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||||
|
.replace(/\s*([{}:;,])\s*/g, '$1') // Remove space around delimiters
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minify TypeScript/JavaScript code for client-side injection
|
||||||
|
export function minifyJS(code: string): string {
|
||||||
|
const transpiler = new Bun.Transpiler({
|
||||||
|
loader: 'ts',
|
||||||
|
minifyWhitespace: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return transpiler.transformSync(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to calculate read time
|
||||||
|
export function calculateReadTime(content: string): number {
|
||||||
|
const wordsPerMinute = 200;
|
||||||
|
const words = content.split(/\s+/).length;
|
||||||
|
return Math.max(1, Math.ceil(words / wordsPerMinute));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format date for display
|
||||||
|
export function formatDate(dateString: string): string {
|
||||||
|
// Parse ISO date string (YYYY-MM-DD)
|
||||||
|
const date = new Date(dateString + 'T00:00:00');
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// Client-side script that runs in <head>
|
|
||||||
// Example: TypeScript with DOM types
|
|
||||||
(() => {
|
|
||||||
const logPageInfo = (): void => {
|
|
||||||
console.log('Page loaded in <head>');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
logPageInfo();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
1
src/public/htmx.min.js
vendored
1
src/public/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
BIN
src/public/profile-picture.webp
Normal file
BIN
src/public/profile-picture.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -1,97 +0,0 @@
|
|||||||
body, a {
|
|
||||||
margin: 0px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #f2f2f2;
|
|
||||||
padding: 15px;
|
|
||||||
height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
header span {
|
|
||||||
font-size: 30px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: black;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin: 0px;
|
|
||||||
opacity: 75%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-spotlight {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
margin: 30px;
|
|
||||||
padding: 4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
article {
|
|
||||||
font-family: 'Arial Narrow Bold', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
article > h2 {
|
|
||||||
font-size: 3em;
|
|
||||||
font-weight: bold;
|
|
||||||
margin: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
article > div {
|
|
||||||
color: gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
article > a {
|
|
||||||
text-decoration: none;
|
|
||||||
border: 1px solid black;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: #f2f2f2;
|
|
||||||
color: black;
|
|
||||||
text-align: end;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
aside {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
align-items: center;
|
|
||||||
margin: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
aside h3 {
|
|
||||||
font-size: 2em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
aside ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about ul {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
"jsx": "react", /* Specify what JSX code is generated. */
|
"jsx": "react", /* Specify what JSX code is generated. */
|
||||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||||
"jsxFactory": "Html.createElement", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
"jsxFactory": "React.createElement", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||||
"jsxFragmentFactory": "Html.Fragment", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
"jsxFragmentFactory": "React.Fragment", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||||
|
|
||||||
/* Modules */
|
/* Modules */
|
||||||
"module": "ES2022", /* Specify what module code is generated. */
|
"module": "esnext", /* Specify what module code is generated. */
|
||||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
@@ -99,5 +99,17 @@
|
|||||||
/* Completeness */
|
/* Completeness */
|
||||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
}
|
},
|
||||||
|
"include": [
|
||||||
|
"index.tsx",
|
||||||
|
"src/**/*",
|
||||||
|
"bun_plugins/**/*",
|
||||||
|
"*.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"content"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user